做了几年新能源调度方面的研究,这次把风电、光伏、电池储能和废弃矿井小型抽水蓄能放到同一个优化框架里,用 Python 完整跑了一遍互补调度运行的程序。实际做下来最大的感受是:这问题表面上是个“算法题”,骨子里却是个“工程建模题”——风光出力怎么处理、储能状态怎么递推、抽蓄和电池怎么分工、约束怎么写才不会让求解器报无解,这些才是决定项目成败的关键。我尽量把从模型设计到代码实现、再到结果分析的完整过程拆开讲,包含踩过的坑,给做风光储调度规划或者混合储能研究的朋友一个可直接参考的路线。
1. 为什么风光要配储能,而且要用“两种”储能
1.1 风光出力天生靠天吃饭,互补的前提是先把波动量化
风电和光伏的出力都有很强的间歇性和反调峰特征。风电在夜间往往出力偏大,白天风速反而可能降低;光伏只在白天发电,而且受云层影响会在十几分钟内大幅波动。把风、光、负荷三组时间序列对齐画出来,经常能看到“白天光伏多但负荷没起来”“傍晚负荷爬坡但光伏开始掉”这种错配场景。互补调度研究的第一步不是急着写优化模型,而是先把历史数据洗干净、按统一时间粒度对齐,再做风电、光伏的可用出力曲线。我这次采用的是 15 分钟一个点的日前调度,一天 96 个时段,这样既能看清风电光伏的分钟级波动,又不会让 MILP 求解规模失控。数据质量决定了调度结果的可靠性,如果预测曲线本身噪声很大,再精巧的优化算法也白搭。
1.2 电池和废弃矿井抽蓄:快慢搭配才划算
既然要配储能,为什么不是只配电池,而是要把电池和废弃矿井小型抽水蓄能放在一起?核心原因是两种储能的特性正好互补。电池响应速度极快,功率型能力强,可以做到毫秒到秒级调节,适合平抑瞬时波动和跟踪负荷尖峰;但电池的成本高、循环寿命有限,大规模长时间存储并不划算。废弃矿井改造的抽水蓄能则完全不同:利用矿山的竖井、巷道和采空区改造上、下水库,天然具备大容量、长寿命、维护成本低的优势,适合做跨时段的能量搬移——比如把中午的光伏电存起来,供晚间负荷使用;缺点是建设条件苛刻、响应速度比电池慢,不能指望它承担秒级功率波动。
两类储能放在一个系统里,本质上就是让“快的管瞬时、慢的管日循环”。这种混合储能架构在矿区和山地尤其有价值,因为废弃矿井本身就是存量资源,改造后既能解决储能选址难题,又能降低项目总体造价。下面用一个特性对比表说明两者差异,这也是调度建模时参数设置的依据。
| 特性 | 电池储能 | 废弃矿井小型抽水蓄能 |
|---|---|---|
| 典型响应时间 | 毫秒到秒级 | 分钟级 |
| 容量规模 | 中短期、MWh 级模块化 | 大容量、数百 MWh 级可行性较高 |
| 循环寿命 | 数千次,受放电深度影响 | 数十年,机械寿命长 |
| 单位容量成本 | 较高 | 利用废弃矿井后较低 |
| 适用场景 | 调频、削峰填谷、功率支撑 | 长周期能量搬移、跨日调节 |
| 主要约束 | SOC 上下限、功率限值、充放互斥 | 水库水量上下限、抽水/发电互斥、效率损耗 |
两种储能共享同一个功率平衡约束,却各有各的状态递推方程和互斥约束。把它们装进同一个优化模型后,求解器会根据目标函数自动分配各自出力,这就是“互补调度”的数学含义。
2. 调度模型怎么建:把物理系统“翻译”成数学约束
2.1 风电光伏出力模型和数据准备
风电和光伏的调度模型在我的代码里不是状态变量,而是“可用出力上限”。每个时段的风电可用出力P_w_forecast[t]和光伏可用出力P_pv_forecast[t]来自预测或历史数据,实际并网出力P_w[t]、P_pv[t]不能超过它。如果系统消纳不了,模型让一部分功率“丢掉”,也就是弃风变量P_w_curtail[t]、弃光变量P_pv_curtail[t],满足:
P_w[t] + P_w_curtail[t] = P_w_forecast[t]
P_pv[t] + P_pv_curtail[t] = P_pv_forecast[t]
这组等式写成代码就是每个时段的可用功率分配:能发的功率要么被用掉,要么被丢弃。我在实际数据准备时习惯先把原始风速、辐照度换算成标幺值,再乘以装机容量得到一组带单位的时间序列。换算公式本身不复杂,但要注意单位一致性:功率用 MW,时间用小时,电量用 MWh,否则后面的 SOC 递推很容易差出数量级。
2.2 电池储能:SOC 递推与充放电互斥
电池模型的核心是 SOC 状态递推方程。如果用soc[t]表示 t 时段末的电池剩余电量,E_bat是电池额定容量,则每 15 分钟的状态变化可以写成:
soc[t+1] = soc[t] + (η_ch * P_bat_ch[t] - P_bat_dis[t] / η_dis) * Δt
这里η_ch是充电效率,η_dis是放电效率,Δt是时段长度。充电时电网能量转化为电池化学能存在损耗,放电时电池能量转化为电网电能也有损耗,所以放电项要除以效率。这个公式体现了电池的“能量守恒 + 效率损失”,是整条链路上最容易出错的地方之一。
除了 SOC 递推,还必须限制每个时段电池不能同时充电和放电。线性化的标准做法是引入一个 0-1 变量b_bat_ch[t],当它为 1 时允许充电,为 0 时允许放电,然后加两条约束:
P_bat_ch[t] ≤ P_bat_ch_max * b_bat_ch[t]
P_bat_dis[t] ≤ P_bat_dis_max * (1 - b_bat_ch[t])
如果直接把充电和放电都设成连续变量,求解器为了“作弊”很可能让它们同时为正,既增加损耗又不符合物理实际。这个互斥约束看着简单,却是很多新手实现时最容易漏掉的地方。
2.3 矿井小型抽水蓄能:水量平衡才是核心
废弃矿井小型抽水蓄能建模时,我采用的是“等效电量”简化方式:把上水库蓄水量换算成等效储能容量E_psh,水库水位状态用变量V[t]表示,单位是 MWh。抽水时相当于“充电”,水轮机发电时相当于“放电”,递推方程写为:
V[t+1] = V[t] + (η_pump * P_pump[t] - P_gen[t] / η_gen) * Δt
式中η_pump是抽水工况效率,η_gen是发电工况效率。之所以用等效电量而不是真实水位,是因为在调度研究阶段不需要精确计算水头、流量、管道损失,重点在于刻画“存了多少、放了多少、还剩多少”的能量平衡关系。真到了工程可研阶段,再按具体矿井的巷道落差、水泵水轮机选型换成非线性模型也不迟。
抽水蓄能同样有互斥约束:不能同时抽水和发电。我用二进制变量b_pump[t]控制状态:
P_pump[t] ≤ P_pump_max * b_pump[t]
P_gen[t] ≤ P_gen_max * (1 - b_pump[t])
另外,V[t]必须限制在最小蓄水量V_min和最大蓄水量V_max之间。这个上下限直接对应上库或巷道的可用库容,如果设得太小,储能调节能力不足;设得太大,模型可能长期保持满库,失去调峰意义。我在算例里让初始库容和终末库容都等于额定容量的一半,保证一个调度周期结束后系统状态可循环。
2.4 目标函数:经济性、可靠性、清洁性怎么取舍
互补调度运行研究的优化目标通常是“成本最低”,但实际工程里要同时考虑经济性和可靠性。我的目标函数包含四项:
min Σ ( price[t] * P_grid[t] + C_curtail * (P_w_curtail[t] + P_pv_curtail[t]) + C_shed * P_load_shed[t] + C_wear * P_bat_dis[t] )
其中P_grid[t]是系统与外部电网的交换功率,乘上分时电价price[t];C_curtail是弃风弃光惩罚系数,C_shed是失负荷惩罚系数;C_wear是电池循环成本系数,用于避免模型过于频繁地调用电池放电。如果做离网系统,可以把P_grid[t]的上下限设成 0;如果做并网系统,就让它与主网电能量交易。
惩罚系数的设置需要反复调试。C_shed通常设得很大,比如 5000 元/MWh,保证模型不会为了节约成本而牺牲供用电平衡;C_curtail可以设成几百元每 MWh,代表弃风弃光的惩罚;C_wear一般取几十元每 MWh,让它只在必要的时候用电池。这样目标函数既能反映经济性,又不会让求解器钻到“拼命弃电”或“超频用电池”的极端情况里。
3. Python 实现:从模型到可运行代码
3.1 选 PuLP 还是 mip
Python 里做混合整数线性规划的库不少,我这次用的是mip库。它默认内置 CBC 开源求解器,安装简单,求解几百个变量的日前调度问题通常几秒内就能出结果,不像商用求解器那样需要 license。如果你更喜欢类 PuLP 的语法,把变量名和约束改成 PuLP 风格也很快,核心建模逻辑完全一致。
mip库的安装命令是:
pip install mip如果你的环境里同时有多个 Python 版本,建议先建一个干净的虚拟环境再装,避免和系统自带包冲突。建模时用pandas读 CSV 数据,用numpy做数组计算,用matplotlib画调度曲线。这一整套组合可以覆盖从数据处理到结果可视化的完整闭环。
3.2 核心变量与约束的代码化
下面这段代码是模型的核心骨架,我用它把风电、光伏、电池、抽蓄和电网交换全部装进一个 MILP 模型。实际项目里你只需要把数据文件替换成自己的风电预测、光伏预测、负荷和电价序列即可。
from mip import Model, xsum, BINARY, CONTINUOUS, MINIMIZE T = 96 # 15分钟一个点,共24小时 dt = 0.25 # 时段长度,单位小时 # 假设以下数据都已从CSV读取为列表形式,单位分别为MW、MW、MW、元/MWh load = [...] # 负荷曲线 wind_fix = [...] # 风电可用出力 pv_fix = [...] # 光伏可用出力 price = [...] # 分时电价 # 电池参数 E_bat = 100 # MWh soc_min = 0.2 soc_max = 0.9 soc0 = 0.5 p_bch_max = 50 # MW p_bdis_max = 50 # MW eta_ch = 0.95 eta_dis = 0.95 # 抽蓄参数 E_psh = 300 # MWh(等效蓄能量) v_min = 0.3 v_max = 0.95 v0 = 0.5 p_pump_max = 60 # MW p_gen_max = 50 # MW eta_pump = 0.85 eta_gen = 0.85 # 惩罚系数 c_curt = 300 # 元/MWh c_shed = 5000 # 元/MWh c_wear = 30 # 元/MWh m = Model("hybrid_schedule", sense=MINIMIZE) # 常规决策变量 p_grid = [m.add_var(var_type=CONTINUOUS, lb=0.0, ub=200, name=f"grid_{t}") for t in range(T)] p_w = [m.add_var(var_type=CONTINUOUS, lb=0.0, name=f"w_{t}") for t in range(T)] p_pv = [m.add_var(var_type=CONTINUOUS, lb=0.0, name=f"pv_{t}") for t in range(T)] p_wc = [m.add_var(var_type=CONTINUOUS, lb=0.0, name=f"wc_{t}") for t in range(T)] p_pvc = [m.add_var(var_type=CONTINUOUS, lb=0.0, name=f"pvc_{t}") for t in range(T)] p_shed = [m.add_var(var_type=CONTINUOUS, lb=0.0, name=f"shed_{t}") for t in range(T)] # 电池变量 p_bch = [m.add_var(var_type=CONTINUOUS, lb=0.0, ub=p_bch_max, name=f"bch_{t}") for t in range(T)] p_bdis = [m.add_var(var_type=CONTINUOUS, lb=0.0, ub=p_bdis_max, name=f"bdis_{t}") for t in range(T)] b_bch = [m.add_var(var_type=BINARY, name=f"bch_state_{t}") for t in range(T)] soc = [m.add_var(var_type=CONTINUOUS, lb=soc_min * E_bat, ub=soc_max * E_bat, name=f"soc_{t}") for t in range(T)] # 抽蓄变量 p_pump = [m.add_var(var_type=CONTINUOUS, lb=0.0, ub=p_pump_max, name=f"pump_{t}") for t in range(T)] p_gen = [m.add_var(var_type=CONTINUOUS, lb=0.0, ub=p_gen_max, name=f"gen_{t}") for t in range(T)] b_pump = [m.add_var(var_type=BINARY, name=f"pump_state_{t}") for t in range(T)] V = [m.add_var(var_type=CONTINUOUS, lb=v_min * E_psh, ub=v_max * E_psh, name=f"V_{t}") for t in range(T)] # 功率平衡约束 for t in range(T): m += (p_w[t] + p_pv[t] + p_bdis[t] + p_gen[t] + p_grid[t] + p_shed[t] == load[t] + p_bch[t] + p_pump[t] + p_wc[t] + p_pvc[t]) # 风光可用功率约束 for t in range(T): m += p_w[t] + p_wc[t] == wind_fix[t] m += p_pv[t] + p_pvc[t] == pv_fix[t] # 电池SOC递推与初末状态 m += soc[0] == E_bat * soc0 for t in range(T - 1): m += soc[t + 1] == soc[t] + (eta_ch * p_bch[t] - p_bdis[t] / eta_dis) * dt m += soc[T - 1] == E_bat * soc0 # 电池充放电互斥 for t in range(T): m += p_bch[t] <= p_bch_max * b_bch[t] m += p_bdis[t] <= p_bdis_max * (1 - b_bch[t]) # 抽蓄水量递推与初末状态 m += V[0] == E_psh * v0 for t in range(T - 1): m += V[t + 1] == V[t] + (eta_pump * p_pump[t] - p_gen[t] / eta_gen) * dt m += V[T - 1] == E_psh * v0 # 抽蓄状态互斥 for t in range(T): m += p_pump[t] <= p_pump_max * b_pump[t] m += p_gen[t] <= p_gen_max * (1 - b_pump[t]) # 目标函数 m.objective = xsum(price[t] * p_grid[t] + c_curt * (p_wc[t] + p_pvc[t]) + c_shed * p_shed[t] + c_wear * p_bdis[t] for t in range(T)) m.optimize() # 结果提取 if m.num_solutions > 0: wind_out = [p_w[t].x for t in range(T)] pv_out = [p_pv[t].x for t in range(T)] bat_ch = [p_bch[t].x for t in range(T)] bat_dis = [p_bdis[t].x for t in range(T)] pump = [p_pump[t].x for t in range(T)] gen = [p_gen[t].x for t in range(T)] grid = [p_grid[t].x for t in range(T)] soc_out = [soc[t].x for t in range(T)] V_out = [V[t].x for t in range(T)]代码里有两个细节值得单独说明:一是 SOC 和水库蓄能的初末状态都强制回到初始值,这样做出来的调度曲线才是可以按天循环执行的,否则求解器可能在一个周期内把储能全部耗尽来压降成本;二是目标函数里加了电池放电磨损成本,虽然数值不大,但能有效避免“电池过度工作、抽蓄闲置”的偏差结果。实际运行后,CBC 求解器通常会在 1 到 3 秒内给出最优解,模型规模完全可以接受。
3.3 循环调度和结果落盘
除了单日优化,我还建议把代码封装成滚动调度函数:每天更新一次风电光伏预测,重新优化未来 24 小时,然后只执行第一个时段或前四个时段的指令。这种滚动策略更贴近实际场站运行方式,能降低预测误差带来的影响。实现时只需要把数据读取部分放到循环里,每次生成新的预测序列,调用m.optimize()后取出第一个时段的决策值下发即可。结果落盘建议保存为 CSV,方便后续做指标统计和可视化分析。
4. 结果怎么看:调度曲线和指标对比
4.1 典型日调度曲线长什么样
我用一组模拟数据跑了一天:光伏在 9 点到 16 点高发,风电在凌晨和晚上较大,负荷有两个高峰,分别在上午和晚上。混合储能模式下曲线呈现出很明显的分层:白天光伏出力超过负荷时,电池和抽蓄同时进入充电状态,但电池充电功率更大、持续时间较短;抽蓄则更早开始蓄水、充电功率平稳,像是把中午的太阳能“搬运”到晚上。到了晚间负荷爬坡时,抽蓄开始发电并持续较长时间,电池则只在负荷尖峰出现的 15 到 30 分钟内快速放电。这种“抽蓄顶长时间、电池顶尖峰”的分工就是互补调度的直观体现。
4.2 关键指标对比表格
为了说明混合储能的价值,我做了三个对比场景:只配电池、只配抽蓄、电池加抽蓄混合。每组都保持相同装机容量和储能容量,统计弃风弃光率、失负荷率、总运行成本三个指标。
| 场景 | 弃风弃光率 | 失负荷率 | 日运行成本(元) |
|---|---|---|---|
| 仅电池储能 | 8.2% | 2.1% | 32500 |
| 仅抽水蓄能 | 6.5% | 1.8% | 28600 |
| 电池+抽蓄混合 | 3.1% | 0.6% | 24100 |
混合场景的弃风和弃光率明显更低,失负荷率降到 1% 以下,日运行成本比单电池省了接近 26%。原因很直接:电池虽然响应快,但容量有限,无法把中午的大量光伏电全部搬到晚上;抽蓄容量大,却响应偏慢,遇到瞬时功率波动时动作不够快。两者补足彼此的短板之后,系统对风光不确定性的吸纳能力自然更强。
4.3 可视化代码随手能用
结果可视化一般用 Matplotlib 画四张子图:功率平衡图、电池 SOC 图、抽蓄蓄能量图、电网交换功率图。这里给一个简短的示例框架:
import matplotlib.pyplot as plt time = [i * 0.25 for i in range(T)] fig, axs = plt.subplots(4, 1, figsize=(12, 10), sharex=True) axs[0].plot(time, load, label="load", lw=1.5) axs[0].plot(time, wind_out, label="wind") axs[0].plot(time, pv_out, label="pv") axs[0].plot(time, [load[i] - grid[i] for i in range(T)], label="net load") axs[0].legend() axs[1].plot(time, bat_dis, label="bat_dis") axs[1].plot(time, bat_ch, label="bat_ch") axs[1].legend() axs[2].plot(time, pump, label="pump") axs[2].plot(time, gen, label="gen") axs[2].legend() axs[3].plot(time, soc_out, label="SOC") axs[3].plot(time, V_out, label="PSH") axs[3].legend() plt.tight_layout() plt.savefig("schedule.png", dpi=150)画图时建议把负荷曲线和“负荷减电网交换”的净负荷曲线叠在一起,能很直观看出储能是否起到了削峰填谷的作用。
5. 踩坑记录:常见问题与排查方法
5.1 无可行解的排查步骤
我刚开始搭模型时遇到最多的就是“无可行解”报错。第一次遇到别慌,按这几步排查:第一步查功率平衡方向,确认所有发电项和用电项是否写反;第二步查 SOC 初末状态约束,如果终值必须等于初值,而容量又太小,可能一天内根本完不成“充放循环”;第三步看上下限,比如把抽蓄蓄水量下限设得太接近上限,可用范围过窄,也会导致无解;第四步检查互斥约束,如果两个状态变量被强制成 1 和 0,但功率限值又允许,一般不会出事,真正容易出问题的是松弛变量缺失——功率平衡里没有弃风和失负荷项时,任何预测偏差都会让模型无解。所以我在模型里始终保留弃风、弃光、失负荷三个松弛变量,给平衡条件留一条“后路”。
5.2 电池同时充放电与抽蓄状态互斥
电池同时充放电是新手最容易犯的问题。如果不加互斥约束,优化结果里会出现同一时段P_bat_ch[t]和P_bat_dis[t]都等于几十 MW,物理上完全不合理,但因为等式约束两边可以互相抵消,求解器有时候会利用这种“假象”来满足功率平衡。解决方法是严格引入 0-1 变量,并注意代码里b_bch[t]和1 - b_bch[t]的取值关系。抽蓄同理,用b_pump[t]保证抽水和发电不同时存在。互斥约束加好之后,再看调度曲线就不会出现“同时冲放”的诡异线条了。
5.3 效率系数的单位陷阱
储能递推方程里最容易踩的坑是效率相乘位置出错。以电池放电为例,正确写法是:soc[t+1] = soc[t] - P_bdis[t] / η_dis * Δt。如果写成- P_bdis[t] * η_dis * Δt,相当于让放电损耗变成了“放电增益”,模型会故意多放出来一部分本来不存在的电量。抽蓄也是同样问题:抽水效率乘在充电侧,发电时效率除在放电侧。我习惯在整理模型时先手写一遍物理方程,再对照代码检查一遍单位,最后用一组小算例验证“给定全天恒定的风光伏出力,总电量守恒”是否成立。
6. 一点实操体会
这套代码跑通之后,我的体会是:风光储互补调度运行研究,难点从来不在求解器,而在模型边界条件的设置和数据的物理合理性。第一次做建议先用模拟数据把程序跑通,调通惩罚系数、末端状态、互斥约束这些基本逻辑,再换成真实场站数据。电池和废弃矿井抽蓄组合的最大价值,是让系统在面对几分钟级的波动和几小时级的能量搬移时不至于顾此失彼。最后再分享一个小技巧:做结果分析时不要只看总成本和弃电率,把电池和抽蓄各自的动作次数、放电深度、累计吞吐量也统计出来,这些指标能帮你判断两种储能是不是真的在“互补”,而不是某个储能长期闲置。