2026/10/8 15:21:49

梯级水光互补系统期望可消纳电量短期优化调度模型复现

梯级水光互补系统期望可消纳电量短期优化调度模型复现 我不是第一次接到复现EI论文的需求但看到这个标题——梯级水光互补系统最大化可消纳电量期望短期优化调度模型——的时候我还是先停了十分钟没有急着写代码。因为这种题目的坑不在代码而在模型梯级水电串联带来的水量耦合、光伏出力不确定性怎么进目标函数、可消纳电量期望到底该用哪种数学形式表达任何一个环节想歪了后面跑出来的结果都不可能是论文里的那个结果。这个模型做的事情可以概括成一句话在日前调度阶段把未来有限时段内光伏出力和来水的各种可能情况都用场景表示出来然后通过优化梯级各水库的出力过程让整个水光互补系统实际并网消纳的电量期望值最大。对于正在做新能源消纳、水电优化调度、或者拿EI论文做复现练手的同学来说这是一个非常标准的随机优化MILP落地案例既能把数学模型写清楚又能在Python里验证求解非常适合作为入门到进阶的过渡项目。我复现这套模型用的环境就是Python 3.8 Gurobi学术版免费许可中间数据用pandas处理不确定性场景用numpy生成和削减。整个工程跑通之后我最大的感受是论文里干干净净的三个公式落地成代码时至少会踩出六个坑。下面我把建模思路、代码结构、以及我实际踩过的三个大坑完整写出来给后面接这个方向的人一条能直接走的路。1. 为什么目标选可消纳电量期望而不是弃电量最小1.1 光伏和水电互补的物理基础光伏出力有两个让人头疼的特征一是间歇性云一朵过来出力可以几分钟内掉一半二是反调峰性中午光照强出力最大但系统在午间往往负荷不是最高晚上负荷上来的时候光伏已经归零。水电的优势恰恰在于调节速度快机组能在几分钟内改变出力而且水库可以把水这种能量介质储存起来相当于一个天然的储能电站。但水电不是想怎么调就怎么调。来水是天然给的你一天不发电水就蓄在那里蓄满了只能弃水也不能无限存库容上下限卡死了调度空间。梯级水电则是把多个水库串在一条河上上游放水下游接着用形成一整个接力系统——上游发完电的水量经过一段时间水流滞时到达下游水库还能再发一次电。这个接力特性让梯级比单库复杂得多也让优化的价值更大。理解了这一步你才能明白为什么论文要把梯级两个字单独拎出来写。1.2 弃电最小与可消纳电量期望的差别很多人在建模时会本能地想目标函数不就是要让弃电量最小吗这两者表面看是一回事实际上有本质差别。弃电量最小是一个事后统计口径。它需要先知道实际发了多少、实际能消纳多少才能算出弃了多少。而在日前优化里光伏出力和来水都是不确定的你手里只有预测值和预测误差分布根本没有实际值。如果你直接把弃电量的预测值写进目标等于把所有不确定性都忽略求出来的计划在极端场景下会大量弃电或电量不足。可消纳电量期望则不同它把每个场景对应一组可能的光伏出力、来水过程都放进取目标里用场景概率加权求和优化器优化的不是某一个假设下的结果而是所有可能性下的平均表现。这就像投资不能只看最好的一天要看期望收益——光伏和水电联合调度也一样目标定成最大化期望可消纳电量计划本身才具备对不确定性的适应能力。1.3 期望目标怎么影响调度决策如果目标只是简单最大化发电量模型会倾向于让水电一直满发光伏来了就并网结果就是中午光伏大发时联合出力突破联络线限额产生大量弃电傍晚光伏消失后水电已经没水可用系统出力又快速下跌。这显然不是最优。当我用最大化可消纳电量期望目标求解时模型给出的策略完全不同午间光伏高峰时段水电主动降出力、往水库蓄水把发电能力腾挪到光伏消退后的时段傍晚到夜间光伏归零水电再放水增发顶上。这一过程就是在对可消纳电量做时间维度上的搬移——水电相当于一个可调度电池目标函数会让它去填光伏留下的坑而不是去抢光伏的高峰。这个直觉非常关键后面所有代码调试都要靠这个直觉来验证结果对不对。2. 模型数学层目标函数、场景概率和梯级约束的表达2.1 变量设计与索引约定模型里我用的索引有这么几层t∈T表示调度时段通常取24个1小时也有人用96个15分钟后者求解规模会直接翻四倍i∈I表示梯级水库按上游到下游排序这个顺序在建模时会反复用到j∈J表示光伏电站s∈S表示不确定性场景S的个数视求解能力从20到200不等。核心决策变量我整理成了表格方便对照数学符号和代码变量名变量含义维度V[i,t,s]水库i在时段t末的库容I × (T1) × SQg[i,t,s]水库i的发电流量用于发电的水量I × T × SSp[i,t,s]水库i的弃水流量I × T × SPH[i,t,s]水电站i的出力I × T × SPVuse[j,t,s]光伏电站j实际被消纳的出力J × T × SPVcurt[j,t,s]光伏电站j被弃掉的出力J × T × S这里所有变量都带场景下标s意味着每个场景下都有自己的一套调度决策。严格来说这是两阶段随机优化中的第二阶段决策——日内可以根据光伏实际出力情况调整水电计划。如果你的论文模型还包含机组启停这类需要提前一天决定的0-1变量那就要把这些变量提到场景无关的第一阶段我复现时把重点放在连续调度上先把消纳逻辑跑通启停变量是后续再往上加的一层。2.2 目标函数场景加权最大消纳电量目标函数写作[ \max \sum_{s \in S} \pi_s \sum_{t \in T} \left( \sum_{i \in I} PH_{i,t,s} \sum_{j \in J} PVuse_{j,t,s} \right) \Delta t ]其中Δt是时段长度小时π_s是场景s的概率满足Σπ_s1。PH加PVuse正好就是并网功率乘Δt就是并网电量。水电出力、光伏消纳都计入消纳电量弃光和弃水不会产生任何收益。因此最大化这个目标模型会自然地优先消纳可再生电量而不是白白弃掉。我实际编码时还会在目标里加一个极小惩罚项比如减去1e-6乘以弃光量总和。原因在于并网约束写的是≤而不是如果没有这个小惩罚可能出现既不上网、也不弃光的中间松弛状态虽然数值上差别不大但也容易干扰对结果的解读。加上惩罚之后模型会把这种模糊空间压缩到零。2.3 梯级水电约束组这里最容易漏东西水量平衡约束是梯级模型的核心也是最容易出错的地方[ V_{i,t1,s} V_{i,t,s} W^{in}{i,t,s} Q^{out}{i-1,t-\tau,s} - Q^{out}_{i,t,s} ]其中Q_out Qg Sp总出库水量W_in是区间入流水量。这里的关键就是滞时τ上游t时刻的出库要到tτ时刻才作为下游的入库。如果漏掉τ上游放的水会瞬间出现在下游水量虽然在总量上守恒但时间错位会让下游库容曲线完全失真。除了水量平衡约束组还包括库容上下限V_min ≤ V ≤ V_max这是水电调节能力的物理边界总出库流量上下限下限常用来表达下游生态流量要求上限对应泄洪能力发电流量上下限对应机组过流能力和振动区限制水电出力与流量关系短期调度里常用固定水头线性化P k·Qg或者用分段线性化表达水头影响并网消纳约束ΣPH ΣPVuse ≤ P_line[t]超出部分只能弃光伏消纳恒等式PVuse PVcurt 场景给定光伏出力。我把并网消纳约束单独列出来解释一下。这个约束等价于系统总能吸收的量是有限的超了就削减。因为目标里水电出力越大收益越大所以模型会优先削减光伏而不是水电——这正好符合水光互补的运行逻辑。如果你改成一个允许全额并网、再事后统计弃电的模型优化结果就会完全变味。2.4 不确定性场景从预测误差到概率场景集场景怎么来是决定模型质量的核心环节。我用三步生成第一步读取光伏预测曲线第二步假设预测误差服从正态分布更保守的做法是拉普拉斯分布逐时段加蒙特卡洛噪声生成500个原始场景第三步用K-means或同步回代削减到20~50个代表场景每个场景的概率为簇内样本占比削减后概率重新归一化。来水不确定性也可以类似处理把历史来水相似日作为候选场景即可。但短期调度的来水预测相对较准我复现时只用了1个确定性来水场景把场景资源全部留给光伏——这是很多EI论文的实际处理方式也是我调试时的初始设置。场景削减这一步绝对不能省500个场景直接进MILP求解时间会让你怀疑人生削减到30个场景之后目标值几乎不变求解时间从几小时降到几分钟。3. Python代码实战数据组织、求解器选型和核心实现3.1 把数据当成系统来管理而不是一堆字典我先说结论复现这种模型90%的bug来自数据混乱不是模型困难。一开始我也图省事把参数塞进各种Python字典结果调试到第三天连自己都分不清哪个key对应哪个水库。后来规整成目录加CSV的结构case/ system.csv # 联络线容量、时段数、步长 hydro.csv # 水库参数库容上下限、初始库容、出力系数 inflow.csv # 各水库各时段来水场景 pv_forecast.csv # 各光伏电站预测出力场景 scenario_prob.csv # 场景编号、概率每一列都用统一单位。我的血泪教训流量单位不要混用m³/s和m³/h库容单位不要混用m³和万m³否则水量平衡根本对不上。建议在代码里写一个单位换算辅助函数所有输入数值进入模型前先过一遍换算能省下大量排查时间。例如1 m³/s持续1小时等于3600 m³也就是0.36万m³这个系数我用一个常量dt_vol表示全模型统一调用。3.2 求解器选型不同梯队的选择不同求解器的选择会直接影响你能跑多大的场景规模我整理了一张对比表求解器授权典型规模MILP支持备注Gurobi学术免费/商业授权数万~数百万变量强EI复现主流Python接口最顺手Cplex学术免费/商业授权大强老牌文档全HiGHS开源中支持PuLP/OR-Tools默认后端SCIP开源中支持学术研究常用SciPy linprog开源小无只能做小规模LP验证为什么我推荐Gurobi一是addVars/addConstr可以按索引批量声明和场景维度完美匹配二是quicksum构建超大目标非常快三是学术许可免费研究生完全够用。如果拿不到学术许可就用PuLPHiGHS代码写法差异很小把addVars换成LpVariable.dicts就行模型规模小一点但仍然能跑。3.3 建模代码变量声明、约束批量添加和求解我给出核心建模代码Gurobi接口省略数据读取部分保留建模骨架import gurobipy as gp from gurobipy import GRB m gp.Model(Cascade_Hydro_PV) # ---------- 决策变量 ---------- V m.addVars(I, T1, S, lbVmin[i], ubVmax[i], nameV) Qg m.addVars(I, T, S, lb0, ubQgmax[i], nameQg) Sp m.addVars(I, T, S, lb0, ubQoutmax[i], nameSp) PH m.addVars(I, T, S, lb0, ubPhmax[i], namePH) PVuse m.addVars(J, T, S, lb0, namePVuse) PVcurt m.addVars(J, T, S, lb0, namePVcurt) # ---------- 场景概率 ---------- pi {s: prob[s] for s in range(S)} # ---------- 目标函数 ---------- obj gp.quicksum( pi[s] * dt_h * ( gp.quicksum(PH[i, t, s] for i in range(I)) gp.quicksum(PVuse[j, t, s] for j in range(J)) ) for t in range(T) for s in range(S) ) m.setObjective(obj, GRB.MAXIMIZE) # ---------- 水量平衡含滞时 ---------- for s in range(S): for i in range(I): for t in range(T): upstream_in 0.0 if i 0: tau_i tau[i] if t - tau_i 0: upstream_in Qg[i-1, t-tau_i, s] Sp[i-1, t-tau_i, s] m.addConstr( V[i, t1, s] V[i, t, s] inflow[i, t, s] * dt_vol upstream_in * dt_vol - (Qg[i, t, s] Sp[i, t, s]) * dt_vol, namefwater_bal_{i}_{t}_{s} ) # 初始库容 m.addConstr(V[i, 0, s] Vinit[i], namefinit_{i}_{s}) # ---------- 水电出力映射固定水头简化 ---------- for i in range(I): for t in range(T): for s in range(S): m.addConstr(PH[i, t, s] k[i] * Qg[i, t, s]) # ---------- 并网消纳与弃光 ---------- for t in range(T): for s in range(S): m.addConstr( gp.quicksum(PH[i, t, s] for i in range(I)) gp.quicksum(PVuse[j, t, s] for j in range(J)) line_cap[t], namefgrid_{t}_{s} ) for j in range(J): m.addConstr( PVuse[j, t, s] PVcurt[j, t, s] pv_scenario[j, t, s], namefpv_balance_{j}_{t}_{s} ) # ---------- 求解 ---------- m.optimize()几个关键点值得展开初始库容要写成约束而不是写死在lb里。因为lb是变量本身的物理下限不等于决策初值如果你把Vinit写进lb模型会认为初始库容可以在这个范围里任意选择结果就错了。滞时处理用了t-tau0才加上游来水的判断这个边界条件特别容易漏。水电出力映射直接用了线性关系Qg24小时内的水头变化不大时足够用如果论文给了分段水头曲线把这一行换成分段线性表达式即可。3.4 场景削减和性能权衡我还做了一个对比实验同一组数据分别用500个原始场景和30个削减场景求解。结果是目标值只差了不到1%求解时间从两小时压到了三分钟。这说明随机优化场景不是越多越好关键是场景集要覆盖分布的主要形态而不是密集体现在某个尾部。削减后的概率计算要特别注意。K-means聚类后每个簇的样本数除以总样本数就是该簇代表场景的概率。但如果直接用欧氏距离在时间序列空间里聚类小时级光伏曲线会占主导来水场景被压扁。我建议把光伏和来水分别归一化后再拼在一起聚类这样两类不确定性都能被保留下来。削减完之后记得检查Σπ_s是否等于1这个简单的校验能防止后面目标函数出现系统性偏差。4. 复现期最容易被卡住的三个坑完整排查链路4.1 坑一滞时导致水量不平衡现象两个水库串联上游放水后下游库容在同一个时段就涨上去了一加滞时模型直接报不可行或者V曲线在某些时段剧烈波动明明来水平稳库容却忽高忽低。原因我把上游出库直接当作下游该时段入库忽略水流在河段里的传播时间。在1小时时段尺度下梯级水库之间的水流需要1~4小时甚至更长漏掉τ会让虚拟水量提前到达下游被迫在错误的时段放水水库一会儿干涸一会儿溢出。排查步骤我建议这样走先用单库调试V曲线和来水曲线应该形状一致再加第二个库暂时把τ设成0确认水量能闭环最后加入τ用一个极端测试上游零出库、下游零来水验证水量守恒检查索引边界t-τ小于0时上游该时段出库还没有流到下游直接置0而不是报错。修复后一个典型结果是上午上游放水傍晚下游库容才开始抬升。这个延迟会在最优解里体现为水电出力最多的地方不在光伏高峰而在光伏消退后几个时段——如果结果曲线没有表现出这种时间错位说明滞时还是没加对。4.2 坑二M参数乱取导致数值问题现象Gurobi求解后显示Optimal但检查解时发现弃光变量出现了0.001 MW这种明明可以避免的微小值或者同一个模型只是改了一下M的大小目标值就变了。原因为了处理分段线性化中是否启用某段曲线这类0-1变量我引入了大M约束。M取值太小切断了可行域模型找不到真正的解M取值太大数值条件数恶化求解器在整数分支里反复试探Gap下不来。修复原则是M不是越大越好要按实际物理上限取。例如某约束里涉及库容差Vmax-Vmin那M取这个差值的1.2倍就够了完全没必要取1e6。另外Gurobi和Cplex支持indicator constraint能不用M就别用# 用指示约束替代大M m.addConstr((z[i, t] 1) (expr rhs))指示约束让求解器自己处理激活逻辑数值稳定性比手写大M好不少。我的建议是先把模型写成纯LP跑通验证逻辑没问题再引入整数变量并且每引入一组整数变量就单独测一个最小算例确认该组变量对解的影响符合预期再往下加。4.3 坑三期望值被算成了算术平均现象无论怎么改场景概率目标值都纹丝不动对比两个场景(0.9, 0.1)的概率分布期望值居然是两场景的平均值。原因构建目标函数时我把π_s漏乘了或者乘了但最后又除以S做了平均。在Gurobi的quicksum里如果写成obj gp.quicksum(... for s in range(S))而忘记乘pi[s]恰好场景概率又设为均匀时结果看起来挺正常一改成非均匀概率就露馅。更隐蔽的是场景削减后我重新计算了概率但概率列没有和场景数据按同一个顺序对齐导致A场景的出力配B场景的概率。排查方法很简单构造两个极端场景s1、s2概率分别0.9和0.1目标值应接近s1的结果再程序里打印Σ_s π_s确认归一化最后单独把概率数组和场景数组一起打印出来眼睛核对前三个场景的顺序。这个小坑非常常见但它会直接破坏期望的含义让整篇复现失去意义。4.4 排查思路本身从退化情形开始而不是直接上全模型我的调试习惯是分四步走第一步关掉目标函数只求可行解先看模型是否可解第二步用3个场景乘2个水库乘24时段的微型算例调通所有约束第三步把场景逐步增加到论文规模第四步每加一组约束看解的变化是否符合物理直觉。如果某一步结果不正常先检查该步骤新增的约束是不是写反了方向。比如把≤写成≥或者变量下标差了一位这类低级错误在梯级模型里特别容易发生因为下标维度太多。我自己就遇到过把V[i,t1,s]写成V[i,t,s]导致水量凭空多出一个时段的情况这种bug如果不靠退化算例逐步逼近直接从大数据里查会查到头大。5. 结果验证与论文对齐怎么证明你复现的是对的5.1 先跑直觉算例确认目标行为符合物理规律我给自己构造了一个小算例两个梯级水库串联一个50MW光伏电站联络线容量100MW光伏预测曲线呈现典型的中午高峰形状。肉眼检查最优解中午光伏接近80MW时水电出力主动压到20MW以下总并网功率正好压在100MW线上傍晚光伏跌到10MW水电立刻拉高到40-60MW夜间来水平稳水电维持稳定出力。弃光基本只发生在光伏预测最高的那1-2个时段。水位曲线表现为上午蓄水、下午放水完全对应水电给光伏让路的预期。这说明模型的核心行为是对的。如果最优解里水电在中午光伏高峰还满发、导致大量弃光那目标函数或约束一定写错了。反过来说当光伏预测很低、总出力碰不到并网上限时目标等价于最大化总发电量此时水电会把水尽量放完发电直到库容下限——这也是一个可以验证模型边界行为的小实验。5.2 和论文数据对齐的要点清单复现论文不是大概差不多是要能对比数量级。我在对齐时检查这几项时间粒度论文是1h还是一刻钟这会直接改变Δt和滞时τ的单位场景数论文用了多少场景你就跑多少场景不要试图用500个场景去比30个场景的目标值边界条件库容初末值、联络线容量、来水过程是否一致概率场景概率是否和论文的削减算法一致单位目标通常输出MWh如果论文输出亿kWh记得换算。只要边界设置一致即使复现的目标值比论文低几个百分点趋势和各时段曲线形态也应该吻合。差异主要来自没有完全还原论文内部的某条约束比如生态流量、机组振动区这些不影响核心逻辑的验证。5.3 扩展灵敏度分析的三个方向复现完成后我顺手做了三个灵敏度分析这也是论文审稿人最常问的三个方向。第一个是光伏装机容量从50MW逐步加到150MW可消纳电量期望边际递增但递增速度明显放缓——因为联络线容量上限开始卡脖子。第二个是光伏预测误差标准差从5%升到15%期望目标下降弃光时段增多——不确定性越大水电越不敢提前把水放完去赌光伏不出力。第三个是联络线容量从80MW放宽到200MW消纳量期望增加但新增量集中在光伏高峰时段——这部分增量完全来自少弃光而不是多发水。这三组曲线不仅验证了模型对参数变化的敏感性也帮我真正理解了梯级水电光伏互补的价值所在水电像一个缓冲器在白天蓄水、在傍晚和夜间发力把光伏的波动消化在梯级水库的调节能力之内。灵敏度分析做出来的图比单纯的目标值更能说服自己模型是对的还是错的。5.4 复现过程中的一点操作体会我复现这个模型从建数据到跑通第一版大约花了一周。最大的体会是先把数学形式想清楚、把退化算例跑通再上全规模数据和万物皆可quicksum的冲动。滞时、概率、大M这三个地方每处都值得单独写一个小测试用例别指望一次把所有bug都憋完再修。最后再分享一个我习惯用的技巧把每个约束组的name都起得有意义比如namewater_bal_{i}{t}{s}。求解之后用m.printAttr(Slack)或者直接遍历约束检查每个场景、时段的约束是否活跃一眼就能看出哪条约束把结果顶到了边界。复现任何EI论文模型这个习惯都能省下大把时间。