简介:pyTEP 是面向过程控制、化工仿真与算法验证的开源 Python 接口,用于田纳西伊士曼(Tennessee Eastman)流程的交互式模拟。它将复杂仿真场景的搭建、操作条件调整和仿真数据存储封装为较直观的 API,使用者无需深入了解模拟器底层,即可完成交互式实验,适合控制算法测试、教学演示与流程仿真入门。压缩包共 76 个文件,约 754KB,包含 23 个 .m、16 个 .py、8 个 .pkl、7 个 .mdl、3 个 .mat 以及 rst、md、txt 等文档与配置,覆盖 Python 接口、MATLAB 桥接、模型数据、测试用例和说明材料,docs、tests、simulator、utils 等目录便于按模块检索。已有 546 人学习下载。借助完整源码、示例配置与测试材料,读者可快速理解 pyTEP 的 API 组织与仿真设置方式,复用数据记录流程,并在此基础上扩展自己的 TEP 实验或控制算法验证;文档与脚本中还保留 TEP 二进制重编译、Python 环境准备等说明,对具备 Python 基础的学习者较为友好。
1. pyTEP 是什么:把 Tennessee Eastman 流程包成 Python 对象的那层壳
第一次碰过程监控方向的人,多半会在 Tennessee Eastman 流程上先摔一跤:Fortran 老代码要自己配编译环境,变量表和故障编号对不上,跑出来的曲线看着像噪声又不敢确定是不是自己设错了。pyTEP 就是冲这件事来的,它是一个 Python 侧的开源仿真 API,把 TEP 这套化工基准流程包成可调用对象,复杂的场景搭建被压成几行配置,同时保留了交互式逐步推进的选项,方便你在每一步检查状态、改参数、插故障再继续。它适合三类人:做故障诊断与过程监控算法的、需要在统一基准上横向对比方法的、以及想把 TEP 直接接进 Python 训练流水线的从业者。这套东西的价值不在于算法多新,而在于它把“基准”这两个字落地了,你跑出来的数据和别人论文里的数据具备可比性。
2. 装起来并能跑通:从虚拟环境到第一条 52 列时间序列
这一章只干一件事,把包装进一个干净环境,然后拿到一条能看懂维度、能存成文件的时间序列。别急着上故障、上批量,先确认单次运行是稳的,后面所有工作都建立在这个前提上。
2.1 环境隔离与安装路径的选择
TEP 这类仿真器通常不是纯 Python 写的,原始实现是 Fortran,pyTEP 在它外面套了一层接口。这就决定了安装这件事有两种走法:官方如果发布了对应平台的预编译包,pip install一把过;如果落到源码编译,就需要本机有 C 或 Fortran 的编译工具链。我一般会先建独立虚拟环境,避免和机器上已有的 numpy、scikit-learn 版本打架,尤其是后面要拿生成的数据去训练模型,numpy 的大版本差异会让随机数和精度表现不一致。
# 独立环境,别偷懒装进 base python -m venv .venv-tep source .venv-tep/bin/activate # Windows: .venv-tep\Scripts\activate python -m pip install -U pip setuptools wheel # 第一步:优先吃预编译轮子 pip install pyTEP # 第二步:装不上再强制源码编译,看日志里缺什么 pip install --no-binary :all: pyTEP -v--no-binary :all:的含义是不接受任何预编译产物,强制从源码构建;-v打开详细日志,编译失败时可以直接看到是缺gfortran、缺meson,还是缺某个头文件。Windows 上这一步最容易翻车,血泪经验是先装好对应版本的构建工具再回头重试,不要在报错里反复试错。装完之后立刻确认模块路径,能import但路径指向了别的目录,说明你装到了另一个环境里。
提示:环境建好后先把
pip freeze存一份到requirements-lock.txt,后面数据生成、模型训练都按这份锁定,换机器复现时才不会出现“同样的代码结果差一个量级”的玄学问题。
2.2 先探接口,再抄骨架
不同版本的 pyTEP 在类名和参数名上可能有出入,直接抄网上的例子经常踩空。稳妥的做法是先让包自己把家底亮出来,看清楚构造入口、运行方法、故障设置函数的真实名字,再往下写业务代码。
import pyTEP # 1) 确认装的是哪个位置 print("模块路径:", pyTEP.__file__) # 2) 列出包级公开接口,类名/函数名一眼看全 public_names = [n for n in dir(pyTEP) if not n.startswith("_")] print("公开接口:", public_names) # 3) 包级 docstring 里通常有最小可运行示例 help(pyTEP)dir()给出的第一层名字只到包级别,真正的仿真对象往往在下一层,用help(pyTEP.TEP)之类的方式继续下钻。这一步看着笨,但它能把“版本差异”这个最大的不确定因素提前消化掉,比写到一半报AttributeError再回头查要快得多。
2.3 跑通一次基线仿真并落成 DataFrame
拿到接口之后,先跑无故障基线。基线数据的用途很实在:它既是后面所有故障数据的参照系,也是你判断仿真器有没有正常运行的第一道关。TEP 的原始变量是 41 个测量量 XMEAS 加 12 个操纵量 XMV,一共 53 个;公开数据集里普遍把搅拌速度 XMV(12) 剔掉,因为它在绝大多数场景下保持不变,于是剩下的就是常见的 52 列结构。
import numpy as np import pandas as pd import pyTEP tep = pyTEP.TEP() # 构造仿真对象,构造参数按 help() 校准 tep.set_fault(0) # 0 表示无故障基线 tep.set_sampling(0.05) # 采样步长,单位小时:0.05 h = 3 min tep.set_duration(24.0) # 仿真时长,单位小时 raw = tep.run() # 跑一次,返回时间序列 arr = np.asarray(raw, dtype=float) # 兜底转置:不同版本返回的可能是 (步数, 变量) 或 (变量, 步数) if arr.shape[0] == 52 and arr.shape[1] != 52: arr = arr.T cols = [f"xmeas_{i}" for i in range(1, 42)] + [f"xmv_{i}" for i in range(1, 12)] df = pd.DataFrame(arr[:, :52], columns=cols) print(df.shape, df.isna().sum().sum()) df.to_parquet("tep_baseline.parquet", index=False)set_sampling(0.05)是这个流程的常规节拍,对应 3 分钟一个采样点,24 小时跑下来是 480 步;set_duration的单位跟采样保持一致,都用小时,这个一致性要盯住,混用分钟和小时是最常见的错。代码里的转置兜底不是洁癖,而是真遇到过返回方向不一致的情况,shape检查比肉眼数行数靠谱。最后的isna().sum()是一道廉价的保险,正常运行不会有缺失值,一旦出现非零,说明仿真中途发散了,后面的数据全部作废。
注意:基线的
shape一定要打出来看。480 步对应 24 小时、960 步对应 48 小时,如果步数对不上,先查采样步长的单位,别急着怀疑包有问题。
3. 故障怎么注:IDV 1-21 的编号、注入时刻与采样步长
能跑通基线只说明环境没问题,真正决定数据能不能用建模的,是故障怎么注、注在什么时刻、注多久。TEP 的故障集是固定的 21 个编号,编号本身是行业公共约定,你抄别人论文里的结果,前提就是编号含义一致。
3.1 21 个 IDV 的构成与可检测难度
TEP 的故障设计有一套内在逻辑:一部分是阶跃型,变量在某一时刻突然跳变然后稳定在新水平;一部分是随机型,围绕均值做随机波动,统计特征变化但瞬时值不突出;还有一部分是慢漂移和阀门粘滞,变化率极低,靠单点阈值几乎抓不到。这个分类直接决定了你后面用什么检测方法,也决定了哪些故障注定做不出好看的指标。
| 编号 | 类型 | 作用对象 | 可检测难度 |
|---|---|---|---|
| IDV(1) | 阶跃 | A/C 进料比,B 恒定 | 易 |
| IDV(2) | 阶跃 | B 进料比,A/C 恒定 | 易 |
| IDV(3) | 阶跃 | D 进料温度 | 中 |
| IDV(4) | 阶跃 | 反应器冷却水入口温度 | 易 |
| IDV(5) | 阶跃 | 冷凝器冷却水入口温度 | 易 |
| IDV(6) | 阶跃 | A 进料损失 | 易 |
| IDV(7) | 阶跃 | C 进料损失,伴随压力恢复 | 中 |
| IDV(8) | 随机 | A、B、C 进料组成 | 中 |
| IDV(9) | 随机 | D 进料温度 | 难 |
| IDV(10) | 随机 | C 进料温度 | 难 |
| IDV(11) | 随机 | 反应器冷却水入口温度 | 中 |
| IDV(12) | 随机 | 冷凝器冷却水入口温度 | 中 |
| IDV(13) | 慢漂移 | 反应动力学 | 难 |
| IDV(14) | 粘滞 | 反应器冷却水阀 | 难 |
| IDV(15) | 粘滞 | 冷凝器冷却水阀 | 难 |
| IDV(16) 至 IDV(20) | 未知 | 未公开的内部扰动 | 难 |
| IDV(21) | 固定位置 | 阀门卡在稳态位置 | 难 |
这张表里 IDV(9)、IDV(15)、IDV(21) 是公认的硬骨头,文献里检出率长期偏低。如果你的模型在这几个编号上表现极差,先别怀疑代码,去对照一下公开基准,确认是不是方法本身的天花板。反过来,如果 IDV(4)、IDV(11) 这种也检不出来,那基本可以确定是数据管道出了问题。
3.2 注入时刻与训练/测试集的常见约定
故障不是从第 0 步就开始注的,前面必须留一段正常工况做基线,否则模型没有参照。业界比较通行的做法是三段式:训练集每个故障跑 25 小时、共 500 个采样点,故障在运行 1 小时后注入;测试集跑 48 小时、共 960 个采样点,故障在运行 8 小时后注入。这个安排的用意是训练集让模型尽早见到故障形态,测试集则留出更长的正常段,用来统计误报率和检测延迟。
# 训练集:25 h,故障在第 1 h 注入 tep = pyTEP.TEP() tep.set_fault(4) # 反应器冷却水入口温度阶跃 tep.set_sampling(0.05) # 3 min tep.set_duration(25.0) # 500 步 tep.set_fault_time(1.0) # 故障注入时刻,单位小时 train = np.asarray(tep.run(), dtype=float) # 测试集:48 h,故障在第 8 h 注入 tep.set_duration(48.0) # 960 步 tep.set_fault_time(8.0) test = np.asarray(tep.run(), dtype=float)set_fault_time这类函数名按你装到的版本对齐,语义是“从第几个小时开始注入”。这里最容易犯的错是训练集和测试集用了同一个注入时刻,这样两边的故障段长度占比不同,模型学到的边界和你评估的边界不是一回事,指标会虚高。我一般会在生成脚本里把(size, fault_time)显式写成配置表,避免手滑。
提示:注入时刻一定要写进数据集的元信息里。半年后回头看一个 parquet 文件,只有 52 列数字,没有故障编号和注入时刻,那这份数据就只能扔了。
3.3 控制模式的取舍:开环、基础控制与闭环
TEP 内置了多种控制配置,粗略可以分成开环、基础控制和更复杂的闭环控制几档。开环模式下操纵变量完全由外部输入决定,仿真更容易发散,但它对控制算法的激励最充分;基础控制模式把若干回路闭合,过程更稳定,也更接近真实装置的运行状态,是绝大多数监控类研究用的配置。选哪一档,取决于你要验证的是控制律还是监控算法,这两件事对数据的要求正好相反。
我的习惯是先把基础控制跑通,确认所有操纵量都在合理区间里波动,再考虑要不要切到开环。切换控制模式之后,同一组故障编号的响应形态会变,之前调好的检测阈值全部要重来。这也是为什么数据集必须带上控制配置的说明,否则别人拿你的数据复现不出结论。
4. 批量造数据集:训练/测试划分、随机种子与多进程落盘
单次仿真只能验证流程,真正喂给模型的是几十上百次运行堆出来的数据集。这一章解决三个问题:怎么划分才不泄漏、怎么跑得快、怎么存才不后悔。
4.1 按仿真轮次划分,而不是按行切
数据泄漏在这类时序仿真数据上极其隐蔽。常见的错误做法是把每个故障的 960 步打乱之后按 8:2 切分,train 和 test 里都混着同一次仿真的相邻片段,模型等于在做插值,指标能到 99%,换一批数据立刻塌。正确做法是按仿真轮次划分:一次run()产出的整条序列,要么全进训练集,要么全进测试集,绝不拆开。
import numpy as np rng = np.random.default_rng(20240501) def split_runs(runs, test_ratio=0.3): """runs: list[np.ndarray],每个元素是一次完整仿真的结果""" idx = rng.permutation(len(runs)) n_test = max(1, int(len(runs) * test_ratio)) test_idx = set(idx[:n_test].tolist()) train = [runs[i] for i in range(len(runs)) if i not in test_idx] test = [runs[i] for i in range(len(runs)) if i in test_idx] return train, testdefault_rng(seed)里的种子要固定并且记录下来,随机型故障(IDV 8 到 12、16 到 20)本身带随机扰动,两次运行结果天然不同,种子不固定的话,你连自己都复现不了自己的数据集。test_ratio我通常按轮次取 0.3,样本量太少时按固定条数切更稳,别让int()截断把测试集吃成 0。
4.2 多进程生成与断点续跑
21 个故障、每个故障重复若干次,串行跑起来耗时相当可观。多进程能压时间,但必须在进程内独立构造仿真对象,不能共享同一个实例,否则状态互相污染,跑出来的曲线会很诡异。
from concurrent.futures import ProcessPoolExecutor from pathlib import Path import numpy as np def run_one(job): """job = (fault_id, repeat_id, seed)""" fault_id, repeat_id, seed = job import pyTEP tep = pyTEP.TEP() tep.set_fault(fault_id) tep.set_sampling(0.05) tep.set_duration(48.0) tep.set_fault_time(8.0) tep.set_seed(seed) # 随机型故障必须固定种子 arr = np.asarray(tep.run(), dtype=float) out = Path("data") / f"idv{fault_id:02d}_r{repeat_id:02d}.npy" out.parent.mkdir(parents=True, exist_ok=True) if out.exists(): # 断点续跑:已存在就跳过 return str(out) np.save(out, arr) return str(out) if __name__ == "__main__": # 多进程必须守住入口 jobs = [(f, r, 1000 + r) for f in range(0, 22) for r in range(10)] with ProcessPoolExecutor(max_workers=6) as ex: for p in ex.map(run_one, jobs): print("done:", p)三个要点。set_seed的真实接口名按版本核对,作用是固定随机型故障的扰动序列。if out.exists(): return是断点续跑的关键,跑长时间任务时机器崩一次不至于全部重来。max_workers别开满,每个进程都吃一份仿真器内存,开 6 到 8 个通常就够,再往上收益递减还可能因为内存压力被杀进程。
注意:
if __name__ == "__main__":在这一段里不是可选项。Windows 上的 spawn 启动方式会因为缺这个守卫导致进程无限递归创建,机器直接卡死。
4.3 存储格式的取舍
文件格式的选择在数据量小的时候无所谓,到几十 GB 就有明显差别。我的取舍标准写在下面这张表里,按你的下游用法选,不要一刀切。
| 格式 | 读写速度 | 是否保留列名 | 适合场景 | 主要缺点 |
|---|---|---|---|---|
| CSV | 慢 | 是 | 交付给别人、需要肉眼检查 | 体积大、浮点精度有损 |
| Parquet | 快 | 是 | 建模流水线、长期存档 | 需要 pyarrow 依赖 |
| npz/npy | 最快 | 否 | 纯数值训练、追求加载速度 | 列信息要单独存一份 |
我自己的习惯是双份:.npy供训练脚本快速加载,同时导出一份meta.csv记录文件名、故障编号、注入时刻、种子、控制模式。这份元信息文件看着不起眼,等你要复现某次实验结论的时候,它就是唯一的后悔药。
5. 避坑与排查:五类让仿真结果不可信的真实故障
数据和代码都能跑,不代表结果可用。下面这五条是我在实际拆这类仿真器时反复遇到的,每条都按现象、原因、解决三段写,遇到对应的症状直接对号入座。
5.1 仿真层的三类异常
现象:跑几十步之后数值突然爆炸,变量量级到 1e8 甚至出现 NaN。原因多半是控制回路没闭合。开环模式下某些积分型环节会持续累积,尤其在没有基础控制兜底的时候,反应器温度和压力会互相推着走高。解决方式是先确认控制配置项是否真的生效——不是设了参数就等于开了控制,有些版本还需要额外一次初始化调用。判断方法很简单:把操纵量的时间序列画出来,正常闭环下它们会在中位值附近小幅波动,开环发散时是一条单调上扬的直线。
现象:同样的设置跑两次,结果差异大到模型学不了。这是随机种子的问题。IDV 8 到 12 和 IDV 16 到 20 这类随机型故障自带扰动项,包内如果没有用固定种子初始化,每次运行的扰动序列都不一样。解决办法是在构造仿真对象之后、运行之前显式设置种子,并且把种子写进文件名和元信息。别指望在 numpy 层面设全局种子就能覆盖,仿真器内部的随机数发生器往往是独立的一套。
现象:换一台机器之后,同一份脚本出来的数据量级差一个数量级。原因通常在两处:编译精度或 numpy 版本。源码编译时单精度和双精度配置不同,浮点累积误差在几千步之后会被放大。解决方式是锁定环境,把pip freeze的结果作为依赖清单,并在数据生成脚本开头把 numpy 版本、仿真器版本、机器架构一起写进日志。这不是强迫症,是复现的底线。
5.2 数据与标签层的两个陷阱
现象:模型在自建测试集上准确率 99%,换成公开基准立刻掉到 60%。原因几乎可以确定是数据泄漏,训练集和测试集共享了同一次仿真的片段,或者两边用了不同的注入时刻导致故障段占比不一致。解决办法是严格按仿真轮次切分,并且把训练集和测试集的(时长, 注入时刻)写成同一张配置表,谁改了都要在元信息里留痕。另外,公开基准通常剔除了 XMV(12),你自己生成的数据如果保留了 53 列,喂给同一个模型会因为维度不匹配直接报错,或者更糟——静默错位。
现象:IDV(9)、IDV(15)、IDV(21) 怎么调都检不出来,怀疑数据有问题。这三个编号在文献里的检出率本来就不高,属于方法层面的天花板,不是数据 bug。验证方法很直接:拿 IDV(4) 或 IDV(11) 当对照组,如果对照组检出率正常、只有这几个编号差,那就是难度问题;如果对照组也差,才回去查数据。评估指标上,别只看准确率,故障检测这个场景里误报率和平均检测延迟更能反映真实水平,一个从不报警的模型准确率也很高。
提示:把每个故障编号的检出率和检测延迟做成一张表存档。三个月后你要判断新方法是真好还是数据换了,这张表是唯一可靠的对照。
6. 数据体检:用 PCA 的 T² 与 SPE 在建模前先验一遍
在把数据丢进任何复杂模型之前,我固定会跑一遍 PCA 体检。理由很朴素:如果连线性子空间方法都分不出来的故障,多半是数据本身有问题;如果 PCA 能分出大部分,说明数据管道是通的,后面上深度模型才有意义。这套体检用的是两个经典统计量:T² 衡量样本在主元子空间里的偏离程度,SPE 衡量残差子空间的能量,两者互补,一个抓幅值异常,一个抓相关性结构破坏。
做法是先只用正常工况数据拟合,算出一组基线阈值,然后拿故障数据去比较检出率。阈值取正常数据上的 99 分位数,这个分位数不是拍脑袋,而是对应 1% 的误报率,方便和文献里的数字直接对齐。
import numpy as np from sklearn.decomposition import PCA # X_normal: 仅正常工况,形状 (n_samples, 52) X = np.asarray(X_normal, dtype=float) mu, sd = X.mean(0), X.std(0) + 1e-8 # 防止常数列除零 Z = (X - mu) / sd # 逐列标准化,量纲差异必须先抹平 pca = PCA(n_components=0.90).fit(Z) # 保留 90% 方差,主元数由数据决定 print("主元个数:", pca.n_components_) def t2_spe(X_raw, pca, mu, sd): Z = (np.asarray(X_raw, dtype=float) - mu) / sd score = pca.transform(Z) t2 = ((score ** 2) / pca.explained_variance_).sum(1) rec = pca.inverse_transform(score) spe = ((Z - rec) ** 2).sum(1) return t2, spe t2_base, spe_base = t2_spe(X_normal, pca, mu, sd) t2_lim = np.percentile(t2_base, 99) # 1% 误报率对应的阈值 spe_lim = np.percentile(spe_base, 99) t2_f, spe_f = t2_spe(X_fault, pca, mu, sd) # X_fault: 待检的故障数据 detect = (t2_f > t2_lim) | (spe_f > spe_lim) print(f"检出率: {detect.mean():.3f}")n_components=0.90让主元个数由方差贡献自动决定,比手工指定更不容易过拟合;pca.explained_variance_必须和中心化后的得分配合使用,少了这一步 T² 的量级会差很多。mu和sd一定要从正常训练集算出来再套到故障集上,如果在故障集上重新标准化,等于把异常信息洗掉了。
| 统计量 | 监控对象 | 典型阈值 | 常见误判原因 |
|---|---|---|---|
| T² | 主元子空间幅值偏离 | 正常集 99 分位 | 主元数取太多,把噪声当信号 |
| SPE | 残差子空间能量 | 正常集 99 分位 | 常数列未做除零保护,值被放大 |
两个指标都要看。有些阶跃型故障(比如 IDV(4))在 SPE 上几乎不反应,因为变量的相关性结构没被破坏,只是整体偏移,全靠 T² 抓;反过来慢漂移类故障在 T² 上迟钝,SPE 先动。谁先动、动多少,这两条曲线本身就是判断数据质量的诊断信息。
最后说个习惯。早些年我图快,生成完数据直接进模型,结果调了两周超参才发现是某次仿真的控制配置没生效,那一批数据全是发散的,模型学到的全是噪声——白干。从那以后我每换一个环境、每换一次随机种子,都会强制先跑一遍这套体检脚本,先看 T² 和 SPE 在正常数据上的误报率是不是接近 1%,再看几个容易检测的故障编号能不能被分开,确认这两关过了才动模型。希望帮到你。
本文还有配套的精品资源,点击获取