简介:这份开源代码包面向供水管网水质建模与控制方向的研究者与工程技术人员,围绕论文《模型预测控制在实时水质调节中有多有效?状态空间建模和可扩展控制》实现了一套完整方案。它针对复杂供水网络(含交汇处、水库、水箱、管道、泵与阀门)中消毒剂水平难以实时调节的问题,给出状态空间建模表示与可扩展的模型预测控制算法,明确刻画了增压站氯剂量输入与全网氯浓度状态/输出之间的关系,并具备较快响应速度与一定抗不确定性能力。资源包共542个文件,约12.14MB,以130个m脚本、110个inp管网算例、75个dat数据文件为主,另含msx、net、rpt等EPANET相关文件及pdf、xlsx、shp等文档与地理数据,便于复现实验与扩展测试。代码基于EPANET-Matlab-Toolkit 2.1.8.1构建,主入口为main.m,配置集中在Constants4Concentrat相关文件中。目前已有853人学习,适合希望掌握水质状态空间建模与MPC控制实现细节的读者参考。
1. 水质预测建模的 MATLAB 实战:从数据到控制策略的完整链路
如果你手头有一份水质监测数据,想预测未来几天的溶解氧、氨氮或总磷变化,并且希望把预测结果直接用于曝气控制或加药决策,那这套 MATLAB 代码值得花时间跑一遍。它不是一个只画几条曲线的演示脚本,而是一套覆盖数据预处理、模型训练、预测输出到控制器设计的完整工程代码。适合环境工程、自动化方向的学生做课题复现,也适合水务运维人员拿真实监测数据做二次开发。我拿到源码后第一件事是看主脚本能不能直接跑通,结果发现作者把数据接口和模型参数都做了模块化封装,改起来比想象中顺手。下面按我实际拆解的路径,从项目定位、目录结构、核心代码到二次开发,逐层讲清楚。
2. 项目定位与技术选型:为什么用 MATLAB 做水质建模
2.1 这套代码到底解决什么问题
水质建模与控制的核心任务可以拆成三块:第一块是用历史监测数据建立预测模型,第二块是用模型输出驱动控制策略,第三块是把整个流程封装成可重复运行的脚本。这套代码的定位很明确——它不是通用机器学习框架,而是针对水质参数时序预测这个具体场景做的工程实现。
具体来说,代码里处理的对象是水质监测站常见的指标:溶解氧(DO)、化学需氧量(COD)、氨氮(NH3-N)、总磷(TP)、总氮(TN)、pH 和浊度。这些指标之间不是孤立的,比如溶解氧受温度、藻类活动、有机物降解多重影响,所以代码里用了多变量输入而不是单变量预测。预测模型部分,代码提供了两种可选方案:一种是基于 ARIMA 的统计模型,适合数据平稳、样本量不大的场景;另一种是基于 BP 神经网络的非线性模型,适合捕捉复杂耦合关系。控制部分则用了一个简化的 PID 控制器,把预测的溶解氧浓度作为反馈量,输出曝气机的功率调节信号。
这种“预测+控制”的闭环结构,在污水处理厂曝气优化、地表水预警、养殖水体管理里都能直接套用。代码没有依赖任何工具箱之外的付费模块,基础 MATLAB 加统计与机器学习工具箱就能跑。
2.2 技术栈与依赖清单
我把代码里实际用到的函数和工具箱列了一下,方便你对照自己的 MATLAB 版本检查:
| 模块 | 用到的函数/工具箱 | 是否必须 |
|---|---|---|
| 数据读取 | readtable, readmatrix | 基础 MATLAB |
| 缺失值处理 | fillmissing, isnan | 基础 MATLAB |
| 数据平滑 | smoothdata, movmean | 基础 MATLAB |
| ARIMA 建模 | arima, estimate, forecast | 计量经济学工具箱 |
| BP 神经网络 | feedforwardnet, train, sim | 深度学习工具箱 |
| PID 控制 | pid, pidtune | 控制系统工具箱 |
| 绘图 | plot, yyaxis, legend | 基础 MATLAB |
如果你的 MATLAB 没有计量经济学工具箱,ARIMA 部分可以用 BP 网络替代,代码里做了条件判断。反过来,如果只有计量经济学工具箱没有深度学习工具箱,那就走 ARIMA 分支。两个工具箱都没有的情况比较少见,但代码里也留了一个基于移动平均的兜底预测函数,精度差一些但能跑通。
注意:MATLAB 版本建议 R2020b 以上,因为
smoothdata和fillmissing的某些参数在旧版本里行为不一致。我一开始在 R2018a 上跑,fillmissing的'movmedian'方法直接报错,换成'linear'才通过。
2.3 目录结构:拿到源码先看什么
解压后的目录结构大致如下(我按实际文件整理):
Water-Quality-Modeling-and-Control/ ├── main.m % 主入口脚本 ├── config/ │ └── params.m % 全局参数配置 ├── data/ │ ├── raw/ % 原始监测数据 CSV │ └── processed/ % 预处理后的 mat 文件 ├── src/ │ ├── preprocess.m % 数据清洗与归一化 │ ├── train_arima.m % ARIMA 模型训练 │ ├── train_bp.m % BP 网络训练 │ ├── predict.m % 统一预测接口 │ └── controller.m % PID 控制策略 ├── results/ │ ├── figures/ % 输出图表 │ └── metrics.mat % 评估指标 └── README.md这个结构的好处是入口清晰:main.m只负责串联流程,具体逻辑都在src/下。改模型不用动主脚本,改参数不用翻代码,直接编辑config/params.m就行。我一般拿到新项目会先看config和main,这两个文件能告诉你项目怎么跑、有哪些可调项。
3. 核心模块拆解:数据预处理与模型训练的代码实现
3.1 数据预处理:缺失值、异常值与归一化
水质监测数据最头疼的就是缺失和跳变。传感器故障、通讯中断、校准漂移都会导致数据不可用。代码里的preprocess.m做了三件事:缺失值填充、异常值剔除、归一化。我逐段拆一下:
function data_clean = preprocess(data_raw, params) % 输入:data_raw 为 table 类型,每列一个水质指标 % 输出:data_clean 为归一化后的矩阵 % 1. 缺失值填充:用线性插值,最大间隔不超过 6 小时 data_filled = fillmissing(data_raw, 'linear', ... 'EndValues', 'nearest', 'MaxGap', 6); % 2. 异常值检测:基于 3 倍标准差,超出范围用中位数替换 for i = 1:width(data_filled) col = data_filled{:, i}; mu = mean(col, 'omitnan'); sigma = std(col, 'omitnan'); outlier_idx = abs(col - mu) > 3 * sigma; col(outlier_idx) = median(col, 'omitnan'); data_filled{:, i} = col; end % 3. 归一化:min-max 归一化到 [0,1] data_norm = normalize(data_filled, 'range'); % 4. 保存归一化参数,预测时反归一化要用 data_clean.norm_params = struct(... 'min', min(data_filled), 'max', max(data_filled)); data_clean.data = data_norm; end这段代码的逻辑说明:fillmissing的MaxGap参数控制最大填充间隔,超过 6 小时的空缺不填,避免用太远的数据硬插。异常值检测用 3σ 原则,对水质数据来说这个阈值偏宽松,如果你数据质量差可以改成 2σ。归一化用 min-max 而不是 z-score,是因为后续 BP 网络的输出层用了 sigmoid 激活函数,输入范围在 [0,1] 更匹配。
参数怎么改:MaxGap在params.m里对应params.preprocess.max_gap,默认 6。如果你数据是小时粒度,改成 3 更稳妥。异常值倍数params.preprocess.outlier_sigma默认 3,数据抖动大就降到 2.5。
提示:归一化参数一定要保存,预测新数据时用同一套 min/max 做变换,否则模型输出会偏得离谱。我见过有人训练时归一化、预测时忘了反归一化,结果溶解氧预测值全是 0.2 左右,查了半天才发现是量纲问题。
3.2 ARIMA 模型训练:定阶与预测
ARIMA 部分在train_arima.m里,核心是自动定阶和滚动预测。代码没有用arima的默认定阶,而是用 AIC 准则在给定范围内搜索最优 p、d、q:
function model = train_arima(series, params) % series: 单变量时序,这里以溶解氧为例 % params: 包含 p_max, d_max, q_max 的搜索范围 best_aic = inf; best_model = []; % 网格搜索 p,d,q 组合 for p = 0:params.arima.p_max for d = 0:params.arima.d_max for q = 0:params.arima.q_max try mdl = arima(p, d, q); mdl = estimate(mdl, series, 'Display', 'off'); aic = summarize(mdl).AIC; if aic < best_aic best_aic = aic; best_model = mdl; end catch continue; % 某些组合不收敛,跳过 end end end end model = best_model; fprintf('最优 ARIMA 阶数: (%d,%d,%d), AIC=%.2f\n', ... model.P, model.D, model.Q, best_aic); end逻辑说明:三重循环遍历 p、d、q,每个组合用estimate拟合,取 AIC 最小的作为最终模型。try-catch是必须的,因为某些阶数组合会导致参数不收敛或矩阵奇异,不捕获的话整个脚本会中断。搜索范围默认 p_max=3、d_max=1、q_max=3,一共 4×2×4=32 种组合,跑一遍大概十几秒,可以接受。
预测部分用forecast函数做多步预测:
% 预测未来 24 步 [y_pred, y_mse] = forecast(model, 24, 'Y0', series); % 反归一化 y_pred_real = y_pred * (max_val - min_val) + min_val;Y0参数是历史观测值,必须传进去,否则预测从零开始。y_mse是预测方差,可以用来画置信区间。如果你要做实时滚动预测,每次新数据进来后重新调用forecast就行,不用重新训练模型。
3.3 BP 神经网络:结构设计与训练参数
BP 网络部分在train_bp.m,用的是feedforwardnet而不是patternnet,因为这是回归任务不是分类。网络结构是 7-12-1:输入层 7 个节点对应 7 个水质指标,隐藏层 12 个节点,输出层 1 个节点对应待预测指标。
function net = train_bp(X, y, params) % X: 输入矩阵,每行一个样本,每列一个特征 % y: 目标向量 % 1. 划分训练集、验证集、测试集 net = feedforwardnet(params.bp.hidden_size); net.divideParam.trainRatio = 0.7; net.divideParam.valRatio = 0.15; net.divideParam.testRatio = 0.15; % 2. 设置训练参数 net.trainParam.epochs = params.bp.epochs; % 默认 1000 net.trainParam.lr = params.bp.lr; % 默认 0.01 net.trainParam.goal = params.bp.goal; % 默认 1e-5 net.trainParam.max_fail = params.bp.max_fail; % 默认 6 % 3. 训练 [net, tr] = train(net, X', y'); % 4. 输出训练信息 fprintf('训练轮数: %d, 最佳验证性能: %.6f\n', ... tr.num_epochs, tr.best_vperf); end参数说明:hidden_size默认 12,如果你数据样本超过 5000 条可以加到 20,但要注意过拟合。epochs默认 1000,配合max_fail=6做早停,验证集连续 6 轮不下降就停止。lr学习率默认 0.01,数据归一化后这个值比较稳,如果损失震荡就降到 0.005。
注意:
train函数每次运行结果不一样,因为权重随机初始化。要做可复现的实验,在训练前加rng(42)固定随机种子。我一开始没加,跑了三次得到三个不同的 RMSE,差点以为代码有 bug。
3.4 统一预测接口与评估指标
predict.m把 ARIMA 和 BP 的输出统一成相同格式,方便切换和对比:
function [y_pred, metrics] = predict(model, X_test, y_test, params) % 根据 model.type 选择预测分支 switch model.type case 'arima' y_pred = forecast(model.mdl, length(y_test), 'Y0', X_test); case 'bp' y_pred = sim(model.net, X_test')'; case 'ma' % 移动平均兜底 y_pred = movmean(X_test, params.ma.window); end % 反归一化 y_pred = y_pred * (params.norm.max - params.norm.min) + params.norm.min; y_test = y_test * (params.norm.max - params.norm.min) + params.norm.min; % 计算评估指标 metrics.rmse = sqrt(mean((y_pred - y_test).^2)); metrics.mae = mean(abs(y_pred - y_test)); metrics.mape = mean(abs((y_pred - y_test) ./ y_test)) * 100; metrics.r2 = 1 - sum((y_test - y_pred).^2) / ... sum((y_test - mean(y_test)).^2); end评估指标里 MAPE 要注意分母不能为零,水质数据里某些指标(如总磷)可能接近零,算出来 MAPE 会爆表。代码里没做保护,实际用的时候建议加一个阈值判断,或者用 MAE 替代。R² 在测试集上如果出现负值,说明模型预测还不如直接取均值,这时候要回头检查数据划分或模型结构。
4. 控制策略与仿真:从预测值到执行器信号
4.1 PID 控制器设计
控制部分在controller.m,场景是曝气池溶解氧控制。预测模型给出未来一段时间的 DO 浓度,PID 控制器根据设定值与预测值的偏差计算曝气机功率:
function [u, ctrl_state] = controller(do_pred, do_set, ctrl_state, params) % do_pred: 预测的溶解氧浓度序列 % do_set: 设定值,默认 2.0 mg/L % ctrl_state: 包含积分项和上次误差的结构体 % 1. 计算误差 error = do_set - do_pred(1); % 2. PID 三项 P = params.pid.kp * error; I = ctrl_state.integral + params.pid.ki * error * params.pid.dt; D = params.pid.kd * (error - ctrl_state.last_error) / params.pid.dt; % 3. 输出限幅 [0, 1] 对应曝气机 0-100% 功率 u_raw = P + I + D; u = max(0, min(1, u_raw)); % 4. 抗积分饱和:输出饱和时停止积分累加 if u_raw == u ctrl_state.integral = I; end ctrl_state.last_error = error; end参数整定:kp、ki、kd默认值在params.m里是 0.8、0.05、0.1。这套参数是我用pidtune对一个简化的一阶惯性加纯滞后模型整定出来的,实际曝气系统滞后更大,ki可以再小一点。dt是控制周期,默认 0.5 小时,对应水质监测的常见采样间隔。
抗积分饱和那段很关键。如果不加,当预测 DO 远低于设定值时,积分项会一直累加,等 DO 回升后输出会长时间卡在最大值,导致过曝气。我调试时故意去掉这段,结果 DO 从 1.2 冲到 4.5 才回落,血泪教训。
4.2 闭环仿真与结果可视化
main.m里把预测和控制串起来跑一个闭环仿真:
% 加载数据 data = readtable('data/raw/do_data.csv'); data_clean = preprocess(data, params); % 训练模型 model = train_bp(data_clean.X, data_clean.y, params); % 闭环仿真 do_set = 2.0; ctrl_state = struct('integral', 0, 'last_error', 0); do_history = zeros(params.sim.steps, 1); u_history = zeros(params.sim.steps, 1); for k = 1:params.sim.steps % 预测未来 6 步 do_pred = predict(model, data_clean.X(k,:), [], params); % 控制 [u, ctrl_state] = controller(do_pred, do_set, ctrl_state, params); % 模拟系统响应(简化一阶模型) do_history(k) = simulate_do(do_history(max(1,k-1)), u, params); u_history(k) = u; end % 绘图 figure; yyaxis left; plot(do_history, 'b-', 'LineWidth', 1.5); ylabel('溶解氧 (mg/L)'); yyaxis right; plot(u_history, 'r--', 'LineWidth', 1.2); ylabel('曝气功率'); legend('DO 浓度', '曝气功率'); title('闭环控制仿真结果');simulate_do是一个简化的一阶响应函数,实际项目中应该替换成真实的系统辨识模型。仿真步数默认 200 步,对应 100 小时。跑完后results/figures/下会生成 DO 跟踪曲线和功率曲线,能直观看到控制器把 DO 稳定在设定值附近,功率在 0.3-0.7 之间波动。
提示:闭环仿真里预测模型和控制器的调用频率要匹配。代码里每个控制步都调一次预测,如果预测模型计算量大,可以改成每 4 步预测一次,中间用插值。我实测 BP 网络单次预测约 0.02 秒,200 步跑完不到 5 秒,完全能接受。
5. 避坑与常见问题:跑不通时先查这几条
5.1 数据格式不匹配导致 readtable 报错
现象:main.m第一行就报错,提示“无法识别文件格式”或“列数不一致”。
原因:代码默认 CSV 第一行是表头,且列顺序为时间、DO、COD、NH3-N、TP、TN、pH、浊度。如果你的数据列顺序不同或没有表头,readtable解析出来的 table 列名对不上。
解决:在params.m里加一个data.columns字段,手动指定列索引。或者用readmatrix读纯数值矩阵,跳过表头处理。我一般会在preprocess开头加一段列名校验,不匹配就报明确错误,而不是让后续代码莫名其妙失败。
5.2 归一化参数未保存导致预测值异常
现象:训练时 RMSE 很小,预测新数据时输出全在 0 附近或全部相同。
原因:训练时对数据做了 min-max 归一化,预测时新数据没有用同一套 min/max 变换,或者预测输出没有反归一化。
解决:把归一化参数存到results/norm_params.mat,预测脚本开头先load进来。反归一化公式是y_real = y_norm * (max - min) + min,注意 max 和 min 要对应目标变量的范围,不是输入变量的。
5.3 BP 网络训练不收敛或过拟合
现象:训练集误差下降但验证集误差上升,或者训练 1000 轮后 RMSE 仍然很大。
原因:学习率太大导致震荡,或者隐藏层节点太多导致过拟合,或者数据没有打乱顺序。
解决:先把lr降到 0.001 试一轮,如果损失平稳下降说明是学习率问题。如果验证集误差早早上升,减少hidden_size到 8 或 6。另外train函数默认会打乱样本,但如果你手动传入了net.divideFcn为'divideblock',时序数据不能打乱,要改成'dividetrain'或自定义按时间划分。
5.4 ARIMA 定阶搜索时间过长
现象:train_arima跑了五分钟还没输出结果。
原因:p、d、q 搜索范围设得太大,或者数据序列太长导致每次estimate都很慢。
解决:把p_max、q_max降到 2,d_max保持 1。如果数据超过 10000 个点,先降采样到小时均值再建模。另外可以在循环里加一个进度打印,知道当前跑到哪个组合了。
5.5 控制器输出抖动
现象:闭环仿真里曝气功率曲线高频振荡,DO 浓度也跟着抖。
原因:kd太大放大了预测噪声,或者dt太小导致微分项对微小误差过度反应。
解决:先把kd设为 0,看振荡是否消失。如果消失,逐步增加kd到 0.05 左右。另外可以在预测值上先做一次移动平均再送入控制器,相当于低通滤波。我一般会在controller入口加一句do_pred = movmean(do_pred, 3),效果立竿见影。
6. 二次开发与改造思路:从跑通到用起来
跑通示例数据只是第一步,真正要用起来得做几处改造。我按自己改过的顺序说。
第一处是数据接口。示例数据是 CSV,实际项目里数据可能来自数据库、OPC 服务器或在线监测平台。我一般会写一个data_adapter.m,把不同来源的数据统一成preprocess能接受的 table 格式。比如从 MySQL 读数据,用 Database Toolbox 的fetch拿到 table 后,列名映射一下就行。如果是实时数据流,把main.m里的循环改成定时器回调,每 5 分钟触发一次预测和控制。
第二处是模型更新策略。示例代码是一次性训练、全程使用。实际水质会随季节变化,模型会漂移。我通常加一个滚动窗口重训练:每积累 7 天新数据,就把最旧的 7 天挤出去,用最近 30 天数据重新训练。代码改动不大,在main.m循环里加一个计数器,满足条件就调train_bp或train_arima。注意重训练时归一化参数要重新计算,但预测时用的还是旧参数,这里有个过渡期,我一般用新旧参数的加权平均。
第三处是控制策略的扩展。示例用的是单回路 PID 控制溶解氧。实际污水处理里,溶解氧设定值本身应该随进水负荷动态调整,否则固定 2.0 mg/L 可能过度曝气。我改过一个版本,用进水 COD 和氨氮预测值查一张模糊规则表,输出 DO 设定值,再送给 PID。规则表大概 5×5,用readfis加载,改动量约 50 行代码。
第四处是部署。MATLAB 代码要上现场,两条路:一是用 MATLAB Compiler 打成独立可执行文件,装到工控机上;二是用 MATLAB Production Server 做成 REST 接口,让 SCADA 系统调用。我倾向后者,因为模型更新不用重新部署,但需要额外授权。如果预算有限,就把核心预测函数用 Coder 转成 C 代码,嵌入到现有 PLC 程序里,不过 PID 部分建议保留在 MATLAB 侧做仿真验证。
验证改造是否成功,我习惯用三步:先用历史数据回放,对比改造前后的 RMSE 和控制偏差;再用半实物仿真,把控制器输出接到一个模拟执行器上跑 24 小时;最后才是现场试运行,而且一开始只监不控,观察一周再切闭环。从那以后我每次改控制逻辑,都强制走一遍“回放-半实物-监而不控”的流程,再急也不跳步。希望帮到你。
本文还有配套的精品资源,点击获取