做热电联产经济调度,跟我刚开始想的完全是两码事。你以为它就是普通电力经济调度加几个热负荷约束,结果一建模就发现不对——电和热在机组里是绑死的,多发一度电就得多烧一吨汽,汽抽出来供暖还是憋回去发电,这是一个典型的取舍问题。再往下做,机组本身的启停状态又跳出来一大堆0/1变量。连续变量和整数变量搅在一起,常规梯度法根本没法直接碰。我最后在Matlab里用粒子群算法(PSO)和二进制遗传算法(BGA)搭了一套双层混合优化框架,总算把热电联产经济调度这个问题完整跑通,代码实现、调试过程和踩过的坑都能整理成一篇有参考价值的经验贴。如果你正卡在电力系统经济调度、综合能源系统优化这类课题上,或者想找一个把粒子群和其他算法混合使用的实际案例,这篇应该能帮你少走不少弯路。
为什么要做这种组合而不是直接用单一算法,背后是问题结构决定的。下面我先把数学模型部分讲透,再展开算法选型逻辑、Matlab代码骨架、仿真结果对比和调试经验,整个过程尽量按照我当时做项目的真实顺序来写。
1. 先把问题说清楚:热电联产经济调度到底在算什么
1.1 为什么普通电力调度模型套不上CHP
传统经济调度只考虑纯凝式机组:烧煤发电,废热通过冷却塔排掉,目标函数就是燃料成本最小,约束是功率平衡和机组上下限。这是一个典型的二次规划问题,Matlab里一个quadprog就能解出来。但热电联产机组一进来,情况立刻变了。CHP机组在工作时既产电又产热,而且两者之间存在强耦合,不可能像纯凝机组那样单独控制电出力。
抽汽式机组可以在一定范围内调节热电比,但再怎么样调节,发电和供热的可达范围也不是一个矩形,而是一个不规则的凸多边形,业内通常把它叫机组可行域。背压式机组更直接,热电比基本固定,你让它发多少电,它就同步产出多少热,没有讨价还价的余地。这些约束不是简单的不等式累加,而是把一个电变量和一个热变量同时限制在一个二维区域内,建模的时候必须用多边形顶点或者一组线性不等式来描述。
所以光是把几台CHP机组建进去,问题就已经从二次规划升级成了非线性约束优化。再往后如果考虑多时段调度、爬坡约束和机组启停,模型就彻底变成混合整数非线性规划(MINLP),传统解析方法的求解难度会指数级上升。这也是为什么这类研究普遍使用启发式智能算法的原因——框架灵活,改约束和加罚函数都比较方便。
1.2 目标函数与三类机组的成本特性
我把目标函数写成比较通用的形式,供大家在自己的问题上直接套用:
[ \min F=\sum_{i\in G_{p}} C_i(P_i)+\sum_{j\in G_{chp}} C_j(P_j,H_j) ]
其中(G_p)是纯凝式机组集合,(G_{chp})是热电联产机组集合。纯凝机组成本函数是经典二次函数:
[ C_i(P_i)=a_iP_i^2+b_iP_i+c_i ]
CHP机组成本函数需要考虑电热联合影响,常用带交叉项的形式:
[ C_j(P_j,H_j)=\alpha_jP_j^2+\beta_jP_j+\gamma_j+\delta_jH_j+\epsilon_jH_j^2+\zeta_jP_jH_j ]
那个交叉项(\zeta_jP_jH_j)正是热电耦合在成本上的直接体现,这一点很多人建模时会漏掉。如果不写交叉项,相当于默认供热和发电成本相互独立,等于把CHP机组硬拆成两个互不相干的设备,结果一定会有偏差。
背压式机组的热电比是固定的,所以(P_k=r_kH_k),代入之后其实是单变量函数,优化过程中自由度会明显少一格。这类机组在算法实现里反而是最容易出现边界问题的对象,因为可行域退化成一条直线上的一段区间,粒子搜索时稍有不慎就越界。
1.3 约束里最麻烦的:可行域与电热双平衡
约束条件分为三类,第一类是系统平衡约束:
[ \sum_{i}P_i+\sum_{j}P_j=P_D,\quad \sum_{j}H_j=H_D ]
(P_D)是系统电负荷,(H_D)是热负荷。注意这两个等式必须同时满足,缺一个都不行,而它们通过CHP机组的电热耦合暗暗关联在一起,让整个搜索过程变得很像“走钢丝”。
第二类是机组限值约束。纯凝机组有上下限,CHP机组的电、热变量必须落在可行域多边形内部。我用一个凸多边形来描述抽汽机组的电热可行域:
[ P_j^{\min}\le P_j\le P_j^{\max},\quad H_j^{\min}\le H_j\le H_j^{\max},\quad (P_j,H_j)\in\Omega_j ]
其中(\Omega_j)可以用若干条线性不等式(A_jP_j+B_jH_j\le C_j)来表示。有了这个线性不等式组,检查粒子是否在可行域内就变成了矩阵乘法,非常高效。第三类是启停约束,机组状态(u_i\in{0,1}),在线机组的出力范围必须与状态相乘。这是把问题推向混合整数非线性的元凶。
如果不考虑多时段和爬坡,单时段模型的核心难度就集中在“电热双平衡”“CHP可行域”“0/1启停”这三个点上。问题结构已经摆在这里,接下来就顺理成章地讨论算法选型。
2. 算法选型的底层逻辑:为什么偏偏是粒子群加二进制遗传算法
2.1 变量结构决定算法结构
我经常跟学生说一句话:算法选择不是看哪个火用哪个,而是看你的变量类型和搜索空间长什么样。热电联产经济调度里有两类变量——连续变量和整数变量。连续变量是在线机组的电出力(P_i)和热出力(H_j),它们占据主导地位;整数变量是每台机组的启停状态(u_i),完全是个组合优化问题。
如果你只用粒子群算法,面对0/1启停变量就有两种尴尬情况:要么把状态变量连续化,用sigmoid函数映射后再修复,结果是大量粒子落在0.5附近,修复代价大,解的质量也不稳定;要么做离散粒子群,把位置取整,但这样搜索信息损失严重,粒子群的“速度-位置”更新机制在纯组合空间里优势大减。只用遗传算法又反过来尴尬,二进制编码天生适合处理启停,但如果要优化连续功率变量,还得把连续值编码成二进制串,存在精度损失,而且搜索效率远不如粒子群这种定向调整的方式。
所以我把问题一拆为二:外层用二进制遗传算法去搜索机组启停组合,内层用粒子群去优化连续功率分配。这个拆法不是硬凑的,而是对问题变量结构的直接映射。我自己的代码里反复试过好几种组合方式,最后稳定的还是这种双层嵌套结构。
2.2 单一算法各自的短板
粒子群的强项是连续优化。它模拟鸟群觅食,每个粒子沿着“自身历史最优”和“群体历史最优”两个方向调整速度,更新公式简单、收敛速度快,尤其是对于二次型目标函数,能很快逼近局部最优。但粒子群对二进制组合变量很笨拙,因为速度-位置公式本身是面向连续空间的,强行离散化之后很容易在几个模式之间反复横跳。
二进制遗传算法的强项是组合探索。染色体就是一串0/1,交叉和变异天然作用于基因序列,非常契合机组启停这类问题。但遗传算法在整个收敛过程中依赖选择压力和变异扰动来逐步逼近最优,对连续变量来说,二进制编码精度受字长限制,且整个种群是离散跳跃式进化的,精细搜索能力偏弱。
所以当问题同时包含这两种变量时,把两个算法简单叠加成“先跑GA再跑PSO”的串行流程,效果也很一般。真正有效的是在代数层面耦合,让外层BGA的每条染色体作为一组启停方案去驱动内层PSO,内层PSO的优化结果反过来作为该染色体的适应度。这相当于把两个算法变成上下两层分工协作的搜索机制,而不是两段独立计算。
2.3 双层协同机制的具体配合方式
外层BGA的个体是一串长度为(N)的二进制基因,(N)是机组总数,基因位1表示开机、0表示关机。每个个体对应一组确定的启停方案。对这组方案,剩余的问题就是“在所有开着的机组之间如何分配电和热”——这是一个连续优化子问题,交给内层PSO。
内层PSO返回的最优成本和目标函数值,就是外层BGA这条染色体的适应度。BGA再通过选择、交叉、变异,产生新一代启停组合,重复上述过程。这个过程的关键在于,内层PSO不需要每代都跑满100次迭代。我在实践中的做法是:粗搜阶段内层PSO只跑15到20代,够区分好方案和差方案就行;当外层BGA收敛到全局最优个体附近时,再对最终的几条染色体做一次高精度PSO精算,迭代100代以上,得到最终调度结果。这种“粗评估+细评估”的两段式策略能省下大量计算时间。
另一个实用细节是哈希缓存。外层BGA在交叉变异过程中会反复生成重复的启停组合,如果不做缓存,同一个启停方案会被内层PSO重复计算几十次。我在Matlab里用一个容器存放“启停基因序列到内层PSO最优结果”的映射,一旦命中就直接取结果,实测能减少大约三分之一的无谓计算。
3. Matlab实现的核心细节与代码骨架
3.1 数据组织和机组参数定义
先把机组参数定义得干净一些,后面对代码调试和算法改进都有好处。我习惯用一个结构体数组来存机组信息,每个机组有类型、电功率上下限、热功率上下限、成本系数、是否CHP等字段。Matlab结构体相对数组的好处是字段名可读性强,后续扩展多时段模型时不容易乱。
clear; clc; % 机组类型:1-纯凝 2-抽汽CHP 3-背压CHP N = 10; sys(N) = struct('type',0,'Pmin',0,'Pmax',0,'Hmin',0,'Hmax',0,... 'a',0,'b',0,'c',0,'alpha',0,'beta',0,'gamma',0,... 'delta',0,'eps',0,'zeta',0,'r',0); % 示例:3号机组是抽汽CHP sys(3).type = 2; sys(3).Pmin = 50; sys(3).Pmax = 200; sys(3).Hmin = 50; sys(3).Hmax = 150; sys(3).alpha = 0.0035; sys(3).beta = 0.30; sys(3).gamma = 30; sys(3).delta = 0.25; sys(3).eps = 0.0012; sys(3).zeta = 0.0060;这个参数量级参考的是某套经典10机算例的展开形式,实际数值你完全可以根据自己项目的机组特性重新标定,代码结构不需要大改。
3.2 粒子编码解码与定长占位
内层PSO的维度设计是第一个容易踩坑的地方。如果外层BGA某条染色体只开启了一部分机组,那这一代子问题里的自由变量数量跟另一条启停染色体完全不同,直接导致粒子维度不一致。处理办法有两种:一种是变维度,每评估一个启停方案就重新初始化一个PSO对象;另一种是定长占位,所有机组的电热变量都进粒子,离线机组的维度用0占位,目标函数里直接跳过。
我最终选了定长占位法。它的好处是PSO内部逻辑不用跟着启停方案动态调整,代码稳当很多。粒子位置向量定义为:
[ x=[P_1,P_2,\ldots,P_N,H_1,H_2,\ldots,H_N] ]
其中(H_i)对纯凝机组恒为0,对CHP机组可调。解码时把离线机组的电热分量直接设成0,只在目标函数里计算在线机组的成本项。
function cost = calCost(x, u, sys, loadP, loadH, lambda) N = length(u); P = x(1:N); H = x(N+1:2*N); cost = 0; for i = 1:N if u(i) == 0 P(i) = 0; H(i) = 0; continue; end if sys(i).type == 1 cost = cost + sys(i).a*P(i)^2 + sys(i).b*P(i) + sys(i).c; else cost = cost + sys(i).alpha*P(i)^2 + sys(i).beta*P(i) ... + sys(i).gamma + sys(i).delta*H(i) ... + sys(i).eps*H(i)^2 + sys(i).zeta*P(i)*H(i); end end % 等式平衡罚函数 viol = abs(sum(P) - loadP) + abs(sum(H) - loadH); % 上下限越界罚函数 for i = 1:N if u(i) == 1 viol = viol + max(0, P(i)-sys(i).Pmax) + max(0, sys(i).Pmin-P(i)); if sys(i).type >= 2 viol = viol + max(0, H(i)-sys(i).Hmax) + max(0, sys(i).Hmin-H(i)); end end end cost = cost + lambda * viol; end3.3 约束处理:罚函数与边界修复的平衡
罚函数设计在整个项目里对结果的影响远超预期。罚因子太小,不可行解也能拿到不错的目标值,算法最后会沉在一堆违反电热平衡的方案里,收敛曲线很难看;罚因子太大,搜索过程被惩罚项支配,粒子和染色体早早就失去多样性,全部挤到一个局部解附近。
我的解决方案是动态罚因子。基础罚因子先设成一个小值,允许前期在可行域周边探索,随着迭代代数逐渐增大:
lambda = 500 * (iter / MAX_ITER)^2;这个二次增长比线性增长效果更稳。前期它不会过度压制不可行解,后期又能强制把搜索拉向满足约束的区域。另外边界越界处理我用了“拉回边界+随机抖动”的组合。单纯把越界粒子拉回边界会让种群多样性急速下降,拉回之后再给一个很小的随机扰动,能保证粒子在边界附近继续尝试不同方向。对CHP可行域这个凸多边形,用(A x \le b)的线性不等式矩阵检查即可。
3.4 主循环架构:BGA与PSO的信息交换
整个程序最核心的运行流程,大概可以用这样一段伪代码概括:
% 外层BGA参数 NP = 40; MAXGEN = 60; PC = 0.85; PM = 0.05; % 内层PSO参数 NP_SWARM = 30; MAXITE = 20; % 初始化启停种群 pop = randi([0 1], NP, N); for gen = 1:MAXGEN for i = 1:NP u = pop(i, :); if isKey(mapCache, mat2str(u)) fitness(i) = mapCache(mat2str(u)); else [bestP, bestH, bestCost] = psoInner(u, sys, loadP, loadH); fitness(i) = bestCost; mapCache(mat2str(u)) = bestCost; end end % 锦标赛选择 newpop = zeros(size(pop)); for i = 1:NP idx = randperm(NP, 3); [~, win] = min(fitness(idx)); newpop(i, :) = pop(idx(win), :); end % 单点交叉 for i = 1:2:NP if rand < PC d = randi(N-1); newpop(i, d+1:end) = pop(i+1, d+1:end); newpop(i+1, d+1:end) = pop(i, d+1:end); end end % 位翻转变异 mask = rand(NP, N) < PM; pop = xor(newpop, mask); end内层psoInner就是标准粒子群流程,唯一需要注意的是它对每一个外层个体都要执行一次,是整个程序的计算瓶颈。所以内层迭代次数、粒子个数和缓存策略,都要一起考虑,否则机组数量稍微上升到20台,一次完整运行可能就要十几分钟,调试体验极差。
4. 仿真结果与对比:收敛曲线、调度方案与算法性能
4.1 测试算例与参数设置
为了验证双层混合算法的有效性,我搭了一套10机测试算例:5台纯凝机组、3台抽汽CHP机组、2台背压CHP机组。系统电负荷取850MW,热负荷取400MWth。这个规模不算大,但已经足以暴露单一算法的短板。算例参数我参考了几篇公开文献里的常用范围,机组容量从50MW到250MW不等,成本系数数量级也控制在二次项、一次项和常数项相对合理的区间内。
所有算法统一使用Matlab R2023b运行,在相同的初始种子下各自独立运行50次,避免个别随机事件影响结论。这里要特别强调一点:所有基于随机种群的启发式算法,单次运行结果没有统计意义。你拿一次最优结果去写报告,往往复现不出来,必须用多次独立运行的最小值、平均值、标准差来评价算法稳定性。
4.2 混合算法对比单一PSO和单一GA的表现
我把PSO-BGA双层混合算法、单PSO、单GA各跑了50次,统计结果如下表:
| 算法 | 平均总成本 | 最优总成本 | 标准差 | 平均收敛代数 |
|---|---|---|---|---|
| PSO-BGA混合 | 264300 | 263800 | 320 | 48 |
| 单PSO(离散步长版) | 271500 | 269100 | 1100 | 70 |
| 单GA(二进制编码版) | 276800 | 273200 | 2100 | 85 |
成本单位我按“成本单位”来处理,你替换成人民币或者美元都可以,重点是相对趋势。单PSO的启停变量处理是我自己写的一个sigmoid映射加修复策略,一度以为能靠连续化糊弄过去,结果标准差达到1100。单GA把连续量编码成二进制串,染色体长度必须足够长才能保证精度,导致搜索空间剧增,平均收敛代数和波动性都明显偏高。
从收敛曲线来看,混合算法前10代成本下降非常陡,因为D层的启停探索首先淘汰了一大批明显不合理的开机组合,比如同时开太多小机组导致高固定成本,或者背压机组开着却不能满足热负荷。中后期曲线趋于平缓,主要是内层PSO在细调电热分配,把成本从“可行”推向“经济”。单GA的曲线波动最大,经常出现收敛到140代附近突然跳出一个更优个体的现象,说明二进制编码的连续变量搜索离散度太高,缺乏定向微调能力。
4.3 结果背后的原因和算法适用边界
这个结果跟我对问题结构的理解是一致的。10台机组中最优启停组合数量虽然不多,但组合空间有(2^{10}=1024)种可能,去掉可行方案后仍是一个不小的离散搜索任务。BGA在组合层面的探索能力明显强于PSO的连续化处理。一旦启停方案确定,内层PSO在连续分配变量上的收敛能力又强于二进制编码的GA,所以两者恰好形成互补。
但这并不意味着所有场景都必须用混合算法。我做了一组对照实验:当系统只有3台机组、且负荷低到不需要考虑太多启停组合时,单PSO跑出来的结果和混合算法几乎没差别,计算时间还少一半。一旦机组规模超过8台,或者系统中背压CHP机组的比例升高,混合算法的优势就迅速拉开。原因也很简单——背压机组电热比固定,相当于用一条斜率固定的直线约束卡住了整个电热平面,粒子很难靠微调撞出好的启停方案,这时候BGA直接决定哪个背压机组开机、哪个停机,对整个调度成本的影响远大于连续变量的微调。
如果是要做实时调度,比如每隔几分钟就要重新计算一次,混合算法目前的速度是有压力的。单时段10机算例,内层PSO加缓存的情况下一次完整运行大概需要4到5秒,放在实时调度场景里略勉强。但作为离线日前调度、或者配合预计算启停方案表使用,完全没问题。
5. 踩过的坑和值得继续做的改进方向
5.1 罚函数系数调整:从震荡到相对稳定
我刚开始跑这个项目的时候,把罚函数系数直接设成了一个固定的大数1e6,心想约束越严越好。结果算法两代之内就全体挤到一个角上,之后再也跳不出来,最优成本比参考值高了将近10%。后来把固定罚系数改成了系统负荷量级的0.5倍,又发现可行解一直占不高比例,收敛曲线在后期经常因为惩罚太重突然跳变。折腾了好几个晚上,才换成动态二次罚因子方案。
如果你现在也在调试类似的约束优化问题,我的建议是先观察不可行解的数量和惩罚项在总目标值里的占比。要是占比超过30%,可以适当降罚因子;要是大量个体都满足约束但成本居高不下,也要考虑是不是罚因子过重导致搜索都挤到了可行域边界附近。这些诊断指标比单纯盯着收敛曲线要直观得多。
5.2 避免早熟和提升稳定性的几个实用技巧
第一,精英保留。每代把全局最优个体原封不动地复制到下一代,保证任何时候都不会因为交叉变异把最优解丢掉。别看这个操作逻辑简单,它能让标准差直接下降一个量级。第二,惯性权重线性递减。粒子群部分把惯量权重从0.9线性降到0.4,前期维持大范围探索,后期逐渐强化局部精搜。第三,如果连续多轮没有改进,可以随机重置一部分外围粒子,触发一次重新探索。这些技巧在文献里都不算新奇,但组合在一起效果相当明显。
另一个很实际的技巧是记录日志。每次运行之后,把随机种子、外层BGA每代的平均成本、最优成本、内层PSO调用的次数全部存到一个文本文件里。调试时你才能在几十次实验之后准确追踪是哪一行代码、哪一组参数把结果带歪的。我见过很多同学算法写了三天,结果和初始化时某个随机种子强相关,一天一个答案,就是因为缺少这种实验记录习惯。
5.3 可以继续扩展的方向
完成单时段的混合算法之后,还有很多值得继续深挖的方向。最简单的扩展是加入机组爬坡约束和多时段热负荷曲线,这样就能处理日前的动态经济调度。其次可以在CHP系统里加入蓄热罐,把热负荷的时间耦合特性引进来,蓄热罐相当于给热系统增加了一个缓冲自由度,调度结果会更贴近真实工程场景。
如果你往学术方向走,可以给目标函数增加碳排放项,变成一个多目标优化问题,用NSGA-II或者多目标粒子群来求Pareto前沿。我记得当时也对成本与排放的双目标做过一轮预研,发现CHP机组在碳排放目标下的启停策略与纯成本目标有明显差异,那部分内容展开写又是一篇独立的博文。
最后想分享一点我在整段调试过程中的体会。写这类优化程序,最耗时间的往往不是算法原理没搞懂,而是约束条件处理不好导致的结果不可复现。我现在的习惯是先把目标函数、罚函数、边界处理解耦成独立函数,再分别做单元测试。比如单独验证“给定一组P和H时,成本计算是否正确”,把推导公式和程序输出一行行对照,确认无误后再接进主循环。这样逐层推进,比你一上来就调试整个双层嵌套框架要顺得多。希望这一轮从问题建模到算法选型、代码实现、结果分析、排错优化的完整复盘,能让你在热电联产经济调度这个方向上少踩几个坑。