这期想聊一个我最近完整复现过的项目,标题是“梯级水光互补系统最大化可消纳电量期望短期优化调度模型(Matlab代码实现)”。说实话,第一次看到这个标题的时候我愣了一下,“最大化可消纳电量期望”这个目标函数不是常规的最小化运行成本或最大化发电量,而是把“期望”放进了优化目标里,这背后其实是场景法处理光伏随机性的思路。我把整个模型从论文拆解到Matlab实现完整走了一遍,包括Yalmip建模、Gurobi求解、水头迭代、场景削减,踩了不少坑,也沉淀了不少经验,这篇文章就当作一份完整的复现笔记,分享给正在做水电、光伏互补调度或者新能源消纳方向的同学。
1. 项目背景与问题本质
1.1 梯级水光互补是干什么的
先把概念捋清楚。梯级水光互补系统,指的是多个位于同一河流上下游的水电站,和光伏电站共同接入电力系统,在调度周期内协同运行。水电和光伏在时间尺度上有天然互补性:光伏白天出力高、夜间为零,波动剧烈且不可控;而水电可以通过调节库容、控制发电流量来快速调整出力,相当于一个天然的储能和调峰资源。把两者放在一起优化调度的意义很简单——光伏多发电的时候,水电站可以少发一点,把水存起来留到晚上或光伏出力低谷再放;反过来,光伏出力骤降的时候,水电站顶上去,保证系统出力平稳。
梯级这个词意味着水电站之间有水力联系。上游电站的发电流量和弃水流量,会成为下游电站的入库流量,加上区间入流之后,直接影响下游电站的可用水量。这就导致梯级调度问题与单站调度有本质区别:你不能只看单个电站的水位和出力,而要把一整条河上的水量平衡串起来看,形成耦合约束。这是做模型时最容易遗漏、也最影响求解结果的地方。
1.2 “最大化可消纳电量期望”的数学含义
为什么要用“期望”而不是直接用某个确定性场景?因为光伏出力具有强随机性。如果只按一组固定光伏出力曲线做优化,得出的调度方案在真实运行时可能严重失效——实际光伏比预测高,你让水电站发了太多,光伏得被迫弃掉;实际光伏比预测低,水电站可能来不及调节,系统出力不足。所以论文级的模型,通常采用多场景法(scenario-based method),用若干条带概率的光伏出力场景来刻画不确定性,优化目标就是所有场景下可消纳电量的概率加权平均,也就是期望值。
可消纳电量,简单理解就是电网实际接纳并送出的水电与光伏电量之和。如果系统总发电量超过消纳能力(或负荷需求),多余部分就要弃掉。所以优化目标是期望意义下的总发电量最大,等价于期望弃电量最小。这种建模方式相比传统确定性模型,更贴近实际运行,因为调度员在决策时总要面对“光伏可能不准”的现实。
1.3 论文复现的路线规划
拿到一个EI论文复现项目,我的习惯是先拆解论文的模型假设、目标函数、约束条件和求解方法,再对照数据需求去搭建代码框架。这篇论文的核心假设有几个:调度周期取典型日(通常24小时或96个时段);光伏出力场景预先给定(或通过预测误差生成);水电站的出力-流量-水头关系采用线性简化或分段线性化;目标函数考虑所有场景期望。搞清楚这些,后面的建模和代码实现就顺理成章了。
我建议复现路线分四步走:第一步,建立确定性调度模型,只跑一组光伏场景,验证约束和基本逻辑;第二步,拓展为多场景随机模型,引入场景概率和目标函数期望化;第三步,加入水电非线性环节(水头变化),用迭代法逼近;第四步,验证结果合理性,包括弃电率、水库水位边界、出力曲线平滑度等。
2. 数学模型与关键约束
2.1 目标函数与决策变量的选择
先从目标函数说起。设调度时段索引为t,场景索引为s,场景概率为p_s,梯级水电站索引为i,光伏电站索引为j。目标函数可以写成:
maximize Σ_s p_s × [ Σ_t Σ_i P_h(i,t,s) + Σ_t Σ_j P_pv(j,t,s) ] × Δt
其中Δt是时段长度(小时)。实际实现时,为了体现“最大化可消纳电量”,同时抑制不合理弃电,有些论文会在目标函数中加入弃电惩罚项,变成:
maximize Σ_s p_s × [ Σ_t (总消纳电量) - α × 弃电量 ]
加入惩罚项的好处是给求解器一个明确的梯度方向,避免出现“为了满足约束而任意弃电”的退化解。α的取值需要根据消纳权重来设定,通常取得比较小,比如0.01到0.1之间,让弃电惩罚只起引导作用,不改变优化主目标。
决策变量包括:各场景下各时段水电站出力P_h(i,t,s)、发电流量Q_turb(i,t,s)、弃水流量Q_spill(i,t,s)、水库库容V(i,t,s),光伏实际出力P_pv(j,t,s),以及弃光量。如果模型还考虑机组启停,会引入二进制变量,问题就从LP变成MILP。
2.2 水量平衡与梯级耦合约束
水量平衡是水电站调度模型的灵魂约束,描述的是水库“入流-出流-蓄水量变化”的关系:
V(i,t+1,s) = V(i,t,s) + [ Q_in(i,t,s) - Q_turb(i,t,s) - Q_spill(i,t,s) ] × Δt
对于梯级系统,Q_in(i,t,s) 不仅包含天然来水,还包括上游电站的出流。上游出流到下游入库有个时间延迟(通常视河段而定,短期调度可以简化为一到两个时段),所以梯级耦合约束是:
Q_in(i,t,s) = Q_natural(i,t,s) + Q_release(i-1, t-τ, s)
这里的τ是水流滞后时间。很多复现代码忽略这个滞后,直接让上游出流等于下游入库,这是简化处理。如果你的论文特别强调了水力联系,那τ就不能省,否则梯级的核心价值就体现不出来。
还要注意库容边界约束:V(i,min) ≤ V(i,t,s) ≤ V(i,max)。调度周期末库容V(i,T,s)通常需要设定在目标区间内,常见做法是要求期末库容不低于某个值,这样才能保证调度方案的可持续性。
2.3 水头-出力非线性与线性化处理
水电站出力与发电流量、水头的关系是:P_h = 9.81 × η × Q_turb × H_net,其中H_net是净水头,η是机组综合效率。净水头又等于上游水位减去下游尾水位和水头损失,而上游水位是库容的非线性函数。
如果在模型中直接引入这个非线性关系,问题就变成NLP,求解难度大幅上升。论文实现中最常用的处理是分段线性化或线性逼近。可取的做法是:把水头-库容关系按水位区间划分为若干段,每段用线性函数近似;出力表达式变成乘积形式后,再通过引入辅助变量和二进制变量(大M法)线性化。
实际工程复现时,如果精度要求不是极致,可以先假设调度周期内水头取固定值(如最大最小水头的平均值),把P_h简化为只与Q_turb相关的线性约束。这个简化对结果的影响可以通过水头迭代来修正:先按固定水头求出一版调度方案,再根据方案修正各时段水头,重新求解,如此迭代两三轮基本收敛。我在实操中验证过,这种迭代法的结果与全NLP模型非常接近,而求解速度和稳定性好得多,强烈推荐先试这个。
2.4 光伏不确定性的场景表达与期望值目标
光伏场景是随机模型的关键输入。场景生成通常有两种途径:一种是从历史数据统计得到预测误差分布,再通过拉丁超立方采样生成多条光伏出力样本;另一种是直接用论文给定的场景集。我复现时数据是自造的:以一条典型光伏日出力曲线为基础,叠加不同幅度的正态扰动,生成10条场景,概率由各场景权重表示。
目标函数里的期望值,本质上是把各场景的消纳电量做概率加权。这样做的好处是,优化器会自动在“各场景下都尽量多消纳”和“某些极端场景下牺牲一点”之间做权衡。需要注意的是,场景权重不能随手拍脑袋,要归一化,否则目标函数量纲不对,求解结果会和论文对不上。
如果场景数量较多(比如50个以上),直接求解随机模型会导致变量规模增大N倍,求解时间飙升。后面我专门讲场景削减方法,这里的先记住一个结论:用同期回代削减法,把10个场景削减到5个,目标函数值变化通常小于1%。
3. Matlab实现:从数据到求解
3.1 环境配置与求解器选型
实现平台是Matlab + Yalmip工具箱 + Gurobi求解器。Yalmip是一个建模语言层,它把Matlab里的变量、约束、目标函数统一封装,再转发给底层求解器,对做优化调度的科研人员来说非常省心。Gurobi是目前求解MILP速度最快的商业求解器之一,做学术用途可以申请免费license。
如果没有Gurobi,也可以先用Matlab自带的linprog或intlinprog跑小规模确定性模型。但多场景随机模型加上二进制变量之后,自带求解器会非常吃力,这时候还是得换Gurobi。下面这段代码是Yalmip调用Gurobi的常用配置:
yalmip('clear'); ops = sdpsettings('solver', 'gurobi', 'verbose', 2, ... 'gurobi.MIPGap', 0.001, ... % MIP间隙设置 'gurobi.TimeLimit', 600); % 单次求解时间上限(秒)3.2 数据准备:电站参数、来水曲线与光伏场景
数据结构设计直接影响代码可读性和扩展性。我习惯用结构体数组来存电站参数,用二维矩阵存时序数据。下面给一个参数配置示例:
| 参数 | 符号 | 电站1 | 电站2 | 单位 |
|---|---|---|---|---|
| 最大库容 | Vmax | 850 | 1200 | 万m³ |
| 最小库容 | Vmin | 300 | 500 | 万m³ |
| 初始库容 | V0 | 600 | 800 | 万m³ |
| 最大发电流量 | Qturb_max | 150 | 200 | m³/s |
| 最小发电流量 | Qturb_min | 10 | 15 | m³/s |
| 机组效率 | η | 0.88 | 0.86 | - |
| 平均水头 | H_avg | 35 | 45 | m |
来水数据为24小时逐时自然入流,光伏场景是10×24的矩阵,每行对应一条场景曲线。生成光伏场景的参考代码如下:
% 基础光伏出力曲线(标幺值,峰值为1) base_pv = 0.5 * (1 + sin(pi * (t - 6) / 12)); base_pv(t < 6 | t > 20) = 0; % 生成10条场景:叠加正态扰动 n_scen = 10; pv_scen = zeros(n_scen, T); for s = 1:n_scen noise = 0.1 * randn(1, T); pv_scen(s, :) = max(0, base_pv + noise); % 归一化到场景对应的峰值容量 pv_scen(s, :) = pv_scen(s, :) * pv_capacity(s); end % 场景概率(归一化权重) prob = ones(n_scen, 1) / n_scen;3.3 Yalmip建模核心代码
下面这段代码是建模的核心骨架,覆盖变量定义、约束装配和目标函数,供参考:
T = 24; % 调度时段数 n_res = 2; % 梯级电站数量 n_scen = 10; % 场景数 % ---------- 决策变量 ---------- P_h = sdpvar(n_res, T, n_scen, 'full'); % 水电站出力 Q_tur = sdpvar(n_res, T, n_scen, 'full'); % 发电流量 Q_sp = sdpvar(n_res, T, n_scen, 'full'); % 弃水流量 V = sdpvar(n_res, T+1, n_scen, 'full'); % 库容(含初末) P_pv = sdpvar(1, T, n_scen, 'full'); % 光伏实际出力 % ---------- 约束装配 ---------- Constraints = []; for s = 1:n_scen for i = 1:n_res for t = 1:T % 库容边界 Constraints = [Constraints, Vmin(i) <= V(i,t,s) <= Vmax(i)]; % 期末库容约束(不低于最低调度水位对应的库容) if t == T Constraints = [Constraints, V(i,T+1,s) >= Vend_min(i, s)]; end % 发电流量边界 Constraints = [Constraints, Qtur_min(i) <= Q_tur(i,t,s) <= Qtur_max(i)]; Constraints = [Constraints, Q_sp(i,t,s) >= 0]; % 水量平衡(忽略水流滞后,上游出流=下游入流) if i == 1 q_in = Q_nat(i,t); else q_in = Q_nat(i,t) + Q_tur(i-1,t,s) + Q_sp(i-1,t,s); end Constraints = [Constraints, V(i,t+1,s) == V(i,t,s) + (q_in - Q_tur(i,t,s) - Q_sp(i,t,s)) * dt]; % 出力与发电流量的线性关系(固定平均水头简化) Constraints = [Constraints, P_h(i,t,s) == 9.81 * eta(i) * H_avg(i) * Q_tur(i,t,s) / 1000]; % MW % 出力上下限 Constraints = [Constraints, 0 <= P_h(i,t,s) <= P_hmax(i)]; end end % 光伏出力不超过场景可用功率 for t = 1:T Constraints = [Constraints, 0 <= P_pv(1,t,s) <= pv_avail(s,t)]; end % 系统消纳能力约束(根据电网送出通道或负荷上限设定) for t = 1:T Constraints = [Constraints, sum(P_h(:,t,s)) + P_pv(1,t,s) <= grid_limit(t)]; end end % ---------- 目标函数 ---------- obj = 0; for s = 1:n_scen for t = 1:T obj = obj + prob(s) * (sum(P_h(:,t,s)) + P_pv(1,t,s)) * dt; end end % ---------- 求解 ---------- ops = sdpsettings('solver', 'gurobi', 'verbose', 2); optimize(Constraints, -obj, ops);这个模型是线性的,因为出力与流量之间用了固定平均水头简化。如果你想体现水头变化,就需要在水量平衡之外再建立水位-库容关系曲线,并让出力表达式中的水头随库容变化,这属于进阶内容,后面展开讲。
3.4 水头迭代求解框架
固定平均水头的缺点是忽略调度周期内水库水位变化对出力的影响。我的做法是加入水头迭代修正,流程如下:
第一步,用平均水头求初始调度方案,记录各时段库容V(i,t,s)。 第二步,根据库容-水位关系曲线(通常给一组插值表)插值得到各时段实际水位Z_up,再减去尾水位,得到净水头H_net(i,t,s)。 第三步,把净水头代入出力约束,重新求解调度模型。 第四步,比较相邻两次迭代的库容曲线变化量。如果小于阈值(比如最大偏差低于1%),停止;否则回到第二步。
这个迭代思路很像电力系统潮流计算里的交替求解法,简单但有效。我在实际测试中,一般3到5次迭代就能收敛,目标函数值相比固定水头方案有2%到3%的偏差,这在水电调度里是不容忽视的量。如果你复现的论文给出了水头相关图表,那这个步骤必须做,否则结果差很多。
迭代中还要注意一个细节:每次迭代更新H_net之后,水位库容关系用的是插值结果,如果库容超出插值表范围,会出NaN或外推失真。我建议在插值前加一个防越界处理,把库容截断到插值范围区间内。
4. 运行调试与常见问题排查
4.1 求解缓慢与变量爆炸
多场景模型最直接的痛点就是求解速度。假设你有3个梯级电站、10个场景、24个时段,单是P_h一个变量就有3×24×10=720个,加上Q_tur、Q_sp、V、P_pv,总变量轻松到几千个。如果再把水头线性化引入辅助变量和二进制变量,规模直接翻倍。Gurobi求解这个规模的MILP通常几秒到几十秒,但如果场景数增加到50、时段细化到96,那求解时间就可能膨胀到小时级。
应对办法:优先检查是不是所有二进制变量都是必要的。我在调试时发现,很多时候“最小发电流量”约束会让模型在低负荷时无解,而加入二进制变量来建模“要么不低于下限、要么停机”的关系,会显著拖慢求解。如果论文没有明确做机组组合,建议先把这类变量去掉,把下限设为0,让模型先跑通,再逐步加复杂度。
4.2 场景削减:用最少的场景保住精度
我们在之前提到,场景数翻倍会让变量规模和求解时间线性甚至超线性增长。场景削减的目的,就是在保持期望值精度前提下压缩场景数量。
最常用的方法是回代削减法(backward reduction)。核心思想是每次找出概率权重最小且与其余场景距离最近的场景,把它合并到最近的场景中去,更新权重,反复执行直到场景数降到目标值。场景距离可以用欧氏距离:d(s1,s2)=sqrt( Σ_t (P_pv(s1,t)-P_pv(s2,t))² )。
我用Matlab写了一个简化版,逻辑很清楚,你自己按下面的思路扩展就行:
function [reduced_scen, reduced_prob] = backward_reduction(scen, prob, target_num) while size(scen,1) > target_num n = size(scen,1); dist = zeros(n,n); for i = 1:n for j = 1:n dist(i,j) = norm(scen(i,:) - scen(j,:)); end end % 找最小距离与对应权重乘积最小的一对 min_val = inf; min_i = 0; min_j = 0; for i = 1:n for j = i+1:n cost = prob(i) * dist(i,j); if cost < min_val min_val = cost; min_i = i; min_j = j; end end end % 删除场景min_j,把权重加给min_i prob(min_i) = prob(min_i) + prob(min_j); scen(min_j,:) = []; prob(min_j) = []; end reduced_scen = scen; reduced_prob = prob / sum(prob); % 重新归一化 end这个东西看似简单,但效果很好。我实际测试中把30个场景削减到10个,目标函数的期望值变化不到2%,求解时间却缩短了60%以上,属于性价比最高的优化手段之一。
4.3 约束冲突导致无解
复现随机模型时,最常见的问题就是“模型无解”或“约束不可行”。原因往往出在以下三个方面:
第一,初始库容与期末库容不匹配。梯级水库在调度周期内受入流和发电出流影响,库容自然涨落,但如果你同时设置初始库容、期末库容下限,而区间来水又很小,那总有某个电站水量不够用,约束冲突就来了。解决方法是放宽期末库容约束,或者给各电站设置可调整的期末库容目标区间,而不是固定点值。
第二,光伏出力与消纳上限矛盾。如果你给的光伏场景出力值很大,同时电网送出上限(grid_limit)设得很低,那系统无论如何都会弃光,模型照样有解,但目标函数值会低到你怀疑人生。这不一定是错的,关键看论文当时怎么设定消纳能力。调试时要先检查一下:在极端场景下,水电全停、光伏全发,是否超过消纳上限。
第三,出力下限约束过紧。前面提到的“最小发电流量”约束,如果设得过高,而夜间负荷很低、光伏为零,水电被迫小出力运行或停机,就可能出现库容不可行。我的经验是,调试阶段把min流量设为0,跑通后再按实际情况加上,逐步收紧。
4.4 结果合理性验证清单
模型求解出结果后,不要急着整理图表,要先做一轮结果体检。我的习惯是检查下面四项:
一是水量平衡校验。把每个电站每个时段的入流、出流、库容变化对一遍,看是否存在微小不平衡(浮点误差通常可以容忍,但超过0.1%就要警惕代码有bug)。
二是库容上下限越界检查。检查V(i,t,s)是否严格落在Vmin和Vmax之间,如果出现负值或者略微越界,多半是约束条件装配漏了某个场景或时段。
三是弃电率合理性。正常调度方案弃电率应该在合理范围,如果模型求出来的弃电率是零,同时光伏场景波动很大,那很可能消纳上限约束没有生效,或者目标函数少加了弃电惩罚项。
四是出力曲线平滑度。看水电出力曲线是否频繁在上下限之间跳动,如果出现锯齿状剧烈波动,说明目标函数里缺少对出力变化的平滑项或爬坡约束。很多论文只关注总电量,不在乎曲线平滑度,但实际调度中机组频繁调节很不可取,如果论文有爬坡约束,别落下。
5. 实操心得:复现论文模型的三个关键认知
复现这篇EI论文模型,我最大的体会是:论文里的公式看着干净漂亮,但落到代码层面全是不起眼的细节堆出来的。哪怕是一个水流滞时时段的设定、一个期末库容的下限值,都会让最终结果和论文对不上。所以我的第一个建议是,不要急着敲代码,先把论文里的所有参数假设和约束条件列成一张清单,标注出哪些是论文明确给你的,哪些是你要自己合理假设补齐的。这样做最大的好处是,后续调试找到问题的时候,你能快速定位到底哪个约束和论文设定不一致。
第二个体会是,“最大化可消纳电量期望”这个目标函数,和常规的“最小化成本”有本质区别。前者没有成本系数,意味着优化器对电量数值非常敏感,模型里任何一个微小的惩罚系数变化,都可能显著改变调度方案。这时候最好先把确定性模型跑通,把弃电率、消纳率这些基准指标测出来,再引入惩罚项和随机场景,逐层加码。每加一层,就对比一次结果变化,这样即便最后结果和论文有差距,你也知道差距是从哪一层引入的。
最后再说一个小技巧:保存工况、保存模型、保存求解日志。Matlab里用yalmip建模后,可以用save命令把sdpvar结构存成.mat文件,下次直接load回来继续调试,不用每次从头生成场景和构建约束。面对动辄上千个变量的模型,重跑一遍设置就要好几十分钟,有这个习惯会舒服很多。这个技巧对后来继续做扩展实验、调参、画对比图都有帮助,算是踩过好几次坑之后沉淀下来的经验。