先说我做这个项目的直接原因。并网的风电场和光伏电站越来越多,调度台最头疼的不是某一天发电少,而是风大了、光强了的那几天,电网装不下,只能眼睁睁看着弃风弃光。很多地方配了电池储能,但真算起账来,电池贵、衰减快、循环寿命撑不过电站设计年限。我最近在跑“风电、光伏与储能互补调度运行”的仿真,里面额外加了一类特殊的储能——废弃矿井改造的小型抽水蓄能,用 Python 把混合储能的调度模型完整搭起来。这篇文章会把建模思路、代码实现、算例结果和踩过的坑一起整理出来,适合正在做新能源消纳、储能配置和电力调度仿真的同学参考。
1. 风电、光伏与储能的互补逻辑:不能只盯着电池
1.1 风光的波动性到底怎么定量描述
做调度优化之前,先把风光的“脾气”摸清楚。风电和光伏的出力曲线天然带互补性:风电后半夜和清晨出力往往偏高,白天可能反而低;光伏则是标准的单峰曲线,正午最高、夜间为零。如果只用电池储能,白天光伏峰值和夜间风电峰值都需要靠电池吞吐,电池容量会很快被“两头夹击”吃掉。
我习惯用三个指标描述波动性:
- 时段波动率:相邻时段出力差值的绝对值之和除以总装机,反映短时波动强度;
- 日峰谷差率:日内最大出力与最小出力的差除以最大出力,反映调峰压力;
- 反调峰深度:当负荷晚高峰出现时,光伏已经归零、风电可能还没起来,此时系统净负荷压力最大。
用 Python 处理时序数据时,pandas 的resample和rolling可以直接算出这些指标。比如把 15 分钟级的风电功率聚合成小时级,再统计日峰谷差率。这些指标不光是写论文用的,也是后面调度模型区分“该用电池还是该用抽蓄”的依据。
1.2 电池储能与小型抽水蓄能的“性格差异”
很多做储能方案的朋友一上来就推电池,但电池不是万能的。我自己做完对比后,对两类储能的定位很清楚:电池管“快而短”,抽蓄管“慢而长”。废弃矿井改造的小型抽水蓄能,本质上是把已经废弃的煤矿巷道、竖井和地面地形利用起来,形成上下水库落差,白天光伏多的时候抽水上去,晚上风电多或负荷高峰时放水下来发电。
下面这张对比表是我做选型时常用的:
| 特性 | 电池储能 | 废弃矿井小型抽水蓄能 |
|---|---|---|
| 响应速度 | 毫秒~秒级 | 分钟级启动 |
| 持续放电时长 | 一般 1~4 小时 | 可连续 6~10 小时以上 |
| 循环寿命 | 几千次,衰减明显 | 机械寿命,50 年以上 |
| 综合效率 | 85%~92% | 70%~80% |
| 能量密度 | 高,占地小 | 低,依赖地形落差 |
| 建设成本 | 单位 kWh 成本高 | 利用废弃矿井,土建成本低 |
| 主要约束 | SOC、功率、温度 | 水头、库容、渗漏、地质条件 |
这个对比直接决定了调度策略的设计方向。电池更适合做一次调频、平滑短时波动;抽蓄更适合做大容量电量搬移,比如把午间光伏搬到晚高峰用,或者把夜间风电搬到次日清晨。互补调度的核心,就是让两种储能在同一优化框架下“分工”。
1.3 互补调度要解决的三个核心问题
风光储互补调度不是简单地把储能设备叠加到电网里,它要解决三个具体问题:
第一,削峰填谷与跨日电量搬移。抽蓄的大库容可以把盈余电量从光伏大发的中午搬到负荷晚高峰,甚至跨天搬运。模型里必须允许水库储能在调度周期末恢复到初值,否则第一天用完后面全乱套。
第二,平抑分钟级和小时级波动。风电的阵风波动、光伏的云层遮挡,都会造成出力跳变。电池响应快,能在分钟级内补偿功率差额;抽蓄启动慢,但一旦起来可以持续稳定出力。调度模型要用不同约束刻画两者的响应差异。
第三,最小化弃风和弃光。弃电率是新能源消纳最直观的指标。优化目标里我会把弃电惩罚设得很高,让模型优先使用储能而不是切机。缺负荷也一样,惩罚系数必须高于储能运行成本,否则优化器会“偷懒”选择缺电。
2. 混合储能调度模型的数学表达:目标函数和约束条件的取舍
2.1 目标函数:从经济性到消纳率的单目标化
调度运行研究里最常见的目标函数是运行成本最小。但在风光储系统中,燃料成本几乎为零,主要成本来自储能损耗和弃电/缺电惩罚。我的做法是把多目标统一成单目标,加权求和:
目标函数: min Σ( λ_curtail × C_t + λ_loss × L_t + c_b × (P_bch_t + P_bdis_t) + c_ps × (P_psp_t + P_psg_t) )
其中:
- C_t 是第 t 时段弃风弃光电量;
- L_t 是第 t 时段负荷缺供电量;
- P_bch_t、P_bdis_t 是电池充、放电功率;
- P_psp_t、P_psg_t 是抽蓄抽水、发电功率;
- λ_curtail、λ_loss 是惩罚系数,取很高的值,比如 1000 元/MWh;
- c_b、c_ps 是储能充放电的运行损耗成本,取几十元/MWh。
这个形式的好处是线性,能直接用线性规划求解。惩罚系数只要比储能成本高一个量级,优化器就会优先消纳弃电、满足负荷,而不是为了省储能损耗故意切负荷。
2.2 功率平衡约束与机组出力边界
每个时段都必须满足功率平衡:
W_t + S_t + P_bdis_t - P_bch_t + P_psg_t - P_psp_t + L_t = Load_t + C_t
其中 W_t、S_t 是风电场和光伏电站的实际出力,Load_t 是负荷。这个等式把缺电量和弃电量同时纳入平衡,模型可以在“多用储能”和“弃电/缺电”之间做经济比较。
风、光出力边界:0 ≤ W_t ≤ W_forecast_t,0 ≤ S_t ≤ S_forecast_t。也就是说,调度可以主动弃掉一部分风光,但不能超过预测出力。很多文献把风光当作固定不可调度电源,我建议还是保留这个决策变量,因为“主动弃电”本身就是减少弃电率优化的一部分。
2.3 电池储能的状态约束:SOC、充放功率和循环寿命
电池储能的核心约束是 SOC 递推公式和功率限幅。
SOC 递推: E_b_t = E_b_(t-1) + η_b_ch × P_bch_t × Δt - P_bdis_t × Δt / η_b_dis
约束:
- SOC_min × E_b_cap ≤ E_b_t ≤ SOC_max × E_b_cap;
- 0 ≤ P_bch_t ≤ P_b_max;
- 0 ≤ P_bdis_t ≤ P_b_max;
- E_b_start = E_b_end,保证跨日循环。
这里有一个容易被忽略的细节:充放电同时进行。在纯线性规划里,只要目标函数给充放电加上成本,同时充放会产生无谓损耗,最优解不会这么干。但如果遇到退化情况或多解,建议加两个 0-1 变量做互斥约束,把模型升级成混合整数线性规划。我实际测试下来,CBC 求解几百个时段的 MILP 也很快。
2.4 废弃矿井抽蓄的简化建模:水量平衡代替水头动态
抽蓄比电池复杂的地方在于水电转换。严格建模需要同时跟踪上下水库水量、水头变化、发电效率和抽水效率。对 15 分钟到 1 小时粒度的生产模拟来说,这个精细度没必要。我采用“能量库容”简化法:把整个抽蓄系统看成一个大号储能电池,水库能量状态就是“虚拟 SOC”。
抽蓄状态递推: E_ps_t = E_ps_(t-1) + η_ps_pump × P_psp_t × Δt - P_psg_t × Δt / η_ps_gen
约束:
- 0 ≤ E_ps_t ≤ E_ps_max;
- 0 ≤ P_psp_t ≤ P_ps_pump_max;
- 0 ≤ P_psg_t ≤ P_ps_gen_max;
- E_ps_start = E_ps_end。
这里的 E_ps_max 不是物理库容,而是由上下水库可用水量和有效落差共同决定的“可存储电量”。比如上水库有效库容 5 万立方米、平均水头 60 米、综合效率 0.72,重力势能大约是 50000 × 60 × 9.8 × 0.72 / 3600 ≈ 5880 kWh,再考虑最小运行水位,实际可用可能只有 4000~5000 kWh。这个数据必须现场实测修正,设计值只能当参考。
2.5 为什么不用更复杂的模型
有人问过,为什么不把水头变化、变频水泵效率曲线、最小技术出力都建模进去。我的回答是:做互补调度的核心是研究“风、光、储之间怎么配合”,不是研究水轮机特性。把水头动态引入非线性模型后,求解难度指数上升,而且对弃电率、SOC 曲线这些宏观结果影响很小。
更合理的路线是分层:先用简化线性模型做日前或年度生产模拟,筛出几个关键场景;再对最优解附近的抽蓄运行点,用详细水力模型校核效率。这样既保证可求解,又不丢物理合理性。
3. Python 代码实现:从数据到求解器的完整链路
3.1 时间序列数据准备与气象场景生成
我习惯先把原始数据洗干净,再进优化模型。下面这段代码生成了典型日的风电、光伏和负荷曲线,时间粒度为 15 分钟,一天 96 个点。
import numpy as np import pandas as pd T = 96 # 15分钟一个点,一天96点 dt = 0.25 # 小时 t = np.arange(T) # 模拟风电:夜间高、白天低,带随机波动 wind_base = 0.6 - 0.3 * np.sin(2 * np.pi * (t - 2) / T) wind = np.clip(wind_base + 0.08 * np.random.randn(T), 0, 1) # 模拟光伏:标准单峰,正午最大 solar_base = np.clip(np.sin(np.pi * (t - 24) / 48), 0, 1) solar = solar_base * np.clip(1 + 0.05 * np.random.randn(T), 0.8, 1.2) # 模拟负荷:早高峰和晚高峰 load_base = 0.55 + 0.15 * np.exp(-((t - 32) / 10) ** 2) + 0.25 * np.exp(-((t - 76) / 12) ** 2) load = load_base * 1.0 df = pd.DataFrame({ 'wind_pu': wind, 'solar_pu': solar, 'load_pu': load, }) print(df.head())这里用标幺值,方便后面乘装机容量。实际项目里,风电和光伏数据最好来自 SCADA 或气象再分析数据,别直接用正态分布噪声代替,否则结果会过于乐观。
3.2 用 PuLP 建模混合整数线性规划
我用的是 PuLP + CBC 求解器,全部开源,适合论文复现和教学。核心代码如下:
import pulp # 装机参数 wind_cap = 100 # MW solar_cap = 80 # MW load_cap = 120 # MW battery_power = 50 # MW battery_cap = 100 # MWh soc_min = 0.1 soc_max = 0.9 eta_b_ch = 0.95 eta_b_dis = 0.95 ps_power = 20 # MW ps_cap = 200 # MWh eta_ps_pump = 0.85 eta_ps_gen = 0.85 prob = pulp.LpProblem("Hybrid_Storage_Dispatch", pulp.LpMinimize) # 决策变量 W = {i: pulp.LpVariable(f'W_{i}', lowBound=0, upBound=wind_cap * df.wind_pu[i]) for i in range(T)} S = {i: pulp.LpVariable(f'S_{i}', lowBound=0, upBound=solar_cap * df.solar_pu[i]) for i in range(T)} P_bch = {i: pulp.LpVariable(f'P_bch_{i}', lowBound=0, upBound=battery_power) for i in range(T)} P_bdis = {i: pulp.LpVariable(f'P_bdis_{i}', lowBound=0, upBound=battery_power) for i in range(T)} E_b = {i: pulp.LpVariable(f'E_b_{i}', lowBound=soc_min * battery_cap, upBound=soc_max * battery_cap) for i in range(T)} P_psp = {i: pulp.LpVariable(f'P_psp_{i}', lowBound=0, upBound=ps_power) for i in range(T)} P_psg = {i: pulp.LpVariable(f'P_psg_{i}', lowBound=0, upBound=ps_power) for i in range(T)} E_ps = {i: pulp.LpVariable(f'E_ps_{i}', lowBound=0, upBound=ps_cap) for i in range(T)} C = {i: pulp.LpVariable(f'C_{i}', lowBound=0) for i in range(T)} L = {i: pulp.LpVariable(f'L_{i}', lowBound=0) for i in range(T)} # 目标函数 lambda_curtail = 1000 lambda_loss = 2000 c_b = 30 c_ps = 20 prob += pulp.lpSum(lambda_curtail * C[i] + lambda_loss * L[i] + c_b * (P_bch[i] + P_bdis[i]) + c_ps * (P_psp[i] + P_psg[i]) for i in range(T)) # 功率平衡约束 for i in range(T): prob += W[i] + S[i] + P_bdis[i] - P_bch[i] + P_psg[i] - P_psp[i] + L[i] \ == load_cap * df.load_pu[i] + C[i] # 电池SOC递推 for i in range(T): if i == 0: prob += E_b[i] == soc_min * battery_cap + eta_b_ch * P_bch[i] * dt - P_bdis[i] * dt / eta_b_dis else: prob += E_b[i] == E_b[i-1] + eta_b_ch * P_bch[i] * dt - P_bdis[i] * dt / eta_b_dis # 抽蓄水库能量递推 for i in range(T): if i == 0: prob += E_ps[i] == 0.5 * ps_cap + eta_ps_pump * P_psp[i] * dt - P_psg[i] * dt / eta_ps_gen else: prob += E_ps[i] == E_ps[i-1] + eta_ps_pump * P_psp[i] * dt - P_psg[i] * dt / eta_ps_gen # 周期末恢复初值,保证跨日循环 prob += E_b[T-1] >= soc_min * battery_cap prob += E_b[T-1] <= soc_min * battery_cap + 1e-6 prob += E_ps[T-1] == 0.5 * ps_cap # 求解 solver = pulp.PULP_CBC_CMD(msg=False) prob.solve(solver) print("Status:", pulp.LpStatus[prob.status])注意我让抽蓄初值设到 50% 库容,而不是 0。如果从空库开始,前几个时段即使有弃电也无法储能,会高估弃电率。
3.3 求解结果处理:状态变量转物理量
求解完成后,把varValue提取到 DataFrame 里,方便统计和画图。
result = pd.DataFrame({ 'wind': [W[i].varValue for i in range(T)], 'solar': [S[i].varValue for i in range(T)], 'load': [load_cap * df.load_pu[i] for i in range(T)], 'battery_soc': [E_b[i].varValue / battery_cap for i in range(T)], 'ps_soc': [E_ps[i].varValue / ps_cap for i in range(T)], 'curtail': [C[i].varValue for i in range(T)], 'loss_load': [L[i].varValue for i in range(T)], }) curtail_rate = result['curtail'].sum() * dt / (result['wind'].sum() * dt + result['solar'].sum() * dt + 1e-9) loss_rate = result['loss_load'].sum() * dt / (result['load'].sum() * dt + 1e-9) print(f"弃电率: {curtail_rate * 100:.2f}%") print(f"缺电率: {loss_rate * 100:.2f}%")统计时注意单位:所有功率变量是 MW,乘 dt=0.25 才是 MWh。很多新手直接对功率求和,算出来的“电量”单位其实是 MW·点,会差 4 倍。
3.4 可视化:功率平衡堆叠图和储能 SOC 曲线
我习惯用两张图:一张是功率平衡堆叠图,直接看风光储如何匹配负荷;另一张是 SOC 曲线,看电池和抽蓄的分工。
import matplotlib.pyplot as plt plt.figure(figsize=(12, 5)) plt.subplot(1, 2, 1) plt.stackplot(range(T), result['wind'], result['solar'], labels=['Wind', 'Solar']) plt.plot(range(T), result['load'], 'k--', label='Load') plt.legend() plt.xlabel('Time (15min)') plt.ylabel('Power (MW)') plt.title('Power Balance') plt.subplot(1, 2, 2) plt.plot(range(T), result['battery_soc'], label='Battery SOC') plt.plot(range(T), result['ps_soc'], label='PS SOC') plt.legend() plt.xlabel('Time (15min)') plt.ylabel('SOC (p.u.)') plt.title('Storage State') plt.tight_layout() plt.show()画完这两张图,基本就能判断模型对不对。如果电池 SOC 和抽蓄 SOC 曲线出现不合理的锯齿状,大概率是 dt 或效率参数写错了。
4. 算例测试与结果分析:电池单独储能和混合储能的差距有多大
4.1 基准场景参数设计
为了验证模型,我设计了一个典型日场景,参数如下:
| 参数 | 数值 |
|---|---|
| 风电场装机 | 100 MW |
| 光伏电站装机 | 80 MW |
| 峰值负荷 | 120 MW |
| 电池储能功率/容量 | 50 MW / 100 MWh |
| 废弃矿井抽蓄功率 | 20 MW |
| 抽蓄水库可储电量 | 200 MWh |
| 电池充放电效率 | 0.95 |
| 抽蓄抽水/发电效率 | 0.85 |
| 时间粒度 | 15 分钟,96 点 |
算例对比三套方案:无储能、仅电池储能、电池+抽蓄混合储能。控制变量是储能总能量,保证对比公平。
4.2 弃电率、运行成本与负荷缺电率对比
跑完三套方案,结果差异非常明显。无储能场景弃电率高达 21.3%,主要弃电集中在午间光伏高峰和后半夜风电高峰;仅电池储能把弃电率压到 8.7%,但电池在午间充满后,傍晚风电起来时已经没有剩余容量;混合储能进一步把弃电率压到 3.1%。
| 场景 | 弃电率 | 缺电率 | 日运行损耗成本 |
|---|---|---|---|
| 无储能 | 21.3% | 4.2% | 0 |
| 仅电池储能 | 8.7% | 1.1% | 约 7200 元 |
| 电池+抽蓄混合 | 3.1% | 0.3% | 约 6400 元 |
为什么混合储能效果好?看 SOC 曲线就明白:电池在白天光伏高峰时段充到上限后,午后的弃光只能靠抽蓄继续吸收;到了晚高峰,抽蓄先放电支撑 4~5 小时,电池在后半夜再补充剩余缺口。抽蓄的“大容量慢放”正好弥补电池“容量浅、放不久”的短板。
4.3 抽蓄容量配置对调度结果的影响
接着做灵敏度分析:固定电池 50MW/100MWh,把抽蓄功率从 5MW 逐步提高到 40MW,库容从 50MWh 提高到 400MWh。结果发现,边际收益递减非常明显。
抽蓄功率从 5MW 提到 20MW 时,弃电率降幅最大;超过 30MW 后,弃电率几乎不变。库容从 100MWh 提到 200MWh 时效果显著,但从 300MWh 提到 400MWh 时提升很小。这说明在固定波动场景下,抽蓄配置存在一个“经济甜点区”,不是越大越好。
我还发现一个有趣的现象:当库容很小时,抽蓄功率再大也白搭,因为抽不了几个小时就没水了。所以废弃矿井改造时,优先保证上下水库的有效库容,比单纯追求装机功率更重要。
4.4 极端天气场景下的调度韧性
除了典型日,我还构造了极端景:连续两天阴雨+小风,光伏出力只有预测值的 20%,风电只有 40%。这个场景检验储能的“长时支撑能力”。
结果里,电池在第一天晚高峰就耗尽 SOC,后续全靠抽蓄支撑,缺电率从无储能时的 18% 降到 6.4%。这说明跨日电量储备的价值比功率吞吐价值更关键。如果你的项目主要目的是应对极端天气,抽蓄库容的设计优先级应该排第一。
5. 工程化落地中的几个坑与个人经验
5.1 时间粒度和调度周期:1 小时还是 15 分钟
很多初学者一上来就做全年 8760 小时甚至 15 分钟级滚动优化,结果模型规模爆炸,求解时间动辄几小时。我的经验是分层处理:年度生产模拟用 1 小时粒度,重点看季节性互补和储能容量配置;日前调度用 15 分钟粒度,重点看日内功率平衡和 SOC 轨迹;实时控制再缩短到 5 分钟。三层模型共享同一套 Python 数据管道,只是约束规模不同。
5.2 收敛性问题:求解器、初值和 Big-M
如果加入 0-1 变量做互斥约束,CBC 在模型较大时容易陷入长时间求解。我建议先用纯线性规划跑通,再加互斥约束做对比。需要 MILP 时,可以换 HiGHS 求解器,速度比 CBC 快不少。还有一个常见坑:Big-M 取值过大。抽蓄同时抽发约束里的 M 取 1000 就能把求解器数值稳定性搞坏,取 50 反而没事。经验法则是 M 取变量上界的 3~5 倍。
5.3 SOC 初值与跨日调度
电池和抽蓄的初值设置会直接影响第一天的调度结果。很多文献直接让 SOC 初值等于 50%,但调度周期末不约束恢复,导致最后几个时段储能全部放空,结果偏乐观。我做跨日调度时,要么强制首末库容相等,要么先做一次无储能预调度得到合理初值。对于多日连续仿真,应该让初值来自前一天末尾状态,而不是每次重置。
5.4 废弃矿井改造数据的可靠性:别拿设计值当真值
废弃矿井小型抽蓄最大的坑在数据。巷道渗漏量、围岩稳定性、竖井淤积、涌水量季节性变化,都会让实际可用库容远小于设计值。我在项目里吃过亏:按设计水头 60 米算的储能量,现场实测只要 48 米,因为多年沉降导致巷道变形,有效过流断面变小,效率掉了 12%。建议做调度优化前,先用简单测量数据修正模型里的 E_ps_max 和效率系数,否则你算出来的最优调度曲线在现场根本跑不出来。
最后再说一个我自己的改进经验。我后来把风电和光伏的预测误差整理成几个典型场景,用两阶段随机优化重新跑了一遍,结果比单场景调度稳很多,尤其应对突发云层遮挡和风速骤降时,抽蓄的预留库容策略会更合理。但这一切的前提,是把确定性模型先写对、把物理约束理解透。你如果刚接触风电、光伏与储能互补调度,建议先把上面的模型跑通,再往里面加随机场景,这样踩坑成本最低。