649 lines
26 KiB
Python
649 lines
26 KiB
Python
|
|
#!/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()
|