
简介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 写的原始实现是 FortranpyTEP 在它外面套了一层接口。这就决定了安装这件事有两种走法官方如果发布了对应平台的预编译包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, dtypefloat) # 兜底转置不同版本返回的可能是 (步数, 变量) 或 (变量, 步数) if arr.shape[0] 52 and arr.shape[1] ! 52: arr arr.T cols [fxmeas_{i} for i in range(1, 42)] [fxmv_{i} for i in range(1, 12)] df pd.DataFrame(arr[:, :52], columnscols) print(df.shape, df.isna().sum().sum()) df.to_parquet(tep_baseline.parquet, indexFalse)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(), dtypefloat) # 测试集48 h故障在第 8 h 注入 tep.set_duration(48.0) # 960 步 tep.set_fault_time(8.0) test np.asarray(tep.run(), dtypefloat)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_ratio0.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(), dtypefloat) out Path(data) / fidv{fault_id:02d}_r{repeat_id:02d}.npy out.parent.mkdir(parentsTrue, exist_okTrue) 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_workers6) 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, dtypefloat) mu, sd X.mean(0), X.std(0) 1e-8 # 防止常数列除零 Z (X - mu) / sd # 逐列标准化量纲差异必须先抹平 pca PCA(n_components0.90).fit(Z) # 保留 90% 方差主元数由数据决定 print(主元个数:, pca.n_components_) def t2_spe(X_raw, pca, mu, sd): Z (np.asarray(X_raw, dtypefloat) - 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_components0.90让主元个数由方差贡献自动决定比手工指定更不容易过拟合pca.explained_variance_必须和中心化后的得分配合使用少了这一步 T² 的量级会差很多。mu和sd一定要从正常训练集算出来再套到故障集上如果在故障集上重新标准化等于把异常信息洗掉了。统计量监控对象典型阈值常见误判原因T²主元子空间幅值偏离正常集 99 分位主元数取太多把噪声当信号SPE残差子空间能量正常集 99 分位常数列未做除零保护值被放大两个指标都要看。有些阶跃型故障比如 IDV(4)在 SPE 上几乎不反应因为变量的相关性结构没被破坏只是整体偏移全靠 T² 抓反过来慢漂移类故障在 T² 上迟钝SPE 先动。谁先动、动多少这两条曲线本身就是判断数据质量的诊断信息。最后说个习惯。早些年我图快生成完数据直接进模型结果调了两周超参才发现是某次仿真的控制配置没生效那一批数据全是发散的模型学到的全是噪声——白干。从那以后我每换一个环境、每换一次随机种子都会强制先跑一遍这套体检脚本先看 T² 和 SPE 在正常数据上的误报率是不是接近 1%再看几个容易检测的故障编号能不能被分开确认这两关过了才动模型。希望帮到你。本文还有配套的精品资源点击获取