初始化项目:规划文档 + 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:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
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)
|
||||
@@ -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)`);
|
||||
Reference in New Issue
Block a user