ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

梯级水光互补系统最大化可消纳电量期望的短期优化调度Python实现

梯级水光互补系统最大化可消纳电量期望的短期优化调度Python实现 最近在复现一篇梯级水光互补系统的EI论文题目非常直接——“最大化可消纳电量期望短期优化调度模型”。光看标题容易以为只是套一个优化框架真正动手以后才发现难点其实藏在两个地方一个是“期望值”光伏出力是带随机性的另一个是“梯级”上下游电站之间的水量耦合会让约束条件互相咬得很紧。这篇文章把我从问题拆解、数学建模到Python代码落地的完整过程梳理出来尤其适合正在做EI/SCI复现、水光互补调度、新能源并网研究的同学也适合想快速上手的读者直接参考。这里讲的全是我实际调试中走通的路结论会比论文附录更接地气。1. 项目背景与需求拆解这到底在解决什么问题1.1 梯级水光互补系统是什么为什么要联合调度梯级水光互补系统简单说就是同一条河流上串联的多个水电站再叠加一片光伏电站共同接入电网。这里的“梯级”意味着上游水库放水之后水流会作为下游水库的入库流量上下游的发电决策不是独立的。短期优化调度一般以小时为粒度调度周期取1天或者几天步长可以是1小时或15分钟目的是确定每一台水电机组在不同时段放多少水、发多少电同时配合光伏出力的波动。为什么要放在一个模型里因为光伏出力受天气影响存在明显的峰谷波动而水电具备快速调节能力。白天光伏大发的时候水电可以少发、把水存起来傍晚光伏快速下跌的时候水电再顶上去。这个“错峰互补”听起来很简单但一旦涉及到多个梯级水库问题就变得复杂了上游为了晚高峰多发电而蓄水下游可能就会因为来水减少而失去调节能力单纯靠人工经验很难把全局调好。这里还要注意一个行业背景很多水电大省存在外送通道受限、当地负荷有限的局面。光伏和水电同时大发时电网消纳能力会成为约束要么弃水要么弃光。模型里“可消纳电量”这个词本质就是在通道和负荷限制下系统能把多少电真正送出去。1.2 “最大化可消纳电量期望”里的三个关键词我复现时把标题拆成了三层来理解。第一层是“可消纳电量”。它不是发电量而是指被电网实际接纳的电量。光伏发了100度电如果通道只能送80度那消纳电量就是80度剩下20度属于弃电。水电如果为了追求发电量而把水库放空可能后面几天反而发不出电所以调度目标不是简单让某一时刻发电最多而是保证系统在调度周期内整体送出的电量尽可能大。第二层是“期望”。光伏出力具有随机性靠点预报去优化最典型的结果就是预报说下午3点有80兆瓦光伏实际却只来了50兆瓦水电调度方案完全不是最优的。期望值的处理方法是把光伏出力的多种可能场景都放到目标函数里用场景概率做加权平均。这样得到的调度方案不追求在某一个场景下最优而是让所有可能天气下的平均消纳电量最高属于风险中性的随机优化。第三层是“短期优化调度”。它对应的是小时级、日前计划层面的决策时间粒度短、约束多、对求解效率要求高。把这个三层拆明白后面的建模和编程就不会跑偏。1.3 项目适用范围与复现定位这类模型在学术上是水电调度和新能源并网交叉领域的经典题目EI论文里很常见在工程上也和省级电网的水光互补调度系统直接相关。我复现后的体会是如果你具备线性规划基础会一点Python和pandas这个项目大约两天可以跑通如果对Pyomo或Gurobi不熟可能需要一周但收益很大因为你会同时掌握“随机优化建模”和“Python求解调度问题”两套能力。复现定位上我建议把它当作一个“能解决实际问题的数学规划模板”而不是死抠某篇论文的某个数值。不同论文差异主要在光伏场景生成方式、是否考虑水头变化、是否加入机组组合约束但目标函数和水量平衡约束的大框架是通用的。2. 数学建模目标函数与约束体系的完整设计2.1 目标函数怎么写期望消纳电量最大化的标准形式我先给出目标函数的通用形式。设调度时段为 t1,...,T梯级水电站编号为 i1,...,N光伏场景为 s1,...,S每个场景概率为 p_s。场景 s 下 t 时段水电总出力为 \sum_i P_{h,i,t,s}光伏可用出力为 P_{pv,t,s}光伏实际削减量为 P_{c,t,s}则并网功率 P_{g,t,s} 满足P_{g,t,s} \sum_i P_{h,i,t,s} P_{pv,t,s} - P_{c,t,s}目标函数为\max \quad \sum_{s1}^{S} p_s \sum_{t1}^{T} P_{g,t,s} \Delta t这里 \Delta t 是时段长度单位小时。如果场景等概率p_s 1/S目标就等价于所有场景消纳电量的算术平均。这个式子看着简单但里面暗含了两个关键选择。第一个选择是把消纳量写在目标函数里而不是作为硬性约束。消纳通道上限会作为不等式约束单独加目标函数里只负责“尽量多送”。第二个选择是加了 P_{c,t,s} 这个削减变量。光伏可用出力由场景给定但模型可以选择弃掉一部分不能超过光伏可用出力因为光伏本身不是可控电源。水电则没有类似的主动削减变量因为水能不放就浪费了优先利用是合理的。这个目标函数还有一个隐含特性它不惩罚弃水。弃水是通过水量平衡约束里的泄水变量体现的如果下游水库装不下水就只能从闸门白白流掉。目标函数不会直接关心弃水量但这并不代表模型会随意弃水因为弃走的水没有参与发电等于损失了潜在消纳电量优化算法会自然倾向于少弃水、多发电。2.2 约束全集清单与物理含义模型约束可以分成四类水量平衡、物理边界、并网限制、末端条件。我整理了一个表格便于对照建模约束类型数学含义实际物理意义水量平衡V_{i,t} V_{i,t-1} (区间入流 上游下泄 - 发电流量 - 弃水) \Delta t水库储水量的变化由来水和放水共同决定库容上下限V_{i,min} \le V_{i,t} \le V_{i,max}水库不能放空也不能超蓄出库流量上下限Q_{i,min} \le Q_{i,t} \le Q_{i,max}受机组过流能力和生态流量限制水库弃水不等式SP_{i,t} \ge 0弃水只能单向不能让水倒流水电出力约束P_{h,i,t} K_i Q_{i,t}发电流量和出力近似线性关系光伏削减约束0 \le P_{c,t,s} \le P_{pv,t,s}只能弃光不能凭空产生功率并网通道约束P_{g,t,s} \le P_{g,max}送出通道和负荷消纳上限末端库容约束V_{i,T} 接近 V_{i,target}调度周期末要保水位服务后续调度梯级之间的耦合主要体现在水量平衡约束里的“上游下泄”项。第 i 级电站的入库除了区间入流还包括第 i-1 级电站的发电流量和弃水流量。也就是说上游怎么调度直接决定下游明天有多少水可以用。这也是梯级调度和单库调度最本质的区别。2.3 梯级耦合与线性化处理的几个关键点很多刚做这个项目的人会困惑水电出力明明和水头有关系为什么能直接写成 P_{h,i,t} K_i Q_{i,t}严格来说出力公式是 P \eta \rho g Q H其中 H 是发电水头而水头又和库容、尾水位有关所以 P 对 Q 不是线性的。短期调度模型里通常有两种处理方式第一种是固定水头近似。调度周期短库水位变化有限可以在模型里用一个代表性水头 H_{i,avg} 把 P 和 Q 近似成线性关系。这种方式简单、求解快适合做滚动计划和期望值优化。第二种是分段线性化。把水头按库容区间分成多段每段对应一个线性系数然后用 SOS2 约束或者增量成本法近似非线性曲线。对于EI复现第一种已经够用我在代码实现的也是这种方式如果要更精细可以直接扩展成分段线性。梯级耦合还有一个容易被忽视的细节水流在上下游之间的传播时间。严格建模时上游出库不等于下游立刻入库会有一个滞后时间。对于小时级调度如果两级电站相距很近滞后通常忽略如果距离很远最好把上游出库往时间轴后移1到2个时段再进入下游水量平衡。忽略这个延迟可能导致上游放水时间安排过于超前下游水库实际来水时间和模型对不上。2.4 不确定性处理场景法与期望值计算的实现思路“期望”落到代码里最常用的就是场景法也叫采样平均近似。思路是把连续的概率分布离散成 S 个场景每个场景是一整条光伏出力曲线然后用算术平均替代期望算子。场景来源一般有三类历史典型日、蒙特卡洛抽样、基于预测误差分布生成。论文里常见的是用预测误差叠加典型出力生成几百个场景再用场景削减算法浓缩成10到20个代表性场景。这里必须注意非预期约束的问题。如果模型里所有变量都带场景下标 s那么等效于假设调度者在每个场景开始前就知道未来整条光伏曲线这在随机规划里是不允许的因为实际调度只能在信息逐步揭示后做决策。严谨的做法是把第一阶段调度变量比如当天第1小时的水电出力设为所有场景相同后续时段再分场景调整。在代码里通常用约束 Q_{i,t1,s} Q_{i,t1,s} 来实现第一阶段的非预期性。我复现时用的是简化版所有时段变量带场景下标但约束第1时段水电出力在所有场景下相等。这样做的好处是求解规模小能保住“期望”的核心思想如果要完全符合随机双层决策的学术定义需要引入多阶段非预期约束或场景树复杂度会明显上升。3. Python实现的前置准备与技术选型3.1 环境配置与依赖安装我实际用的环境是Python 3.11装了四个核心库numpy、pandas、matplotlib、pyomo。求解器选用HiGHS开源免费对于这种纯线性规划问题速度非常理想如果机器上有Gurobi学术许可直接把求解器名称换成gurobi即可代码结构不用改。安装命令我给出来运行环境建议用conda新建一个独立环境避免和别的项目冲突conda create -n hydro python3.11 -y conda activate hydro pip install numpy pandas matplotlib pyomo highspyPyomo是一个建模语言库它和scipy.optimize最大的区别是可以直接用Python语法描述优化问题写起来接近数学公式求解器可以随意切换HiGHS、Gurobi、Cbc都能接。对于100个左右的变量规模HiGHS速度足够如果场景数放大到100个以上模型规模会到几万个变量建议直接换Gurobi免费求解器在这种规模下会比较吃力。3.2 数据准备入库流量、光伏出力、库容参数这个模型需要的数据不复杂但很琐碎。我把常用参数列出来方便对照准备参数符号作用梯级电站数N水电站串级数量时段数T24小时或96个15分钟时段场景数S光伏随机场景个数各电站库容上下限V_min/V_max调度期库容约束各电站初始库容V_0模型起点各电站期望末库容V_target调度期末保水各电站出库流量上下限Q_min/Q_max发电流量边界各电站出力系数K_i水头简化后的出力系数区间入流inflow[i,t,s]场景化的区间来水光伏场景出力pv[t,s]随机场景生成并网通道上限grid_max[t]可消纳功率上限我复现时把数据统一放在一个Excel文件里用pandas直接读取。一个很实用的习惯是所有参数先按“唯一数据源”组织再在建模程序里转成numpy数组避免程序里到处写魔法数字否则后面改参数特别痛苦。我见过不少同学直接在代码里写V_max 120等换一个数据案例时满世界找这个常量极容易出错。3.3 求解器选型为什么线性化比整数化更划算我一开始尝试过在模型里加入机组开停机变量也就是把水电出力表示成机组启停状态和流量的乘积这会形成混合整数线性规划Gurobi也能解。但问题在于目标函数和约束已经很多场景数一大整数变量会让求解时间指数上涨几十个场景下经常要跑几分钟甚至更久。后来我调整了思路短期调度里水电站机组组合问题可以先用简化模型求出总出力和流量再在事后用“同倍比分配”或“经济负荷分配”把总流量分到具体机组。这样主模型完全保持为线性规划没有整数变量规模大也能秒解。这也是很多工程化调度系统的常见做法。如果你研究的重点是梯级水量协调不建议一上来就塞整数变量如果重点是机组组合才需要把单元状态建模加进去。4. 核心代码实现与逐段解析4.1 定义场景与公共参数我给出一个能跑通的演示骨架。为了方便说明参数用随机数生成实际使用时要替换成真实数据。第一步是建立场景数据和公共参数import numpy as np import pandas as pd import pyomo.environ as pyo # ------------------ 公共参数 ------------------ T 24 # 调度时段数24小时 N 3 # 梯级水电站数 S 10 # 光伏场景数 DT 1.0 # 时段长度单位小时 # 库容边界单位亿立方米 V_max np.array([12.0, 8.0, 6.0]) V_min np.array([5.0, 3.0, 2.0]) V_0 np.array([8.0, 5.0, 4.0]) V_target np.array([7.0, 4.5, 3.5]) # 发电流量边界单位立方米/秒 Q_min np.array([20.0, 30.0, 25.0]) Q_max np.array([120.0, 150.0, 140.0]) # 出力系数单位MW / (m3/s)由水头、效率折算 K np.array([0.08, 0.06, 0.05]) # 弃水流量上限单位立方米/秒 SP_max np.array([150.0, 160.0, 150.0]) # 并网通道上限单位MW grid_max np.linspace(150, 180, T) # ------------------ 场景数据实际应读取Excel或场景库 ------------------ np.random.seed(42) # 区间入流维度电站数 x 场景数 x 时段数单位m3/s inflow np.random.rand(N, S, T) * 30.0 # 光伏场景功率维度场景数 x 时段数单位MW pv np.random.rand(S, T) * 120.0这里所有的量纲都要在建模前想清楚。库容用亿立方米流量用立方米/秒时间用小时转换系数统一按 1 m³/s × 3600 s / 1e8 0.0036 亿立方米处理。如果量纲不统一水量平衡约束会出现数值不匹配轻则结果没物理意义重则直接无解。4.2 在Pyomo中构建优化模型接下来是模型核心。先建集合和变量注意变量都带场景下标这样才能表达“期望”model pyo.ConcreteModel() model.Tidx pyo.RangeSet(0, T - 1) model.Hidx pyo.RangeSet(0, N - 1) model.Sidx pyo.RangeSet(0, S - 1) model.V pyo.Var(model.Hidx, model.Tidx, model.Sidx, boundslambda m, i, t, s: (V_min[i], V_max[i])) model.Q pyo.Var(model.Hidx, model.Tidx, model.Sidx, boundslambda m, i, t, s: (Q_min[i], Q_max[i])) model.SP pyo.Var(model.Hidx, model.Tidx, model.Sidx, boundslambda m, i, t, s: (0, SP_max[i])) model.PH pyo.Var(model.Hidx, model.Tidx, model.Sidx, bounds(0, 200)) model.PG pyo.Var(model.Tidx, model.Sidx, bounds(0, None)) model.CUT pyo.Var(model.Tidx, model.Sidx, bounds(0, None))变量带上下限的好处是不用再单独写一批边界约束模型规模会小很多。这里有一个容易踩的坑库容上下限直接写进V变量的bounds但水量平衡算出来如果超出边界求解器会直接报不可行。这时候往往不是模型逻辑错而是初始库容、区间入流和出库边界本身就配不平。排查顺序建议是先检查V_0是否在V_min和V_max之间再看下游电站能否在最小出库流量下容纳上游来水。水量平衡约束是整个模型里最需要仔细写的部分def balance_rule(m, i, t, s): dt DT * 3600.0 / 1e8 # 由m3/s和小时折算到亿立方米 if t 0: v_prev V_0[i] # 初始库容 else: v_prev m.V[i, t - 1, s] if i 0: upstream 0.0 else: upstream m.Q[i - 1, t, s] m.SP[i - 1, t, s] return m.V[i, t, s] v_prev (inflow[i, t, s] upstream - m.Q[i, t, s] - m.SP[i, t, s]) * dt model.balance_con pyo.Constraint(model.Hidx, model.Tidx, model.Sidx, rulebalance_rule)这段代码里i0表示最上游水库它没有上游来水i0时来水项就把上一台电站的发电流量加上弃水流量作为梯级耦合。注意time index t从0开始t0代表第1小时V_0是这一步开头库容。下游所有时段的水量都是靠这个递归关系串起来的。水电出力约束和并网功率平衡也很直观def hydropower_rule(m, i, t, s): return m.PH[i, t, s] K[i] * m.Q[i, t, s] model.hydropower_con pyo.Constraint(model.Hidx, model.Tidx, model.Sidx, rulehydropower_rule) def grid_balance_rule(m, t, s): return m.PG[t, s] sum(m.PH[i, t, s] for i in m.Hidx) pv[t, s] - m.CUT[t, s] model.grid_balance_con pyo.Constraint(model.Tidx, model.Sidx, rulegrid_balance_rule) def grid_limit_rule(m, t, s): return m.PG[t, s] grid_max[t] model.grid_limit_con pyo.Constraint(model.Tidx, model.Sidx, rulegrid_limit_rule)grid_balance_con定义的就是“消纳电量”的物理含义并网功率等于水电出力加上光伏实际吸纳功率光伏削减量越大可消纳电量就越少。通道上限约束则强制并网功率不能超过外送能力。把这两个约束配合起来看模型自动会优先让水电蓄水、在光伏大发时段少发电把并网空间让给光伏。再看末端库容和非预期约束。末端保水是为了不让优化算法把水库全部放空来换当前调度期电量否则会牺牲后续调度def terminal_rule(m, i, s): return m.V[i, T - 1, s] V_target[i] 0.2 model.terminal_con pyo.Constraint(model.Hidx, model.Sidx, ruleterminal_rule) # 非预期约束第1时段发电流量在所有场景下相同 def nonanticip_rule(m, i, s): return m.Q[i, 0, s] m.Q[i, 0, 0] model.nonanticip_con pyo.Constraint(model.Hidx, model.Sidx, rulenonanticip_rule)非预期约束我加给了第1时段让所有场景共享同样的初始调度决策这是随机规划里最基本的“不能预知未来”要求。如果你直接跑上面这段代码末端约束我用的是“小于目标0.2”而不是严格等于这个松弛是为了减少不可行概率实战中也可以用终端库容的报酬函数代替硬约束让优化器在发电和保水之间做权衡。目标函数按场景概率平均求期望等概率时直接求和平均def objective_rule(m): return sum(m.PG[t, s] * DT for t in m.Tidx for s in m.Sidx) / S model.obj pyo.Objective(ruleobjective_rule, sensepyo.maximize)到这一步一个完整的随机期望短期调度模型就建好了。4.3 求解并保存结果求解代码非常简单但求解器配置有讲究opt pyo.SolverFactory(appsi_highs) results opt.solve(model, teeFalse) if results.solver.termination_condition pyo.TerminationCondition.optimal: print(求解成功目标值:, pyo.value(model.obj)) else: print(求解状态:, results.solver.termination_condition)之后把结果提取到DataFrame里方便后处理和自己复查。pg_data np.zeros((S, T)) q_data np.zeros((N, S, T)) for s in range(S): for t in range(T): pg_data[s, t] pyo.value(model.PG[t, s]) for i in range(N): q_data[i, s, t] pyo.value(model.Q[i, t, s]) df_pg pd.DataFrame(pg_data.T, columns[fscene{s} for s in range(S)])我把结果列成DataFrame之后通常第一件事是检查每个场景的消纳电量差异。如果不同场景结果差很多说明光伏不确定性对调度方案影响大模型确实在发挥“期望”作用如果几乎一样可能是并网通道约束或水电能力成了主导约束光伏不确定性被“锁死”了。4.4 结果可视化与调度曲线输出结果可视化用matplotlib画三张图就够了。第一张是并网功率曲线把所有场景画成半透明细线再叠加期望值粗线第二张是梯级电站的日出力堆叠图能看出水电如何起来补光伏的“晚高峰”第三张是水库库容变化曲线用来检查库容是否触边、是否会因为优化而极限运行。import matplotlib.pyplot as plt plt.figure(figsize(10, 4)) for s in range(S): plt.plot(pg_data[s], colororange, alpha0.3) plt.plot(pg_data.mean(axis0), colorblack, linewidth2, label期望并网功率) plt.xlabel(时段) plt.ylabel(MW) plt.legend() plt.grid(alpha0.3)画图本身不难但我在复现时发现一个很有价值的检查习惯库容曲线如果有某个电站连续多个时段压在下边界上说明模型贪心地把水都用来发电了虽然目标函数值高但实际运行非常危险。工程上会通过调高V_min或添加库容惩罚来处理这也是论文里不会写但实战必须用的技巧。5. 实际运行中的常见问题与避坑经验5.1 求解器提示不可行先查水量平衡还是先查并网约束模型不可行是所有调模型的人最先碰到的坎。我的排查顺序固定是先禁用并网通道约束和末端约束只保留水量平衡、库容和出库边界如果这种情况下仍无解问题一定在水量平衡。常见原因有三个初始库容不在库容上下限内最小出库流量太大导致某个时段库容突破上限梯级耦合方向写反把下游的出库加到了上游的入库里。如果去掉并网约束后模型能解说明问题出在消纳侧的约束上。最常见的是grid_max设得太小低于水电最小出力这样即使光伏完全不发水电也不能全部送出去模型必然无解。遇到这种情况不能只靠调大通道上限来糊弄要看具体是哪个约束被拉满可以用Pyomo的suffix功能导出对偶变量或者简单地把grid_max逐步放大看目标变化趋势判断到底是哪个环节卡住消纳量。5.2 场景数太多导致求解慢怎么压缩场景期望值模型最大的隐患在场景数量。场景从10个增加到100个变量数量几乎线性增长求解时间可能从几秒变成几分钟。有效做法是在建模前先做场景削减常用的有k-means聚类和快前向选择法。k-means的思路是把几百条光伏出力曲线聚类成10到20个代表场景再按簇内样本数量占比设置场景概率。我实测下来15个代表场景基本能保留原场景集的期望值精度目标函数误差可以控制在2%以内。还有一个细节场景削减后不能直接丢进模型要检查削减后场景集是否覆盖了光伏极端出力时段。如果只做k-means极端高光和极端低光场景容易被平均掉导致调度方案偏保守。我会在削减后的场景集里手动加入两个极端场景再重新归一化概率模型结果会稳健很多。5.3 数值尺度不一致是隐形的求解瓶颈这个坑非常隐蔽。库容动辄几亿立方米流量只有几十立方米/秒出力是几十兆瓦这些数量的尺度差了好几个数量级。求解器内部会做预处理但如果经验不足很容易被数值噪音干扰。我处理的方式是把所有涉及水量平衡的量都按亿立方米统一流量乘以时间后再除以1e8目标函数功率按兆瓦计乘以1小时就是兆瓦时。这样模型里所有数值大致落在0.1到200之间收敛快且稳定。另一个容易忽视的数值问题是水库容差。比如求解器精度默认是1e-6但水量平衡左右两边的值可能差到小数点后七八位容差设置不合适会导致约束判负。我一般会在求解前把容差调到1e-6以下或者把库容单位缩小到“万立方米”让模型数值更友好。5.4 常见问题速查表现象大概率原因解决办法求解提示infeasible初始库容越界或出库边界矛盾检查V_0和水量平衡约束暂时关闭末端约束定位所有场景结果几乎一样并网通道或水电能力是主导约束观察约束影子价格确认光伏波动不是瓶颈目标值对场景数不敏感场景削减过度极端场景被抹平保留高光/低光极端场景求解时间过长场景数过多或模型含整数变量场景聚类削减把机组组合拆到事后分配库容长期贴边界目标函数太“贪心”添加库容惩罚或抬高V_min我还在一个小规模案例上对比过“固定水头线性模型”和“水头分段线性模型”的结果差异。当库容波动不大时两者目标值差异通常在1%以内但分段线性模型求解时间会成倍增加。所以复现初期先用固定水头版本把整个流程跑通后再按论文需求升级水头模型是性价比最高的路径。最后分享一个我实际操作的体会。做这类调度模型复现最大的收益不是跑通了一篇论文的代码而是建立了一套“从物理过程到数学模型再到求解验证”的思考方式。我第一次跑通时目标值虽然算出来了但把库容曲线画出来后才发现上游水库几乎一直贴着下限运行这种方案在实时调度里根本不可用。后来给模型加了末端库容约束和库容惩罚结果才变得真实。你复现的时候也一定要把优化结果画出来看数值上最优不代表工程上合理。如果后续还想扩展可以往三个方向做一是把光伏场景生成从随机数改成基于历史数据和预测误差的真实场景库二是加入水头分段线化三是改成分层模型让水电总出力再通过机组组合分配到具体机组。这套基础模型就像积木的第一块后面很多调度研究都可以搭在它上面继续做。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进