初始化项目:规划文档 + FFU 洁净室流场实时仿真 Demo

- 规划文档 01-07:愿景 / 架构 / 路线图 / 数据与AI / 风险 / 参考 / FFU Demo
- 演示图:系统架构、数据流水线、里程碑路线图(SVG + PNG)
- FFU Demo:D2Q9 LBM 高保真(360x270, 76s)+ 粗网格快速代理(120x90, 7.3s, 16-27x 加速)
- 趋势验证:速度 r=0.95 / 粒子 r=0.83 / 温度 r=0.68
- 工具链:仿真脚本、快速代理、图表生成器、SVG->PNG 转换
This commit is contained in:
2026-09-18 00:05:18 +08:00
commit 792daa6feb
27 changed files with 9037 additions and 0 deletions
+648
View File
@@ -0,0 +1,648 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
FFU 单向流洁净区流场仿真 Demo
================================
用 D2Q9 格子玻尔兹曼方法 (LBM) 模拟 FFU (Fan Filter Unit, 风机过滤单元)
下方洁净区的流场, 并用半拉格朗日输运方程求解:
- 温度场 T (设备发热源)
- 相对湿度 RH (人员呼吸/发湿源)
- 尘埃粒子浓度 C (设备 + 人员散发, 含重力沉降)
- 静压场 p (LBM 直接输出, 转换为 Pa)
物理设置
--------
计算域 : 4.0 m (宽) x 3.0 m (高), 90 px/m
FFU 送风 : 顶棚满布, 面风速 0.45 m/s (ISO 14644 单向流推荐值)
回风口 : 底部两侧 (0~0.5 m, 3.5~4.0 m), 高架地板/下侧回风
障碍物 : 工作台 + 设备(热/尘源) + 操作人员(湿/尘源) + 物料架
雷诺数 : Re = U*H/nu ~ 540 (层流单向流)
网格 : 360 x 270 = 97200 格点, D2Q9 BGK, tau = 0.62
输出
----
docs/assets/ffu-flow-field.png 速度场 + 流线 (主图)
docs/assets/ffu-scalar-fields.png 温度 / 湿度 / 粒子浓度 / 压力 四联图
docs/assets/ffu-particle-tracks.png 粒子拉格朗日轨迹
docs/assets/ffu-flow-field.svg 主图矢量版
运行
----
python tools/ffu_simulation.py
"""
import os
import numpy as np
from numba import njit
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle, Circle, FancyArrow
from matplotlib.colors import LinearSegmentedColormap
from matplotlib.collections import LineCollection
# --------------------------------------------------------------------------
# 全局样式
# --------------------------------------------------------------------------
BG = "#0d1117"
PANEL = "#0d1117"
FG = "#e6edf3"
MUTED = "#8b949e"
ACCENT = "#58a6ff"
WARM = "#ff7b72"
COOL = "#39d0d8"
GREEN = "#7ee787"
plt.rcParams.update({
"font.sans-serif": ["Microsoft YaHei", "SimHei", "DejaVu Sans"],
"axes.unicode_minus": False,
"svg.fonttype": "path",
"figure.facecolor": BG,
"savefig.facecolor": BG,
"axes.facecolor": BG,
"text.color": FG,
"axes.labelcolor": FG,
"xtick.color": MUTED,
"ytick.color": MUTED,
"axes.edgecolor": "#30363d",
})
# --------------------------------------------------------------------------
# 物理 / 数值参数
# --------------------------------------------------------------------------
W_M, H_M = 4.0, 3.0 # 计算域尺寸 [m]
PPM = 90 # 像素/米
NX, NY = int(W_M * PPM), int(H_M * PPM) # 360 x 270
U0_LB = 0.08 # FFU 送风速度 [lattice]
U_PHYS = 0.45 # FFU 送风速度 [m/s]
SCALE_U = U_PHYS / U0_LB # lattice -> m/s
NU_LB = 0.04 # 运动粘度 [lattice]
TAU = 0.5 + 3.0 * NU_LB
RE = U0_LB * NY / NU_LB
T_AMB, T_SRC = 22.0, 35.0 # 环境温度 / 设备表面温度 [degC]
RH_AMB, RH_SRC = 45.0, 70.0 # 送风湿度 / 呼吸区湿度 [%]
RHO_AIR = 1.2 # 空气密度 [kg/m3]
SCALE_P = RHO_AIR * SCALE_U ** 2 # lattice -> Pa
DT_SCALAR = 4 # 标量输运时间步 (每 4 个 LBM 步输运一次)
ADV_EVERY = 4
KAPPA_T = 0.0020 # 温度扩散系数 [lattice]
KAPPA_RH = 0.0020
KAPPA_C = 0.0015
V_SETTLE = 0.004 # 粒子沉降速度 [lattice] (~2 cm/s)
N_STEPS = 14000 # LBM 总步数
SCALAR_FROM = 6000 # 从该步开始输运标量
TRACK_FROM = 9000 # 从该步开始记录粒子轨迹
# D2Q9
EX = np.array([0, 1, 0, -1, 0, 1, -1, -1, 1], dtype=np.int32)
EY = np.array([0, 0, 1, 0, -1, 1, 1, -1, -1], dtype=np.int32)
WT = np.array([4/9, 1/9, 1/9, 1/9, 1/9, 1/36, 1/36, 1/36, 1/36])
OPP = np.array([0, 3, 4, 1, 2, 7, 8, 5, 6], dtype=np.int32)
# --------------------------------------------------------------------------
# 几何: 单位 m
# --------------------------------------------------------------------------
BENCH = (0.60, 0.00, 1.80, 0.90) # 工作台 (x0,y0,x1,y1)
EQUIP = (0.85, 0.90, 1.50, 1.25) # 设备 (热源 + 尘源)
BODY = (2.35, 0.00, 2.75, 1.45) # 人员躯干
HEAD_C = (2.55, 1.62, 0.15) # 人员头部 (圆心, 半径)
RACK = (3.00, 0.00, 3.50, 1.15) # 物料架
GRILL_L = (0.00, 0.50) # 左侧回风口 x 范围
GRILL_R = (3.50, 4.00) # 右侧回风口 x 范围
def build_geometry():
"""构建固体掩膜 + 源项掩膜"""
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]
head = (xx - cx * PPM) ** 2 + (yy - cy * PPM) ** 2 <= (rr * PPM) ** 2
solid |= head
# 回风口 (底部, 非固体 => 压力出口)
outlet = np.zeros_like(solid)
iy = 0
for g0, g1 in (GRILL_L, GRILL_R):
ix0, ix1 = int(g0 * PPM), int(g1 * PPM)
outlet[iy, max(ix0, 2):min(ix1, NX - 2)] = True
solid[outlet] = False
inlet = np.zeros_like(solid)
inlet[NY - 1, 2:NX - 2] = True
# 设备热/尘源: 设备外扩 3 格的流体单元
eq = np.zeros_like(solid)
rect(*EQUIP, eq)
shell = np.zeros_like(solid)
for dy in range(-3, 4):
for dx in range(-3, 4):
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
# --------------------------------------------------------------------------
# LBM 求解器 (numba)
# --------------------------------------------------------------------------
@njit(cache=True, fastmath=True)
def lbm_collide(f, rho, ux, uy, solid, ex, ey, w, tau):
ny, nx = rho.shape
for iy in range(ny):
for ix in range(nx):
if solid[iy, ix]:
continue
r = 0.0; mx = 0.0; my = 0.0
for k in range(9):
fk = f[k, iy, ix]
r += fk
mx += ex[k] * fk
my += ey[k] * fk
u = mx / r
v = my / r
rho[iy, ix] = r
ux[iy, ix] = u
uy[iy, ix] = v
uu = u * u + v * v
for k in range(9):
eu = ex[k] * u + ey[k] * v
feq = w[k] * r * (1.0 + 3.0 * eu + 4.5 * eu * eu - 1.5 * uu)
f[k, iy, ix] -= (f[k, iy, ix] - feq) / tau
@njit(cache=True, fastmath=True)
def lbm_bounce_back(f, solid, opp):
ny, nx = solid.shape
for iy in range(ny):
for ix in range(nx):
if solid[iy, ix]:
for k in range(9):
ko = opp[k]
if k < ko:
tmp = f[k, iy, ix]
f[k, iy, ix] = f[ko, iy, ix]
f[ko, iy, ix] = tmp
@njit(cache=True, fastmath=True)
def lbm_stream(f, ftmp, ex, ey):
ny, nx = f.shape[1], f.shape[2]
for k in range(9):
dx = ex[k]; dy = ey[k]
for iy in range(ny):
sy = iy - dy
for ix in range(nx):
sx = ix - dx
if 0 <= sy < ny and 0 <= sx < nx:
ftmp[k, iy, ix] = f[k, sy, sx]
else:
ftmp[k, iy, ix] = f[k, iy, ix]
@njit(cache=True, fastmath=True)
def lbm_bcs(f, ux, uy, solid, inlet, outlet, ex, ey, w, u0):
ny, nx = solid.shape
for ix in range(nx):
if inlet[ny - 1, ix]:
u = 0.0; v = -u0; r = 1.0
uu = u * u + v * v
for k in range(9):
eu = ex[k] * u + ey[k] * v
f[k, ny - 1, ix] = w[k] * r * (1.0 + 3.0 * eu + 4.5 * eu * eu - 1.5 * uu)
if outlet[0, ix]:
u = ux[1, ix]; v = uy[1, ix]; r = 1.0
uu = u * u + v * v
for k in range(9):
eu = ex[k] * u + ey[k] * v
f[k, 0, ix] = w[k] * r * (1.0 + 3.0 * eu + 4.5 * eu * eu - 1.5 * uu)
# --------------------------------------------------------------------------
# 标量输运 (半拉格朗日 + 扩散)
# --------------------------------------------------------------------------
@njit(cache=True, fastmath=True)
def advect_scalars(F, ux, uy, solid, kappa, dt, vs):
nc, ny, nx = F.shape
out = np.empty_like(F)
for c in range(nc):
k = kappa[c]
s = vs[c]
for iy in range(ny):
for ix in range(nx):
fc = F[c, iy, ix]
if solid[iy, ix]:
out[c, iy, ix] = 0.0
continue
xd = ix - ux[iy, ix] * dt
yd = iy - (uy[iy, ix] + s) * dt
if xd < 0.0: xd = 0.0
if xd > nx - 1.001: xd = nx - 1.001
if yd < 0.0: yd = 0.0
if yd > ny - 1.001: yd = ny - 1.001
x0 = int(xd); y0 = int(yd)
if x0 > nx - 2: x0 = nx - 2
if y0 > ny - 2: y0 = ny - 2
fx = xd - x0; fy = yd - y0
v00 = F[c, y0, x0]; v01 = F[c, y0, x0 + 1]
v10 = F[c, y0 + 1, x0]; v11 = F[c, y0 + 1, x0 + 1]
if solid[y0, x0]: v00 = fc
if solid[y0, x0 + 1]: v01 = fc
if solid[y0 + 1, x0]: v10 = fc
if solid[y0 + 1, x0 + 1]: v11 = fc
val = (v00 * (1 - fx) * (1 - fy) + v01 * fx * (1 - fy) +
v10 * (1 - fx) * fy + v11 * fx * fy)
l = fc; r = fc; d = fc; u = fc
if ix > 0 and not solid[iy, ix - 1]: l = F[c, iy, ix - 1]
if ix < nx - 1 and not solid[iy, ix + 1]: r = F[c, iy, ix + 1]
if iy > 0 and not solid[iy - 1, ix]: d = F[c, iy - 1, ix]
if iy < ny - 1 and not solid[iy + 1, ix]: u = F[c, iy + 1, ix]
out[c, iy, ix] = val + k * dt * (l + r + d + u - 4.0 * fc)
return out
# --------------------------------------------------------------------------
# 粒子轨迹 (拉格朗日)
# --------------------------------------------------------------------------
@njit(cache=True, fastmath=True)
def sample_uv(ux, uy, x, y):
ny, nx = ux.shape
if x < 0: x = 0.0
if x > nx - 1.001: x = nx - 1.001
if y < 0: y = 0.0
if y > ny - 1.001: y = ny - 1.001
x0 = int(x); y0 = int(y)
if x0 > nx - 2: x0 = nx - 2
if y0 > ny - 2: y0 = ny - 2
fx = x - x0; fy = y - y0
u = (ux[y0, x0] * (1 - fx) * (1 - fy) + ux[y0, x0 + 1] * fx * (1 - fy) +
ux[y0 + 1, x0] * (1 - fx) * fy + ux[y0 + 1, x0 + 1] * fx * fy)
v = (uy[y0, x0] * (1 - fx) * (1 - fy) + uy[y0, x0 + 1] * fx * (1 - fy) +
uy[y0 + 1, x0] * (1 - fx) * fy + uy[y0 + 1, x0 + 1] * fx * fy)
return u, v
@njit(cache=True, fastmath=True)
def step_particles(px, py, alive, age, ux, uy, solid, vs):
n = px.shape[0]
ny, nx = solid.shape
for i in range(n):
if not alive[i]:
continue
x, y = px[i], py[i]
u1, v1 = sample_uv(ux, uy, x, y)
xm, ym = x + 0.5 * u1, y + 0.5 * (v1 - vs)
u2, v2 = sample_uv(ux, uy, xm, ym)
xn, yn = x + u2, y + (v2 - vs)
ix, iy = int(xn), int(yn)
if 0 <= ix < nx and 0 <= iy < ny and not solid[iy, ix]:
px[i] = xn; py[i] = yn
else:
ix, iy = int(xn), int(y)
if 0 <= ix < nx and 0 <= iy < ny and not solid[iy, ix]:
px[i] = xn
else:
ix, iy = int(x), int(yn)
if 0 <= ix < nx and 0 <= iy < ny and not solid[iy, ix]:
py[i] = yn
age[i] += 1.0
if py[i] < 1.5 or px[i] < 2.0 or px[i] > nx - 3.0 or age[i] > 2500:
alive[i] = False
# --------------------------------------------------------------------------
# 主循环
# --------------------------------------------------------------------------
def main():
out_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"docs", "assets")
os.makedirs(out_dir, exist_ok=True)
print(f"[FFU] grid {NX}x{NY} Re={RE:.0f} tau={TAU:.3f} steps={N_STEPS}")
solid, inlet, outlet, src_equip, src_head = build_geometry()
f = np.zeros((9, NY, NX))
ftmp = np.zeros_like(f)
rho = np.ones((NY, NX))
ux = np.zeros((NY, NX))
uy = np.zeros((NY, NX))
uy[:] = -U0_LB
for k in range(9):
eu = EX[k] * 0.0 + EY[k] * (-U0_LB)
f[k] = WT[k] * (1.0 + 3.0 * eu + 4.5 * eu * eu - 1.5 * U0_LB ** 2)
# 标量: [T, RH, C]
F = np.zeros((3, NY, NX))
kappa = np.array([KAPPA_T, KAPPA_RH, KAPPA_C])
vs = np.array([0.0, 0.0, V_SETTLE])
# 粒子
N_PART = 2400
px = np.zeros(N_PART); py = np.zeros(N_PART)
alive = np.zeros(N_PART, dtype=np.bool_)
age = np.zeros(N_PART)
gen = np.zeros(N_PART)
gen_counter = 0
next_p = 0
max_pts = 400000
traj = np.zeros((max_pts, 4))
n_pts = 0
src_px = np.concatenate([np.where(src_equip)[1], np.where(src_head)[1]])
src_py = np.concatenate([np.where(src_equip)[0], np.where(src_head)[0]])
rng = np.random.default_rng(7)
for step in range(N_STEPS):
lbm_collide(f, rho, ux, uy, solid, EX, EY, WT, TAU)
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 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 - 1, :] = 0.0
F[:, solid] = 0.0
if step >= TRACK_FROM:
if step % 40 == 0:
for _ in range(18):
i = next_p % N_PART
next_p += 1
gen_counter += 1
j = rng.integers(0, src_px.size)
px[i] = src_px[j] + rng.uniform(-1, 1)
py[i] = src_py[j] + rng.uniform(-1, 1)
alive[i] = True
age[i] = 0.0
gen[i] = gen_counter
step_particles(px, py, alive, age, ux, uy, solid, V_SETTLE)
if step % 4 == 0:
idx = np.where(alive)[0]
m = min(idx.size, max_pts - n_pts)
if m > 0:
sel = idx[:m]
traj[n_pts:n_pts + m, 0] = px[sel]
traj[n_pts:n_pts + m, 1] = py[sel]
traj[n_pts:n_pts + m, 2] = age[sel]
traj[n_pts:n_pts + m, 3] = gen[sel]
n_pts += m
if step % 2000 == 0:
umax = np.sqrt(ux ** 2 + uy ** 2).max() * SCALE_U
print(f" step {step:6d} |U|max={umax:6.3f} m/s rho=[{rho.min():.4f},{rho.max():.4f}]")
print(f"[FFU] simulation done, trajectory points: {n_pts}")
traj = traj[:n_pts]
np.savez_compressed(os.path.join(out_dir, "ffu_fields.npz"),
ux=ux, uy=uy, T=F[0], RH=F[1], C=F[2], rho=rho, traj=traj)
make_figures(out_dir, ux, uy, F, rho, solid, src_equip, src_head, traj)
print("[FFU] figures written to", out_dir)
def make_figures(out_dir, ux, uy, F, rho, solid, src_equip, src_head, traj):
speed = np.sqrt(ux ** 2 + uy ** 2) * SCALE_U
speed[solid] = np.nan
T_phys = T_AMB + F[0] * (T_SRC - T_AMB)
RH_phys = RH_AMB + F[1] * (RH_SRC - RH_AMB)
C_norm = F[2].copy()
p_pa = (rho - 1.0) / 3.0 * SCALE_P
for _ in range(3):
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 arr in (T_phys, RH_phys, C_norm, p_pa):
arr[solid] = np.nan
plot_hero(out_dir, ux, uy, speed, solid)
plot_scalars(out_dir, T_phys, RH_phys, C_norm, p_pa, solid, src_equip, src_head)
plot_tracks(out_dir, speed, traj, solid)
def replot():
"""仅重绘图形, 不重跑仿真"""
out_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"docs", "assets")
d = np.load(os.path.join(out_dir, "ffu_fields.npz"))
solid, inlet, outlet, src_equip, src_head = build_geometry()
F = np.stack([d["T"], d["RH"], d["C"]])
make_figures(out_dir, d["ux"], d["uy"], F, d["rho"], solid,
src_equip, src_head, d["traj"])
print("[FFU] replotted from ffu_fields.npz")
# --------------------------------------------------------------------------
# 绘图
# --------------------------------------------------------------------------
CMAP_FLOW = LinearSegmentedColormap.from_list(
"flow", ["#0a1626", "#123c63", "#1f6feb", "#39d0d8", "#7ee787", "#f0f6fc"])
def draw_room(ax, solid):
"""绘制房间轮廓与障碍物"""
for (x0, y0, x1, y1) in (BENCH, EQUIP, BODY, RACK):
ax.add_patch(Rectangle((x0, y0), x1 - x0, y1 - y0,
facecolor="#2d333b", edgecolor="#adbac7",
linewidth=1.1, zorder=6))
cx, cy, rr = HEAD_C
ax.add_patch(Circle((cx, cy), rr, facecolor="#2d333b",
edgecolor="#adbac7", linewidth=1.1, zorder=6))
# FFU 顶棚
ax.add_patch(Rectangle((0.06, H_M - 0.06), W_M - 0.12, 0.045,
facecolor="#1f6feb", edgecolor="#58a6ff",
linewidth=0.8, alpha=0.85, zorder=6))
# 回风口
for g0, g1 in (GRILL_L, GRILL_R):
ax.add_patch(Rectangle((g0 + 0.02, 0.0), g1 - g0 - 0.04, 0.035,
facecolor="#7ee787", edgecolor="#7ee787",
alpha=0.65, zorder=6))
def draw_sources(ax):
ax.plot(1.18, 1.25, marker="*", ms=13, color="#ff7b72", zorder=7)
ax.plot(2.75, 1.62, marker="o", ms=8, color="#39d0d8", zorder=7)
ax.plot(1.18, 1.35, marker="^", ms=7, color="#f0e68c", zorder=7)
def decorate_hero(ax):
"""FFU 送风箭头 + 回风箭头 + 标注"""
for x in np.linspace(0.45, W_M - 0.45, 9):
ax.annotate("", xy=(x, H_M - 0.30), xytext=(x, H_M - 0.075),
arrowprops=dict(arrowstyle="-|>", color="#58a6ff",
lw=1.4, alpha=0.9), zorder=7)
ax.text(W_M / 2, H_M - 0.145, "FFU 送风口 0.45 m/s(满布率 100%)",
ha="center", va="bottom", fontsize=11.5, color="#58a6ff", zorder=8)
for g0, g1 in (GRILL_L, GRILL_R):
xm = (g0 + g1) / 2
ax.annotate("", xy=(xm, -0.02), xytext=(xm, 0.16),
arrowprops=dict(arrowstyle="-|>", color="#7ee787", lw=1.6),
annotation_clip=False, zorder=7)
ax.text(xm, -0.09, "回风口", ha="center", va="top",
fontsize=10, color="#7ee787", clip_on=False, zorder=8)
ax.annotate("工作台", xy=(1.20, 0.92), xytext=(1.55, 1.62),
fontsize=10.5, color="#e6edf3", zorder=8,
arrowprops=dict(arrowstyle="-", color="#8b949e", lw=0.8))
ax.annotate("设备(热源+尘源)", xy=(1.18, 1.25), xytext=(0.42, 2.02),
fontsize=10.5, color="#ff7b72", zorder=8,
arrowprops=dict(arrowstyle="-", color="#8b949e", lw=0.8))
ax.annotate("操作人员(湿源+尘源)", xy=(2.58, 1.66), xytext=(2.62, 2.30),
fontsize=10.5, color="#39d0d8", zorder=8,
arrowprops=dict(arrowstyle="-", color="#8b949e", lw=0.8))
ax.annotate("物料架", xy=(3.25, 1.18), xytext=(3.10, 1.72),
fontsize=10.5, color="#e6edf3", zorder=8,
arrowprops=dict(arrowstyle="-", color="#8b949e", lw=0.8))
def plot_hero(out_dir, ux, uy, speed, 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, 150)
ys = np.linspace(0.08, H_M - 0.10, 110)
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.5, color=(0.79, 0.82, 0.85, 0.5),
linewidth=0.55, 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 单向流洁净区流场仿真(D2Q9 LBM,Re≈540,网格 360×270)",
fontsize=15, pad=14)
ax.text(0.5, 1.012, "送风 0.45 m/s · 顶棚满布 · 底部两侧回风 · 流线为气流轨迹",
transform=ax.transAxes, ha="center", va="bottom",
fontsize=10.5, color=MUTED)
fig.tight_layout()
fig.savefig(os.path.join(out_dir, "ffu-flow-field.png"), dpi=155)
fig.savefig(os.path.join(out_dir, "ffu-flow-field.svg"))
plt.close(fig)
print(" -> ffu-flow-field.png / .svg")
def _panel(ax, field, solid, cmap, label, vmin=None, vmax=None, center=None):
if center is not None:
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)
return im
def plot_scalars(out_dir, T, RH, C, p, solid, src_equip, src_head):
fig, axes = plt.subplots(2, 2, figsize=(15.5, 10.6))
ax = axes[0, 0]
_panel(ax, T, solid, "inferno", "温度 T (°C)", vmin=T_AMB, vmax=T_SRC)
ax.set_title("(a) 温度场 —— 设备热羽被单向流下扫", fontsize=12.5, color=FG, pad=8)
ax = axes[0, 1]
_panel(ax, RH, solid, "GnBu", "相对湿度 RH (%)", vmin=RH_AMB, vmax=RH_SRC)
ax.set_title("(b) 湿度场 —— 呼吸区湿气随气流下送", fontsize=12.5, color=FG, pad=8)
ax = axes[1, 0]
_panel(ax, C, solid, "magma", "归一化粒子浓度 C/C₀", vmin=0, vmax=0.6)
ax.set_title("(c) 尘埃粒子浓度 —— 尘源散发与输运", fontsize=12.5, color=FG, pad=8)
ax = axes[1, 1]
_panel(ax, p, solid, "RdBu_r", "静压偏差 Δp (Pa)", center=0)
ax.set_title("(d) 压力场 —— FFU 送风正压与回风负压", fontsize=12.5, color=FG, pad=8)
fig.suptitle("FFU 对下方温湿度、压力与尘埃粒子浓度的影响(稳态,D2Q9 LBM + 标量输运)",
fontsize=15, y=0.985)
fig.text(0.5, 0.012,
"★ 热/尘源(设备) ● 湿/尘源(人员呼吸区) ▲ 尘源(设备上方) "
"送风 0.45 m/s,设备表面 35 °C,呼吸区 RH 70%",
ha="center", fontsize=10, color=MUTED)
fig.tight_layout(rect=[0, 0.03, 1, 0.965])
fig.savefig(os.path.join(out_dir, "ffu-scalar-fields.png"), dpi=150)
plt.close(fig)
print(" -> ffu-scalar-fields.png")
def plot_tracks(out_dir, speed, traj, solid):
fig, ax = plt.subplots(figsize=(15.0, 8.9))
ax.imshow(speed, origin="lower", extent=[0, W_M, 0, H_M],
cmap=CMAP_FLOW, vmin=0, vmax=0.75, interpolation="bilinear", alpha=0.9)
if traj.shape[0] > 1:
x = traj[:, 0] / PPM
y = traj[:, 1] / PPM
pid = traj[:, 3]
age = traj[:, 2] / 60.0
m = pid[1:] == pid[:-1]
segs = np.stack([np.column_stack([x[:-1][m], y[:-1][m]]),
np.column_stack([x[1:][m], y[1:][m]])], axis=1)
lc = LineCollection(segs, cmap="cool", linewidths=0.7, alpha=0.6)
lc.set_array(age[1:][m])
ax.add_collection(lc)
cb = plt.colorbar(lc, ax=ax, fraction=0.032, pad=0.015)
cb.set_label("粒子在洁净区内的滞留时间 (s)", fontsize=10.5)
cb.outline.set_edgecolor("#30363d")
draw_room(ax, solid)
draw_sources(ax)
decorate_hero(ax)
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("尘埃粒子拉格朗日轨迹 —— 输运路径与滞留时间", fontsize=15, pad=14)
ax.text(0.5, 1.012, "粒子释放自设备与人员源 · 含重力沉降 · 颜色越暖表示滞留越久(易沉积/死角)",
transform=ax.transAxes, ha="center", va="bottom", fontsize=10.5, color=MUTED)
fig.tight_layout()
fig.savefig(os.path.join(out_dir, "ffu-particle-tracks.png"), dpi=150)
plt.close(fig)
print(" -> ffu-particle-tracks.png")
if __name__ == "__main__":
import sys
if "--replot" in sys.argv:
replot()
else:
main()
+343
View File
@@ -0,0 +1,343 @@
#!/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()
+256
View File
@@ -0,0 +1,256 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
规划图生成器: 系统架构图 / 数据流水线图 / 里程碑路线图
输出 docs/assets/*.svg (矢量源文件), 再由 tools/svg2png.js 转 PNG。
运行:
python tools/gen_diagrams.py
"""
import os
OUT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"docs", "assets")
os.makedirs(OUT, exist_ok=True)
BG = "#0d1117"
PANEL = "#161b22"
PANEL2 = "#0d1117"
STROKE = "#30363d"
FG = "#e6edf3"
MUTED = "#8b949e"
FONT = "Microsoft YaHei, Segoe UI, sans-serif"
C_PINK = "#f778ba"
C_PURPLE = "#bc8cff"
C_YELLOW = "#d29922"
C_BLUE = "#58a6ff"
C_GREEN = "#3fb950"
C_RED = "#ff7b72"
C_CYAN = "#39d0d8"
def esc(s):
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def text(x, y, s, size=14, fill=FG, weight="normal", anchor="start", op=1.0):
return (f'<text x="{x}" y="{y}" font-family="{FONT}" font-size="{size}" '
f'fill="{fill}" font-weight="{weight}" text-anchor="{anchor}" '
f'opacity="{op}">{esc(s)}</text>\n')
def box(x, y, w, h, fill=PANEL, stroke=STROKE, rx=10, sw=1.2, dash=None, op=1.0):
d = f' stroke-dasharray="{dash}"' if dash else ""
return (f'<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{rx}" '
f'fill="{fill}" stroke="{stroke}" stroke-width="{sw}"{d} opacity="{op}"/>\n')
def arrow(x1, y1, x2, y2, color=C_BLUE, sw=2.0, dash=None, marker="arr"):
d = f' stroke-dasharray="{dash}"' if dash else ""
return (f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{color}" '
f'stroke-width="{sw}"{d} marker-end="url(#{marker})"/>\n')
def svg_open(w, h, extra_defs=""):
return (f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" '
f'viewBox="0 0 {w} {h}">\n'
f'<defs>\n'
f'<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" '
f'markerHeight="7" orient="auto-start-reverse">'
f'<path d="M 0 0 L 10 5 L 0 10 z" fill="{C_BLUE}"/></marker>\n'
f'<marker id="arrG" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" '
f'markerHeight="7" orient="auto-start-reverse">'
f'<path d="M 0 0 L 10 5 L 0 10 z" fill="{C_GREEN}"/></marker>\n'
f'<marker id="arrY" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" '
f'markerHeight="7" orient="auto-start-reverse">'
f'<path d="M 0 0 L 10 5 L 0 10 z" fill="{C_YELLOW}"/></marker>\n'
f'<marker id="arrM" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" '
f'markerHeight="7" orient="auto-start-reverse">'
f'<path d="M 0 0 L 10 5 L 0 10 z" fill="{MUTED}"/></marker>\n'
f'{extra_defs}</defs>\n'
f'<rect width="{w}" height="{h}" fill="{BG}"/>\n')
# ==========================================================================
# 1. 系统架构图
# ==========================================================================
def architecture():
W, H = 1600, 1150
s = svg_open(W, H)
s += text(50, 56, "PhysicsAiFluentAnalysis · 系统架构", 28, FG, "bold")
s += text(50, 88, "六层架构:从高保真真值到实时交互应用,物理约束贯穿模型层", 14.5, MUTED)
layers = [
(C_PINK, "L5 应用层", "Application",
[("数字孪生", "传感器同化 · 在线重建"), ("工程设计优化", "实时参数扫描 · 设计空间探索"),
("教学与科研", "流场可视化实验"), ("实时决策", "风场 / 流场秒级预测")]),
(C_PURPLE, "L4 交互与渲染", "Interaction & Rendering",
[("WebGPU 前端", "three.js / VTK.js"), ("参数实时调节", "拖拽 Re / 攻角 / 几何"),
("场可视化", "体渲染 · 流线 · 矢量场"), ("场景管理", "剖切面 · 时间轴 · 导出")]),
(C_YELLOW, "L3 推理服务层", "Inference Service",
[("推理引擎", "TensorRT / ONNX Runtime"), ("时间推进", "自回归 + 潜空间动力学"),
("后处理校正", "散度修正 · 守恒律"), ("服务接口", "gRPC + WebSocket 流式")]),
(C_BLUE, "L2 AI 模型层", "AI Models",
[("FNO 傅里叶神经算子", "主力基线 · 分辨率无关"), ("DeepONet", "算子学习备选路线"),
("PINN 物理约束", "NS 残差 · 少数据工况"), ("MeshGraphNet", "非结构网格 · 复杂几何")]),
(C_GREEN, "L1 数据与训练", "Data & Training",
[("PyTorch 分布式训练", "AMP · DDP / FSDP"), ("主动学习", "不确定性驱动采样"),
("数据湖", "HDF5 / VTK / Zarr"), ("数据工程", "归一化 · 增广 · 插值统一")]),
(C_RED, "L0 高保真真值", "Ground Truth CFD",
[("Ansys Fluent", "RANS / URANS / LES"), ("PyFluent 自动化", "参数化建模与求解"),
("参数化几何", "翼型族 · 车体 · 管道"), ("边界条件库", "工况模板 · 湍流模型")]),
]
y0, bh, gap = 118, 148, 20
pitch = bh + gap
bx, bw = 230, 317
for i, (color, name, en, mods) in enumerate(layers):
y = y0 + i * pitch
s += box(40, y, 1520, bh, PANEL, STROKE, 12)
s += f'<rect x="40" y="{y}" width="6" height="{bh}" rx="3" fill="{color}"/>\n'
s += text(64, y + 66, name, 17.5, color, "bold")
s += text(64, y + 92, en, 11, MUTED)
for j, (t, sub) in enumerate(mods):
x = bx + j * (bw + 14)
s += box(x, y + 22, bw, 104, PANEL2, STROKE, 10)
s += f'<rect x="{x}" y="{y+22}" width="{bw}" height="4" rx="2" fill="{color}" opacity="0.75"/>\n'
s += text(x + 16, y + 62, t, 15, FG, "bold")
s += text(x + 16, y + 90, sub, 11.5, MUTED)
if i < len(layers) - 1:
ym = y + bh + gap / 2
s += arrow(880, ym + 7, 880, ym - 7, C_BLUE, 1.8)
s += arrow(760, ym - 7, 760, ym + 7, C_GREEN, 1.8, marker="arrG")
s += text(40, 1128, "数据流 ↑(真值 → 训练 → 推理 → 应用) "
"控制流 ↓(应用需求 → 模型选型 → 数据采集) "
"物理约束:连续性 / 动量守恒 · 不确定性量化 · 守恒校正",
12.5, MUTED)
s += "</svg>\n"
open(os.path.join(OUT, "architecture.svg"), "w", encoding="utf-8").write(s)
print(" -> architecture.svg")
# ==========================================================================
# 2. 数据与训练流水线
# ==========================================================================
def pipeline():
W, H = 1600, 760
s = svg_open(W, H)
s += text(50, 56, "数据与训练流水线", 28, FG, "bold")
s += text(50, 88, "从参数采样到实时仿真服务:全链路自动化 + 主动学习数据飞轮", 14.5, MUTED)
stages = [
(C_CYAN, "① 参数空间采样", ["拉丁超立方采样", "Re / 攻角 / 几何参数"]),
(C_RED, "② Fluent 批量仿真", ["PyFluent 无人值守", "稳态 + 瞬态工况"]),
(C_GREEN, "③ 数据工程", ["网格统一 · 归一化", "质检 / 增广 / 划分"]),
(C_BLUE, "④ 神经算子训练", ["FNO / DeepONet / GNN", "物理约束损失"]),
(C_PURPLE, "⑤ 推理引擎部署", ["ONNX → TensorRT", "FP16 / INT8 量化"]),
(C_PINK, "⑥ 实时仿真服务", ["gRPC + WebSocket", "流场流式推送"]),
]
arts = ["工况表 + 元数据", "CAS / DAT / VTK", "HDF5 数据集", "模型权重 .pt", "TensorRT .engine", "实时流场帧"]
bw, gap = 224, 24
x0, y = 68, 150
bh = 150
for i, (color, title, lines) in enumerate(stages):
x = x0 + i * (bw + gap)
s += box(x, y, bw, bh, PANEL, color, 12, sw=1.6)
s += f'<rect x="{x}" y="{y}" width="{bw}" height="5" rx="2.5" fill="{color}"/>\n'
s += text(x + 16, y + 44, title, 16, color, "bold")
for k, ln in enumerate(lines):
s += text(x + 16, y + 76 + k * 24, ln, 12, MUTED)
if i < len(stages) - 1:
s += arrow(x + bw + 2, y + bh / 2, x + bw + gap - 3, y + bh / 2, C_BLUE, 2.0)
s += box(x + 8, y + bh + 16, bw - 16, 42, PANEL2, STROKE, 8, dash="4 3")
s += text(x + bw / 2, y + bh + 43, arts[i], 11.5, MUTED, anchor="middle")
ly = y + bh + 96
s += arrow(x0 + 5 * (bw + gap) + bw / 2, ly - 12, x0 + bw / 2, ly - 12,
C_YELLOW, 1.8, dash="7 5", marker="arrY")
s += text(W / 2, ly + 16, "主动学习闭环:不确定性评估 → 高价值工况回灌仿真 → 再训练(数据飞轮)",
13, C_YELLOW, anchor="middle")
gy = 600
gates = [("精度门禁", "相对 L2 < 5%"), ("稳定性门禁", "1000 步不发散"),
("成本门禁", "单工况预算达标"), ("可复现门禁", "流水线一键复现")]
gw = 340
for i, (t, sub) in enumerate(gates):
x = 68 + i * (gw + 20)
s += box(x, gy, gw, 66, PANEL, STROKE, 10)
s += f'<circle cx="{x+26}" cy="{gy+33}" r="7" fill="{C_GREEN}" opacity="0.85"/>\n'
s += text(x + 46, gy + 30, t, 14.5, FG, "bold")
s += text(x + 46, gy + 52, sub, 11.5, MUTED)
s += text(50, 726, "每个阶段设置 Go / No-Go 决策点:未通过门禁不允许进入下一阶段",
12.5, MUTED)
s += "</svg>\n"
open(os.path.join(OUT, "pipeline.svg"), "w", encoding="utf-8").write(s)
print(" -> pipeline.svg")
# ==========================================================================
# 3. 里程碑路线图
# ==========================================================================
def roadmap():
W, H = 1600, 940
s = svg_open(W, H)
s += text(50, 56, "里程碑路线图(12 个月 · 7 个里程碑)", 28, FG, "bold")
s += text(50, 88, "先窄后宽:2D 基准打通全链路 → 3D 复杂几何 → 实时系统 → 数字孪生", 14.5, MUTED)
x0, x1 = 300, 1560
mw = (x1 - x0) / 12
ty = 140
for m in range(13):
x = x0 + m * mw
s += f'<line x1="{x}" y1="{ty-24}" x2="{x}" y2="840" stroke="#21262d" stroke-width="1"/>\n'
for m in range(12):
s += text(x0 + (m + 0.5) * mw, ty - 6, f"{m+1}", 12.5, MUTED, anchor="middle")
s += text(x0 - 16, ty - 6, "月份", 12.5, MUTED, anchor="end")
miles = [
(C_RED, "M0 立项与调研", 1, 1, "规划文档 · 选型评审 · 基准定义"),
(C_CYAN, "M1 数据工厂", 2, 3, "PyFluent 自动化 · ≥500 工况数据集"),
(C_BLUE, "M2 基线模型", 4, 6, "FNO 基线 · 相对 L2 < 5% · 1000 步稳定"),
(C_PURPLE, "M3 数据飞轮", 6, 7, "主动学习 · 同等精度省 40% 仿真量"),
(C_GREEN, "M4 3D 与复杂几何", 7, 9, "翼型 / 车体代理 · MeshGraphNet"),
(C_YELLOW, "M5 实时推理系统", 9, 10, "TensorRT · 端到端 < 100 ms"),
(C_PINK, "M6 可视化与数字孪生", 10, 12, "WebGPU 交互 · 稀疏观测同化"),
]
ry, rh, pitch = 170, 58, 96
for i, (color, name, m0, m1, deliv) in enumerate(miles):
y = ry + i * pitch
s += text(40, y + 30, name, 16, FG, "bold")
bx = x0 + (m0 - 1) * mw + 3
bwid = (m1 - m0 + 1) * mw - 6
s += box(bx, y, bwid, rh, color, color, 10, sw=0, op=0.28)
s += f'<rect x="{bx}" y="{y}" width="{bwid}" height="{rh}" rx="10" fill="none" stroke="{color}" stroke-width="1.6"/>\n'
span = f"第 {m0} 月" if m0 == m1 else f"{m0}-{m1} 月"
s += text(bx + 16, y + 35, span, 13, color, "bold")
est_w = sum(12.5 if ord(c) > 127 else 7 for c in deliv)
if bx + bwid + 40 + est_w > 1580: # 右侧放不下 -> 放左边
dx = bx - 14
s += text(dx - 16, y + rh / 2 + 5, deliv, 12.5, MUTED, anchor="end")
s += f'<path d="M {dx} {y+rh/2-7} L {dx-7} {y+rh/2} L {dx} {y+rh/2+7} L {dx+7} {y+rh/2} z" fill="{color}"/>\n'
else:
dx = bx + bwid + 14
s += f'<path d="M {dx} {y+rh/2-7} L {dx+7} {y+rh/2} L {dx} {y+rh/2+7} L {dx-7} {y+rh/2} z" fill="{color}"/>\n'
s += text(dx + 18, y + rh / 2 + 5, deliv, 12.5, MUTED)
s += box(40, 856, 1520, 60, PANEL, STROKE, 10)
s += text(60, 884, "决策点", 14, C_YELLOW, "bold")
s += text(130, 884, "M2 精度不达标 → DeepONet / MeshGraphNet 双路线并行 "
"M4 3D 不达标 → 退守「2D + 切片 3D」保证 M5/M6 交付 "
"每里程碑预留 20% 时间缓冲", 12.5, MUTED)
s += text(60, 906, "成功标准", 14, C_GREEN, "bold")
s += text(130, 906, "P1(3 个月)可行性验证 · P2(6 个月)系统闭环 · P3(12 个月)数字孪生原型",
12.5, MUTED)
s += "</svg>\n"
open(os.path.join(OUT, "roadmap.svg"), "w", encoding="utf-8").write(s)
print(" -> roadmap.svg")
if __name__ == "__main__":
architecture()
pipeline()
roadmap()
print("done:", OUT)
+39
View File
@@ -0,0 +1,39 @@
/**
* SVG -> PNG 批量转换 (resvg, 支持中文系统字体)
*
* npm install # 首次安装依赖
* node tools/svg2png.js # 转换 docs/assets 下全部 svg
* node tools/svg2png.js foo.svg # 只转换指定文件
*/
const fs = require("fs");
const path = require("path");
let Resvg;
try {
({ Resvg } = require("@resvg/resvg-js"));
} catch (e) {
console.error("缺少依赖 @resvg/resvg-js,请先执行: npm install");
process.exit(1);
}
const dir = path.join(__dirname, "..", "docs", "assets");
const args = process.argv.slice(2);
const files = args.length
? args
: fs.readdirSync(dir).filter((f) => f.toLowerCase().endsWith(".svg"));
let ok = 0;
for (const f of files) {
const src = path.join(dir, f);
const dst = src.replace(/\.svg$/i, ".png");
const svg = fs.readFileSync(src, "utf8");
const r = new Resvg(svg, {
fitTo: { mode: "zoom", value: 2 },
font: { loadSystemFonts: true },
background: "#0d1117",
});
fs.writeFileSync(dst, r.render().asPng());
console.log(` ${f} -> ${path.basename(dst)}`);
ok++;
}
console.log(`converted ${ok} file(s)`);