1. 这个课题到底在解决什么问题:源荷不确定性下的低碳调度难题
这几年双碳目标带火了电力系统的低碳调度研究,但真正动手写过代码的人都知道,难点不在"低碳"二字上,而在"不确定性"这三个字。拿我自己的经历来说,最早接手这个题目时,我以为只是给传统经济调度加一个碳排放惩罚项就行,结果程序跑出来的结果怎么都不对劲——有些场景下机组启停方案特别激进,有些场景下备用容量明明充足却大量弃风。后来才意识到,问题出在我把所有输入都当成了确定值。
在含风电的电力系统里,不确定性来自两个方向。电源侧,风电出力取决于风速,而风速本身是个随机过程,一天之内发电功率可以从零跳到额定容量的百分之七八十;负荷侧,用户的用电行为虽然有一定规律,但预测误差随预测时长增加会明显放大,尤其在峰谷时段,偏差可能达到预测值的5%到10%。这两个不确定性叠加在一起,调度方案就不能再做单点估计,必须考虑一套决策在多种可能场景下都"可运行"且"够经济"。
低碳调度的本质,是在传统以煤耗成本最小为目标的基础上,把碳排放成本内部化。国内目前主流做法是引入碳排放权交易机制:政府给发电企业分配碳配额,实际排放低于配额的部分可以出售获利,超出配额的部分必须到市场上购买,或者接受惩罚性碳价。这样一来,高碳机组的发电成本上升,低碳机组(风电、燃气机组)的相对经济性提升,调度结果自然向低碳倾斜。但问题在于,一旦把不确定性考虑进来,碳成本的计算也变得不确定——同样是某一时段开机,风电出力的高低直接决定了火电机组要发多少电、排多少碳。
所以这个课题的核心链路是:源荷双侧不确定性建模 → 场景生成与削减 → 含碳交易成本的多目标(或单目标带惩罚)调度模型 → 混合整数规划求解 → 结果对比分析。每个环节都有独立的坑,下面我分块拆解。
2. 源荷两侧不确定性的数学建模:从概率分布到典型场景
2.1 风电出力不确定性:分风速和功率两步走
风电出力不确定性的建模,业界常用的是"风速-功率"两级转换。风速近似服从威布尔分布(Weibull distribution),其概率密度函数为:
[ f(v) = \frac{k}{c}\left(\frac{v}{c}\right)^{k-1}\exp\left(-\left(\frac{v}{c}\right)^k\right) ]
其中 ( k ) 是形状参数,( c ) 是尺度参数。实际做项目时不需要自己拟合这两参数,直接参考文献中常给的经验值即可,比如 ( k=2.2 )、( c=8.5 ),大概对应的就是平均风速七八米每秒的风电场。有了风速之后,通过风机的功率特性曲线换算成出力。常见的简化模型是分段线性:
[ P_w(v) = \begin{cases} 0, & v < v_{in} \text{ 或 } v \geq v_{out}\ P_r \cdot \frac{v - v_{in}}{v_r - v_{in}}, & v_{in} \leq v < v_r\ P_r, & v_r \leq v < v_{out} \end{cases} ]
( v_{in} ) 是切入风速、( v_{out} ) 是切出风速、( v_r ) 是额定风速、( P_r ) 是风机额定功率。这套转换逻辑不难,但写代码时最容易忽略的是:最终的出力场景要叠加一个截断处理。很多初学者直接拿正态分布或威布尔分布采样后套公式,却忘了风电出力有上限和下限,导致生成的场景里出现负值或超过装机容量的值,场景削减后调度模型直接无解。
2.2 负荷不确定性:正态分布与预测误差
负荷侧的不确定性相对好处理,通常假设负荷预测误差服从均值为零、标准差为预测值一定比例的正态分布。即实际负荷可以表示为:
[ P_L(t) = \hat{P}_L(t) + \varepsilon(t), \quad \varepsilon(t) \sim \mathcal{N}(0, \sigma^2(t)) ]
( \sigma(t) ) 一般取预测值的2%到5%,我习惯取3%,因为再小体现不出不确定性效果,再大调度方案会过于保守、经济性损失明显。对于一天24小时的调度周期,每个时段都独立采样误差,然后叠加到预测负荷曲线上,就得到一组负荷场景。
有个细节值得注意:负荷的真实分布其实更接近截断正态或t分布,因为极端偏差的概率被电网运行经验证明是偏低的。但论文里用标准正态分布已经足够,除非审稿人特别较真,否则不必在这个地方过度设计。
2.3 场景生成与削减:蒙特卡洛 + 同步回代削减
不确定性处理的思路,最终要落在"场景"这两个字上。所谓场景,就是一组同时包含风电出力、负荷水平的时段序列。我做这个课题时用的是蒙特卡洛采样生成大量初始场景,然后用同步回代削减法(Scenarios Reduction by Simultaneous Backward Reduction)把场景数量压缩到计算可接受的范围。
具体做法分四步:
第一步,对每个时段的风速和负荷误差分别采样,得到一组初始场景。比如一天24个时段,每个时段风电和负荷各采800个样本,组合起来就是800个完整场景。
第二步,定义场景之间的距离。常用的是欧几里得距离,即所有时段上两个场景对应值的差的平方和。这里注意,风电和负荷的量纲一致(都是MW),可以直接算距离;但如果你后续扩展模型,加入电价、光伏等不同量纲变量,需要先归一化再算距离。
第三步,用同步回代削减迭代:每次找出会使总概率距离损失最小的场景,并把它删除,同时将其概率加到离它最近的场景上。迭代直到场景数满足预设值(我一般削减到10到20个)。
第四步,把削减后的场景及对应概率作为调度模型的输入。
这里有个经验谈:场景削减不能只追求数量少,还要看削减后的场景集能否覆盖极端情况。比如风电出力的高风场、低风场场景都保留至少一个,否则削减算法为了"总距离最小"会把极端场景抹平,调度结果在极端天气下完全不靠谱。我自己的做法是削减后再手动检查一遍,看最大出力场景和最小出力场景是否还在,不在就手动补回。
3. 低碳调度模型构建:目标函数与约束的取舍
3.1 目标函数:综合成本最小化
低碳调度最常见的目标函数是系统总成本最小,总成本由三块构成:
第一块是火电机组的煤耗成本。一般用二次函数:
[ C_f(P_i) = a_i P_i^2 + b_i P_i + c_i ]
( a_i, b_i, c_i ) 是机组煤耗系数。MILP里二次项不好处理,通常用分段线性化逼近。我这里直接取了10段线性化,精度已经足够,而且求解速度快不少。
第二块是碳交易成本。先算系统碳排放量:
[ E_{total} = \sum_{t} \sum_{i \in G} \left( \alpha_i P_{i,t} + \beta_i \right) \Delta t - E_{w} ]
其中 ( \alpha_i, \beta_i ) 是机组i的碳排放强度系数(单位:tCO₂/MWh 或 tCO₂/h),( E_w ) 是风电的碳排放抵扣(一般风电低碳甚至零碳,且可冲减一定比例)。然后对比碳配额 ( E_{cap} ):
[ C_{CO2} = \begin{cases} \lambda (E_{total} - E_{cap}), & E_{total} \geq E_{cap} \text{(需要购买配额)}\ \lambda \cdot \mu (E_{total} - E_{cap}), & E_{total} < E_{cap} \text{(盈余配额可出售,但折价)} \end{cases} ]
( \lambda ) 是碳交易价格,( \mu ) 是出售折扣系数,常取0.5到0.8。这里必须说明,如果不加这个非对称处理,系统会被引向极端低碳而牺牲经济性,实际市场中碳价也是有同样机制的。折价系数是为了避免"为了卖配额而过度压减火电"的不合理行为。
第三块是弃风惩罚成本:
[ C_{curtail} = \rho \sum_{t} \left( P_{w,t}^{fore} - P_{w,t}^{sch} \right) ]
( \rho ) 是弃风惩罚单价,( P_{w,t}^{fore} ) 是风电预测出力,( P_{w,t}^{sch} ) 是调度实际消纳的风电功率。加这一项是为了让优化器"尽量"消纳风电,但又不至于为了消纳风电而付出过高的火电调节代价——这是调度问题里常用的柔性约束处理方式。
这三项加权求和,得到总目标函数。权重怎么定?我建议碳价和弃风惩罚单价直接参考实际市场数据,不要拍脑袋。比如碳价取25到40美元/吨(视研究年份),弃风惩罚取100美元/MWh左右(足够高但不至于荒谬)。
3.2 约束条件:该卡的卡死,该放开的放开
约束方面,电力系统调度有几条不可违反的硬约束,也有可灵活处理的软约束,要分清。
第一是功率平衡约束:
[ \sum_{i \in G} P_{i,t} + \sum_{w \in W} P_{w,t}^{sch} = P_{L,t}^{sch} ]
这个约束是等式约束,必须严格满足,否则系统就不平衡了。注意我这里用的是 ( P_{L,t}^{sch} ) 即调度后的负荷值,不是预测值。因为源荷不确定性建模时,每个场景下负荷是不同的。
第二是机组出力上下限约束:
[ P_i^{min} \leq P_{i,t} \leq P_i^{max} ]
以及爬坡约束:
[ -P_{i}^{ramp_down} \leq P_{i,t} - P_{i,t-1} \leq P_{i}^{ramp_up} ]
爬坡约束最容易在代码里出问题,尤其是机组启停状态切换的那个时段,启机瞬间引起的出力跳跃可能会违反爬坡限制。解法是引入机组运行状态变量 ( u_{i,t} )(0/1变量),爬坡约束写成:
[ P_{i,t} - P_{i,t-1} \leq (P_i^{ramp_up})u_{i,t-1} + P_i^{max}(1 - u_{i,t-1}) ]
这个式子的逻辑是:如果上一时段机组在运行(( u_{i,t-1}=1 ))则爬坡上限生效;如果不在运行(( u_{i,t-1}=0 )),则本时段可以自由启动到最大出力。很多人写代码时把爬坡约束写成对所有时段统一的形式,结果在机组合并启停的优化结果里出现违例,这个坑很典型。
第三是旋转备用约束:
[ \sum_{i \in G} \min\left(P_i^{max} - P_{i,t}, P_i^{ramp_up}\right) + \sum_{w} \left( P_{w,t}^{fore} - P_{w,t}^{sch} \right) \geq R_t ]
这个约束的含义是:系统在时刻 ( t ) 要能应对突发扰动,所以所有机组的可调容量之和必须大于备用需求。( R_t ) 的取值一般取当期负荷的5%到10%,或者取最大单机容量。因为不确定性场景的存在,( R_t ) 可以在每个场景下分别取值,这样能够真实反映源荷不确定性对备用需求的改变——风电出力低的场景,火电就得多留一些调节余量。
第四是碳排放约束。这个有两种写法:一种是作为目标函数里的碳交易成本项而不加硬性约束(即上面3.1的写法),另一种是给系统设一个总碳排放上限,强制满足:
[ E_{total} \leq E_{limit} ]
两种都常见,区别在于论文的论证偏好。我个人推荐先做目标函数里的碳交易成本,再在敏感性分析部分对比加上硬约束的结果,这样能直观展示碳价机制和碳限额机制的区别,审稿人也喜欢看这个对比。
3.3 场景约束的合并形式:鲁棒与随机的折中
当有多个场景时,约束怎么合并?标准做法是:对于每个场景,功率平衡、旋转备用、爬坡约束都需要分别满足。机组出力和启停状态变量要区分"第一阶段变量"和"第二阶段变量"。
第一阶段变量是预调度变量(日前决策),在不确定性实现前就要确定——典型的是机组启停状态 ( u_{i,t} );第二阶段变量是再调度变量(实时调整),可以在场景实现后调整——典型的是机组出力 ( P_{i,t,s} )。
这种两阶段结构在数学上叫"带补偿的随机规划",MILP的规模会随着场景数和时段数急剧膨胀。假设10个场景、24个时段、6台机组,变量数和约束数就在几千量级,Gurobi还能轻松解。如果场景数上到50,求解时间会明显变长,这时候就需要取舍。我在代码里默认支持的场景数是10,这也是论文里最常出现的数据量。
4. 求解工具选型与Matlab代码架构
4.1 为什么选Yalmip + Gurobi而不是纯Matlab
Matlab里求解混合整数线性规划,原生可以用intlinprog,但这个求解器面对几百上千个整数变量就力不从心了,尤其在多场景MILP里,求解时间能拖到半小时以上。我在这个课题里用的是Yalmip接口加Gurobi求解器。
Yalmip是一个Matlab的建模语言,它最大的价值是让建模和求解分离——你用人类可读的代数符号写约束和目标,然后指定用哪个求解器,Yalmip负责翻译成求解器能吃的形式。这个设计让调试速度快很多,因为你可以先写小规模问题验证模型正确性,再扩大场景规模,而不需要来回换代码框架。
Gurobi的MILP求解性能在学术圈是公认的标杆,对问题规模和整数变量数量的承受力比intlinprog强一个量级。不过Gurobi是商业软件,教育版需要申请license。如果没有Gurobi,退而求其次可以用CBC(free)或Matlab自带的intlinprog,但在场景数超过20的情况下,我不太推荐intlinprog。
4.2 代码文件结构:模块化是调试的救星
我的Matlab代码架构分六个模块,每个模块单独一个.m文件,主脚本只负责按顺序调用:
case_data.m:系统参数,包括机组参数、负荷数据、风电场参数、碳交易参数。所有数据集中在尾部的一个大struct里,方便批量修改。generate_scenarios.m:根据不确定性模型,生成并削减初始场景,输出场景集和对应概率。reduce_scenarios.m:实现同步回代削减算法,输入初始场景集,输出削减后的场景集和概率索引。build_scheduling_model.m:搭建Yalmip模型,把目标函数和约束条件全部实例化。solve_and_postprocess.m:调用求解器求解,输出机组出力时序、风电消纳情况、碳成本构成等关键数据。plot_results.m:绘制机组出力堆叠图、风电消纳图、碳排放柱状图、场景对比图。
模块化的好处是,如果结果有问题,你可以单独验证某一部分。比如怀疑场景削减有bug,就跑一遍generate_scenarios.m看削减前后的总距离是否合理递减;怀疑碳交易成本算错,就把目标函数里其他项先注释掉,看单独碳成本项是否按预期变化。这种逐步排查的习惯,远比我一开始写"一个大脚本从头到尾"的方式高效。
4.3 Yalmip建模核心代码逻辑
搭建调度模型的核心代码骨架大概长这样(省略参数定义):
%% 定义变量 P = sdpvar(n_gen, n_horizon, n_scenario); % 火电机组出力 u = binvar(n_gen, n_horizon); % 机组启停状态(第一阶段) Pw = sdpvar(n_wind, n_horizon, n_scenario); % 风电消纳功率 % 还有碳交易相关的连续变量等 %% 目标函数 objective = 0; for s = 1:n_scenario for t = 1:n_horizon % 煤耗成本(分段线性化后可以直接线性求和) objective = objective + prob(s) * (C_fuel(P(:,:,s), t)); % 碳交易成本 objective = objective + prob(s) * C_carbon(P(:,:,s), t); % 弃风惩罚 objective = objective + prob(s) * C_curtail(Pw(:,:,s), t, Pw_fore(:,:,s)); end end %% 约束 constraints = []; for s = 1:n_scenario for t = 1:n_horizon constraints = [constraints, sum(P(:,t,s)) + sum(Pw(:,t,s)) == P_load(t,s)]; % 出力上下限、爬坡约束(带启停状态)、备用约束、碳约束等 end end %% 求解 optimize(constraints, objective, sdpsettings('solver', 'gurobi', 'verbose', 2));这里有个容易忽略的坑:目标函数里的机组煤耗成本二次项,Yalmip可以直接写二次函数并用Gurobi的非凸MIQP求解,但求解速度惨不忍睹。更好的做法是自己写分段线性化函数,把二次项转化为线性项,整个模型变成MILP。Gurobi解MILP的速度比MIQP快十倍不止,尤其在场景多的时候。我在代码里早就封装了一个linearize_cost.m,输入煤耗系数和分段数,输出线性化后的各段斜率和截距,运行效率高很多。
4.4 场景削减代码的细节实现
同步回代削减的代码不复杂,但要注意数值精度问题。核心循环是:
while num_active > target_num min_dist_loss = inf; k_star = 0; for k = 1:num_active % 找离k场景最近的另一个场景 dist_to_others = sqrt(sum((scenario_set(:,k) - scenario_set).^2, 1)); dist_to_others(k) = inf; % 排除自身 [min_dist, nearest_idx] = min(dist_to_others); % 计算删除k场景带来的概率距离损失 dist_loss = prob(k) * min_dist; if dist_loss < min_dist_loss min_dist_loss = dist_loss; k_star = k; nearest_star = nearest_idx; end end % 删除k_star,概率加到nearest_star上 prob(nearest_star) = prob(nearest_star) + prob(k_star); scenario_set(:, k_star) = []; prob(k_star) = []; num_active = num_active - 1; end这段代码最耗时的是计算所有场景两两之间的距离矩阵,数据量大时O(n²)的复杂度会拖慢整体。优化方法是用大循环加向量化,避免内层套一个小循环。我在我的实现里用pdist2函数配合矩阵索引,一次算完所有距离,再从距离矩阵里贪婪筛选,速度提升明显。
还有个小陷阱:prob和scenario_set在循环里不断删行,初始索引一定要备份,否则削减完你就不知道剩下的场景对应原始的风速序列还是负荷序列了。我在结构体里加了original_idx字段,专门记录每个存活场景对应原始数据集合中的哪一组,这样后续分析结果时能回溯到具体场景的物理含义,不至于拿着一个"不知从哪来"的场景做图表。
5. 结果分析与敏感性验证:跑出图之后还能看到什么
5.1 不同场景下的调度结果差异
模型跑通之后,第一件该做的事是对比确定性模型和不确定性模型的结果差异。做法很简单:把源荷两侧的预测值当作真实值,只保留一个场景(即确定性场景),求解一次;再拿10个场景的随机规划模型求解一次,对比两者的总成本、碳排放量、风电消纳率。
我做过的实验里,确定性模型给出的总成本通常比随机规划低5%到10%,但碳排放量却往往更高。原因倒不复杂:确定性模型有"信息优势",相当于提前精确知道风电出力,可以把火电出力压得更低、碳成本更少。但现实世界中风电出力不可能被精确预测,一旦实际风电与预测偏差大,确定性模型给出的调度方案就需要大量弃风或强制启动高碳机组来补救,实际碳排放反而上升。把这个对比写进论文里,比空口强调"不确定性很重要"有说服力得多。
5.2 碳价与弃风惩罚系数的影响
碳价是低碳调度里最核心的杠杆参数。我建议做一个碳价敏感性分析:把碳价从10元/吨依次提高到100元/吨,看系统总碳排放量和总成本怎么变化。明显的趋势是:碳价低时,系统会倾向于多发火电、少用风电调节,因为风电的不确定性在低碳价下不值得用高成本的火电备用去消化;碳价提高后,调度系统愿意付出更多火电爬坡成本来消纳风电,碳排放量逐渐下降。这个拐点往往出现在碳价与火电煤耗成本、弃风惩罚成本三者的平衡点附近。
另一个值得测的参数是弃风惩罚系数。如果这个系数设置得太低,调度模型会倾向于大量弃风来省去火电调节的麻烦,风电消纳率很难看;如果太高,又会强迫系统接受所有风电,哪怕要火电疯狂爬坡,总成本高得离谱。合理的区间要让系统弃风率落在5%到10%的自然水平,而不是人为强迫到零。
5.3 求解时间与场景数的权衡
10个场景下Gurobi求解时间一般在几十秒到几分钟的量级,完全可接受。如果加到30个场景,求解时间可能会膨胀到半小时以上。我试过把场景数加到50,求解时间直接奔着两个小时去了,而且解的改善幅度非常有限。效率与精度的平衡点,我建议做研究时用10个场景做基准实验,在敏感性分析里可以单独测一次20个场景,说明结果对场景数不敏感即可,没必要追求大规模场景。
6. 复现这个课题时的几处关键避坑点
6.1 参数单位与量纲的统一
这个项目里最容易翻车的不是数学建模,而是参数单位。煤耗系数的单位可能是(元/MWh²)或(元/MWh),碳排放强度的单位可能是(tCO₂/MWh)或(kgCO₂/MWh),碳价的单位更是千差万别——有按元/吨、按美元/吨、按元/kg的。稍不留神,目标函数里碳交易成本和煤耗成本就差了三个数量级,优化结果完全跑偏。
我踩过最狠的一次坑:从一篇论文里抄了碳排放强度系数,没注意它用的单位是kg/MWh,而我前面的煤耗成本和碳价都按吨来算,结果碳交易成本比煤耗成本小了1000倍,系统根本不在意碳排放,优化结果跟普通经济调度没区别。所以拿到任何数据,第一步统一单位制,第二步写个简单的unit_check.m脚本,打印出各项成本的中位数量级是否合理,比人工肉眼检查可靠得多。
6.2 场景削减后概率和为1的校验
场景削减过程中每删除一个场景都要把概率转移给最近邻场景,如果算法实现有误或者浮点误差累积,削减完的概率数组可能不为1。这个看似小问题,在Yalmip里会表现为约束数值奇异或目标函数不一致。我养成的习惯是每次削减完立刻assert(abs(sum(prob) - 1) < 1e-8)校验一次,不通过马上打印日志定位哪一步概率转移出错。
6.3 Yalmip模型中的"sdpvar"维度一致性
Yalmip建模时候最让人头大的错误是变量维度不匹配。尤其当P定义成(n_gen, n_horizon, n_scenario),而约束里误写成sum(P(:, t))(只取第一个场景)时,Yalmip不会直接报错,而是自动广播维度,导致约束池异常庞大、求解器疯狂报"infeasible"。我建议在每个约束写完后,用一个assert(length(constraints) == expected_num)的检查来拦截维度异常,虽然不够智能,但至少能快速缩小排查范围。
6.4 两阶段变量结构的时段索引错误
机组启停变量是第一阶段,而出力变量是第二阶段(分场景调整)。这意味着启停决策在场景实现前就定了,各场景下同一机组同一时段的启停状态必须一致。很多初学者在写约束时把启停变量也标成场景相关,结果允许了"每个场景有不同的启停方案",不仅求解规模爆炸,而且跑出来的调度结果在物理上不可执行。我在代码里刻意把u声明为binvar(n_gen, n_horizon)而不是binvar(n_gen, n_horizon, n_scenario),从变量声明层面杜绝了这个错误,建议你也这样写。
7. 扩展思路:从本课题还能往哪里走
这个模型做完后,如果想继续深化或改造成自己的论文方向,有几个现成的扩展点。
第一个是引入考虑储能或需求响应的低碳调度。源荷不确定性在处理时,本质上是给系统加灵活调节资源。储能的充放电决策天然是两阶段变量的一部分——日前不确定时定好储能基准充放电计划,日内根据实际出力和负荷偏差实时充放电,正好匹配随机规划框架。需求响应同理,把可中断负荷和可转移负荷建模为虚拟机组,它们的调节范围就是负荷侧的额外灵活性,能显著降低备用需求。
第二个是把场景法换成分布鲁棒优化。场景法的局限在于你需要知道精确概率分布,但实际中风速分布参数本身就有不确定性。分布鲁棒优化把"概率分布"本身当作不确定集,用模糊集约束所有可能的分布,得到的调度方案鲁棒性更强,但建模难度和求解复杂度也会上一个台阶。如果审稿人要求方法有创新性,分布鲁棒是个不错的方向。
第三个是把碳排放流模型引入。目前这个模型是"总量碳交易"模式,即用系统总碳排放量乘碳价。但如果要更精细地分析碳排放"在哪一个机组、哪一个时段、因为哪一个负荷而产生",就得用到碳排放流理论,把碳排放责任分摊到负荷侧。这个方向这两年论文产出很多,适合做深度扩展。
我个人实际做这个课题最大的感受是:写代码从能跑通到结果合理,中间隔着的不是数学问题,而是对物理问题理解得透不透。源荷不确定性、低碳调度,每一个名词背后都对应着一堆现实约束。把这个代码从头到尾调试一遍,远比背十篇论文更能帮你建立对电力系统调度问题的直觉。如果碰到哪里解不出来或者结果不对劲,欢迎在评论区交流,踩过的坑值得拿出来分享。