先说一个结论:这个概念在电力系统方向的论文里出现频率非常高,但你真去复现的时候,卡住你的往往不是那些好看的曲线,而是几个藏在细节里的建模假设。标题里的“双重低碳需求响应”,拆开看其实是两条线叠加:一条是发电侧的碳交易机制,一条是负荷侧的需求响应。两部分单独看都不复杂,合在一起就成了一个混合整数线性规划问题(MILP),用Matlab做,大部分课题组选Yalmip工具箱建模,求解器用CPLEX或Gurobi。这篇博文我把整个模型怎么搭、代码怎么组织、结果怎么验证,以及复现时最容易翻车的地方,全部过一遍。
1. 双重低碳需求响应到底是什么:先把这个课题拆明白
1.1 课题背景与核心价值
先说说为什么这个题目值得做。过去电力系统经济调度只关心“花多少钱能发够电”,目标函数是煤耗或发电成本最小化。但后来碳达峰、碳中和目标提出来以后,发电侧的碳排放不再是没有成本的行为,碳交易市场给每吨CO₂定了价,发电多、排放多,就要多花钱买配额。于是调度模型的目标函数里,除了燃料成本还得多一项碳成本,这叫发电侧的低碳化。
但光管发电侧还不够。用电负荷如果能在高峰时段少用点、低谷时段多用点,系统就可以少开高碳排放的调峰机组,整体碳排放自然降下来。这就是负荷侧的“需求响应”。需求响应不是让用户无条件拉闸,而是通过电价信号或补偿激励,引导用户主动调整用电行为。把两边合在一起,就构成了“双重低碳需求响应”。
你复现这个项目的时候,最终要得到的是一套Matlab代码,输入是系统参数(机组数据、负荷曲线、风电出力、碳配额、电价弹性矩阵),输出是未来24小时或更长时间尺度的机组出力计划、需求响应后的负荷曲线、碳排放量和总成本。
1.2 “双重低碳”的两条线:碳交易机制与负荷侧响应
“双重”这个表述在不同的文章里含义略有差异。有的文章指“碳交易+碳捕集”,有的指“碳排放约束+绿证交易”。但从热词和常见复现场景来看,这个标题更常见的对应是:
- 第一重:碳交易机制。给每台火电机组分配碳排放免费配额,实际排放超过配额的部分需要去碳市场购买,低于配额的部分可以出售获利。这样碳排放就成了成本的组成部分。
- 第二重:需求响应。负荷不再是固定刚性值,而是可以随电价或激励调整。常见建模方式有两种:一种是用价格弹性矩阵描述负荷对电价的响应,另一种是把负荷分为可转移负荷、可削减负荷和固定负荷,调度中心可直接优化调整量或给出补偿。
注意,这两条线不是简单拼在一起。碳交易会影响机组出力,机组出力变化会改变系统边际电价,电价变化又会影响需求响应后的负荷曲线,负荷曲线反过来再影响机组调度。这是一个双向耦合的优化问题。复现时的核心工作,就是把这种耦合关系用约束和目标函数的形式表达出来。
1.3 复现目标:能跑通的代码需要包含哪些模块
我按常见论文的实现口径,整理一下完整复现至少需要包含的模块:
| 模块 | 作用 | 关键参数 |
|---|---|---|
| 机组数据模块 | 定义火电机组数量及技术参数 | 出力上下限、爬坡率、煤耗系数、启停成本 |
| 新能源出力和负荷模块 | 提供系统净负荷和需求响应后的负荷基础数据 | 24h负荷曲线、风电预测出力 |
| 碳交易模块 | 计算配额、实际碳排放、碳交易成本 | 单位排放强度、免费配额系数、碳价 |
| 需求响应模块 | 计算响应后的负荷曲线和补偿成本 | 弹性矩阵、可转移负荷比例、可削减比例 |
| 目标函数与约束 | 将优化问题建模为MILP | 在各模块成本基础上构建总目标 |
| 求解与后处理模块 | 求解模型并输出表格、曲线 | 求解器状态、gap值、曲线绘图 |
这个结构可以直接对应你接下来写的每段代码。我建议你写代码时不要把所有内容堆在一个脚本里,否则后面换机组、改参数、换场景会非常痛苦。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数学模型落地方案:目标函数和约束条件怎么定
2.1 目标函数:燃料成本、碳交易成本、需求响应补偿成本的耦合
调度模型的目标函数一般是总运行成本最小化。考虑双重低碳需求响应后,主要有四项成本(下面以火电系统加上风电、考虑需求响应为例):
总成本 = 火电燃料成本 + 启停成本 + 碳交易成本 + 需求响应补偿成本(+ 弃风惩罚,看你是否考虑)
在一个标准复现项目里,燃料成本可以用二次函数或分段线性函数表示。二次函数形式:
C_fuel = Σ Σ (a_i × P_i(t)² + b_i × P_i(t) + c_i)
其中i是机组编号,t是时段,a、b、c是煤耗系数。这里的P_i(t)是优化变量,所以目标函数里出现平方项,如果直接用Yalmip把它声明为变量,求解器会把它当二次规划处理(如果是MILP则需要分段线性化)。
碳交易成本的形式取决于配额分配方式。最简单的基准线法计算方式是:
E_i(t) = δ_i × P_i(t) (机组i在t时段的实际碳排放量)
E_free,i = λ × P_i,max(机组i的免费配额,也可以按总配额再分配)
C_carbon = c_carbon × (ΣE_i(t) - ΣE_free,i)
这里有一个需要注意的地方:如果实际排放小于免费配额,右边括号是负数,即卖碳的收入,目标函数里会体现为负成本。
需求响应补偿成本有两种常见形式。价格型需求响应一般不计补偿,而是通过售电收入变化间接影响;激励型需求响应则直接给参与响应的负荷支付补偿。见下节。
2.2 碳交易建模:免费配额与阶梯碳价
碳交易机制,我复现时最常用的是“阶梯碳价”模型,也就是当超排量超过某个阈值后,碳价会跳到更高档位,这是模拟碳市场上配额价格随供需变化的特征。
阶梯碳价可以用如下的分段函数描述:
C_carbon =
如果 0 ≤ E_excess ≤ d1,则单价为 c1
如果 d1 ≤ E_excess ≤ d2,则单价为 c2
如果 E_excess > d2,则单价为 c3
其中 c1 < c2 < c3
这个阶梯函数在目标函数里是分段线性函数,处理方式一般有两种:
- 方法一:直接用三个辅助0-1变量表示超排量落在哪个区间,再引入大M约束线性化。
- 方法二:把超排量拆成三段非负变量,每段有不同的成本系数,配合容量约束,再用“顺序逻辑”或直接依赖目标系数保证低段先被填满。
后者更常用,因为当且仅当目标函数中低段成本更低时,优化器会自动优先使用低价段,不需要加0-1变量,求解速度更快。
2.3 需求响应建模:价格弹性矩阵与激励型可转移/可削减负荷
需求响应建模是整个复现中概念最多、最容易出错的部分。
第一种是价格型需求响应(Price-based DR,简称PDR)。它的数学形式是:
ΔP_load(t) / P_load0(t) = E(t, t) × Δρ(t) / ρ0(t) + Σ E(t, τ) × Δρ(τ) / ρ0(τ)
其中E(t, t)是自弹性系数(通常为负),E(t, τ)是交叉弹性系数(通常为正,表示其他时段价格上涨时,本时段负荷增加)。Δρ是电价变化量,ρ0是基准电价。
但你做Matlab复现时,更常见的写法是直接算响应后的负荷:
P_load_DR(t) = P_load0(t) × (1 + Σ E(t, τ) × (ρ(τ) - ρ0(τ)) / ρ0(τ))
这里有个非常容易踩的坑:弹性矩阵的行列顺序。不同论文里E(t, τ)定义不一样,有的表示“第t行、第τ列”是t时段负荷对τ时段电价的弹性,有的反过来。复现前一定要先看原文献怎么写,别直接套别人的代码。
第二种是激励型需求响应(Incentive-based DR,简称IBDR)。常见形式是优化可转移负荷量和可削减负荷量:
P_load_DR(t) = P_load0(t) + P_trans_in(t) - P_trans_out(t) - P_cut(t)
约束通常包括:
- Σ P_trans_in(t) = Σ P_trans_out(t)(转移负荷总量守恒)
- 0 ≤ P_cut(t) ≤ α_cut × P_load0(t)(可削减比例上限)
- 0 ≤ P_trans_out(t) ≤ α_trans × P_load0(t)(可转移比例上限)
补偿成本就是:
C_DR = c_trans × Σ P_trans_out(t) + c_cut × Σ P_cut(t)
2.4 约束条件一览:从功率平衡到响应量限制
一个完整的复现模型,约束条件大概有下面这些,我按会不会导致代码报错和维护困难的程度排个序:
功率平衡约束(最关键)
Σ P_g(t) + P_wind(t) = P_load_DR(t) + P_loss(t)
很多复现代码忽略网损,直接用等号简化,这样没问题,算例规模不大的时候误差可接受。但如果你的系统里风电渗透率很高,忽略网损可能导致极端出力场景下无解,建议在论文里说明“忽略网络损耗或采用直流潮流模型”。
机组出力上下限与爬坡约束
P_i,min ≤ P_i(t) ≤ P_i,max
-Ramp_i,down ≤ P_i(t) - P_i(t-1) ≤ Ramp_i,up
这里要注意,如果机组启停变量是0-1变量,那么上下限约束往往是:
u_i(t) × P_i,min ≤ P_i(t) ≤ u_i(t) × P_i,max
爬坡约束同样要乘启停状态,否则出现“停机机组还在出力”的矛盾。
旋转备用约束
Σ min(u_i(t) × P_i,max - P_i(t), Ramp_i,up) ≥ R_reserve(t)
这个约束在Yalmip里写的时候比较绕,因为带了min。常见的线性化写法是:
Σ (u_i(t) × P_i,max - P_i(t)) ≥ R_reserve(t)
也就是用最大可增出力之和作为备用,虽然有点保守,但线性、直接、好实现。
需求响应相关约束
这个取决于你用哪种需求响应模型。如果是价格型,负荷变化量不是一个独立的优化变量,而是由电价差直接计算出来,需要在目标函数或迭代逻辑里体现;如果是激励型,则需要额外加上转移守恒、削减上限等约束。
3. Matlab实现:从公式到代码的翻译细节
3.1 整体代码架构与文件组织
当你准备把上述模型写成Matlab代码时,我建议按以下文件结构组织:
code复制demo_DR_carbon.m % 主脚本:参数初始化、建模、求解、出图
data_load.m % 数据函数:返回负荷、风电、机组参数等
model_dr.m % 建模函数:输入参数,返回Yalmip变量和约束
solve_and_plot.m % 结果整理与绘图
这样做的原因很实际:复现时你通常需要跑多组场景对比,比如“无需求响应”“有需求响应”“有碳交易”“双重低碳”。如果把所有代码写在一个脚本里,切换场景时注释和反注释非常容易出错。我看到很多同学改来改去,最后跑出来的曲线对不上号,其实就是场景开关没控制好。
3.2 Yalmip建模的关键写法
Yalmip是目前Matlab下做优化调度最方便的工具箱。核心用法先简单过一遍:
matlab复制% 定义变量
P = sdpvar(n_gen, T); % 机组出力
u = binvar(n_gen, T); % 启停状态
Pcut = sdpvar(1, T); % 可削减负荷
Ptr_out = sdpvar(1, T); % 可转移出负荷
Ptr_in = sdpvar(1, T); % 可转入负荷
% 约束
Constraints = [];
for t = 1:T
% 功率平衡
Constraints = [Constraints, sum(P(:,t)) + Pw(t) == Pload_dr(t)];
% 机组上下限
Constraints = [Constraints, Pmin .* u(:,t) <= P(:,t) <= Pmax .* u(:,t)];
% 爬坡约束
if t > 1
Constraints = [Constraints, P(:,t) - P(:,t-1) <= RampUp];
Constraints = [Constraints, P(:,t-1) - P(:,t) <= RampDown];
end
end
% 目标函数
Objective = sum(sum(a .* P.^2 + b .* P + c)) + CarbonCost + DRCost;
% 求解
ops = sdpsettings('solver', 'gurobi', 'verbose', 2, 'showprogress', 1);
optimize(Constraints, Objective, ops);
这里有几处必须注意:
第一,二次函数a .* P.^2在Yalmip中如果P是sdpvar,会生成二次约束或二次目标。如果你的a比较小,而b、c比较大,数值上容易出现求解精度问题,建议用scale设置或者把a扩大1000倍再换算。
第二,Pmin .* u(:,t)用在这里,是把机组出力下限制约与启停状态关联。如果你直接写Pmin <= P(:,t) <= Pmax,那么停机机组也会被强制要求出力,模型绝大多数情况下无解。
第三,爬坡约束必须用P(:,t) - P(:,t-1),千万不要直接用车上的约束Pmean中间变量,否则会破坏耦合关系。我见过很多新手把这写成独立约束,导致爬坡完全没有起到限制作用。
3.3 非线性项线性化:分段线性处理的两种做法
这个课题里最常见的非线性项是燃料成本的二次函数和碳交易成本的分段阶梯。燃料成本在不要求高精度的时候,可以直接保留二次,然后用CPLEX/Gurobi的MIQP求解。但如果你想要完全线性,可以考虑以下分段线性化思路:
把火电机组出力范围[P_min, P_max]切分成K段,每段长度相同或根据机组特性不等长,然后引入连续变量和0-1变量的组合,使得总成本等于各段斜率乘以分段出力之和。Yalmip中可以直接使用pwl函数,或者手动构造:
matlab复制% 分段点
P_break = linspace(Pmin, Pmax, K+1); % K段
C_break = a * P_break.^2 + b * P_break + c; % 端点成本
% Yalmip内置分段函数
C_fuel = pwl(P(i,t), P_break, C_break);
如果你用的Yalmip版本较老不支持pwl,就要手动引入辅助变量。这种时候有一个小技巧:每个机组的每个时段都做一次分段处理,会生成非常多变量,求解时间会显著增加。对于三机六机组的小算例可能还能跑,如果机组数到10以上,建议只在成本曲线线性化精度要求高的机组上做,其余用二次近似。
碳交易成本的阶梯函数线性化,更简单的方法是引入三组非负变量和对应的上限约束:
matlab复制E1 = sdpvar(1,1); % 第一档超排量
E2 = sdpvar(1,1);
E3 = sdpvar(1,1);
Constraints = [Constraints, 0 <= E1 <= d1];
Constraints = [Constraints, 0 <= E2 <= d2 - d1];
Constraints = [Constraints, 0 <= E3 <= d3 - d2];
E_excess = E1 + E2 + E3;
CarbonCost = c1 * E1 + c2 * E2 + c3 * E3;
关键在于,由于c1 < c2 < c3,优化器为了最小化成本会自动优先填满第一档,再填第二档,不需要额外加0-1变量和逻辑约束。如果你试过发现它跳过了第一档直接去用第二档,那很可能是第二档的斜率或边界设置错误。
4. 算例结果怎么分析:判断模型是否运行正确的检查点
4.1 场景设计:四组对比实验
复现这个项目,不只是把代码跑出来,更要把结果对比做出来。最常见的场景划分:
| 场景 | 是否考虑碳交易 | 是否考虑需求响应 | 预期效果 |
|---|---|---|---|
| S1:基础调度 | 否 | 否 | 总成本最高、碳排放最高 |
| S2:仅碳交易 | 是 | 否 | 碳排放下降,但高负荷时段仍紧张 |
| S3:仅需求响应 | 否 | 是 | 负荷曲线被削峰填谷,成本下降 |
| S4:双重低碳需求响应 | 是 | 是 | 碳排放和成本综合最优 |
你在代码里建议用开关变量控制:
matlab复制isCarbon = 1;
isDR = 1;
然后通过if语句在不同场景下选择目标函数和约束的构成。这样同一个代码结构可以一次性把所有场景跑完。
4.2 负荷曲线变化与碳排放结果
跑完后第一张图应该画“需求响应前后的负荷曲线”。理想情况下,双重低碳需求响应的负荷曲线会比原始曲线更平缓——峰时段负荷下降,谷时段负荷上升,这就是削峰填谷效果。
第二张图是机组出力堆叠图。你会看到,在考虑碳交易后,高效低排放的大机组出力比例上升;在考虑需求响应后,原本需要在早晚高峰启动的高碳排放调峰机组,可能不再需要启动。
第三张图是碳排放量对比柱状图。各场景的碳排放应满足:S1 > S3 > S2 > S4,这是一个从理论上就成立的排序,如果你跑出来S2的碳排放高于S3,或者S4反而比S3高,那一定是建模或代码有bug。
我复现时还会额外检查碳交易量是否为正负合理。如果系统免费配额充裕,实际排放低于配额,目标函数里碳交易量会出现负值,即卖碳收益。这会导致总成本可能比不考虑碳交易更低,虽然看起来反直觉,但在配额宽松的参数下是完全合理的。
4.3 抽丝剥茧:结果异常时先从哪几个地方查
如果结果不对,按优先级排查以下位置:
第一,看求解器的状态。Yalmip里optimize返回的sol.info如果是Infeasible problem,那模型本身可能就有不可满足的约束。此时先把需求响应约束和碳交易约束全部注释掉,跑一遍基础调度,如果还是不可行,那问题出在基础约束(功率平衡或机组启停)上。
第二,看负荷平衡是否精确成立。功率平衡约束在高风电渗透时会因为预测出力很大而出现尖峰被切掉的情况。如果约束形式是sum(P(:,t)) + Pw(t) == Pload_dr(t),可以加一个小容差,比如abs(...) <= 1e-3,防止浮点误差造成的不可行。
第三,看需求响应后的负荷是否为负。价格型需求响应如果自弹性系数设置过大,比如-1.5,再叠加上交叉弹性项,某些时段的负荷可能变成负值。虽然优化器会出力避免这种结果,但如果你看到负荷曲线上有0以下的点,先检查弹性矩阵和基准电价的范围。
第四,看启停变量是否出现抖动。MILP求解时,如果gap设置太大,比如默认的1e-2,可能出现机组的启停在相邻时段反复跳变,这在实际中是不允许的。建议把MIP gap设小一些,并在后处理中检查:如果u(i,t)和u(i,t-1)差异很大但前后出力变化很小,大概率是gap问题。
5. 复现过程中最容易被卡住的几个细节
5.1 弹性矩阵方向与量纲
价格型需求响应无论是用弹性矩阵还是用分段响应曲线,最容易出错的就是方向和量纲。
方向指的是:交叉弹性应该是正还是负。自弹性为负,表示电价上涨,本时段负荷下降。交叉弹性为正,表示其他时段电价上涨,本时段负荷上升,因为用户把转移到其他时段的负荷挪回来了。有些论文的“交叉弹性”写成“替代弹性”,符号定义完全相反。你复现时,先画一张响应前后负荷曲线对比图,如果峰谷完全反了,不用怀疑,就是弹性符号错了。
量纲方面,常见的写法是百分比变化除以百分比变化,所以整个公式里没有明确的物理单位。你需要注意Matlab里的数值大小。典型自弹性系数在-0.3到-0.1之间,交叉弹性在0.01到0.1之间。如果你看到论文里直接给出弹性矩阵且数值从几十到几百,那说明它不是百分比弹性,而可能是“负荷转移系数”,建模逻辑完全不同。
5.2 需求响应比例上限与功率平衡冲突
激励型需求响应中,可转移负荷守恒约束Σ P_trans_in(t) = Σ P_trans_out(t)看似简单,实际上在24时段的大系统里很容易和功率平衡约束产生冲突。原因在于,当转移出负荷较多的时段刚好是风电低谷时段,而系统又要求风电必须全部消纳时,功率平衡可能无法满足。
解决方案有两个。一是放宽可转移负荷的比例限制,例如把α_trans从0.1提高到0.2或0.3;二是在功率平衡约束中给风电允许一定程度的弃风,增加一个松弛变量P_curtail(t),并在目标函数中加入弃风惩罚:
增加约束:sum(P(:,t)) + Pw(t) - P_curtail(t) == Pload_dr(t)
目标函数中加入:c_curtail × Σ P_curtail(t)
这个处理非常实用,尤其是用风电实测数据或低风时段数据时,硬性等号会经常无解。
5.3 求解器配置与大M处理
如果你用的是Gurobi,建议在sdpsettings里调整求解器参数:
matlab复制ops = sdpsettings('solver', 'gurobi', ...
'gurobi.MIPGap', 1e-3, ...
'gurobi.TimeLimit', 300, ...
'gurobi.NonConvex', 2, ...
'verbose', 2);
我遇到过的情况是,默认gap跑到2%就停了,这时候机组启停方案不稳定,经常出现相邻时段来回跳变。把MIPGap调到1e-3后,结果稳定多了,代价是求解时间从几秒涨到几十秒,但24时段、6机组的小算例完全能接受。
大M约束在这个项目里最常用于阶梯碳价的分段选择或备用约束的线性化。设M值时有个原则:尽量收紧。M值过大不仅会增加求解难度,还会在数值上产生精度问题。比如,M取10000而其他参数量级只有100,就会出现约束“看似满足,实际不满足”的情况。你应该根据变量的物理上限来设M,比如机组最大出力P_max加上一个较小的缓冲就足够。
5.4 多时间尺度系统的矩阵维度逐维核对
最后再说一个纯代码层面的坑。当你从别人GitHub上下载代码来复现时,最容易出现的就是“矩阵维度不一致”的报错。这个课题的时间尺度和机组数量维度互相嵌套,我建议按以下顺序逐维核对:
- 第一步,确认
P是n_gen × T,u也是n_gen × T。 - 第二步,确认
Pw、Pload0、Pload_dr都是1 × T或T × 1,并且和sum(P(:,t))的维度能对上。 - 第三步,确认目标函数里的
sum方向。sum(a .* P.^2)默认是对所有元素求和,结果是标量,没问题;但如果手滑写成sum(a .* P.^2, 1),得到一个1 × T向量放进目标函数,Yalmip也能处理,但后续查看value(P)时可能会产生混淆。 - 第四步,检查爬坡约束在
t=1时的处理。我见过很多代码在循环里从t=1开始写P(:,t) - P(:,t-1),这样P(:,0)会触发索引越界。正确做法是t从2开始,或者在t=1时使用机组初始出力作为已知量(通常为Pmax×0.5或给定值)。
这些维度问题在算例小时不容易暴露,但一旦你扩展开来,比如从24时段改成96时段,从6台机组改成10台,问题就会集中爆发。所以从第一天开始,就给每条约束、每个变量写清楚维度注释,别偷懒。
写在最后的一点建议
这个项目我前后复现过很多次,最大的体会是:不要把注意力全放在求解器和代码技巧上,模型假设的前后一致才是关键。碳交易模块里配额怎么分,需求响应的弹性怎么定义,目标函数里有没有计入弃风惩罚,这些细节直接决定你的结果能不能和论文对得上。你在Matlab里花半小时调通一个求解器报错,真的不如花半小时把参数表的单位、符号、量纲理清楚。建议拿到任何一份复现代码后,先画出系统拓扑图或框架流程图,把每个变量的输入输出关系理清楚,再动手跑代码。这样即使后面出了问题,你也能很快定位到具体是哪个模块的锅。
