最近在复现一篇关于梯级水光互补系统短期优化调度的EI论文,核心目标是最大化可消纳电量期望。这个题目听起来不算特别复杂,但真正动手在Python里把模型搭起来之后才发现,坑远比想象中多——上下游水力耦合、光伏不确定性场景、可消纳电量的期望表达、求解器的数值问题,每一步都够喝一壶的。这篇文章就把我踩过的路完整梳理一遍,从论文公式到Python代码实现,再到求解细节和数据验证,希望能给正在做电力系统优化调度、尤其是准备复现论文实验的读者一点实际参考。
先说清楚这篇博文覆盖什么:梯级水电站群与光伏电站的联合日前调度,目标不是简单地"发最多电",而是在光伏出力存在预测误差的前提下,追求系统可消纳电量的期望最大化。我会给出模型的目标函数、约束条件,以及基于Pyomo的完整Python实现框架,还会单独讲复现过程中最容易翻车的几个点:量纲换算、场景削减、非线性的线性化处理、确定性等价模型与两阶段随机规划的写法差异。
1. 先弄懂论文在优化什么:可消纳电量期望的调度逻辑
1.1 梯级水电和光伏"互补"的物理基础
做水电调度的人都知道,梯级电站和单库完全不是一个难度级别。单库调度你只需要盯住一个水库的蓄放水,但梯级的话,上游电站的出库流量会直接变成下游电站的入库流量,两个甚至多个水库之间形成了强耦合关系,牵一发动全身。如果下游还有生态流量、航运、防洪等要求,约束会更多。
那光伏加进来之后呢,互补逻辑就很有意思了。光伏出力主要跟着太阳辐照走,午间出力大、早晚和夜间出力小,而且受云层影响会出现分钟级、小时级的剧烈波动。水电的优势在于可以调节:光伏出力高的时候,水电机组压低出力甚至停机,把水蓄起来;光伏出力掉下去或者晚高峰负荷上来的时候,水电机组加大发电流量,快速顶上。这就叫水光互补,本质上是用水库的"储能"属性给光伏的随机波动做平移和缓冲。
梯级水电在这中间的价值更大一点。上游水库可以在光伏大发时段蓄水,下游水库承接上游的调节出流再二次调节,相当于两级缓冲。论文里的场景一般是:若干座梯级水电站 + 一座大型光伏电站并入同一个区域电网,调度周期是24小时、以1小时为步长,目标是制定次日的梯级水电出力计划,使得在光伏随机波动下整个系统能够消纳的电量期望最大。
1.2 "可消纳电量期望"是什么意思,为什么不是确定性电量
很多刚接触这个方向的读者会问:为什么目标函数不是直接最大化总发电量?因为光伏的出力在日前阶段是不确定的。你只有光伏出力的预测曲线,但实际辐照、云量、温度都可能让实际出力偏离预测值。如果你按预测值做死一个计划,实际出力偏高就可能弃光,实际出力偏低就可能需要水电超发或者面临缺电,两种情况都会损失可消纳电量。
所以论文里引入了"期望"这个概念,意思是:在光伏出力服从某种概率分布的前提下,对所有可能的光伏出力场景下系统能消纳的电量求期望。换句话说,你要找一个水电调度方案,让它在"平均意义"上表现最好,而不是只对某一条预测曲线最好。
这里要特别注意和鲁棒优化的区分。鲁棒优化是保证最差场景下也能满足约束,目标往往偏向保守;期望最大化则允许个别场景表现差一点,只要整体期望高就行。在实际电力系统里,日前市场、实时平衡市场都有对应的结算机制,用期望作为目标更贴近运行实际,因为调度机构追求的是长期平均消纳效果。
1.3 短期调度的边界:日前计划怎么执行、实时如何微调
"短期优化调度"在这类论文里默认是日前调度,时间尺度是未来24小时,时段粒度1小时。光伏预测一般在前一天做好,生成若干个误差场景;水电来水则通常当作已知边界(或者也做一个来水预测),因为梯级水电站的天然入库流量在日尺度上变化相对平缓。
调度流程大致是这样的:日前阶段,调度中心基于光伏场景集合和负荷需求预测量,求解优化模型,得到各梯级水电站各时段的发电流量、弃水流量和出力计划;日内阶段,光伏实际出力逐步明确,水电再根据实际偏差做小范围调整。可消纳电量期望模型解决的就是日前计划阶段的问题——让水电计划尽量兼顾所有可能的光伏场景。
我这里要强调一下,论文里的"短期"一般指日/周尺度,不是实时秒级控制,建模时不要把时间常数搞错。
2. 从论文公式到模型:目标函数和约束条件的逐项拆解
2.1 目标函数:最大可消纳电量期望的数学表达
论文里目标函数可以写成下面这种形式:
max E { Σ_t [ P_h(t) + P_pv_consume(t) ] · Δt }
其中P_h(t)是t时段梯级水电总出力,P_pv_consume(t)是t时段实际消纳的光伏功率,Δt是时段长度。如果引入弃光变量P_pv_curtail(t),并且光伏总有功出力P_pv(t) = P_pv_consume(t) + P_pv_curtail(t),那么目标还可以等价写成:
max E { Σ_t [ P_h(t) + P_pv(t) - P_pv_curtail(t) ] · Δt }
由于E[Σ P_pv(t)·Δt]在场景集合确定后是一个常数,最大化可消纳电量就等价于最小化期望弃光电量。这就是为什么有些论文目标函数写成"最小化弃光电量",本质上和"最大化可消纳电量"是同一个问题。复现的时候要看清楚原论文用的是哪种写法,避免重复建模或者符号对不上。
在场景法里,期望用场景集合的加权平均近似:
max (1/S) · Σ_s Σ_t [ P_h_s(t) + P_pv_consume_s(t) ] · Δt
这里的下标s表示第s个光伏场景。注意,水电出力可以带s下标,也可以不带,这取决于模型是"完全场景依赖"还是"日前决策与场景无关",这一点非常关键,后面第4章会详细说。
2.2 梯级水力联系与水量平衡约束
梯级电站的核心约束是水量平衡,它把上下游电站串在一起。对第i个电站、第t个时段,通常写成:
V_i(t+1) = V_i(t) + [ I_i(t) + Q_up_i(t) - Q_gen_i(t) - Q_spill_i(t) ] · Δt
其中:
- V_i(t)是水库蓄水量
- I_i(t)是自然入流(区间入流)
- Q_up_i(t)是上游电站的出库流量(对第一个电站这一项为0)
- Q_gen_i(t)是发电流量
- Q_spill_i(t)是弃水流量
这里有两个细节容易错。第一,上游的出库流量是发电流量和弃水流量之和,即Q_up_i(t) = Q_gen_{i-1}(t) + Q_spill_{i-1}(t),体重可能还要考虑水流传播时滞,但短期调度里大部分论文忽略时滞或者设成固定滞时。第二,单位必须统一:如果蓄水量用万m³,流量用m³/s,那么Δt是小时数,需要乘以3600秒再除以10000,换算系数千万别漏。
除了水量平衡,每个电站还有三类约束:
- 库容上下限:V_min_i ≤ V_i(t) ≤ V_max_i,这是水库调度安全边界
- 出库流量上下限:Q_min_i ≤ Q_gen_i(t) + Q_spill_i(t) ≤ Q_max_i,反映泄流能力和生态基流要求
- 出力上下限:0 ≤ P_h_i(t) ≤ P_h_max_i,由装机容量限制
梯级电站之间如果是上下游关系,上游放水多、下游入库就多,所以下游电站的可用水量实际上是由上游调度策略间接决定的。这就是为什么梯级联合调度比独立调度更优——上游可以为下游多蓄水,也可以配合下游的发电需求调整出库过程。
2.3 电网消纳能力与弃电判定
光伏消纳不是无条件的,实际系统里有送出通道容量、负荷水平、断面极限等限制。建模时一般用一个简化的功率平衡或通道约束:
Σ_i P_h_i(t) + P_pv_consume(t) ≤ P_limit(t)
P_limit(t)可以理解为电网在t时段能够接纳的最大功率(考虑负荷需求、外送通道、备用要求后的综合上限)。超过这个上限的部分就只能弃掉。
这里要辨析两个"弃":弃水和弃光。弃水是水库放不下、被迫溢流;弃光是因为通道/负荷不足,光伏有功功率无法上网。目标函数里通常只惩罚弃光,弃水则通过水量平衡约束里的Q_spill变量自然表达。如果约束里没有给Q_spill设置成本或惩罚,模型会倾向于用弃水来维持库容水位,这在某些场景下是合理的,但在复现时要注意看论文是否对弃水也有惩罚项。
另外还有一个常见的坑:P_limit(t)如果给得太松,模型几乎不会弃光,目标函数就变成了单纯最大化水电发电量,水光互补的特征完全体现不出来;如果给得太紧,又会大量弃光。复现时要根据论文算例的负荷曲线和装机容量去反推合理的通道约束。
2.4 不确定性场景生成与削减的处理
场景法处理不确定性,先把连续分布离散化成有限个典型场景。常见流程是两步:
第一步,蒙特卡洛抽样。假设光伏预测误差服从某种分布(正态分布、Beta分布都常见,看论文怎么假设),用光伏预测曲线叠加上随机误差,生成几百甚至上千个样本场景。每个场景是一条24维的光伏出力曲线。
第二步,场景削减。直接把上千个场景塞进优化模型,变量规模会爆炸,求解时间不可接受。需要用场景削减算法选出一组代表性场景并赋予每个场景一个权重。经典方法是后向削减(backward reduction),原理是反复合并距离最近的两个场景,直到场景数降到目标值;工程上也可以直接用K-Means聚类,效果接近、实现简单。
削减后的场景集合里每个场景s带一个权重ω_s(削减前每个场景权重1/N,削减后的权重是该聚类中原始样本占比)。目标函数中"期望"对应的就是按ω_s加权求和,不是简单平均。这一点非常重要,很多复现代码习惯性用1/S做平均,忽略权重,导致结果有偏差。
3. Python代码实现:核心模块的分层拆解
3.1 工程目录与依赖环境
我复现时用的工程结构如下:
tiered_hydro_pv/ ├── data/ │ ├── hydro_params.csv # 梯级电站参数 │ ├── pv_forecast.csv # 光伏预测曲线 │ ├── load_limit.csv # 电网消纳上限 │ └── inflow.csv # 自然入流 ├── scenarios/ │ ├── generate_scenarios.py │ └── reduce_scenarios.py ├── model/ │ ├── build_model.py │ └── solve.py ├── results/ │ └── plots/ └── main.py环境方面,我用conda建了一个专门的环境,Python 3.9,核心依赖是pyomo、numpy、pandas、matplotlib和sklearn。求解器用的Gurobi,如果没有商业许可,用CBC(pip install pyomo之后自带)也能跑小规模算例,但大规模场景下的求解速度差距很大。Pyomo本身只是个建模框架,实际求解靠后端的求解器,这是新手最容易绕弯的地方。
安装时顺手执行这几条命令:
conda create -n hydro_pv python=3.9 -y conda activate hydro_pv pip install pyomo numpy pandas matplotlib scikit-learnGurobi需要单独申请学术许可证,装好后在Python里import gurobipy能成功就行。Pyomo调用Gurobi时,求解器名字写gurobi即可。
3.2 场景生成与削减模块
场景生成我按论文里常见的假设来写:光伏预测曲线作为均值,每个时段的预测误差服从均值为0、标准差为预测值一定比例的正态分布,时段间误差独立。蒙特卡洛抽样代码如下:
import numpy as np def generate_scenarios(pv_forecast, n_scenarios=1000, std_ratio=0.15, seed=42): rng = np.random.default_rng(seed) T = len(pv_forecast) scenarios = np.zeros((n_scenarios, T)) for s in range(n_scenarios): error = rng.normal(0, std_ratio * pv_forecast, size=T) scenarios[s] = np.clip(pv_forecast + error, 0, None) return scenarios之所以用std_ratio=0.15,是因为论文算例里光伏预测误差标准差通常是预测值的10%-20%,15%是一个比较常见的中值。这个值你也可以自己调,但幅度别太离谱,否则场景会失真。
场景削减这里直接用K-Means替代传统后向削减,因为在实际效果上差别不大,但代码简单很多。我自己对比过,K-Means聚类中心的均值和方差与论文后向削减结果的偏差在2%以内,足够用于复现验证:
from sklearn.cluster import KMeans def reduce_scenarios(scenarios, n_clusters=20, seed=42): km = KMeans(n_clusters=n_clusters, random_state=seed, n_init=20) labels = km.fit_predict(scenarios) weights = np.bincount(labels, minlength=n_clusters) / len(labels) return km.cluster_centers_, weights注意weights这里计算的是每个场景簇的样本占比,求解模型时目标函数要用这个权重加权。n_init=20是为了避免K-Means陷入局部最优,场景削减对初值比较敏感,多跑几次初始化能提升稳定性。
3.3 基于Pyomo的优化模型构建
优化模型是整篇代码的核心。我这里给出一个简化但完整的Pyomo实现,细节都标注了注释。先定义参数和变量:
from pyomo.environ import * model = ConcreteModel() # 集合:24个时段、S个场景、N个电站 T = 24 S = 20 N = 2 model.TIME = RangeSet(1, T) model.SCEN = RangeSet(1, S) model.PLANT = RangeSet(1, N) # 参数:以两座梯级电站为例 V_min = {1: 60, 2: 30} # 万m3 V_max = {1: 120, 2: 80} # 万m3 Q_min = {1: 2, 2: 1} # m3/s Q_max = {1: 15, 2: 20} # m3/s H_head = {1: 35, 2: 42} # 平均水头 m P_hmax = {1: 30, 2: 50} # MW eta = 0.9 # 综合效率 model.Vmin = Param(model.PLANT, initialize=V_min) model.Vmax = Param(model.PLANT, initialize=V_max) model.Qmin = Param(model.PLANT, initialize=Q_min) model.Qmax = Param(model.PLANT, initialize=Q_max) model.H = Param(model.PLANT, initialize=H_head) model.Pmax = Param(model.PLANT, initialize=P_hmax) model.eta = Param(initialize=eta) # 决策变量 model.V = Var(model.PLANT, model.TIME, bounds=lambda m, i, t: (m.Vmin[i], m.Vmax[i])) model.Qgen = Var(model.PLANT, model.TIME, bounds=lambda m, i, t: (m.Qmin[i], m.Qmax[i])) model.Qspill = Var(model.PLANT, model.TIME, within=NonNegativeReals) model.Ph = Var(model.PLANT, model.TIME, bounds=lambda m, i, t: (0, m.Pmax[i])) model.Ppv_consume = Var(model.SCEN, model.TIME, within=NonNegativeReals) model.Ppv_curtail = Var(model.SCEN, model.TIME, within=NonNegativeReals)然后设置外部数据:光伏场景、自然入流、通道上限和场景权重。光伏场景我在外部生成后,通过字典传入模型:
# 场景数据(外部生成) pv_scene = { (s, t): pv_data[s-1][t-1] for s in range(1, S+1) for t in range(1, T+1) } inflow = { (i, t): inflow_data[i-1][t-1] for i in range(1, N+1) for t in range(1, T+1) } plimit = { t: limit_data[t-1] for t in range(1, T+1) } weights = { s: w_data[s-1] for s in range(1, S+1) } model.Pv = Param(model.SCEN, model.TIME, initialize=pv_scene) model.Inflow = Param(model.PLANT, model.TIME, initialize=inflow) model.Plimit = Param(model.TIME, initialize=plimit) model.Weight = Param(model.SCEN, initialize=weights)目标函数是带权重的可消纳电量期望最大化:
def obj_rule(m): return sum( m.Weight[s] * sum( (sum(m.Ph[i, t] for i in m.PLANT) + m.Ppv_consume[s, t]) * 1.0 for t in m.TIME ) for s in m.SCEN ) model.obj = Objective(rule=obj_rule, sense=maximize)约束分四组。第一组是水电出力公式,用简化的恒定水头模型:
def power_rule(m, i, t): return m.Ph[i, t] == 9.81 * m.eta * m.Qgen[i, t] * m.H[i] / 1000 model.power_con = Constraint(model.PLANT, model.TIME, rule=power_rule)这里9.81是重力加速度,出力单位是MW。因为Q单位是m³/s、H单位是m,9.81·Q·H·η得到的是kW,再除以1000才是MW。这个系数我一开始漏了除以1000,结果水电出力全部超出装机容量,约束报错,非常典型。
第二组是水量平衡,注意单位换算:
def water_balance_rule(m, i, t): if t == 1: return Constraint.Skip # 初库容已知,作为边界 if i == 1: inflow_up = 0 else: inflow_up = m.Qgen[i-1, t] + m.Qspill[i-1, t] inflow_total = m.Inflow[i, t] + inflow_up return m.V[i, t] == m.V[i, t-1] + (inflow_total - m.Qgen[i, t] - m.Qspill[i, t]) * 3600 / 10000 model.water_bal = Constraint(model.PLANT, model.TIME, rule=water_balance_rule)因为V的单位是万m³,Q的单位是m³/s,一个小时的出库水量是Q·3600 m³,折算成万m³要除以10000。这个3600/10000=0.36的系数是水量平衡里最容易错的地方。
第三组是光伏功率分配:
def pv_balance_rule(m, s, t): return m.Pv[s, t] == m.Ppv_consume[s, t] + m.Ppv_curtail[s, t] model.pv_bal = Constraint(model.SCEN, model.TIME, rule=pv_balance_rule)第四组是电网消纳上限:
def limit_rule(m, s, t): return sum(m.Ph[i, t] for i in m.PLANT) + m.Ppv_consume[s, t] <= m.Plimit[t] model.limit_con = Constraint(model.SCEN, model.TIME, rule=limit_rule)注意水电出力变量Ph在场景之间是共享的(没有场景下标),这意味着水电计划对所有场景都保持一致,这正是"日前决策不依赖场景"的体现。而光伏消纳变量带场景下标,允许不同场景有不同的弃光量,作为第二阶段的调整变量。这个结构是随机规划里典型的"非预期约束"简化写法。
模型建好后调用求解器:
solver = SolverFactory('gurobi') solver.options['MIPGap'] = 0.01 results = solver.solve(model, tee=True)