风电、光伏、储能、互补调度——这几个词放在一起,其实就是一个“看天吃饭”的能源系统学会自己调度的故事。今天要分享的项目,完整名称是《风电、光伏与储能(含电池和废弃矿井小型抽水蓄能)互补调度运行研究(Python代码实现)》,一套使用Python搭建的能源优化调度代码。它解决的核心问题很直接:在风机和光伏出力随机波动、电网容纳能力有限的前提下,怎么安排电池和抽水蓄能的充放电节奏,才能让弃风弃光最少、运行成本最低、供电最稳。无论你是做电力系统研究的学生,还是刚接触新能源调度的工程师,只要有一定的Python基础,都能在这篇内容里找到可以直接复现的思路和代码骨架。
这套研究里最让我感兴趣的点是“废弃矿井小型抽水蓄能”。常规抽水蓄能需要建上下两个水库,受地理条件制约大,但废弃矿井自带高差和巷道空间,改造成本低,还能盘活矿区闲置资源。它和电池储能在响应速度和容量上形成互补,让整个调度系统更有层次。下面我就从问题拆解、数学模型、Python实现到排坑经验,一层层把这块硬骨头啃明白。
1. 项目整体思路与场景拆解
1.1 为什么非要把风、光、储放在一起调度
风电和光伏是天生的“看天吃饭”选手。风电出力夜间往往较大,白天反而小;光伏则恰好相反,中午出力达到峰值,夜间直接归零。这种日内互补特性看起来很美,但真正并网时问题很大:电力系统要求发电和用电实时平衡,而风、光出力又是不可控的,波动起来几分钟内就能飙升或骤降。如果电网调峰能力不足,就会出现两个极端——要么弃风弃光,要么出现功率缺口。
这时候储能的价值就出来了。它像一个缓冲水池,发电多了先存起来,发电少了再放出来,把时间维度上的错配给抹平。但储能本身也有优劣之分:电池响应快、效率高,但容量有限,长时间大功率吞吐会加速老化;抽水蓄能容量大、寿命长,但响应速度慢,启停也有时间成本。单靠任何一种储能都很难同时满足快速调节和长时间尺度的能量搬移需求。所以把电池和抽水蓄能放在同一个调度框架里,让电池管短时高频的波动,让抽水蓄能管长时大容量的削峰填谷,这才是工程上真正合理的配置。这套代码做的正是这件事,用一个统一的优化模型去协调这些不同特性的设备。
1.2 电池储能与抽水蓄能的定位差异
很多初学者容易把储能当成一个黑箱,只关心“能存多少电”,但实际调度时必须区分两类储能的运行特性。电池储能(比如磷酸铁锂或者铅炭电池)的特点是充放电功率大、响应快,从零到满功率输出只需要毫秒到秒级,非常适合做一次调频或平滑短时波动。但它也有硬伤:循环寿命有限,频繁深度充放电会加速容量衰减;自放电率也不可忽视,如果调度策略激进,一个调度周期内可能损耗好几个百分点的能量。
抽水蓄能则完全是另一套逻辑。它本质上是“水—电—水”的能量转换,把电能转化为水的势能存储起来,放水时再通过水轮机发电。它的优势是容量大、寿命长,一个库容设计合理的小型抽蓄站,等效储能容量可能达到几十兆瓦时甚至几百兆瓦时,而且循环次数几乎不受限制。缺点是机组启停耗时较长,一般从启动到满发可能需要几分钟到十几分钟,水头变化还会影响发电效率。所以在调度模型里,我不会把两种储能写成同样的约束,而是让电池承担高频调节任务,让抽蓄承担日尺度以上的能量搬移,这样才能体现出互补的意义。
1.3 废弃矿井怎么变成小型抽水蓄能电站
把废弃矿井改造成抽水蓄能电站,听起来像天方夜谭,但原理上非常通顺。矿井内部有成体系的巷道、竖井和采空区,天然存在竖直方向的高差。建设时利用上部巷道或地面场地作为上水库,利用底部采空区或相邻巷道作为下水库,中间通过原有井筒布置压力管道和水轮机组。用电富余时把水从下水库抽到上水库,用电紧张时放水发电。相比新建常规抽水蓄能电站,它最大的优势有两个:一是无需新建大规模水库,不占用额外土地,造价大幅降低;二是矿区基础设施(供电线路、交通、厂房)可以复用,建设周期短。
当然,这种方案也有需要特殊处理的技术细节。矿井水下渗和围岩稳定性是首要考虑的安全因素,必须做防渗处理和结构加固;采空区的水质可能偏酸性或含有金属离子,对水泵水轮机有腐蚀风险。不过在调度研究层面,我们可以简化处理:把上下水库看作一个具有容量上下限和能量转换效率的储能单元,重点考虑它的功率约束、容量约束和效率参数。这样既贴合工程实际,又不会让模型复杂到无法求解。
2. 数学模型:目标函数与约束条件拆解
2.1 目标函数怎么设计才合理
调度模型的核心是目标函数,它决定了优化方向。我见过不少项目一上来就把“成本最小化”写在目标里,却忽略了不同设备在成本结构上的差异,结果模型解出来的策略并不符合实际运行需求。在这套代码里,我采用的是“运行成本+弃电惩罚+储能损耗”的加权最小化结构。
运行成本这部分,主要考虑向电网购电的费用(如果系统与大电网相连)以及风电、光伏的运行维护成本。弃电惩罚是一个关键项:当风电和光伏出力超过负荷需求时,如果不允许弃电,模型可能会强制储能以超出额定功率的方式充电,这显然不现实;如果弃电没有惩罚,模型又会倾向于无成本地削减可再生能源出力,失去调度的意义。所以我把弃电惩罚系数设为一个较大的数值,让模型在“弃电”和“调节储能”之间优先选择后者,只有在储能能力确实耗尽时才允许弃电。储能损耗项用于反映电池充放电循环对寿命的折损,我用一个与充放电功率平方相关的惩罚系数来表示,它能让模型尽量避免过于频繁的深度充放电。
这个目标函数设计的关键在于权重系数的比例。惩罚系数如果过高,模型会过度保守,储能设备的利用率偏低;惩罚系数如果过低,模型又可能为了多用储能而频繁充放,牺牲电池寿命。我在实际调试中发现,弃电惩罚系数取购电电价的3到5倍,储能损耗系数取一个很小的正数(比如0.001),就能得到比较均衡的调度结果。
2.2 约束条件逐条拆解
目标函数只是回答“往哪个方向优化”,真正的约束条件才是模型能够落地运行的关键。我按以下几条来组织约束:
第一条是功率平衡约束,也就是任意时刻所有电源出力加上储能放电、减去储能充电,必须等于负荷需求(如果并网,还包括与电网的交换功率)。这条约束是电网安全运行的红线,绝对不能违反。第二条是各设备出力上下限约束,风机、光伏的出力不能超过当前时刻的预测可用功率,也不能低于零;电池和抽蓄的充放电功率要受额定功率限制。第三条是储能系统的动态约束,电池的荷电状态(SOC)按“上一时刻SOC + 充电能量 - 放电能量 / 充放电效率”递推,抽水蓄能的上水库水量同理,但水量和电量的换算要考虑水头和机组效率。第四条是充放电状态互斥约束——在同一时刻,储能装置不能同时充电和放电,否则模型会出现“一边充一边放”的荒谬结果,白白损耗能量。这个约束要用0-1变量配合大M法来实现。第五条是调度周期末端的储能SOC约束,通常要求调度周期结束时SOC回到初始值附近,这样第二天才能继续按同样的策略运行。
其中最容易出问题的是SOC递推约束。它涉及相邻两个时段的耦合,如果写成非线性等式,会让求解难度陡增。我的处理方式是把它拆成两个不等式:电池的SOC增量不超过充电能量乘以充电效率,也不超过放电能量除以放电效率,再通过状态互斥约束保证同一时刻只有一个方向有能量流动。这样既保持了物理意义,又让模型保持在线性规划的框架内。
2.3 这些约束里的非线性项怎么处理
电力系统优化中最常见的非线性来源有三个:一是功率与效率的乘积关系,二是充电放电状态互斥,三是分段成本函数。前两个可以用0-1变量和大M法解决,第三个可以分段线性化。
拿状态互斥来说,假设电池的充电状态用二值变量u_ch表示,放电状态用u_dis表示,物理上要求u_ch + u_dis ≤ 1即可。但光有这个还不够,因为充电功率P_ch和放电功率P_dis都还是连续变量,模型仍然可能让P_ch和P_dis同时取正值,然后再让它们分别乘以各自的效率再叠加进SOC递推式。所以我需要再写两条约束:充电功率上限受u_ch控制(P_ch ≤ P_ch_max × u_ch),放电功率上限受u_dis控制(P_dis ≤ P_dis_max × u_dis)。这样一来,只要u_ch为0,充电功率就必须为0,互斥就真正成立了。这个“二值变量锁连续变量”的思路,是整个MILP模型里最关键的一步。
分段线性化的思路也类似。如果电池的寿命损耗成本是充电深度的分段函数,我不会直接构造非线性表达式,而是用一组二进制变量确定当前充电深度落在哪个分段区间,再用该区间的线性斜率计算成本。这个技巧在学术论文里叫“分段线性近似”,在工程代码里其实就是多写几条约束,但效果立竿见影,模型依然是线性混合整数规划,求解器可以高效处理。
3. Python实现:环境准备与数据工程
3.1 工具链选型:为什么是pulp加pandas
Python做优化调度,可选的工具不少:有付费的Gurobi、CPLEX,有开源的CBC、GLPK,也有更偏学术的Pyomo。我在这套代码里用的是pulp加CBC求解器,原因很朴素——开源免费、pip直接安装、API简单直观。pulp的建模语法非常接近数学表达式,像我这样从论文公式“翻译”成代码,几乎不会遇到理解障碍。对于研究规模的问题(比如168个小时的调度周期、15分钟一个时段),CBC求解器的速度完全够用,通常几秒到几十秒就能收敛到最优解。如果你的问题规模特别大,pulp也支持无缝切换求解器,把CBC换成Gurobi只需要改一行代码,这是它最方便的地方。
数据处理方面,我用pandas做主力。风电和光伏的出力预测数据、负荷数据通常以CSV或Excel给出,pandas的read_csv和DataFrame操作能轻松完成清洗、对齐和重采样。时间索引的处理尤其重要——不同数据源的采样间隔可能不同,有的15分钟一个点,有的1小时一个点,必须统一重采样后再喂给模型。另外,numpy用来做数组运算和随机数生成(后续做蒙特卡洛场景生成时会用到),matplotlib用来画调度结果曲线。
安装环境也很简单,一个requirements.txt就能搞定:pandas、numpy、matplotlib、pulp。不需要装额外的求解器,pulp会自带CBC的可执行文件,开箱即用。这点对新手特别友好,踩坑的概率大大降低。
3.2 数据获取与数据清洗的那些坑
做真实调度研究,数据永远是最耗时间的环节。我常用的是欧洲ENTSO-E开放数据和国内某些公开数据集,格式一般是CSV,包含时间戳、地区、风电出力(MW)、光伏出力(MW)、负荷(MW)等字段。
拿到数据后,我做的第一件事永远是画时序图,用眼睛检查有没有明显的异常值。比如风电出力出现负值,多半是数据单位或符号约定有问题;负荷在凌晨突然变成零,可能是原始数据缺测。这些异常值如果直接喂给模型,轻则结果不可信,重则求解器报不可行。清洗策略上,我会对缺失值做线性插值,对超出合理范围(比如风电出力大于装机容量)的数据做截断处理,再用一个滚动中位数过滤器剔除毛刺。
另一个容易踩坑的地方是数据结构设计。我习惯把所有数据合并成一个长表,列名分别是“time”、“wind”、“pv”、“load”,时间列统一转换成pandas的DatetimeIndex,并按15分钟或1小时间隔重采样。重采样时要注意聚合方式:风力和光伏出力取平均值,负荷也取平均值,不要用求和,因为功率的单位是MW,时间段的平均值才代表该时段的典型出力水平。数据准备好之后,我会额外绘制一个“风光出力与负荷的日特性曲线”图,直观观察三类曲线的峰谷关系,这对接下来的调度策略设计非常有帮助。
3.3 核心数据结构:从DataFrame到优化变量的映射
pandas负责数据管理,但优化模型需要的是“决策变量”。我的做法是用字典结构来组织决策变量:以时段t为键,每个键对应一个LpVariable对象。比如P_bat_ch[t]表示t时段电池的充电功率,SOC_bat[t]表示t时段末电池的荷电状态。把所有变量按类型分组存进不同的字典,模型求解后取数值时也按同样的字典结构去读,逻辑非常清晰。
这里有个小技巧:LpVariable.dicts函数支持批量生成变量列表。比如soc = LpVariable.dicts("SOC_bat", T, lowBound = 0.1, upBound = 0.9),一行代码就能生成从t=0到t=T-1的所有SOC变量,而且自动带上SOC_bat_0、SOC_bat_1这样的名字,方便调试时输出分析。同样的方式也可以批量生成二进制变量,只需要指定cat = "Binary"。这个API设计对代码的可读性和可维护性帮助极大,也是我推荐pulp的原因之一。
数据从DataFrame到优化变量的映射,核心是“时段数T”这个参数。我通常把调度周期取为24小时或168小时,时段间隔为1小时。T越大,模型越精细,但变量数量和约束数量线性增长,求解时间相应增加。对于研究验证,24小时单日调度是最常见的起点;如果要研究季节性特征,就取168小时的周调度,但需要给模型更多的求解时间。
4. 核心代码实现与调度逻辑
4.1 参数设置与数据读入
这部分是整个代码的地基。我会把所有物理参数集中放在一个config字典里,包括:风电装机容量、光伏装机容量、负荷峰值、电池额定功率、电池容量、充放电效率、抽蓄额定功率、抽蓄等效容量、抽蓄效率、初始SOC、末态SOC要求等。这样做的最大好处是后续做敏感性分析时,不需要改模型代码,只需要改config字典就能跑不同场景。
数据读入的代码逻辑是:读CSV,清洗,重采样,然后截取要研究的时段。如果数据长度不足一个调度周期,我会用“最后一天重复”或“线性外推”的方式补齐。实际处理中,我更推荐保留原始长序列,然后在构建模型时用切片的方法选取研究窗口,这样反复调参时不需要反复读文件。
4.2 优化模型的构建与求解
先上一段核心代码框架,展示如何用pulp建立和求解这个混合整数线性规划模型:
import pandas as pd import numpy as np import pulp as pl # 读取和预处理数据(示例) df = pd.read_csv("wind_pv_load.csv", parse_dates=["time"]) df = df.set_index("time").resample("1H").mean().dropna() T = len(df) t = range(T) # 参数配置 config = { "P_wind_max": df["wind"].max() + 10, # 含一点裕度 "P_pv_max": df["pv"].max() + 5, "P_bat": 30, # 电池额定功率 MW "E_bat": 120, # 电池容量 MWh "eta_bat_ch": 0.95, "eta_bat_dis": 0.95, "P_phs": 25, # 抽蓄机组额定功率 MW "E_phs": 200, # 抽蓄等效容量 MWh "eta_phs": 0.75, # 抽蓄综合效率 "soc_bat_init": 0.5, "soc_phs_init": 0.5, } # 创建模型 model = pl.LpProblem("Hybrid_Energy_Scheduling", pl.LpMinimize) # 决策变量 wind_curt = pl.LpVariable.dicts("wind_curt", t, lowBound=0) pv_curt = pl.LpVariable.dicts("pv_curt", t, lowBound=0) bat_ch = pl.LpVariable.dicts("bat_ch", t, lowBound=0, upBound=config["P_bat"]) bat_dis = pl.LpVariable.dicts("bat_dis", t, lowBound=0, upBound=config["P_bat"]) phs_gen = pl.LpVariable.dicts("phs_gen", t, lowBound=0, upBound=config["P_phs"]) phs_pump = pl.LpVariable.dicts("phs_pump", t, lowBound=0, upBound=config["P_phs"]) soc_bat = pl.LpVariable.dicts("soc_bat", t, lowBound=0.1, upBound=0.9) soc_phs = pl.LpVariable.dicts("soc_phs", t, lowBound=0.2, upBound=0.95) # 0-1状态变量 u_bat_ch = pl.LpVariable.dicts("u_bat_ch", t, cat="Binary") u_bat_dis = pl.LpVariable.dicts("u_bat_dis", t, cat="Binary") u_phs_gen = pl.LpVariable.dicts("u_phs_gen", t, cat="Binary") u_phs_pump = pl.LpVariable.dicts("u_phs_pump", t, cat="Binary") # 目标函数:购电成本 + 弃电惩罚 + 储能损耗 # 假设当前场景是孤网运行,所以购电项为0;以弃电惩罚和损耗为主 penalty_curt = 100.0 loss_coeff = 0.001 model += ( pl.lpSum(penalty_curt * (wind_curt[i] + pv_curt[i]) + loss_coeff * (bat_ch[i] + bat_dis[i] + phs_gen[i] + phs_pump[i]) for i in t) ) # 约束:功率平衡(孤网:负荷 = 风电 + 光伏 + 储放 - 储充 - 弃电) for i in t: model += (df["wind"].iloc[i] - wind_curt[i] + df["pv"].iloc[i] - pv_curt[i] + bat_dis[i] - bat_ch[i] + phs_gen[i] - phs_pump[i] == df["load"].iloc[i], f"power_balance_{i}") # 约束:风电、光伏弃电上限 for i in t: model += wind_curt[i] <= df["wind"].iloc[i] model += pv_curt[i] <= df["pv"].iloc[i] # 约束:电池SOC递推 for i in t: if i == 0: model += soc_bat[i] == config["soc_bat_init"] + (bat_ch[i] * config["eta_bat_ch"] - bat_dis[i] / config["eta_bat_dis"]) / config["E_bat"] else: model += soc_bat[i] == soc_bat[i-1] + (bat_ch[i] * config["eta_bat_ch"] - bat_dis[i] / config["eta_bat_dis"]) / config["E_bat"] # 约束:抽水蓄能SOC递推(等效电量) for i in t: if i == 0: model += soc_phs[i] == config["soc_phs_init"] + (phs_pump[i] * config["eta_phs"] - phs_gen[i] / config["eta_phs"]) / config["E_phs"] else: model += soc_phs[i] == soc_phs[i-1] + (phs_pump[i] * config["eta_phs"] - phs_gen[i] / config["eta_phs"]) / config["E_phs"] # 约束:储能充放电状态互斥 for i in t: model += bat_ch[i] <= config["P_bat"] * u_bat_ch[i] model += bat_dis[i] <= config["P_bat"] * u_bat_dis[i] model += u_bat_ch[i] + u_bat_dis[i] <= 1 model += phs_pump[i] <= config["P_phs"] * u_phs_pump[i] model += phs_gen[i] <= config["P_phs"] * u_phs_gen[i] model += u_phs_pump[i] + u_phs_gen[i] <= 1 # 末态约束:SOC回到初始值 model += soc_bat[T-1] == config["soc_bat_init"] model += soc_phs[T-1] == config["soc_phs_init"] # 求解 solver = pl.PULP_CBC_CMD(msg=False, timeLimit=120) result = model.solve(solver) print("求解状态:", pl.LpStatus[model.status]) print("目标函数值:", pl.value(model.objective))这段代码虽然是一个简化版,但已经把“功率平衡、SOC递推、状态互斥、末态约束”四大核心要素全部体现出来了。实际研究项目里,你可能还要增加线路潮流约束、备用容量约束、机组启停时间约束等,但骨架是完全一样的。
求解完成后,取变量值也是一门学问。我习惯先判断model.status是不是"Optimal",如果是,再把各变量的value逐个取出来组装成DataFrame,方便后续分析和画图。对于MILP模型,如果求解器没有找到最优解而是停留在某个可行解,你也应该把当前解输出来看——很多时候它已经足够接近最优。CBC求解这类小规模MILP通常没有任何压力,但如果求解时间超过两三分钟,建议检查一下是不是约束写错了、变量边界设置不合理,或者数据量确实太大。
4.3 结果可视化与调度曲线解读
调度的输出结果,如果没有可视化,等于白算。我一般画四张图放在一起组成一个2×2的figure:第一张是风电与光伏的出力曲线(柱状堆叠),标注出被弃掉的部分和实际利用的部分;第二张是储能设备的充放电功率曲线(电池一条线、抽蓄一条线,正值为放电,负值为充电);第三张是SOC曲线(两条线,左轴电池SOC,右轴抽蓄SOC);第四张是负荷平衡曲线(负荷、总供给、储能贡献叠加)。
调度结果怎么看,比怎么看代码更重要。一个合理的调度方案,从图上应该能读出三个信息:一是系统是否出现了明显的弃电,如果弃电集中在中午光伏高峰时段,说明储能容量不足以平移这部分能量,接下来应该考虑扩容电池或抽蓄;二是电池的充放电切换是否过于频繁,如果SOC曲线像锯齿一样密集振动,说明目标函数里对储能损耗的惩罚太弱,模型在用电池“刷”平衡;三是末态SOC是否回到初始值附近,如果偏差过大,说明约束写错了或者模型压根没满足末态约束。我调参时,90%的时间都在盯着这四张图找规律,而不是看求解器的日志输出。
5. 常见问题与调试经验实录
5.1 求解器报“不可行”怎么办
这是最打击新手的错误,几乎每个人都遇到过。模型不可行,说明你写的约束之间互相矛盾,不存在任何一组变量取值能同时满足所有条件。我在调试时有一个固定的排查顺序:
先检查功率平衡约束。最常见的问题是负荷数据有零值或负值,而约束要求负荷等于所有电源出力之和,这就把可行域直接压缩没了。遇到这种情况,我把负荷小于0的记录替换成0,或者检查是不是单位换算出了问题(kW和MW混用是经典错误)。再检查SOC递推约束。如果在某个时段,电池的初始SOC加上最大充电量减去最小放电量之后超出了0到1的范围,模型就会不可行。这时候需要检查SOC的上下限设置是否合理,以及末态约束和初始SOC之间的兼容性。最后检查状态互斥约束。有时候我为了图省事,只写了u_ch + u_dis ≤ 1,但忘了写P_ch ≤ P_ch_max × u_ch,那模型依然可以让功率取非零值,虽然不会不可行,但结果必然怪。
最有效的排查工具其实是pulp自带的模型导出功能。设置model.writeLP("debug.lp"),用文本编辑器打开这个LP文件,把约束一条条读一遍,通常几分钟之内就能定位到矛盾点。这个方法我认为比任何printf调试都高效。
5.2 电池SOC数值波动异常的分析
有一次模型求解正常,结果却显示电池SOC在下半夜无缘无故上升,白天负荷高峰反而放电很少。检查后发现原因出在充电效率参数写反了:我把充电效率同时应用在“充电时存入的能量”和“放电时消耗的SOC”上,等于系统里的能量凭空多出来一部分,模型当然会钻这个空子,在低谷电价时段疯狂充电然后高SOC值虚高。
正确的写法是充电时SOC增量等于充电功率乘以充电效率,放电时SOC减量等于放电功率除以放电效率,两处效率参数必须分开写。类似的坑也出现在抽水蓄能上:抽水耗电量和发电电量之间不是同一个效率值,发电工况还要考虑水轮机效率随水头变化的因素。虽然研究模型可以简化成固定综合效率,但至少在参数设置上要区分充放电方向,避免出现“永动机”式的循环增益。
5.3 时间尺度与求解速度的权衡
调度周期取24小时、以15分钟为时段间隔,会产生96个时段;如果取168小时周调度、1小时间隔,只有168个时段。变量数量差异带来的是求解时间的天壤之别。我实测过,CBC求解一个96个时段的MILP模型通常只需要几秒到十几秒,但如果是168个时段且加入了机组启停约束,可能几分钟都算不完。
如果你的研究需要长时间尺度的仿真,我的建议是“分层调度”:先以1小时为间隔跑一个周调度,把每小时的储能SOC目标值算出来;再以15分钟为间隔跑日内滚动优化,把SOC目标作为硬约束嵌入进去。这种两阶段策略既保证了计算速度,又不丢失短时波动的细节,是工程上常见的做法。
6. 从确定性调度到更贴近实际的扩展方向
6.1 考虑风光出力预测误差的鲁棒优化
前面介绍的是一个确定性模型,假设风电、光伏出力在调度前是已知的常数。但真实运行中,预测误差是常态:天气预报说下午三点风速9米/秒,实际可能是12米/秒或者6米/秒。应对预测误差,最简单的扩展是做“滚动时域优化”和“场景树随机规划”。
滚动时域的思路很好理解:每15分钟重新读取最新的预测数据,重新求解未来4小时的调度计划,但只执行第一个时段的指令。这种“边走边看”的策略能持续修正误差,工程应用非常广泛。随机规划则是把预测误差的概率特征纳入模型:生成10个或50个可能的风光出力场景,让目标函数变成所有场景下的期望成本,约束条件在每个场景下都必须满足。这样求得的调度策略比单一确定性场景稳健得多。代价是变量和约束数量成倍增加,模型更大,但现代求解器配合场景缩减技术(比如k-means聚类选出代表性场景)完全扛得住。
6.2 把废弃矿井抽蓄的地理约束写进模型
我之前把抽水蓄能简化成了一个“带效率的电池”,但在真正的工程预可研阶段,这个简化是不够的。矿井型抽蓄有几个特殊约束:一是上下水库的库容曲线往往不是线性的,水位越高,单位水头对应的储能电量越多,因此SOC和可发电量之间不是线性关系;二是井下巷道的水位波动会影响水泵的最小淹没深度,低水位时机组可能无法启动;三是矿山的沉降和渗漏风险可能要求水库不得在某个水位区间长时间停留。把这些因素写成约束并不复杂,关键是把库容—水位曲线分段线性化,再把“禁止停留区间”表示成一组0-1变量约束。加入这些细节之后,模型才算真正“长在”了废弃矿井这个物理场景上。
6.3 我的几点实操体会
最后分享几条个人经验。第一,不要一上来就追求模型的完美,先把最基本的功率平衡加电池约束跑通,拿到一组哪怕很粗糙的调度曲线,再逐步加抽蓄、加状态互斥、加末态约束。每加一类约束,就画一次图检查物理意义,这样能最快定位“是模型错了还是代码错了”。第二,参数设置时养成写注释的习惯。我吃过亏,一个效率参数忘记注释方向,三天后再跑代码完全不知道当初怎么想的,白白浪费一个下午。第三,如果你提交论文或者报告,一定要附上调度结果的时序图,而不是只写优化目标值——审稿人和导师看图的直觉比看数字快得多,一张漂亮的调度曲线比两千字说明都更有说服力。
这套风电、光伏和储能互补调度的Python代码,本质上是一个“搭积木”的框架:把每种电源和储能都抽象成一组变量和约束,然后交给优化求解器去找最佳组合。如果你把风电、光伏替换成其他分布式电源,把抽蓄替换成压缩空气储能,模型骨架几乎可以原封不动地复用。这也是我当初选择用Python做这套研究的直接原因——它的生态足够丰富,建模足够灵活,让一个研究想法能以最快的速度变成一条可以反复调试、验证和扩展的代码流水线。