1. 先把问题说清楚:CCHP调度到底在优化什么
最近我接手了一个园区综合能源系统的优化调度模拟项目,系统里包含燃气轮机、余热回收、吸收式制冷、电制冷、燃气锅炉,外加蓄电池和蓄热槽,典型的一套冷热电联供(CCHP)配置。目标很明确:在满足电、热、冷三类负荷的前提下,把一天的运行成本压到最低。一开始我按习惯上了遗传算法,后来换成粒子群,最后真正跑顺的反而是很多人不太在意的POA算法——渐进优化算法,英文全称 Progressive Optimality Algorithm。这篇博文把POA怎么用来解CCHP调度、MATLAB代码框架怎么搭、中间踩了哪些坑,一次讲清楚。
先说结论:POA这套算法在电力系统里其实早就有应用,尤其是梯级水电调度,几乎属于经典方法。但把它搬到冷热电联供系统里的人不算多。我试过之后发现,CCHP这种“多设备、强耦合、含储能、分时电价”的场景,恰恰是POA的舒适区。它不需要像动态规划那样把状态变量离散到爆炸,也不像遗传算法那样跑一次一个样、让人没法跟领导解释结果为什么每次都不同。只要模型写对、初值给得合理,POA的收敛结果是稳定可复现的,这一点在工程交付里非常重要。
1.1 冷热电联供系统里到底有哪些决策要做
CCHP系统的核心逻辑是“能量梯级利用”:燃气轮机烧天然气发电,发电后的高温烟气不扔掉,进余热锅炉回收变成蒸汽或热水,这部分热能再去驱动吸收式制冷机吹出冷风,或者直接供热。这样一来,一套系统同时产出电、热、冷三种能量,综合能源利用率可以做到70%到85%,比单独发电高出一大截。
但问题也出在这个“同时产出”上。电负荷、热负荷、冷负荷的变化规律不一样,设备之间又有强耦合,比如燃气轮机发多少电,就会连带产生多少可利用的余热;吸收式制冷机用多少热,又会反过来挤占供热能力。再加上分时电价的影响,白天电价高,你可能希望多开燃气轮机、少买电;晚上电价低,你又想让蓄电池充电、把燃气轮机压到最低。这里面的决策变量包括每个时段的燃气轮机出力、燃气锅炉出力、吸收式制冷量、电制冷量、蓄电池充放电功率、蓄热槽充放热功率,林林总总算下来,一个24时段的调度问题,决策变量轻松超过100个。
而且这个系统不是拍脑袋定的,它必须满足三条硬约束:电功率平衡、热功率平衡、冷功率平衡。储能设备还额外带来时序耦合——这一小时充了多少电,直接影响下一小时能放多少电,蓄电池的荷电状态(SOC)是一条贯穿全天的链条,不能只看单时段。这就是为什么这类问题不能简单地“每个时段独立优化”,必须放在一个完整的时间轴上整体求解。
1.2 为什么我没选动态规划、也没选遗传算法
最开始我试图用动态规划(DP)来做。DP在理论上是最优的,但它的痛点非常现实:状态变量每增加一个维度,状态数量就指数级膨胀。CCHP系统里至少有蓄电池SOC、蓄热槽SOC两个储能状态,每个状态离散成100个点,组合起来就是100乘100等于一万个状态,再乘以24个时段,每个状态还要做一次设备组合优化,算到后面内存和时间都吃不消。想要精度更高,离散点数加多,直接算不动。
遗传算法和粒子群这类智能算法我也跑了。它们的好处是能处理非凸、非线性目标,不用求梯度;但问题是结果有随机性,每次运行成本可能差1%到2%,调参还极其玄学,种群大小、交叉概率、变异概率随便动一下,收敛曲线就换一副面孔。更麻烦的是,工程上你很难判断算法到底是收敛到了好的解,还是碰巧撞了一个好结果。我在模拟项目X里跑了20次遗传算法,最好和最差之间能差出将近5%的成本,这对一个以“降本”为卖点的系统来说,决策风险太大了。
POA的思路则完全不同。它把一整个N时段优化问题拆成N减1个“两阶段子问题”,每次只优化相邻两个时段的决策,其他时段全部固定。听着简单,但它有理论依据:基于Bellman最优性原理,一条最优决策轨迹的任何子轨迹相对其端点来说也是最优的。所以固定前后端点、反复扫描相邻时段,就能逐步逼近全局最优。这个算法不需要离散状态、不需要求梯度、不依赖随机数种子,收敛稳定,内存开销也小。
1.3 POA在CCHP场景下的整体求解思路
具体到CCHP调度,我把每个时段的设备出力组合定义为决策变量,把蓄电池SOC和蓄热槽SOC作为跨时段状态量。POA的流程分三层:
- 外层是迭代循环,从初始轨迹出发,反复执行“从第1时段扫到第N时段”的扫描过程;
- 中层是两阶段子问题求解。对任意相邻时段t和t+1,固定其他时段的决策,只调整这两个时段的设备出力和储能功率,用非线性规划求解器做局部优化;
- 内层是状态更新,子问题解完后,更新该时段的SOC和下一时段的起始SOC,再进入下一对相邻时段。
一个关键细节是:每次子问题里,两端的状态边界要取“上一轮扫描得到的值”,不是当前轮已经更新过的值。如果顺序更新时不加区分,误差会从第一对时段一路传导到最后一对,导致目标函数不降反升。这个问题我一开始没注意,结果POA迭代了好几十轮还在振荡,最后把状态传递逻辑改成“先全部读旧值、解完再统一写新值”才稳定下来。
2. MATLAB代码框架与参数准备:从数据到求解器
代码不是一上来就写求解器的。我个人的习惯是先把系统参数、负荷曲线、能源价格整理成结构体,再写目标函数和约束,最后才碰POA循环。顺序反了的话,后面改一个参数就要翻半天代码,非常痛苦。
2.1 设备参数、负荷曲线与分时电价准备
我用的是24个时段,每时段1小时。如果要跑96点(每15分钟一个点),模型结构不用大改,只要把数组维度从24改成96,再把爬坡约束的时间尺度换算一下就行。下面这张表是我在模拟项目X里用到的参数,单位统一为kW和kW·h,供参考:
| 设备 | 关键参数 | 数值 |
|---|---|---|
| 燃气轮机 | 额定电功率 | 800 kW |
| 燃气轮机 | 发电效率 | 0.33 |
| 燃气轮机 | 热电比 | 1.4 |
| 余热锅炉 | 余热回收效率 | 0.8 |
| 吸收式制冷机 | COP | 1.2 |
| 电制冷机 | COP | 4.5 |
| 燃气锅炉 | 热效率 | 0.9 |
| 蓄电池 | 容量 | 600 kW·h |
| 蓄电池 | 最大充放电功率 | 200 kW |
| 蓄电池 | 充放电效率 | 0.92 |
| 蓄热槽 | 容量 | 800 kW·h |
| 蓄热槽 | 最大充放热功率 | 250 kW |
负荷数据我用的是夏季典型日的曲线:电负荷白天高、晚上低,冷负荷在下午两三点冲到峰值,热负荷主要是生活热水,相对平稳。分时电价采用常见的峰平谷三段:峰时段8点到11点、18点到21点,电价1.2元/kW·h;平时段11点到18点、21点到23点,电价0.7元/kW·h;谷时段23点到次日8点,电价0.4元/kW·h。天然气价格固定为2.6元/立方米,折算成单位热值成本后参与目标函数计算。
这里有个容易踩的坑:蓄电池和蓄热槽的SOC初值由我手动指定,但一天结束后的终值不能随意。工程上通常要求一天结束回到初始SOC(比如0.5),保证第二天调度可以连续运行。这个“终端SOC约束”必须在模型的约束函数里显式写出来,否则优化结果会让储能设备在最后几个时段疯狂放电,把SOC榨干,第二天直接没法用。
2.2 代码模块划分与关键数据结构
我的MATLAB工程目录不复杂,坑都在每个文件内部。整体分四个模块:
data_par.m:定义设备参数、负荷、电价、气价,统一放到一个结构体par里;obj_fun.m:目标函数,输入是决策变量矩阵和参数结构体,输出是全天运行总成本;cons_fun.m:约束函数,包括等式平衡约束和不等式容量约束,输出给非线性规划求解器;poa_main.m:POA主循环,负责初始化轨迹、调用子问题求解、判断收敛、输出结果。
决策变量我用一个二维矩阵X表示,行数等于时段数24,列数等于每个时段的决策变量个数。具体列的顺序我固定为:燃气轮机出力、燃气锅炉出力、吸收式制冷功率、电制冷功率、蓄电池充电功率、蓄电池放电功率、蓄热槽充热功率、蓄热槽放热功率。列顺序一旦定了,后面所有函数都按这个约定索引,别换来换去。
实际编码中我强烈建议把所有功率统一归一到基准值,比如以100kW为基准,或者以燃气轮机额定功率为基准。我自己最开始没归一化,燃气轮机出力是几百kW、成本是几万元、效率是0.3,几个数量级完全不一样的东西一起进优化器,结果fmincon一直在原地打转,目标函数下降极慢。归一化之后,所有变量基本都在0到1的范围内,求解器的数值稳定性好很多。
2.3 初始轨迹的生成方式
POA属于局部寻优算法,初始轨迹会给定的好,收敛就快,结果也更优;给得差,虽然也能收敛,但有可能落在一个不太好的局部解。所以我不建议随机初始化,更推荐用工程启发式方法生成两个候选初始解,跑一遍取成本低的那条。
第一种是“电跟随”策略:燃气轮机按电负荷的某个比例出力,余热全部回收,优先满足热负荷,不够的部分用燃气锅炉补,冷负荷先让吸收式制冷机吃余热,不足再用电制冷。
第二种是“热跟随”策略:燃气轮机按热负荷反推出力,发出的电不够就用购电补,冷负荷仍然先吸收式后电制冷。
两种策略都有道理,但成本差异不小。在模拟项目X的夏季典型日里,电跟随初始解对应的全天运行成本大约是7900元,热跟随大约是8100元,最后POA用更好的那条初始轨迹起跑,迭代次数明显更少。代码里就是把这两种策略各写一个30行的循环,生成两条初始轨迹,选目标函数值更小的作为启动轨迹。
3. POA求解CCHP的核心代码逐段拆解
这一部分是文章的重点。我不会贴整份工程代码,那样篇幅太长也不利于理解,但会把目标函数、约束、子问题求解、主循环这几块最核心的代码框架拿出来逐行解释。你能看明白这个框架,在自己的项目里改参数就能直接复现。
3.1 目标函数与约束的MATLAB实现
目标函数的物理含义是全天运行总成本,我把它拆成四块:燃气费用、购电费用(减去售电收入)、设备维护费用、储能折旧折算。总表达式写成:
function J = obj_fun(X, par) N = par.N; % 时段数 Pgt = X(:,1); % 燃气轮机出力 Pgb = X(:,2); % 燃气锅炉热出力 Pac = X(:,3); % 吸收式制冷功率 Pec = X(:,4); % 电制冷功率 Pch = X(:,5); % 蓄电池充电功率 Pdis = X(:,6); % 蓄电池放电功率 Qch = X(:,7); % 蓄热槽充热功率 Qdis = X(:,8); % 蓄热槽放热功率 % 燃气轮机耗气量:与电出力和热电比有关 gas_gt = Pgt ./ par.eta_gt; % 单位:kW 天然气热量 % 燃气锅炉耗气量 gas_gb = Pgb ./ par.eta_gb; % 燃料成本 fuel_cost = sum(gas_gt + gas_gb) * par.gas_price; % 购电成本(假设不能向电网售电,简化处理) Pbuy = max(0, par.Pload - Pgt - Pdis + Pec + Pch); buy_cost = sum(Pbuy .* par.elec_price); % 维护成本:按出力线性折算 om_cost = sum(Pgt) * par.om_gt + sum(Pgb) * par.om_gb + ... sum(Pac) * par.om_ac + sum(Pec) * par.om_ec; % 储能折旧:按充放电量折算,延长寿命 bat_cost = sum(Pch + Pdis) * par.om_bat; tank_cost = sum(Qch + Qdis) * par.om_tank; J = fuel_cost + buy_cost + om_cost + bat_cost + tank_cost; end这里有几个要点。第一,购电功率Pbuy我写成max(0, ...)的形式,相当于默认系统不向电网倒送电。如果你的项目里允许余电上网,就要额外加一个售电变量和售电收益项,去点max改成等式约束。第二,维护成本和储能折旧不在物理平衡约束里体现,直接体现在目标函数中,让优化器自动权衡“多用设备省钱”和“过度损耗设备多掏维护费”这两件事。
约束函数重点处理三类约束。第一类是功率平衡等式约束,典型写法如下:
function [c, ceq] = cons_fun(X, par) N = par.N; Pgt = X(:,1); Pgb = X(:,2); Pac = X(:,3); Pec = X(:,4); Pch = X(:,5); Pdis = X(:,6); Qch = X(:,7); Qdis = X(:,8); % 电平衡:发电+购电+放电 = 负荷+电制冷+充电 Pbuy = max(0, par.Pload - Pgt - Pdis + Pec + Pch); ceq1 = Pgt + Pdis + Pbuy - par.Pload - Pec - Pch; % 热平衡:余热+锅炉+蓄放热 = 热负荷+供热型吸收式耗热 q_gt = par.hpr * Pgt; % 燃气轮机余热 q_hrsg = q_gt * par.eta_hrsg; ceq2 = q_hrsg + Pgb + Qdis - par.Qload - Qch - Pac / par.cop_ac; % 冷平衡:吸收式+电制冷 = 冷负荷 ceq3 = Pac + Pec - par.Cload; % SOC递推约束:蓄电池 soc_bat = par.soc_bat_init; for t = 1:N soc_bat = soc_bat + (Pch(t)*par.eta_ch - Pdis(t)/par.eta_dis) / par.bat_cap; end ceq4 = soc_bat - par.soc_bat_init; % 终端回到初值 % 不等式约束:设备容量边界、SOC上下限、爬坡约束 c = [...]; ceq = [ceq1; ceq2; ceq3; ceq4]; end热平衡等式里,吸收式制冷消耗的热量是Pac / par.cop_ac,这个单位换算是新手最容易漏的地方。很多人把吸收式制冷功率Pac直接当成消耗的热功率,实际不对,吸收式制冷机的COP通常是1.2左右,意思是消耗1份热可以产出1.2份冷,所以热消耗是制冷量除以COP。
3.2 POA主循环和两阶段子问题怎么搭
POA主循环的核心思想,是一次只优化两个相邻时段。我把子问题封装在一个函数里,输入是当前轨迹X、时段t、参数par,输出是更新后的第t和第t+1时段决策。
function X = poa_main(X0, par) X = X0; N = par.N; maxIter = par.maxIter; tol = par.tol; J_prev = inf; J_hist = zeros(maxIter, 1); for iter = 1:maxIter for t = 1:N-1 X = solve_subproblem(X, t, par); end J = obj_fun(X, par); J_hist(iter) = J; if abs(J - J_prev) < tol break; end J_prev = J; end end子问题的实现是重头戏。相邻时段t和t+1的决策变量总共有16个(每个时段8个变量),我把它们从整个轨迹里提出来,调用fmincon求解,解完再放回去。关键点在于,第t时段之前的SOCl状态和第t+2时段之后的状态都要固定为上一轮的值,不能动。这样才符合POA“只动两个端点之间”的原则。
function X = solve_subproblem(X, t, par) n = par.nVar; % 每个时段的决策变量个数 % 提取当前两个时段决策作为初值 x0 = [X(t,:), X(t+1,:)]; lb = [par.lb_t, par.lb_t]; % 两个时段的下界 ub = [par.ub_t, par.ub_t]; % 等式约束函数:第三个参数是固定边界信息 ceq = @(x) sub_cons(x, X, t, par); options = optimoptions('fmincon', 'Algorithm', 'sqp', ... 'MaxIterations', 200, 'MaxFunctionEvaluations', 3000, ... 'OptimalityTolerance', 1e-6, 'Display', 'off'); [xopt, ~] = fmincon(@(x) sub_obj(x, par, t), x0, [], [], [], [], ... lb, ub, ceq, options); % 解回填到轨迹 X(t,:) = xopt(1:n); X(t+1,:) = xopt(n+1:2*n); end关于sub_obj和sub_cons的实现,我再补充一点:相邻两个时段的子问题目标函数不用重新写一个完整版,直接把两个时段各自的成本项加总就行。约束条件里除了两个时段的功率平衡,还要把储能SOC递推关系写进去,并且固定边界SOC:第t时段的初始SOC等于当前轨迹中第t-1时段算出来的值,第t+1时段的末端SOC等于当前轨迹中第t+2时段起始的SOC。
这里最隐蔽的一个坑是固定边界的“当前值”到底取哪一轮。我在调试过程中发现,如果子问题里用的边界SOC是本轮已经更新过的,整个迭代过程就会出现“追尾巴”效应,目标函数降几轮之后开始震荡。解决方案是在主循环里做一个“影子轨迹”X_old = X,子问题约束里统一读X_old的边界值,解完再更新X。虽然多占了一点内存,但稳定性提升非常明显。
3.3 收敛判据与加速收敛的实用手段
POA的外层迭代不需要跑很多轮,我在24时段模型上通常设定最大迭代次数为30,实际上15到20轮就收敛了。判据用目标函数值相对变化量,小于1e-4就停。有个细节是,第一次迭代的J_prev不能设成inf吗?可以,但建议第一次迭代完成后直接把J_prev = J赋上,同时记录一下初值轨迹的成本,方便对比POA到底降了多少成本。
加速收敛还有一个非常有效的手段:双向扫描。标准POA是从t=1扫到t=N-1,但单向扫描有时会在目标函数比较平坦的区域“卡住”几个轮次。我在主循环里改成一次正向扫描加一次反向扫描,也就是t从1到N-1,再从N-1到1各来一遍,一正一反作为一个外层迭代。实测在同样的容差下,总迭代次数能少个4到6轮。
如果觉得fmincon求解子问题比较慢,可以换个思路:把两个时段的变量用“决策变量离散化+枚举”的方式直接搜索。因为相邻时段只有16个决策变量,你可以在初值附近构造一个网格,枚举几十个候选方案,选目标函数最小的那个。这种方式的好处是稳定、不用调求解器参数,缺点是精度受网格密度限制。我建议先用精细网格跑一遍拿到结果,再用这个结果作为fmincon的初值做一次微调,等于“粗筛+精修”两段式求解,速度和精度都能兼顾。
4. 算例结果、常见问题与调试避坑
模型搭好、代码跑通只是开始,真正花时间的是看结果、调参数、处理各种“说不上来哪里出错但就是不对”的问题。这一节我把典型结果和一个经常要翻的排查清单都整理出来。
4.1 典型日结果长什么样
在模拟项目X的夏季典型日里,负荷峰值是电负荷820kW、冷负荷600kW、热负荷280kW。分别用规则策略、遗传算法、POA求解,全天总成本如下(模拟数据,仅供对比趋势):
| 求解方式 | 全天运行成本/元 | 备注 |
|---|---|---|
| 电跟随规则策略 | 7963 | 无优化,纯启发式 |
| 遗传算法 | 7428 | 种群100,迭代200,标准差约110 |
| POA | 7369 | 确定性结果,多次运行一致 |
POA跑出来的设备运行规律也符合直觉:白天电价高、冷负荷大,燃气轮机基本顶着额定功率运行,发电满足电负荷后,余热优先喂给吸收式制冷机;下午冷负荷峰值时段,电制冷机会补上一部分缺口,因为此时燃气轮机的余热已经用满了。蓄电池在凌晨谷电时段充电,上午峰电价时段放电,下午再充再放做一次峰谷套利。蓄热槽则更像一个“热缓冲池”,燃气轮机余热多的时候先把热存在槽里,晚上热负荷高的时候再放出来。
收敛曲线方面,POA前5轮成本下降最快,大概能降掉总降幅的80%;之后是缓慢修正,从第二轮到第18轮累计只降了不到1%。这就提示我们,如果只做工程估算,跑个8轮就停也能接受;但如果做正式方案比选,建议还是跑到完整收敛,反正也就几秒钟的事。
4.2 常见问题排查速查表
我把自己调试过程中遇到的高频问题整理成了一张表,按症状、原因、解决办法三列排列。
| 症状 | 可能原因 | 解法 |
|---|---|---|
| 目标函数前几轮下降,后面开始振荡 | 子问题边界读的是本轮更新值 | 改用影子轨迹统一读上轮值 |
| 蓄电池SOC被优化到边界,终值不归位 | 缺终端SOC约束 | 约束函数显式加ceq4,终端回到初值 |
| 电平衡/热平衡等式相对误差大 | 罚函数系数太小,或fmincon容差太宽 | 提高罚系数;把OptimalityTolerance收严到1e-6;检查单位是否统一 |
| 迭代很多轮但成本几乎不动 | 初始轨迹太差,落进局部解 | 换电跟随/热跟随两条初值取较优;或加双向扫描 |
| 96点模型跑起来非常慢 | 单次fmincon调用次数太多 | 先用24点调通逻辑;子问题改用粗网格枚举+fmincon精修 |
| 三套平衡约束总有一两个时段不满足 | 冷、热、电量纲混了,COP换算漏除 | 逐时段检查三个平衡残差,定位是哪类设备项写错 |
其中“量纲混了”是我见过最隐蔽的问题。冷负荷单位是kW,但吸收式制冷消耗的热量是Pac/cop_ac,电制冷消耗的电量是Pec/cop_ec,如果不除以COP,冷平衡表面看起来凑得上,实际在热平衡和电平衡里就会多出一块说不清的能量。排查的时候我习惯把约束残差单独打印出来,对每个时段看电、热、冷三条残差曲线,哪条不平整就去查哪个平衡式。
4.3 几条独家调试经验
最后分享几条从实战里总结出来的经验,都不是能直接搜到的内容。
第一,fmincon的算法设置建议用sqp而不是interior-point。内点法在变量边界接近约束边界时偶尔会卡在可行域内部,导致SOC进不了高位区间;SQP处理这类边界约束更直接,收敛更干净。当然这不是绝对的,但我在两个不同配置的CCHP模型上都观察到类似现象。
第二,罚函数的系数不是越大越好,但要保证等式约束的残差在最终解里落到一个很小的量级。我自己习惯的做法是先在目标函数里不带罚项跑一次,看约束残差水平;再把罚系数从1e3起步翻倍往上试,找到“约束残差降低速度明显放缓”的那个拐点。这个拐点附近的系数通常就是比较合适的值。
第三,把24点扩展到96点时,不要直接改维度就开跑。我吃过这个亏:第一次扩到96点,运行时间直接翻了不止4倍,因为fmincon在每个子问题里的函数评估次数没有跟着时段长度缩放。后来我把子问题里的MaxFunctionEvaluations从3000降到1500,外层POA容差从1e-4放宽到5e-4,求解时间降了一半多,结果质量差别小于0.3%,完全可以接受。说白了,工程上不是每个环节都要追求数学意义上的最优,够用就行。
第四,结果出来后一定要画一张“全设备出力堆叠图”,而不是只看成本数字。堆叠图把燃气轮机、购电、蓄电池放电、蓄热槽放热按顺序叠起来,和负荷曲线放在一起看,能在五秒钟内发现“电负荷曲线有个尖峰但蓄电池在那个时段恰好放不出电”这类潜在问题。我曾经就对着一张堆叠图发现了蓄热槽容量差200kW·h导致凌晨热负荷缺口,改完参数成本又降了1.2%。
我个人做完这个项目,最大的体会是:POA算法在冷热电联供调度里属于“上限不算惊艳,但下限非常高”的方案。它不像遗传算法那样能给你一种“正在全局搜索”的幻觉,也不像动态规划那样让你盯着状态网格数量发愁。它的优势就是稳定、可复现、可解释,而且对工程落地的人来说,这三条比“理论最优”重要得多。最后再分享一个小技巧:如果你后续想把单目标扩展成“成本加碳排放”的双目标,不需要推翻POA框架,只要把目标函数里的成本项换成加权和就行,外层迭代逻辑完全不用动。这个算法框架的扩展性,是我当初选择它的时候没预料到的惊喜。