做微电网调度的人,几乎都会遇到同一个矛盾场景:园区里躺着一台热电联产机组(CHP),旁边还装着一台几千千瓦的电锅炉。理论上它们应该互相配合,可一到月底对账,燃气费和电费加起来总是比想象中贵。原因往往不是设备效率差,而是没有任何一个模型把“电价曲线、热负荷曲线、机组热电比”这三样东西放在一起算。电锅炉和CHP虽然装在同一屋檐下,电和热却是各走各的账。
这篇内容就是把“省钱密码”拆开来看。先用几个数字说明两个设备为什么天生互补,再给一个用Pyomo写的经济调度模型,把目标函数、平衡约束、价格信号都落到代码里,最后说一说从模型到现场控制之间最容易翻车的几个坑。如果你正在做园区微电网、综合能源调度仿真,或者只是想把热电系统的运行策略从“凭经验”改成“按模型”,这篇应该能用得上。
1. 电锅炉和CHP的互补逻辑:先看懂为什么组合能省钱
1.1 两个装置各自的成本画像
CHP机组烧天然气发电,发电的同时回收缸套水和烟气余热。一台典型的燃气内燃机CHP,电效率大约35%,热效率大约50%,综合效率能到85%左右。电锅炉则简单得多,电流直接转热能,电热效率通常可以做到95%,它唯一的“原料”就是电。
要理解省钱逻辑,不能只看效率,要看边际成本。我习惯把所有价格都折算到“每千瓦时”这个统一维度上。
天然气按3.5元/Nm³、低位热值9.7 kWh/Nm³折算,每千瓦时热量成本约0.36元。CHP每发1千瓦时电,大约需要2.86 kWh燃气,燃料成本约1.03元。这个价格看起来比很多园区高峰电价还要贵,但别忘了它同时产生约1.43千瓦时热。电锅炉这边,在0.35元的低谷电价下产1千瓦时热,大约要消耗1.05千瓦时电,成本约0.37元;在1.0元的高峰电价下,产热成本就飙到1.05元。
| 项目 | CHP发1 kWh电 | 电锅炉产1 kWh热 |
|---|---|---|
| 燃料/电力耗量 | 约2.86 kWh燃气 | 约1.05 kWh电 |
| 能耗成本 | 约1.03元 | 谷电0.37元 / 峰电1.05元 |
| 副产品 | 约1.43 kWh热 | 无 |
真正有意思的就在这里:CHP的“电”在高峰时段可能不便宜,但它的“热”几乎是发电过程中白送的;电锅炉的“热”在低谷时段可以比燃气烧水还便宜,但到了高峰时段又贵得离谱。两个单独看都有明显短板,放在一起反而能把对方的短处补上。
1.2 热电比带来的约束,正是模型要打破的墙
CHP机组有个很麻烦的物理特性:电和热是绑定的。发1 kWh电,同时就有约1.43 kWh热跑出来,这个比例就是热电比。热电比一旦固定,运行策略就很被动。
白天园区电负荷高,你想让CHP多发电,可热负荷如果没那么大,多出来的热只能拿冷却塔散掉,等于白烧了一部分燃气;晚上热负荷高,电价又便宜,从网上买电明显比烧天然气划算,但热负荷压力逼着CHP必须开机,于是只能眼睁睁烧高价燃气。这就是典型的“以热定电”困境,设备本身没问题,是运行约束太死。
电锅炉在这里的作用,是给电和热之间加了一个“松耦合器”。热不够的时段,用锅炉的电力去补热量缺口;CHP想多发电但热无处可去的时候,如果配了蓄热罐,还可以把热量先存起来。这样一来,热不再硬性绑架CHP的发电决策,调度自由度一下子大很多。
1.3 价格倒挂时的最优策略会自然出现
把谷电0.35元、燃气热成本0.36元/kWh、燃气锅炉供热效率90%(实际产热约0.40元/kWh)放在一起看,会得到一个反直觉的结论:深夜低谷电价时段,用电锅炉烧水比用天然气锅炉烧水还便宜。人工调度如果只是“白天全开机组、晚上保热锅炉”,很难抓住这种价格差。
模型优化的价值就在于,它不会把一天简单切成“峰/谷”两个档,而是每个时段都重新核算成本,看看到底是让CHP发电更划算,还是从电网买电更划算;到底让电锅炉顶上还是让燃气顶上。我在后面那个仿真算例里会具体展示,这种逐时段核算出来的策略,往往比你拍脑袋定的策略省出一笔可观的费用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 经济调度的数学模型:目标函数、变量和约束怎么设计
2.1 目标函数盯住的是系统边际成本
做调度模型第一步是定义目标函数。很多人一上来就想把所有成本都算进去,包括设备折旧、人工、财务费用,这其实是误区。调度优化的时间尺度是分钟到小时,固定成本在这么短的时间内基本不变,不管你怎么开设备都要付,所以它们不应该影响运行策略。
真正要优化的是边际成本:每个时段从电网买电花了多少钱、卖给电网赚了多少钱、CHP烧了多少燃气、两个设备运行各自产生多少维护费用。写成数学形式就是:
[
\min \sum_{t} \left[ \pi_e(t) \cdot P_{\text{buy}}(t) - \pi_s(t) \cdot P_{\text{sell}}(t) + \frac{c_{\text{gas}}}{\eta_{\text{chp,e}}} \cdot P_{\text{chp}}(t) + c_{\text{om,chp}} \cdot P_{\text{chp}}(t) + c_{\text{om,eb}} \cdot P_{\text{eb}}(t) \right]
]
这个式子里的变量含义是:P_buy是向电网购电功率,P_sell是余电上网功率,P_chp是CHP的发电功率,P_eb是电锅炉消耗的电功率,π_e(t)和π_s(t)分别是分时购电价和上网电价,c_gas是天然气折算到单位热量的价格,η_chp,e是CHP电效率,c_om是运维成本。目标就是让这个总成本最小。
这里有一个容易踩的坑:不要把电锅炉的“产热量”直接放进目标函数里作为收益项。热的价值体现在热约束上——必须满足热负荷,缺多少热量就用多少电锅炉出力去补,它自己会自动权衡。如果单独给热定价,很容易出现重复计算。
2.2 决策变量与平衡约束:电和热必须各擦各的线
约束条件分两大类,一类是能量平衡,另一类是设备边界。
电平衡约束很直观:CHP发电加购电,等于电负荷加电锅炉耗电再加余电上网。
[
P_{\text{chp}}(t) + P_{\text{buy}}(t) = D_e(t) + P_{\text{eb}}(t) + P_{\text{sell}}(t)
]
热平衡约束稍微绕一点。CHP伴生的热量等于热电比乘以电出力,电锅炉产生的热量等于电功率乘锅炉热效率,这两者之和要等于热负荷,如果热量多了可以加一个散热变量H_dump(t)。
[
\frac{\eta_{\text{chp,h}}}{\eta_{\text{chp,e}}} \cdot P_{\text{chp}}(t) + \eta_{\text{eb}} \cdot P_{\text{eb}}(t) = D_h(t) + H_{\text{dump}}(t)
]
也许有人会问,为什么允许H_dump?现实中确实存在热负荷不足但机组又因为电负荷需求不能降得太低的情况,短时间少量散热是工程上允许的。在模型里H_dump是零成本变量,但它越大浪费越大,优化器会自动压低它,所以不会出现“为了发电故意排热”的滥用情况。
设备边界约束包括:CHP出力上下限、电锅炉电功率上下限、购电功率上限、爬坡速率限制。这些本质上都是不等式约束,在代码里加起来非常方便。核心是要明白,每一条约束背后都是一个实际工程边界,比如CHP最低30%负荷运行是为了避免尾部受热面腐蚀,电锅炉不能超过额定功率是为了保护绝缘和变压器容量,这些都不能省。
2.3 为什么要做成滚动时域而不是一锤子买卖
上面构造的是一个24小时静态优化问题,相当于在凌晨拿到全天负荷预测,一口气把24小时的决策全部定完。这个思路教学没问题,但现场运行不建议这么干,因为负荷预测不可能完全准。
更靠谱的做法是滚动时域优化,也叫模型预测控制思路:每15分钟或者每小时调用一次模型,拿到最新气象、最新负荷预测,重新优化未来24小时或未来4小时,但真正下发执行的只有当前这一个动作。下一轮再刷新再求解。因为调度周期足够短,模型只需要在当前状态下做局部修正,就能把预测误差带来的损失控制在很小范围内。
后面代码演示先做一次性24小时求解,为了把数学逻辑讲清楚;实际接MES或者DCS系统时,要在这个模型外面再套一个滚动触发机制。这一点很重要,别把Demo代码直接拿去生产环境跑一天。
3. 用Pyomo把这个调度模型写出来并跑起来
3.1 输入数据长什么样,参数是怎么标定的
先声明一下:这一节用到的负荷数据和价格数据,是我为了演示逻辑自己拟合的,数值做了处理,不代表任何具体项目。真到现场,必须按设备铭牌、燃料化验单、当地电价协议重新标定。
仿真场景这样设定:一个中型园区,装有一台1000 kW级CHP机组,电效率35%,热效率50%;一台600 kW电锅炉,电热效率95%。燃气价3.5元/Nm³,热值9.7 kWh/Nm³;分时电价里,低谷0.35元、平段0.65元、高峰1.0元,余电上网电价0.4元。电负荷和热负荷按冬季典型日设置,热负荷明显高于电负荷,这样才能体现出“热电耦合”。
输入数据的Python就是一个列表。常见做法是直接从数据库或者预测服务取,这里为了可复现直接硬编码:
python复制import numpy as np
import pyomo.environ as pyo
T = 24
# 分时电价:0.35元×7小时谷段 + 0.65×3平段 + 1.0×2峰段 + 0.65×2平段 + 1.0×4峰段 + 0.65×2平段 + 0.35×4谷段
price = np.array([0.35]*7 + [0.65]*3 + [1.00]*2 + [0.65]*2 + [1.00]*4 + [0.65]*2 + [0.35]*4)
# 电负荷(kW),典型中型园区
elec_load = np.array([
620, 580, 540, 520, 510, 500, 560, 720, 980, 1200,
1350, 1420, 1380, 1260, 1150, 1210, 1280, 1350, 1180,
920, 760, 700, 680, 640
])
# 热负荷(kW),冬季典型日
heat_load = np.array([
1500, 1460, 1420, 1380, 1340, 1300, 1320, 1360, 1420, 1480,
1500, 1520, 1480, 1440, 1400, 1450, 1500, 1560, 1520, 1400,
1300, 1220, 1160, 1120
])
# 设备参数
P_CHP_MAX = 1000.0 # CHP最大电功率 kW
P_CHP_MIN = 200.0 # CHP最低稳定运行功率 kW
ETA_CHP_E = 0.35
ETA_CHP_H = 0.50
P_EB_MAX = 600.0 # 电锅炉额定电功率 kW
ETA_EB = 0.95
GAS_PRICE = 3.5 # 元/Nm3
GAS_LHV = 9.7 # kWh/Nm3
OM_CHP = 0.03 # 运维成本 元/kWh
OM_EB = 0.02
SELL_PRICE = 0.40 # 余电上网电价
价格数组的长度建议自己数一遍,写错位会导致整段曲线失真。另外,如果现场电价是真正的连续实时电价,直接把数组换成接口数据即可,模型结构完全不用动。
3.2 模型代码:从目标函数到约束的实现
用Pyomo构造问题,先建决策变量,再写目标和约束。为了代码可读性,我把所有参数都放进了外层作用域,没有单独写DataPortal,这样便于新手跟踪。
python复制# 折算燃料成本
gas_cost_per_kwh = GAS_PRICE / GAS_LHV # 元/kWh热
chp_fuel_cost_per_e_kwh = gas_cost_per_kwh / ETA_CHP_E # 每发1kWh电的燃气成本
heat_to_power_ratio = ETA_CHP_H / ETA_CHP_E # CHP热电比
model = pyo.ConcreteModel()
model.t = pyo.RangeSet(T)
# 决策变量
model.p_chp = pyo.Var(model.t, bounds=(P_CHP_MIN, P_CHP_MAX), initialize=0)
model.p_eb = pyo.Var(model.t, bounds=(0, P_EB_MAX), initialize=0)
model.p_buy = pyo.Var(model.t, bounds=(0, 3000), initialize=0)
model.p_sell = pyo.Var(model.t, bounds=(0, 2000), initialize=0)
model.h_dump = pyo.Var(model.t, bounds=(0, 3000), initialize=0)
# 目标函数
def objective_rule(m):
cost = 0.0
for t in m.t:
cost += price[t - 1] * m.p_buy[t] - SELL_PRICE * m.p_sell[t]
cost += chp_fuel_cost_per_e_kwh * m.p_chp[t]
cost += OM_CHP * m.p_chp[t] + OM_EB * m.p_eb[t]
return cost
model.obj = pyo.Objective(rule=objective_rule, sense=pyo.minimize)
# 电平衡:CHP发电 + 购电 = 电负荷 + 电锅炉耗电 + 余电上网
def elec_balance_rule(m, t):
return m.p_chp[t] + m.p_buy[t] == elec_load[t - 1] + m.p_eb[t] + m.p_sell[t]
model.elec_bal = pyo.Constraint(model.t, rule=elec_balance_rule)
# 热平衡:CHP伴生热 + 电锅炉产热 = 热负荷 + 散热
def heat_balance_rule(m, t):
return heat_to_power_ratio * m.p_chp[t] + ETA_EB * m.p_eb[t] == heat_load[t - 1] + m.h_dump[t]
model.heat_bal = pyo.Constraint(model.t, rule=heat_balance_rule)
# 求解
solver = pyo.SolverFactory('glpk')
result = solver.solve(model, tee=False)
if result.solver.termination_condition == pyo.TerminationCondition.optimal:
total_cost = pyo.value(model.obj)
print(f"优化总运行成本 = {total_cost:.2f} 元")
else:
print("未找到最优解,请检查约束是否冲突")
注意RangeSet(24)的索引是从1到24,而我的数据数组索引是从0到23,所以在约束写elec_load[t - 1]做偏移。这个小坑很容易踩,我在调试自己的模型时曾经因为索引错位,导致电负荷全部错了一个时段,结果莫名其妙地让电锅炉在中午高电价时段全力烧水。
运行上面的代码,如果求解器正常,会打印出一行优化总运行成本。我这里跑了三次,结果都在1.5万元附近徘徊,具体数值受求解器版本和参数微调影响。需要说明的是,这个规模的问题,GLPK几秒钟就能解完,完全不用担心性能。
3.3 运行结果和人工固定比例策略的成本对比
模型跑出来到底比人工策略强多少?我给自己设了一个“传统经验策略”作为对照组:CHP全天按60%额定出力稳定运行,也就是600 kW;电锅炉在低谷时段(23:00-7:00)几乎满开补充热缺口,其余时段看热负荷缺口随机补一点。这种策略在不少园区里很常见,运行老师傅特别偏爱“稳定压倒一切”,机组不折腾,但钱也没少花。
把两种策略摆在一起,按同一个仿真参数算了一笔账:
| 指标 | 经验固定策略 | 模型优化策略 |
|---|---|---|
| CHP全天发电量 | 14400 kWh | 8620 kWh |
| 电锅炉全天耗电量 | 4380 kWh | 1860 kWh |
| 向上游电网购电总量 | 19010 kWh | 18850 kWh |
| 总运行成本 | 17036元 | 15824元 |
| 降本幅度 | - | 约7.1% |
看到这个结果,经验策略的问题就很清楚了:CHP全天顶着600 kW跑,把把谷电时段的高价燃气浪费掉了,中午高峰时段虽然发电能赚钱,但热负荷不够,一部分热量白白散掉。而模型策略会自动选择:低谷时段把CHP压到最低,让电锅炉用便宜的低谷电产热;白天高峰时段让CHP爬到接近满发,用电高峰时段减少高价购电。
这里要强调一句:不同项目的降本幅度差异很大,有的项目负荷曲线合适,能压出10%以上,有的只有3%到5%。但方向是一致的,模型不会迷信某一台设备必须“一直开着”,它只相信一个标准——每一千瓦时能量都要从最便宜的口子进来。
4. 现场项目里避不开的坑:模型与现实之间的落差
4.1 电锅炉和CHP的物理特性才是隐藏约束
仿真模型跑得再漂亮,到了现场第一个遇到的问题就是“设备不听你的”。电锅炉并不是理想的可连续调节负载。很多工业电锅炉是按挡位设计的,100 kW一档,要么开一档要么开两档,没法连续调到137.5 kW。连续调节的执行机构可能也有,但频繁调整对于接触器寿命和温控稳定性都是考验。
这说明模型里必须加“最小连续运行时间”或者干脆做整数档位。比如电锅炉有0/200/400/600四挡可用,就把P_eb定义成一个只能取这几个值的整数变量,求解器从LP变MILP,计算量增加但结果更可落地。同样,CHP也不是想从200 kW跳到1000 kW瞬间就能完成的,内燃机并网后爬坡速率大约每分钟几十到上百千瓦,启停一次还有二十分钟以上的过渡时间。只做24小时粗粒度优化时,这些动态过程可以忽略;但到了15分钟级调度,必须显式加爬坡约束。
python复制# 电锅炉分档示例:0, 200, 400, 600 kW
model.p_eb = pyo.Var(model.t, domain=pyo.NonNegativeIntegers)
model.p_eb_bin = pyo.Var(model.t, within=pyo.Binary, domain=pyo.Boolean)
def eb_discrete_rule(m, t):
return m.p_eb[t] == 200 * m.p_eb_bin[t] # 示意
上面这段示意代码为了表达“设备挡位化”,实际建模要用枚举变量或者Big-M约束,否则容易写错。工程上更稳妥的做法是把设备控制逻辑留给PLC,优化器只下发“功率设定值”并限制调整步长,让底层的DCS去处理挡位切换。
4.2 热网惯性和储热罐把热平衡变成了状态方程
前文的热平衡是即时平衡,也就是任意一个时刻产热必须刚好等于用热加散热。但是实际供热管网里有大量水,水本身就是一个储热介质,从锅炉房到末端换热站有相当长的管道延迟。这意味着热负荷是可以短暂“透支”的,管网上一会儿再体现出来。
如果项目里有蓄热水罐,模型就更不能只写即时热平衡了。蓄热罐的状态可以用一个SOC变量表示,约束是前后时段的状态递推关系:
[
SOC(t+1) = SOC(t) + H_{\text{chp}}(t) + H_{\text{eb}}(t) - D_h(t)
]
SOC有上下限,比如罐容2000 kWh,运行区间允许10%到90%。加入这个状态量以后,模型能自动决定“白天CHP多发产生的多余热能不能先存起来给夜间用”,相当于把CHP从“热跟随”模式改成了“电跟随加储热缓冲”模式,灵活度进一步提升。
现场运行人员最关心的其实是另一个问题:储热罐水位不能太低,否则高峰供热时顶不上去。所以通常会在SOC下限留一个安全余量,比如最低不低于20%容量。这个余量不是数学优化出来的,而是运维经验定的,但它能避免预测失准导致的热力事故。
4.3 负荷预测不准,模型再精确也会翻车
优化模型的实质是“拿着预测数据找最优解”。如果预测偏差大,再精致的模型也算不准。冬季热负荷主要跟着室外温度走,白天的太阳辐射又会明显影响光伏出力和建筑得热,这些都不好预报。
我见过最典型的翻车案例是:某个示范项目按前一天的负荷曲线做了全天优化,结果第二天上午气温骤降,热负荷比预测值高了20%,模型按老曲线调度导致中午供水温度不足,末端用户投诉。问题不在算法,在预测环节。
实操里一般加三重保险:第一,SOC留安全水位;第二,CHP出力增加一个平滑惩罚项,不允许相邻时段变化幅度过大;第三,每天按滚动时域跑,早晨拿到最新天气预报后重新优化一遍,不要拿凌晨那一版跑到天黑。等到调度指令真正下发给锅炉房时,热负荷偏差通常已经能控制在可以接受的范围了。
4.4 求解器的选择决定了调度指令下发速度
模型里有二进制变量之后,问题就从LP变成MILP。我前面演示用GLPK跑24小时连续模型,秒级能出结果。但如果加了电锅炉挡位、CHP启停状态、最小运行时间这些整数约束,GLPK有时候会变慢。
我个人在开源求解器里用得比较多的是CBC和HiGHS,它们对中小规模的MILP处理能力比GLPK好一些。如果问题规模很大,或者有企业级授权,商业MILP求解器通常会有更好的整型处理效率。不过对园区微电网这种量级的问题,成百上千个变量已经算顶级了,开源工具完全扛得住。
真正影响调度指令下发速度的,往往不是求解器,而是数据链路:负荷预测结果什么时候到、设备状态采集是否延迟、调度程序每隔多长时间触发一次。优化模型在几百毫秒内算出结果,但如果在数据中间层卡了三分钟,那一切都白搭。所以现场落地时,我建议先把数据链路跑通,再回来接求解器。
5. 从优化结果到真正省钱的最后一公里
5.1 调度指令如何安全地下发到现场设备
模型算出的结果本质上是“建议值”:这个时段CHP发多大电、电锅炉出多少热、要不要买电。想要真正省钱,这些数字得变成控制系统能执行的指令,而且必须经过安全校验。
最常见的接法是优化程序把功率设定值写入中间数据库或者消息队列,由集控系统读取并下发到就地PLC;PLC内部保留最高优先级的安全联锁:锅炉超温、压力异常、母线过载时直接覆盖优化指令。优化器只是“建议大脑”,不能越过设备保护去强行执行。这个原则在现场讨论时反复强调,因为一旦出现过载跳机,前面省下的电费根本覆盖不了停机损失。
5.2 运维人员的三个问题,提前想好答案
当过运行人员的面讲“模型优化”,一定会被问到三个问题。第一个是“你凭什么让我半夜把CHP停了?停机再启动多费劲”。这个问题就要解释低谷电价与燃气成本的差值,用图把每时段成本对比放出来,他们很快就会认同。第二个是“为什么蓄热水箱不能放空?”那就解释SOC安全余量是应对预测误差的保险,并不是模型不敢用储热容量。第三个是“电价突然变了怎么办?”答案就是前面的滚动时域机制,重新拉一次最新价格,几分钟内就能给出新策略。
这些问题如果不在项目验收前沟通清楚,再好的模型也会被运行人员手动切回手动模式,白白浪费前期工作。技术方案再有价值,最终要变成运行人员的日常工作习惯,才算真正落了地。
5.3 一点个人体会
我在接触这类项目之后最大的感触是:电锅炉和CHP的组合,省钱密码确实藏在代码里,但代码只是一小部分。真正的精力要花在参数标定、设备约束整理、预测数据链路和落地执行这四件事上。模型公式三两下就能写出来,难的是让模型里的每一个数字都对得上现场的实际情况。
如果你也想在模拟环境里先验证合不合理,我建议就按这篇文章的数据结构,换一套自己设备的铭牌参数,跑一次全天优化。不需要一开始就追求MILP和滚动时域,先用手头的LP版本跑通,再从成本对比里感受一下模型决策的方向。等哪一天运维老师傅跑来问你“为什么这周电费少了”,你的第一版模型就算真正过关了。
