ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

梯级水光互补短期调度:最大化可消纳电量期望的MILP建模与Python实现

2026/10/5 11:17:34 拓冰建站 浏览量
梯级水光互补短期调度:最大化可消纳电量期望的MILP建模与Python实现 梯级水光互补系统说白了就是把上游串联的好几座水电站和一大片光伏电站放在同一个调度框架里打配合。光伏出力跟着太阳走午间出高峰晚上归零单靠一条固定外送通道往往装不下而梯级水电可以靠库容把水存起来在光伏低谷时段顶上去在光伏高峰时段压低或者蓄水于是“水光互补”天然就是个优化调度问题。前段时间我完整复现了一篇EI期刊里的“最大化可消纳电量期望短期优化调度模型”用Python把光伏随机场景生成、MILP建模和求解器求解串了起来。这篇文章直接讲模型目标、约束条件和代码实现顺便把我在复现时踩过的坑也列出来适合正在做EI复现、水光互补调度或者电力系统随机优化的研究生和工程师参考。1. 项目背景与核心问题拆解1.1 梯级水光互补到底在补什么很多刚接触这个方向的同学会把“水光互补”理解成“白天光伏不够就水电补、晚上光伏没有就水电扛”这个理解没有错但梯级水光互补比单库配光伏要复杂一个量级。梯级水电不只是“一座水电站”而是一串上下游串联的水电站。上游电站发完电水并不消失而是通过河道流到下游电站还能再发一次电这就是梯级水电的“重复利用”特性。再加上水流在河道里还有传播时滞上游出库要隔几小时甚至十几小时才到下游调度决策不能只看本电站的库容还得盯着全梯级的蓄能状态。光伏出力在这里扮演的是“强随机、强波动”的角色晴天中午猛涨、阴天骤降、凌晨和傍晚基本为零。这种波动对电网来说并不友好尤其当涉及新能源基地的外送通道容量有限时光伏发多了可能会挤占水电出力水电蓄着水发不出来反过来又会造成弃水两头吃亏。所以梯级水光互补的真正意义是在时间和空间两个维度上做协调时间上利用水库的调节能力把光伏高峰时段的富余水存住等光伏退坡后再放水发电空间上利用上下游梯级之间的水力联系让上游出库流量依次为下游所用。这个协调过程不是靠人工拍脑袋定的而是需要通过短期优化调度模型找出一条最合理的水库放水曲线、光伏消纳曲线和断面送出计划。1.2 “可消纳电量期望”不是一个玄学概念标题里的“最大化可消纳电量期望”可能是最难理解的一环。先说“可消纳电量”它不等于光伏理论发电量也不等于水电理论出力而是受到外送通道、库容约束、运行边界限制后系统真正能吸收并上网的电量。光伏发了但送不出去就是弃光水电有水但为了保库容或配合电网调峰而不能放就是弃水。让弃光弃水尽可能少等价于让可消纳电量尽可能大。再说“期望”。光伏出力在中短期尺度上是有预测误差的比如午间预测功率100 MW实际可能只有80 MW也可能冲到120 MW。确定性模型只会拿一个预测值去算一旦实际偏差大了要么造成大量弃光要么水电补得不够导致断面送不满。随机期望模型的做法是构造一组光伏场景每个场景对应一种可能的光伏出力曲线并给每个场景一个概率目标函数优化的是所有场景下的概率加权平均可消纳电量。举个例子两个时段A场景光伏强、B场景光伏弱。如果把水库放水策略完全压到A场景上B场景就会送上不完反过来只适配B场景A场景又会弃光。期望最大化模型要找到的是一个在所有场景下平均表现最好的调度方案它不一定在某个单一场景下最优但能把长期运行下来的总消纳量拉高。这个思想跟金融里的“期望收益最大化”非常像不能只看最好的一天要看长线。2. 短期优化调度模型的数学框架2.1 目标函数最大化可消纳电量期望的建模路径把梯级水光互补短期调度写成优化模型第一步就是定义目标函数。我的做法是直接写成最大化期望消纳电量的形式$$\max \sum_{s \in \mathcal{S}} \pi_s \sum_{t \in \mathcal{T}} \left( \sum_{i \in \mathcal{H}} P_{h,i,t,s} P_{pv,t,s} \right)$$其中 $\mathcal{S}$ 是光伏场景集合$\pi_s$ 是场景 $s$ 的概率$\mathcal{T}$ 是调度时段集合$\mathcal{H}$ 是梯级水电站集合。 $P_{h,i,t,s}$ 表示场景 $s$ 下时段 $t$ 第 $i$ 座水电站的出力$P_{pv,t,s}$ 是场景 $s$ 下时段 $t$ 光伏实际消纳到电网中的功率。目标变量不带惩罚项因为可消纳电量本身已经包含了“能发且能送出”的含义。为什么不用“弃光弃水惩罚最小化”那种写法从数学本质上讲目标里加弃电惩罚也可以比如 $\min \sum_s \pi_s \sum_t (\gamma_{pv} C_{pv,t,s} \gamma_h C_{h,t,s})$但惩罚系数怎么定会直接影响最优解还得额外解释权重来源。直接最大化消纳电量期望比较符合EI论文里“提高新能源消纳水平”的表述习惯也方便后处理统计弃电率。注意这里没有把弃水写进目标是因为弃水变量主要由水量平衡决定在不发电的情况下水库多出来的水只能从溢洪道走如果给弃水变量一个很小的负系数反而能引导求解器尽量避免不必要的弃水实际工程见仁见智我建议目标中保留一个极小的弃水惩罚系数比如 $10^{-4}$ 量级防止无界变量把模型带跑。2.2 约束条件水量平衡、出力特性与外送通道目标定完关键是约束。模型里最核心的是梯级水量平衡约束。对于场景 $s$ 下的第 $i$ 座水库时段 $t$ 到 $t1$ 的库容变化满足$$V_{i,t1,s} V_{i,t,s} \left( I_{i,t} Q_{up,i,t,s} - q_{i,t,s} - sp_{i,t,s} \right)\Delta t$$$V$ 是库容$I$ 是天然入流$Q_{up}$ 是上游电站的出库流量必要时带时滞 $\tau$$q$ 是发电流量$sp$ 是弃水流量$\Delta t$ 是时段时长。第一条水库没有上游 $Q_{up}$ 直接取零第二条水库要把第一条的出库流量作为入流加进来依次类推梯级耦合关系就建立起来了。光有水量平衡还不够每座水库还有库容上下限、出库流量上下限、发电流量上下限$$V_{i,\min} \le V_{i,t,s} \le V_{i,\max}, \quad 0 \le q_{i,t,s} \le q_{i,\max}$$水电出力不是想发多少就发多少它和发电流量、水头高度有关。严格来说出力等于 $9.81 \eta q H$包含水头变量就是非线性模型求解难度直接上了一个台阶。EI论文里常见做法是分段线性化把库容—水头—出力关系切成分段函数再引入二进制变量或特殊有序集SOS2处理。如果只是复现一个简化框架更常见的是用常水头线性近似$$P_{h,i,t,s} \le K_i q_{i,t,s}$$$K_i$ 是简化出力系数。这个约束的含义很直接同一时段里放水越大出力越高但被 $K_i$ 和最大出力上限共同卡住。正式项目里我建议至少做三段式线性化否则峰荷时段的水头变化会让调度结果偏离实际。光伏侧的约束比较简单$$0 \le P_{pv,t,s} \le P_{pv,t,s}^{forecast}$$$P_{pv,t,s}^{forecast}$ 是该场景下的光伏可发功率预测实际消纳变量只能小于等于它差值就是弃光。最后是全网或外送断面的功率平衡约束$$\sum_{i \in \mathcal{H}} P_{h,i,t,s} P_{pv,t,s} P_{other,t,s} \le L_t^{limit}$$$L_t^{limit}$ 表示该时段外送通道或受端负荷能容纳的功率上限。如果模型中还有抽蓄、储能或外购电可以在这一条约束里继续扩展。这套框架写下来本质上就是带随机场景的混合整数线性规划MILP求解器可以拿到全局最优解这也是它适合复现的主要原因。2.3 光伏场景生成随机优化的数据基础期望目标需要场景输入场景怎么来也需要说清楚。短期光伏预测误差通常假设服从正态分布中心是日前预测值标准差随天气类型变化。我的做法是先有一个基础预测序列 $P_{t}^{fc}$然后对每个场景加一个随机扰动$$P_{pv,t,s}^{forecast} \max\left(0, P_{t}^{fc} \varepsilon_{t,s}\right), \quad \varepsilon_{t,s} \sim \mathcal{N}(0, \sigma_t^2)$$再用截断方式把负值归零、把超短时尖峰限制在装机容量以内。场景数量不能太少太少期望值近似误差大也不能太多否则MILP规模膨胀CBC求解器会跑不动。实操中常见做法是先生成200到500个原始场景再通过快速前向缩减或k-means聚类聚成20到50个代表性场景每个场景赋予等概率或按聚类面积加权概率。我在复现时一般取30个场景够用目标值偏差已经能控制在1%以内。别忘了固定随机种子否则每次跑出来的调度方案都不一致论文里没法写结果。3. Python工程实现从数学到可运行代码3.1 工具选型为什么用mip库而不是手写遗传算法很多初学者拿到优化问题第一反应是写遗传算法或者粒子群理由是“看起来简单、不用装求解器”。但这类启发式算法没法保证全局最优而且约束一多罚函数调参能调到你怀疑人生。EI论文里的短期调度模型目标函数是线性的约束也是线性的本质上就是MILP直接用数学规划求解器才是正道。Python里做MILP的库很多我用的是开源库mip。它封装了CBC求解器支持变量目标函数约束的快速定义接口风格接近Gurobi但完全免费且pip安装方便。如果单位有Gurobi或者CPLEX授权把mip换成Gurobi Python接口也不难模型结构基本不用动。为什么不用SciPy的linprog因为linprog只做线性规划没法原生支持整数变量和大量复杂索引变量为什么不用遗传算法因为梯级水量平衡这种等式约束用随机搜索很难精确满足最后你大概率会花大量时间修bug。安装环境很简单pip install mip numpy matplotlib一个小提醒mip库在Windows下通过pip安装时一般会自带CBC可执行文件但如果你自己编译过其他求解器要注意环境变量里的路径冲突。3.2 数据准备与场景生成代码先把模型需要的基础数据准备好。下面这段代码生成两个梯级水库、24个时段、30个光伏场景的示范数据实际项目里要替换成真实的电站参数和预测数据。import numpy as np np.random.seed(42) T 24 # 时段数按1小时一个时段 I 2 # 梯级水库数量 S 30 # 光伏场景数 dt 3600.0 # 1小时对应的秒数 # 基础光伏预测MW pv_base np.random.rand(T) * 120 30 # 构造光伏场景加正态误差截断到非负 pv_error np.random.normal(0, 15, size(S, T)) pv_scen np.clip(pv_base pv_error, 0, None) prob np.ones(S) / S # 外送通道上限MW load_limit np.random.rand(T) * 150 180 # 各水库天然入流m3/s inflow np.random.rand(I, T) * 8 5 # 库容参数m3 V_init np.array([300e4, 200e4]) Vmin np.array([100e4, 80e4]) Vmax np.array([600e4, 400e4]) V_end_min np.array([150e4, 100e4]) V_end_max np.array([400e4, 250e4]) # 发电流量上下限m3/s q_min np.zeros(I) q_max np.array([80.0, 60.0]) # 简化出力系数MW/(m3/s) K np.array([0.35, 0.30])这里要特别强调单位问题。流量是立方米每秒库容是立方米时间步长是秒所以水量平衡约束里入流和出流要乘以dt。如果不乘模型里库容变化和流量会差出几个数量级求解器要么报不可行要么会给出莫名其妙的结果。我在实际工程中经常看到有人把库容单位写成亿立方米流量单位写成立方米每秒然后忘记换算这是复现类项目最高频的坑。3.3 模型搭建与求解核心代码数据准备好了下面直接上完整可运行的最小示例。模型目标就是最大化期望可消纳电量包含梯级水量平衡、库容约束、出力约束、光伏消纳约束和外送通道约束。from mip import Model, xsum, maximize, CONTINUOUS m Model(hydro_pv_short_term) m.verbose 1 # 决策变量库容、发电流量、弃水、水电出力、光伏消纳 V [[[m.add_var(lbVmin[i], ubVmax[i], namefV_s{s}_i{i}_t{t}) for t in range(T 1)] for i in range(I)] for s in range(S)] q [[[m.add_var(lbq_min[i], ubq_max[i], namefq_s{s}_i{i}_t{t}) for t in range(T)] for i in range(I)] for s in range(S)] sp [[[m.add_var(lb0, namefsp_s{s}_i{i}_t{t}) for t in range(T)] for i in range(I)] for s in range(S)] P_h [[[m.add_var(lb0, ubK[i] * q_max[i], namefPh_s{s}_i{i}_t{t}) for t in range(T)] for i in range(I)] for s in range(S)] P_pv [[m.add_var(lb0, ub1000, namefPpv_s{s}_t{t}) for t in range(T)] for s in range(S)] # 目标函数最大化概率加权期望可消纳电量 m.objective maximize(xsum(prob[s] * xsum(P_h[s][i][t] P_pv[s][t] for i in range(I) for t in range(T)) for s in range(S))) # 约束条件 for s in range(S): for i in range(I): # 水量平衡 for t in range(T): up q[s][i - 1][t] if i 0 else 0.0 m (V[s][i][t 1] - V[s][i][t] (inflow[i][t] up - q[s][i][t] - sp[s][i][t]) * dt) # 初始和期末库容 m V[s][i][0] V_init[i] m V_end_min[i] V[s][i][T] m V[s][i][T] V_end_max[i] # 水电出力与发电流量关系常水头简化 for t in range(T): m P_h[s][i][t] K[i] * q[s][i][t] # 光伏消纳上限和外送通道约束 for t in range(T): m P_pv[s][t] pv_scen[s][t] m xsum(P_h[s][i][t] for i in range(I)) P_pv[s][t] load_limit[t] # 求解 m.optimize() print(f最优期望可消纳电量: {m.objective_value:.2f} MWh)这个代码直接跑就能出结果但要注意几个细节示例里同一时刻上游出库直接作为下游入流没有加时滞。真实梯级调度要加入传播时间比如上游放水两小时后才到下游那么up应该取q[s][i-1][t-tau]。所有变量都带场景下标相当于每种光伏场景下都有一套完整的水力和光伏决策。这种建模方式在实际应用中代表“看到光伏场景后再调整调度”适合做“事后最优”或“决策评估”。如果你要模拟日前计划需要在光伏场景实现前就把部分决策定下来那就是典型的两阶段随机规划。目标里没有弃水惩罚所以sp只是一个平衡变量只要有水多出来就会弃水目标不受影响这也是可行的。3.4 结果分析与调度策略解读求完解以后不能只看目标函数值要把决策变量拿出来画图检查合理性。常用做法是统计所有场景下的期望水电出力和期望光伏消纳E_P_h np.zeros((I, T)) E_P_pv np.zeros(T) for s in range(S): for i in range(I): for t in range(T): E_P_h[i, t] prob[s] * P_h[s][i][t].x E_P_pv[t] prob[s] * P_pv[s][t].x然后画出堆叠面积图横轴是24小时纵轴是MW。正常的互补策略长这样凌晨和夜间光伏为零水电承担全部外送功率库容缓慢下降早晨光伏开始爬坡水电出力同步下调水存进水库午间光伏峰值时段水电出力压到很低甚至接近零库容持续上升傍晚光伏快速退坡水电立刻顶上补足外送通道容量。如果画出来发现水电出力曲线跟光伏曲线“同向波动”那就有问题说明互补没有形成光伏高的时候水电也高断面可能挤爆光伏低的时候水电也不补外送通道空着目标函数没有最大化。出现这种情况多半是外送通道约束没写紧或者是水电出力上下限太宽导致求解器选择了不合理的调度结果。我还会额外统计弃光率total_pv_energy pv_scen.sum(axis1).sum() / S absorbed_pv_energy sum(P_pv[s][t].x for s in range(S) for t in range(T)) / S curtail_rate 1 - absorbed_pv_energy / total_pv_energy一般来说通道容量越紧弃光率越高库容调节能力越强弃光率越低。跑敏感性分析时这些曲线都会用到。4. 常见问题与排查技巧实录4.1 场景数太少期望目标“营养不良”我第一次复现这个模型时为了图快只用了5个场景结果最优目标值每次跑都差不少换一组随机种子甚至可能得到完全不同的调度方案。这不是模型错了而是场景数量太少样本期望的方差太大。处理办法很直接增加场景数或者用场景缩减。我的经验是当S5时目标波动可能超过5%S20时降到2%以内S50以上基本稳定。但场景越多求解越慢尤其CBC这种开源求解器S50、T96、电站3座以上的MILP就可能跑十分钟。所以建议原始场景生成200个用快速前向缩减到30个代表性场景既保留随机特性又控制模型规模。4.2 水量平衡不满足时滞和初值的锅梯级水量平衡是最容易出问题的约束。第一个坑是时滞索引如果上游出库要经过两小时才到下游而你直接写成up q[s][i-1][t]那时间段就错位了下游库容曲线会出现“凭空多水”的假象。正确写法是tau [0, 2] # 第一库无上游第二库入流滞后2小时 for t in range(T): if i 0 and t - tau[i] 0: up q[s][i - 1][t - tau[i]] elif i 0: up 0.0 # 前几个时段上游放水还没到 else: up 0.0这里我们看到上游时段初放的水到下游时段的入库量必须按时滞进行滑移。要注意t - tau[i]小于零的部分不能简单越界访问否则Python会报错。第二个坑是初始库容给得太随意。如果初始库容很小水库几乎没有调节能力发电流量只能跟着天然入流走模型相当于变成“径流式电站”水光互补等于没补。初始库容最好取库容上下的50%-70%才能真正体现调节能力。4.3 求解慢MILP规模爆掉怎么破开源CBC对大MILP的求解效率一般我见过T96、S50、I5的问题跑半小时还没出最优解。遇到这种问题有几种处理思路第一优先砍二进制变量。很多EI论文里的短期调度模型其实不需要开停机二进制变量只要机组爬坡约束和出力上限约束写清楚水电出力可以连续调节模型就退化成LP或者纯连续变量的MILP求解会快很多。第二减少场景数。这是最直接的办法但前提是目标值不能明显失真。第三用Gurobi/CPLEX替代CBC。同样的模型规模商业求解器经常能快一个数量级。mip库支持指定其他求解器Gurobi也不难接。第四设置求解时间上限先拿可行解分析。在m.optimize(max_seconds300)后检查m.num_solutions是否大于0如果只有可行解可以结合gap判断结果质量。症状常见原因解决办法模型不可行初始库容超出上下限 / 弃水变量上限过小检查参数边界弃水变量不要设过低上限求解时间过长场景多、时段多、二进制变量多场景缩减、去掉多余整数变量、换求解器目标值波动大场景个数太少增加到30以上或用场景缩减水库库容曲线异常水量平衡没乘dt / 时滞索引错统一单位检查时滞边界4.4 从复现到EI论文还要补哪些验证复现模型只是第一步真要把成果写成EI论文或者作为项目报告还需要补一些实验。我的习惯是在主模型跑通后加三类对比一是跟确定性调度模型对比。用同一个预测值构造单场景求出来的可消纳电量叫“预测最优”再用随机期望模型的结果做蒙特卡洛回测统计实际消纳电量。随机模型通常表现出更低的弃光率和更高的期望消纳量这就是随机优化的价值所在。二是敏感性分析。改变外送通道容量、光伏装机容量、水库初始库容观察期望消纳电量如何变化。这一套结果能画出很漂亮的曲线审稿人也喜欢看。三是对比不同算法。如果能拿到原文结果最好复现原文方法如果没有可以跟规则调度、启发式调度对比证明MILP求解的优越性。图表上一定要标注单位库容用万m³或m³功率用MW电能量用MWh别混着来。5. 实操心得与扩展方向5.1 我在这类项目中踩过的坑代码层面的事前面说了不少这里再说几个容易被忽略的经验。第一个是单位换算。流量乘时间才是水量库容是体积出力是功率乘时间才是电量。很多复现代码跑出来目标值大得离谱不是算法问题是单位没统一。我建议在写参数表之前先把所有物理量列一张表统一用国际标准单位再考虑是否需要缩放。第二个是随机种子。优化模型里一旦涉及场景生成不固定np.random.seed每次跑的结果都不一样。写报告时表格里数据前后对不上多半就是这个原因。代码开头固定随机种子这也是基本工程素养。第三个是变量场景归属。两阶段随机规划里第一阶段变量日前计划不能带场景下标第二阶段变量日内调整才可以根据场景变化。如果所有变量都带场景下标相当于“先知模型”结果过于乐观。我在复现EI论文时会先看原文怎么定义决策变量如果明确说明调度计划在光伏出力实现前确定就要用两阶段建模如果只是在评估消纳潜力带场景下标问题不大。这个区别会直接改变结果含义一定不能搞混。5.2 后续扩展日内滚动、鲁棒优化与多时间尺度这个模型虽然是短期调度框架但扩展空间很大。把时段粒度从1小时细化到15分钟并加入实时预测更新的滚动MPC框架就是日内滚动调度。模型主体不用大改只需要在每次滚动时更新光伏场景和当前库容初值即可。如果担心光伏预测误差的极端场景可以把期望目标换成条件风险价值CVaR或者把场景集合改造成不确定集合做两阶段鲁棒优化模型就从“期望最大化”变成了“最坏情况下消纳最优”。如果再加抽水蓄能电站或电化学储能可以把储能SOC约束和设备启停约束叠进来目标里同时优化储能充放电时段。不过说实话EI论文里很多模型看起来很复杂扒开来看核心就是“目标函数约束条件决策变量”优化模型本身不会难到天上去。难点一是对物理过程的理解二是把工程约束转成数学表达三是数据参数对不对、单位换算准不准。先把这篇文章里的最小模型跑通再慢慢往里面加约束和场景复杂度你就能真正掌握这套梯级水光互补短期调度模型的复现思路了。