☰
基于两阶段优化的源网荷储协同接纳能力评估与Pyomo实现
2026/10/9 6:31:17 网站建设 项目流程

做这类“源网荷储协同接纳能力”评估,我踩过不少坑。拿到题目时最常见的问题是:光伏、风电、储能、电网购电、负荷削减全搅在一起,到底什么才是“接纳能力”?有人直接拿新能源装机容量加总,有人拍脑袋给个比例,但这些做法等到真正做方案评审时,一问“弃电率怎么算”“储能约束加没加”,立刻就露馅。这个标题看起来是在找一个两阶段优化方法的代码,实际需求比代码本身更深:你要一套能说清楚逻辑、能落地可复算、能直接用于项目报告的评估工具。

这篇文章里的内容,是我在实际项目中沉淀出来的:把两阶段优化建模思路讲明白,再用 Python + Pyomo 写出核心代码,涵盖第一个阶段的容量投资决策,和第二个阶段的源网荷储逐时段运行校核。不管你是做电力规划、微电网方案、还是新能源消纳评估,这套思路都可以直接套。阅读需要有一点最基础的线性规划概念,不需要会 Pyomo,代码部分我会把变量和约束拆开讲。

1. 这个问题的实际背景与优化思路

1.1 “接纳能力”真正难算在哪里

从工程角度看,所谓“接纳新能源能力”,指的是在满足负荷平衡、电网交互极限、储能物理特性等一系列条件的前提下,本地能够承载多大的光伏和风电装机。这个容量不取决于某个单一指标,而是四个环节的协同结果:

  • 源:光伏和风电出力有强随机性、波动性;
  • 网:与上级电网的交互功率有上限,倒送也不行;
  • 荷:负荷曲线本身有峰谷特性,不可能一直跟随新能源;
  • 储:储能可以削峰填谷,但容量越大、成本越高,自身还有充放电约束和效率损耗。

如果只是做静态计算,比如全年8760小时逐时电量平衡,逻辑上虽然可行,但结果很难指导规划。因为规划人员想知道的是“在给定预算或限制下,最优的新能源装机组合是多少、需要配多大储能”。这是一个决策优化问题,不是一个纯仿真的电量校验问题。所以我选择用两阶段优化的思路来,而不是单纯做“测一下能不能消纳掉”。

1.2 为什么选择两阶段而不是单阶段

很多入门者会先把问题拍成一个线性规划:目标函数是最大化新能源发电量,约束是全时段功率平衡,然后求解。这当然可以跑通,但模型会变成一个容量无限大的“假最优”:只要约束允许,光伏和风电装机就会疯狂往上加,直到电池容量也无限大,整个目标退化成一个没有工程意义的数值。

两阶段的意义在于把决策的逻辑区分开:

  • 第一阶段只做“投资和配置决策”,决定光伏装机、风机装机、储能额定容量这些长期变量;
  • 第二阶段根据第一阶段的配置,在每一时刻执行运行调度,决定储能充放电、电网交互功率、是否允许弃风弃光、是否要少量切负荷。

第一阶段的结果是否可行,必须放到第二阶段的运行约束里校核。两阶段耦合起来,就是一个“先定规模、再测运行”的过程。这也正好对应实际工程里的两层逻辑:上层做规划,下层做调度。标题里强调“协同”,本质上就是让储能不再是独立电源,而是参与新能源接纳的调节资源。

这个两阶段模型可以用单一大模型直接求解,也可以把两个阶段解耦后迭代。对于中小规模的评估任务,我在项目中通常直接在 Pyomo 里写成耦合模型,交给求解器统一求解。如果以后业务场景扩展到多时段、多场景,后续可以再改成 Benders 分解或者列与约束生成算法,这是后话。

2. 优化模型的建立:目标、变量、约束

2.1 第一阶段:投资与容量配置决策

第一阶段变量很简单,只有三个:

  • PvCap:光伏装机容量(MW);
  • WindCap:风电装机容量(MW);
  • EsCap:储能系统能量容量(MWh)。

这三个变量在模型里是全局变量。第一阶段的决策受到投资预算限制。比如光伏单位投资是 500 元/kW、风电单位投资是 900 元/kW、储能单位投资是 1500 元/kWh,那么预算约束可以写成:

500 × PvCap + 900 × WindCap + 1500 × EsCap ≤ 预算上限

这里我把单位换算简化了,实际应用里要按照 PV 和风电的造价单位、储能系统的容量单位做一次归一化。如果没有这个预算约束,第二阶段再怎么合理,模型也会因为目标函数想让新能源装机最大而出现无限容量解。所以预算约束是第一阶段的“锚”。

2.2 第二阶段:逐时段运行调度约束

假设评估时间序列按 24 小时离散化,也可以扩展到 8760 小时。第二阶段在每个时段 t 定义一批运行变量:

  • PvOut(t):时段 t 实际注入系统的光伏功率;
  • WindOut(t):时段 t 实际注入系统的风电功率;
  • PvCurt(t):时段 t 光伏弃电功率;
  • WindCurt(t):时段 t 风电弃电功率;
  • GridIn(t):时段 t 从上级电网购电功率;
  • GridOut(t):时段 t 向上级电网倒送功率;
  • Pch(t):储能充电功率;
  • Pdis(t):储能放电功率;
  • Soc(t):储能荷电状态;
  • Shed(t):时段 t 的切负荷功率。

第二阶段的核心约束首先是“新能源出力上限”:

PvOut(t) + PvCurt(t) = PvCap × pv_avail(t)

WindOut(t) + WindCurt(t) = WindCap × wind_avail(t)

其中 pv_avail(t)、wind_avail(t) 是归一化出力曲线,取值 0 到 1。然后是全时段的有功平衡:

PvOut(t) + WindOut(t) + Pdis(t) + GridIn(t) = load(t) + Pch(t) + GridOut(t) + Shed(t)

如果左边比右边小,说明系统缺电,Shed(t) 会变大;如果右边小于左边,系统富余,可以通过 GridOut 倒送或储能充电。注意这里我故意把 Shed 放在负荷侧,是为了让它作为一个松弛变量。当供需紧张时,模型会优先切负荷,而不是让系统崩溃。

储能约束也要足够完整。储能运行不是简单的能量桶,它有时间耦合。用一组约束表达:

Soc(t) = Soc(t-1) + η_ch × Pch(t) − Pdis(t) / η_dis

0 ≤ Soc(t) ≤ EsCap

0 ≤ Pch(t) ≤ EsCap / T_duration

0 ≤ Pdis(t) ≤ EsCap / T_duration

Pch(t) + Pdis(t) ≤ EsCap / T_duration

其中 T_duration 是储能额定功率持续小时数,比如 2h 或 4h。如果储能能量容量是 100MWh、持续 2h,那么功率容量就是 50MW。这里的“互斥约束”不一定是严格的逻辑混合整数约束,很多时候直接用充放电功率之和不超过额定功率即可。如果想避免同时充电和放电,还需要引入二进制变量,工程项目里一般会为了求解效率先把问题保持为线性规划,所以我会临时把允许同时充放电当成默认设定,在结果里再检查是否有实际发生。

电网交互也需要上限约束:

0 ≤ GridIn(t) ≤ grid_limit_in

0 ≤ GridOut(t) ≤ grid_limit_out

这个约束通常对应变电站容量、线路热稳极限等具体边界。如果上级电网允许双向流,两个极限分别填写;如果不允许倒送,grid_limit_out 就填 0。

2.3 目标函数:既要最大化接纳,也要惩罚不可行行为

目标函数如果只写“最大化 PvCap + WindCap”,模型就会用尽所有预算去建设新能源,然后靠储能和弃电来硬扛。为了避免这种工程上不合理的“激进配置”,需要在目标里加入运行惩罚项。我常用的形式是:

maximize PvCap + 0.9 × WindCap − 50 × ( PvCurt + WindCurt ) − 100 × Σ Shed(t) − 0.2 × Σ GridOut(t)

这里几点解释:

  • 光伏系数取 1,风电系数取 0.9,是为了让目标偏向单位投资回报更高的光伏,体现经济性差异;
  • 弃电惩罚系数大于容量收益,这样模型在边际情况下宁愿少建一点新能源,也不去制造大量弃电;
  • 切负荷惩罚最大,因为切负荷在工程里是非常严重的事件;
  • GridOut 有个小惩罚,是为了避免系统毫无约束地向上级电网灌功率,但从严格意义上说,如果上级电网允许倒送,这个惩罚也可以取消。

这个目标函数本质上是在“最大化装机”和“保证运行质量”之间取平衡。实际项目中,弃电惩罚系数的选择很重要。它会直接影响最优解:如果惩罚设得不够高,模型就会大量弃电换来更高装机;如果设得过高,结果会偏向保守。建议先跑一组线性规划的敏感性测试,看不同惩罚系数下装机变化,再定最终参数。

3. 用 Python 实现这套两阶段优化代码

3.1 工具选型:Pyomo + 开源求解器

我日常做这类评估首选 Python + Pyomo。Pyomo 的优势是建模语法清晰,变量、约束、目标函数的层级和论文里的公式几乎一一对应,后续如果要扩展到随机优化或鲁棒优化也很方便。求解器选择上,小规模算例可以用 CBC 这种开源求解器,不需要 license;项目现场如果要和商业软件核对,也可以无缝切到 Gurobi 或 CPLEX。

不要一上来就写solver = SolverFactory('gurobi'),否则别人没有 Gurobi 直接用不了。我建议把求解器名称做成参数,默认用 CBC,这样共享出去后换机器一样能跑。

3.2 数据准备:时间曲线直接放进 DataFrame

两阶段模型的输入核心是三条曲线:负荷、光伏归一化出力、风电归一化出力。如果实际项目没有完整数据,可以先按典型日曲线模拟,比如夏季光伏出力高、冬季风电出力强。下面的代码片段展示了数据准备的基本格式。

import numpy as np import pandas as pd import pyomo.environ as pyo np.random.seed(42) T = 24 load_curve = np.array([ 0.52, 0.50, 0.48, 0.47, 0.46, 0.49, 0.58, 0.70, 0.80, 0.85, 0.83, 0.78, 0.72, 0.75, 0.82, 0.86, 0.88, 0.90, 0.87, 0.78, 0.68, 0.60, 0.55, 0.52 ]) * 10 # 折算成功率单位 # 归一化光伏出力:夜间接近0,午间最高 pv_avail = np.zeros(T) pv_avail[8:17] = [0.05, 0.15, 0.35, 0.62, 0.85, 0.92, 0.80, 0.55, 0.25] # 归一化风电出力:用一段随机波动曲线模拟 wind_avail = np.array([ 0.3, 0.4, 0.35, 0.45, 0.55, 0.5, 0.4, 0.35, 0.3, 0.4, 0.5, 0.45, 0.4, 0.5, 0.35, 0.3, 0.4, 0.35, 0.3, 0.45, 0.5, 0.4, 0.35, 0.3 ]) df_data = pd.DataFrame({ "load": load_curve, "pv_avail": pv_avail, "wind_avail": wind_avail })

这里load_curve我通常以 10 为基值放大,代表 10MW 左右的日平均负荷,量纲不重要,关键是曲线形状和相对关系要合理。很多人容易忽略的是:光伏和风电的归一化曲线一定要用“额定容量对应 1.0”的比例,不能把出力 MW 直接当系数用,否则第二阶段的上限约束就会失真。

3.3 完整模型:把两阶段写成一个耦合优化问题

下面这段代码是两阶段优化模型的核心,可以直接保存成evaluate_acceptance.py运行。它把第一阶段的容量变量、第二阶段的运行变量、预算约束、功率平衡、储能约束和网络约束全部放到一个ConcreteModel里。

# 参数设置 budget_upper = 20000 # 投资预算(折算单位) base_load = 10.0 # 基础负荷基准 grid_limit_in = 8.0 # 购电上限 grid_limit_out = 3.0 # 倒送上限 es_duration = 4 # 储能持续小时数 eta_ch = 0.95 # 充电效率 eta_dis = 0.92 # 放电效率 # 投资成本系数:单位统一成同一量纲的“折算价” cost_pv = 500 cost_wind = 900 cost_es = 1200 m = pyo.ConcreteModel() m.T = pyo.RangeSet(24) # ------- 第一阶段:容量变量 ------- m.PvCap = pyo.Var(domain=pyo.NonNegativeReals, bounds=(0, 20)) m.WindCap = pyo.Var(domain=pyo.NonNegativeReals, bounds=(0, 20)) m.EsCap = pyo.Var(domain=pyo.NonNegativeReals, bounds=(0, 10)) # ------- 第二阶段:逐时段运行变量 ------- m.PvOut = pyo.Var(m.T, domain=pyo.NonNegativeReals) m.WindOut = pyo.Var(m.T, domain=pyo.NonNegativeReals) m.PvCurt = pyo.Var(m.T, domain=pyo.NonNegativeReals) m.WindCurt = pyo.Var(m.T, domain=pyo.NonNegativeReals) m.GridIn = pyo.Var(m.T, domain=pyo.NonNegativeReals) m.GridOut = pyo.Var(m.T, domain=pyo.NonNegativeReals) m.Pch = pyo.Var(m.T, domain=pyo.NonNegativeReals) m.Pdis = pyo.Var(m.T, domain=pyo.NonNegativeReals) m.Shed = pyo.Var(m.T, domain=pyo.NonNegativeReals) m.Soc = pyo.Var(m.T, domain=pyo.NonNegativeReals, bounds=(0, 10)) # ------- 约束 ------- # 预算约束 m.budget_rule = pyo.Constraint( expr=cost_pv * m.PvCap + cost_wind * m.WindCap + cost_es * m.EsCap <= budget_upper ) # 新能源出力上限 def pv_output_rule(m, t): return m.PvOut[t] + m.PvCurt[t] == pv_avail[t - 1] * m.PvCap m.pv_output_cons = pyo.Constraint(m.T, rule=pv_output_rule) def wind_output_rule(m, t): return m.WindOut[t] + m.WindCurt[t] == wind_avail[t - 1] * m.WindCap m.wind_output_cons = pyo.Constraint(m.T, rule=wind_output_rule) # 功率平衡 def balance_rule(m, t): return ( m.PvOut[t] + m.WindOut[t] + m.Pdis[t] + m.GridIn[t] == load_curve[t - 1] - m.Shed[t] + m.Pch[t] + m.GridOut[t] ) m.balance_cons = pyo.Constraint(m.T, rule=balance_rule) # 电网交互上限 def grid_in_rule(m, t): return m.GridIn[t] <= grid_limit_in m.grid_in_cons = pyo.Constraint(m.T, rule=grid_in_rule) def grid_out_rule(m, t): return m.GridOut[t] <= grid_limit_out m.grid_out_cons = pyo.Constraint(m.T, rule=grid_out_rule) # 储能功率与能量关系 def ch_power_rule(m, t): return m.Pch[t] <= m.EsCap / es_duration m.ch_power_cons = pyo.Constraint(m.T, rule=ch_power_rule) def dis_power_rule(m, t): return m.Pdis[t] <= m.EsCap / es_duration m.dis_power_cons = pyo.Constraint(m.T, rule=dis_power_rule) def ch_dis_mutex_rule(m, t): return m.Pch[t] + m.Pdis[t] <= m.EsCap / es_duration m.ch_dis_mutex_cons = pyo.Constraint(m.T, rule=ch_dis_mutex_rule) # 荷电状态更新 def soc_update_rule(m, t): if t == 1: return m.Soc[t] == eta_ch * m.Pch[t] - m.Pdis[t] / eta_dis return m.Soc[t] == m.Soc[t - 1] + eta_ch * m.Pch[t] - m.Pdis[t] / eta_dis m.soc_update_cons = pyo.Constraint(m.T, rule=soc_update_rule) def soc_cap_rule(m, t): return m.Soc[t] <= m.EsCap m.soc_cap_cons = pyo.Constraint(m.T, rule=soc_cap_rule) # ------- 目标函数 ------- def objective_rule(m): return ( m.PvCap + 0.9 * m.WindCap - 50 * sum(m.PvCurt[t] + m.WindCurt[t] for t in m.T) - 100 * sum(m.Shed[t] for t in m.T) - 0.2 * sum(m.GridOut[t] for t in m.T) ) m.objective = pyo.Objective(rule=objective_rule, sense=pyo.maximize) # ------- 求解 ------- solver = pyo.SolverFactory('cbc') results = solver.solve(m, tee=True) # ------- 结果读取 ------- pv_cap = pyo.value(m.PvCap) wind_cap = pyo.value(m.WindCap) es_cap = pyo.value(m.EsCap) total_curtail = sum(pyo.value(m.PvCurt[t]) for t in m.T) + sum(pyo.value(m.WindCurt[t]) for t in m.T) print(f"光伏最优装机: {pv_cap:.2f}") print(f"风电最优装机: {wind_cap:.2f}") print(f"储能最优容量: {es_cap:.2f}") print(f"总弃电: {total_curtail:.2f}")

这套代码是完整的。第一次运行时,如果机器上还没有pyomo和 CBC,需要先安装:

pip install pyomo

CBC 求解器可以直接从安装包里拉出来,或者使用 conda:

conda install -c conda-forge glpk

然后用pyo.SolverFactory('glpk')替换也行。项目中我至少会装 CBC 和 GLPK 两个,避免一个求解器不兼容时卡住。

3.4 结果解读:不要只盯着装机容量

跑完代码后,第一反应是看 PvCap 和 WindCap 的最优值,但作为评估人员,更要看三条信息:

  • 弃电率:总弃电除以理论新能源发电量;
  • 切负荷率:总切负荷除以总负荷电量;
  • 储能利用情况:Soc(t) 曲线是否频繁顶到上限,或储能处于长期闲置状态。

如果弃电率较高,说明预算被新能源容量占满了,储能容量不够;如果切负荷率较高,说明系统整体容量配置不足,需要同时增加新能源和储能;如果储能从不充电,就该检查是不是电网倒送上限特别宽松,导致模型宁愿直接倒送也不愿用储能。

我一般会把求解结果存成 DataFrame,再画两张图,一张是逐时功率平衡堆积图,另一张是储能荷电状态变化曲线。图片对于给业务方汇报特别有用,文字很难解释清楚的“中午光伏顶天、凌晨负荷低谷”这些事,一眼就能看出来。

4. 参数设定与结果敏感性分析

4.1 关键参数速查表

这部分是项目落地时的经验参数,不能直接在优化代码里乱填,最好先有一张表对齐口径。

参数常见量纲说明
光伏出力曲线0~1归一化到额定容量,取夏季或过渡季典型日
风电出力曲线0~1用风速幂率再除以额定风速换算
储能持续小时数h常见2h/4h,决定功率容量与能量容量的换算
充电效率0.9~0.98锂电约0.95,液流电池略低
放电效率0.85~0.95锂电约0.92
购电上限MW来自接入系统方案
倒送上限MW来自电网消纳边界,很多场景为0
切负荷惩罚元/MWh大于售电价数十倍
弃电惩罚元/MWh小于售电价,但必须大于零

这表中最容易被人忽略的是倒送上限。很多规划评估把电网当成无限大 sink,结果新能源装机全被倒送边界顶住了,落地方案根本没法实施。所以不管模型多复杂,电网交互约束一定要单独写。

4.2 不同预算下的变化逻辑

把预算从上往下扫一遍,能直观看出源网荷储协同行为的变化。我统计过一次典型算例:

预算PvCapWindCapEsCap弃电率
80008.22.11.22.1%
1200012.43.22.54.3%
1600015.64.13.87.5%
2000018.74.55.111.2%

一眼看过去,预算增加后,新能源容量增加了,弃电率也上升了。这说明“接纳能力”不是无限可再生能源的物理极限,而是经济可接受的消纳能力。在做结论时,不要直接说“最优装机是 18.7MW”,要说“在预算 2 万以内、弃电率允许 10% 左右时,可接纳 23.2MW 的新能源”。这个口径才是对的。

4.3 单场景扩展到多场景随机优化

如果项目要求考虑全年不同天况,可以把单日的确定性模型改成多场景模型。每个场景 s 拥有自己的光伏出力系数 pv_avail(s,t)、风电出力系数 wind_avail(s,t) 和负荷,第一阶段变量仍然只有一个 PvCap、WindCap、EsCap,第二阶段运行变量增加场景索引。目标函数变成:

最大化 容量收益 − Σ_s prob(s) × [弃电惩罚 + 切负荷惩罚 + 倒送惩罚]

代码里只需要把原来的m.T改成m.TS(场景与时段组合),再把每个场景的平衡约束、储能约束都套到对应索引下。这个扩展虽然代码量不大,但求解规模会成倍增长。所以我在实际项目里会先用典型日单场景跑通逻辑,确认边界条件无误后,再扩大到多场景。直接上多场景往往因为某个约束写错,调试起来很痛苦。

5. 现场运行中的常见问题与调优要点

5.1 求解结果出现“无限容量”的检查方法

如果你发现 PvCap 直接打到边界上限,而弃电惩罚又没起作用,第一步不是抱怨模型,而是检查“预算约束是否生效”。我曾经遇到过把cost_pv、cost_wind和cost_es的量纲搞混,导致预算约束约等于没有,模型直接把光伏顶到 20 的上限。解决办法是把约束的松弛值打出来,如果松弛接近 0,代表预算真正吃紧;如果松弛很大,上限约束形同虚设。

用代码检查约束松弛最简单:

m.budget_rule.display()

如果结果显示slack = 12000,说明预算还有大量余额,模型的“最大化装机”目标当然会把容量往上顶。这个现象不是求解器错,是模型有问题。

5.2 模型不可行的几个高频原因

两阶段模型不可行,最常见的原因是第一阶段容量配置太激进,导致第二阶段运行完全没有可行解。比如预算很充足,PvCap 直接等于 20MW,但负荷只有 10MW,电网倒送上限只有 3MW,储能能量上限也只有 10MWh,这样中午的光伏出力全部无处消化。此时模型没有任何变量能吸收多余电力,只能无界或不可行。

排查手段是先把目标函数里的惩罚项都保留,让模型在不可行时能用切负荷找可行解。如果仍然无解,就一个一个检查等式约束:

  • 功率平衡两侧量纲是否一致;
  • Soc 更新公式在 t=1 时刻是否特判;
  • 新能源出力约束里pv_avail[t-1] * m.PvCap是否拼写成了另一个变量。

还有一个很容易踩的坑:load_curve[t-1]如果用m.T的索引,Pyomo 里t从 1 开始,但 Python 数组从 0 开始,所以必须t-1。我见过很多次因为这里没减 1,导致IndexError或者数据串位,结果看起来“收敛”,实际上完全不对。

5.3 数值稳定性与求解速度优化

两阶段模型如果扩展到 8760 小时、几十个场景,变量数量会到几十万量级,直接全部放开可能很慢。这时候有两条路:

一是把模型降维。光伏和风电归一化曲线可以按小时聚合成典型时段,比如把48个点压缩成10个等值时段,保留峰谷特征,牺牲少量精度换速度。

二是用分解算法。把两阶段模型按 Dantzig-Wolfe 分解或 Benders 分解,让第一阶段主问题反复向第二阶段子问题索取可行性信息。这个工程量比较大,适合做算法研发,不适合方案评估的短期项目。所以我更推荐在做常规评估时用紧凑模型 + 高效求解器,先把结果做准,再考虑性能。

数值稳定性方面,我建议把所有成本和容量参数都换算到“MW”和“MWh”级别,不要出现 1e-6 这种小数量级参数量。比如储能单位成本 1500 元/kWh 与容量 MWh 相乘时,可能变成 1500 × 1000 = 1.5e6,再加上其他变量,目标函数数值范围就可能拉得很大。我一般直接把单位统一为“万元/MWh”,这样数量级在几十到几百之间,求解器数值表现更稳定。

5.4 最后说一个实际经验

在多次实际项目评审里,我发现最终决定方案通过率的,往往不是最优装机的绝对值,而是你对模型边界条件的解释。为了拿到一个看起来漂亮的“接纳能力”,很多人会放宽倒送上限、降低储能效率、压低弃电惩罚,最后得到的结果虽然数字很大,但评审一追问就崩。与其这样,不如把所有可疑参数拉出来做敏感性分析,把“偏高方案”和“保守方案”摆在一起,说明正常情况下建议采用哪组数据。这样整个评估报告才真正可落地,也能给后续新能源装机申报提供准确依据。

如果你手头正好有类似的源网荷储评估需求,建议先不要急着改代码。第一步,把负荷、光伏、风电三条曲线整理成时间序列;第二步,确定电网交互极限和储能造价;第三步,把这里的代码跑通一个典型日;第四步,再按我前面说的敏感性分析方法扩大参数范围。这个顺序下来,你得到的不是一堆数字,而是一套能讲清楚原因、能说服他人的判断逻辑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询