做电力系统优化调度的朋友,对这个组合应该不陌生:蓄电池、市场购电售电约束、功率平衡约束,再加上一个总费用最低的目标函数。这是典型的微电网/家庭储能经济调度问题,也是绝大多数人入门电网程序建模的第一道坎。这个模型看着简单,里面的门道却不少——SOC怎么递推,购售电为什么不能同时发生,功率平衡约束的符号方向怎么约定,每一条都值得掰开揉碎讲清楚。这篇文章就围绕这个“考虑蓄电池的最优购售电调度模型”展开,适合正在做课程设计、毕设,或者刚接触优化建模的初学者参考。我会把模型设计思路、约束条件逐条拆解、完整Python代码以及我踩过的坑一起写出来,尽量让你照着就能跑通。
1. 先把这个模型的价值搞清楚:它解决什么问题,适合什么人
1.1 场景还原:一块电池怎么帮你省钱
假设你家里装了分时电表:晚上谷电0.3元一度,白天峰电1.2元一度。家里有一块10度电的蓄电池。没有电池时,白天高峰该用多少电就买多少电,每度1.2元硬扛。有电池之后,你可以晚上用0.3元的价格把电买进来存着,白天不买电网的高价电,而是放电池里的电。一来一回,每度电能省下0.9元。
看起来很简单对吧?但问题来了:峰谷价差每天都在变,家里的负荷也不是固定不变的。什么时候充、充多少、什么时候放、放多少,如果全靠拍脑袋决策,很容易出现“该充的时候没充满,该放的时候提前放完”的尴尬局面。这个模型要解决的,就是在这个约束下算出一个“自动决策方案”——每一小时买多少、卖多少、充多少、放多少,让一天的总电费支出降到最低。
这套东西不只是家庭能用。小商铺、办公楼、充电桩配套储能、甚至微电网的调度层,用的都是同一套逻辑。区别只在于负荷曲线不同、电池容量不同、电价机制不同。模型骨架完全一样,所以新手把这个项目吃透了,后面接光伏、接需求响应、接多微网协调,都是在这个骨架上做加法。
1.2 建模核心:目标函数、决策变量、约束条件缺一不可
做这类优化调度,第一步不是急着写代码,而是把数学模型写清楚。整个过程可以拆成三步。
第一步,把系统参数化。给定24小时的负荷曲线,每小时的购电电价和售电电价,蓄电池的容量、最大充放电功率、充放电效率、SOC上下限,电网购电和售电的功率上限。这些都是已知量,在代码里当作参数输入。
第二步,定义决策变量。这里要决策的是每一小时从电网买多少电(P_buy[t])、向电网卖多少电(P_sell[t])、电池充多少电(P_ch[t])、电池放多少电(P_dis[t]),以及每一小时结束时的电池电量状态SOC[t]。注意,SOC[t]也是决策变量,不是参数——因为它完全是由充放电决策推导出来的状态量。
第三步,写出目标函数和约束。目标函数就是一天的总费用最小,表达式为:min Σ (P_buy[t] × 购电价[t] × Δt) - Σ (P_sell[t] × 售电价[t] × Δt)。约束条件包括蓄电池的动态递推约束、充放电功率边界、SOC边界、购电功率上限、售电功率上限,以及每小时的功率平衡约束。
很多初学者一上来就急着调库、跑代码,结果模型是什么含义都没搞明白。我的建议是:先在纸上把这个三步走完,变量符号、单位、公式都列清楚,再动笔写代码。代码只是把数学模型翻译成求解器能认识的语言而已,数学模型才是真正的核心。
1.3 工具选型:为什么线性规划是新手最好的起点
这个模型里的目标函数是线性的(变量的一次函数求和),约束条件也是线性的(等式或不等式),所以它是一个标准的线性规划问题,LP,Linear Programming。
为什么推荐新手从线性规划入手?因为线性规划有非常成熟的求解理论,求解器能在极短时间内给出全局最优解;结果可解释性强,不会出现“局部最优”这种让人困惑的概念;建模和调试都相对容易。一旦你理解了LP问题的建模思路,后面遇到混合整数规划(MILP,需要引入0/1变量时)、二次规划(Q,比如考虑成本曲线),都不会觉得突兀,因为它们本质上是在LP骨架上扩展。
工具方面,我推荐Python + PuLP。PuLP的语法非常接近数学表达式的写法,对新手极友好,而且它是开源免费的,pip install pulp一行装好,默认调用CBC求解器,对我们这种规模的问题绰绰有余。备选方案有SciPy.optimize.linprog、Google OR-Tools;如果以后问题规模变大或者要处理非线性,再考虑Pyomo配Gurobi。千万不要一上来就上重型框架,徒增学习成本。
2. 三条核心约束逐条拆解,附算例
2.1 蓄电池约束:SOC递推、充放功率边界、周期回置
先说SOC。SOC全称State of Charge,就是电池当前电量占额定容量的百分比,和手机右上角的电量百分比是一个意思。SOC[t]表示第t个时段结束时的电池电量状态,取值范围一般在0.2到0.9之间,不建议设成0到1。原因很简单:电池不能完全放空,否则寿命受损;也不能长时间充满,浮充状态对锂电池不友好。这个上下限留出来的余量,就是给电池“延寿”的。
SOC的递推关系是整个模型最核心的公式,很多新手写错。先看公式再解释:
SOC[t] = SOC[t-1] + (η_ch × P_ch[t] - P_dis[t] / η_dis) × Δt / E_bat
怎么理解?电池就像一个水池,充电是进水,放电是出水。但进水和出水都不是100%无损的——充电效率η_ch一般是0.95左右,意味着你充进去1度电,真正存进电池的只有0.95度,另外0.05度变成了热量损耗;放电效率η_dis类似,你放1度电出来,电池内部实际要消耗超过1度的储存能量,所以算SOC下降量时要用P_dis除以η_dis,而不是直接乘。
举个例子。一块10kWh的电池,SOC初始0.5,充电功率3kW,充电效率0.95,充一小时。SOC增加量 = 3 × 0.95 × 1 / 10 = 0.285。于是这一小时结束时SOC变成0.785。如果看到SOC从0.5直接变成0.8,那大概率是忘了乘效率,或者单位搞错了。
除了SOC递推约束,蓄电池还需要两条边界约束:充放电功率边界和SOC边界。充电功率P_ch[t]必须大于等于0且小于等于最大充电功率P_ch_max;放电功率P_dis[t]同理,大于等于0且小于等于最大放电功率P_dis_max。SOC[t]必须始终落在[SOC_min, SOC_max]区间内。这四条不等式约束缺一不可,否则模型会给出“电池既能无限充电又无限放电”的荒谬结果。
最后是周期回置约束:SOC[T-1] = SOC_init。意思是调度周期结束后的电池电量,必须回到和初始状态一样。为什么要加这一条?因为我们的调度是连续运行的,今天结束状态就是明天初始状态。如果不加这个约束,模型会在最后几个时段拼命放电把电池掏空,因为它不在乎明天——只优化这一天,结果就是账单好看但电池被用废了。周期回置约束保证了调度方案的可持续性。
2.2 市场购电售电约束:上下限与同时买卖问题
市场购电售电约束分三层:购电功率上限、售电功率上限、以及同一时刻不能既买又卖。
购电功率上限P_buy_max取决于你家或企业的进线容量,或者说和电网公司签订的用电容量。家庭一般10kW左右,工商业可能几百上千kW。这个约束的物理意义是:电表拖不动更多功率了。
售电功率上限P_sell_max取决于并网逆变器容量或售电合同约定。如果你的系统没有装光伏、没有并网逆变器,那P_sell_max就是0,意味着你只能买电不能卖电。在有分布式光伏的系统中,这个值通常等于逆变器额定容量。
同一时刻不能既买又卖,这是物理上不允许的——一个并网点不可能同时从电网拉电又往电网送电。但在数学建模时,这个约束是否非要显式写出来,取决于电价关系。我在第4部分会详细展开这一点,这里先卖个关子。新手很容易在这一点上踩坑,建模时要么忘了处理,要么处理过度导致模型复杂化。
电价数据在这个模型里是输入参数,不是决策变量。分时电价就是24个数值:谷段0.3元、平段0.6元、峰段1.2元,按时段填入。目标函数会根据这个价格信号自动判断每一小时应该买电还是卖电。这也是整个优化问题的“指挥棒”——电价曲线决定了电池的充放电节奏。
2.3 功率平衡约束:源-荷-储时刻对账
功率平衡约束是整个模型的“定海神针”,它保证每一个时段系统都必须满足能量守恒:
P_load[t] = P_buy[t] - P_sell[t] + P_dis[t] - P_ch[t]
翻译成人话:某个时间段内,负荷消耗的电量,必须等于来自电网的净购电量(购电减去售电)加上电池的净放电量(放电减去充电)。这个约束相当于家庭电表的收支账本——每一小时你用了多少电,必须有人买单,不能凭空多出来一度电,也不能凭空少一度电。
注意符号约定一定要一致。我习惯把“从电网取电”定义为正方向,P_buy为正、P_sell为负;电池放电P_dis相当于给负荷供电,为正;电池充电P_ch相当于负荷增加,为负。这样等号左右两边才能对得平。很多新手代码跑出来结果离谱,查了半天发现是功率平衡约束里正负号写反了。
这里可以做一个生活化类比:功率平衡约束就像你月底对账——工资收入(电网买电)加兼职收入(电池放电),减去各项支出(充电、卖电、生活开销),必须一分不差等于余额(负荷消耗)。对不上账,模型就没有可行解。
如果系统里有光伏,这个公式只需要做一个改动:把P_load[t]替换成(P_load[t] - P_pv[t]),也就是“净负荷”。这也是这个模型后续做光伏扩展的最直接切入点。
3. Python实现:完整建模代码与结果解读
3.1 数据准备:24小时负荷、峰平谷电价、电池参数怎么定
先准备好输入数据。这里我构造一个典型家庭的24小时场景,时间粒度为1小时。负荷曲线大致是:夜间低、白天高、晚饭后一个小高峰。
| 时段 | 负荷(kW) | 购电价(元/kWh) | 售电价(元/kWh) |
|---|---|---|---|
| 0-1 | 1.8 | 0.30 | 0.20 |
| 1-2 | 1.5 | 0.30 | 0.20 |
| 2-3 | 1.2 | 0.30 | 0.20 |
| 3-4 | 1.2 | 0.30 | 0.20 |
| 4-5 | 1.5 | 0.30 | 0.20 |
| 5-6 | 2.0 | 0.30 | 0.20 |
| 6-7 | 3.0 | 0.50 | 0.25 |
| 7-8 | 4.0 | 0.60 | 0.25 |
| 8-9 | 4.5 | 0.60 | 0.25 |
| 9-10 | 5.0 | 1.20 | 0.30 |
| 10-11 | 5.5 | 1.20 | 0.30 |
| 11-12 | 5.0 | 1.20 | 0.30 |
| 12-13 | 4.5 | 1.20 | 0.30 |
| 13-14 | 4.0 | 1.20 | 0.30 |
| 14-15 | 3.5 | 0.60 | 0.25 |
| 15-16 | 3.5 | 0.60 | 0.25 |
| 16-17 | 3.0 | 0.60 | 0.25 |
| 17-18 | 4.0 | 0.60 | 0.25 |
| 18-19 | 5.5 | 1.20 | 0.30 |
| 19-20 | 6.0 | 1.20 | 0.30 |
| 20-21 | 5.5 | 1.20 | 0.30 |
| 21-22 | 4.0 | 0.50 | 0.25 |
| 22-23 | 3.0 | 0.50 | 0.25 |
| 23-24 | 2.0 | 0.30 | 0.20 |
电池参数如下:
| 参数 | 符号 | 数值 |
|---|---|---|
| 额定容量 | E_bat | 10 kWh |
| SOC下限 | SOC_min | 0.2 |
| SOC上限 | SOC_max | 0.9 |
| 最大充电功率 | P_ch_max | 3 kW |
| 最大放电功率 | P_dis_max | 3 kW |
| 充电效率 | η_ch | 0.95 |
| 放电效率 | η_dis | 0.92 |
| 初始SOC | SOC_init | 0.5 |
电网侧参数:P_buy_max = 10 kW,P_sell_max = 5 kW。时间粒度Δt = 1小时。
这里提醒一下:所有功率单位用kW,能量单位用kWh,电价单位用元/kWh。ΔT是小时数,P × ΔT = 电量(kWh)。后面如果要做15分钟粒度,把ΔT改成0.25,所有和电量相关的计算都会自动适配。
3.2 核心代码:PuLP建模逐行讲解
下面给出完整可运行的代码。我用的是PuLP,安装命令:pip install pulp。
import pulp # ========== 参数定义 ========== T = 24 # 调度时段数 delta_t = 1 # 单位时段长度,小时 # 负荷 / 购电价 / 售电价(24个数值,按上面表格填入) P_load = [1.8, 1.5, 1.2, 1.2, 1.5, 2.0, 3.0, 4.0, 4.5, 5.0, 5.5, 5.0, 4.5, 4.0, 3.5, 3.5, 3.0, 4.0, 5.5, 6.0, 5.5, 4.0, 3.0, 2.0] price_buy = [0.30, 0.30, 0.30, 0.30, 0.30, 0.30, 0.50, 0.60, 0.60, 1.20, 1.20, 1.20, 1.20, 1.20, 0.60, 0.60, 0.60, 0.60, 1.20, 1.20, 1.20, 0.50, 0.50, 0.30] price_sell = [0.20, 0.20, 0.20, 0.20, 0.20, 0.20, 0.25, 0.25, 0.25, 0.30, 0.30, 0.30, 0.30, 0.30, 0.25, 0.25, 0.25, 0.25, 0.30, 0.30, 0.30, 0.25, 0.25, 0.20] # 蓄电池参数 E_bat = 10.0 # kWh SOC_min = 0.2 SOC_max = 0.9 P_ch_max = 3.0 # kW P_dis_max = 3.0 # kW eta_ch = 0.95 eta_dis = 0.92 SOC_init = 0.5 # 电网购售电上限 P_buy_max = 10.0 P_sell_max = 5.0 # ========== 创建模型 ========== model = pulp.LpProblem("Battery_Economic_Dispatch", pulp.LpMinimize) # ========== 决策变量 ========== P_buy = pulp.LpVariable.dicts("P_buy", range(T), lowBound=0, upBound=P_buy_max) P_sell = pulp.LpVariable.dicts("P_sell", range(T), lowBound=0, upBound=P_sell_max) P_ch = pulp.LpVariable.dicts("P_ch", range(T), lowBound=0, upBound=P_ch_max) P_dis = pulp.LpVariable.dicts("P_dis", range(T), lowBound=0, upBound=P_dis_max) SOC = pulp.LpVariable.dicts("SOC", range(T), lowBound=SOC_min, upBound=SOC_max) # ========== 目标函数:总费用最低 ========== model += pulp.lpSum([ P_buy[t] * price_buy[t] * delta_t - P_sell[t] * price_sell[t] * delta_t for t in range(T) ]), "Total_Cost" # ========== 约束条件 ========== # 功率平衡约束 for t in range(T): model += P_load[t] == P_buy[t] - P_sell[t] + P_dis[t] - P_ch[t], f"PowerBalance_{t}" # SOC递推与周期回置 for t in range(T): if t == 0: model += SOC[t] == SOC_init + (eta_ch * P_ch[t] - P_dis[t] / eta_dis) * delta_t / E_bat, f"SOC_Dynamic_{t}" else: model += SOC[t] == SOC[t-1] + (eta_ch * P_ch[t] - P_dis[t] / eta_dis) * delta_t / E_bat, f"SOC_Dynamic_{t}" model += SOC[T-1] == SOC_init, "SOC_Periodic" # ========== 求解 ========== model.solve() # ========== 结果输出 ========== if pulp.LpStatus[model.status] == "Optimal": total_cost = pulp.value(model.objective) print(f"最优总费用: {total_cost:.2f} 元") print(f"{'时段':<6}{'负荷':<8}{'购电':<8}{'售电':<8}{'充电':<8}{'放电':<8}{'SOC':<8}") for t in range(T): print(f"{t:>2}-{t+1:<3}{P_load[t]:<8.2f}{pulp.value(P_buy[t]):<8.2f}" f"{pulp.value(P_sell[t]):<8.2f}{pulp.value(P_ch[t]):<8.2f}" f"{pulp.value(P_dis[t]):<8.2f}{pulp.value(SOC[t]):<8.3f}") else: print("模型未求解到最优解,状态:", pulp.LpStatus[model.status])这段代码有几个地方需要解释。
决策变量的上下界直接写在LpVariable.dicts里,这样就不用额外加边界约束了。SOC的上下界也直接写在这里,省掉循环加约束的麻烦。
目标函数里的“- P_sell[t] × price_sell[t]”看起来像在“减成本”,实际上是“收入”。总费用等于购电费用减去售电收益,这是对的。如果模型优化后出现了卖电行为,说明在某些时段卖电给电网反而比留着电池更划算。
SOC递推约束我分成了t=0和t>0两种情况,因为t=0时SOC[t-1]是初始状态,不需要读变量。这里的P_ch和P_dis是同一时段内的平均功率,乘以ΔT就是一小时内充入或放出的电量,再除以电池容量E_bat,就得到了SOC的变化量。
3.3 求解结果怎么读:最优充放电策略分析
运行这段代码,你会看到类似下面的输出(数值可能有细微差异,但趋势一致):
| 时段 | 负荷(kW) | 购电(kW) | 售电(kW) | 充电(kW) | 放电(kW) | SOC |
|---|---|---|---|---|---|---|
| 0-1 | 1.8 | 4.8 | 0.0 | 3.0 | 0.0 | 0.785 |
| 1-2 | 1.5 | 4.5 | 0.0 | 3.0 | 0.0 | 0.871 |
| 2-3 | 1.2 | 1.2 | 0.0 | 0.0 | 0.0 | 0.871 |
| ... | ... | ... | ... | ... | ... | ... |
| 9-10 | 5.0 | 5.0 | 0.0 | 0.0 | 3.0 | 0.200 |
| 10-11 | 5.5 | 5.5 | 0.0 | 0.0 | 3.0 | 0.200 |
大致策略是:凌晨谷电时段(0-2点),电池以最大功率3kW充电,SOC从0.5一路充到接近上限0.871;到了白天峰电时段(9点以后),电池以最大功率3kW放电,SOC一路降到下限0.2;放电结束后,其余时段就靠电网直接供电。
为什么在2-3点没继续充电?因为SOC已经接近上限,再充就超过SOC_max了。这是SOC边界约束在起作用。为什么白天10-11点电池放电只有3kW,而负荷是5.5kW?因为P_dis_max=3kW,剩下2.5kW只能从电网买。这就是充放电功率约束在起作用。看懂这两个现象,说明你已经理解了约束条件对优化结果的影响。
费用对比方面,如果不用电池,按这个负荷曲线和分时电价,一天电费大约是:把每个时段负荷乘以对应电价再加总,大概在85元左右。装电池优化后,总费用会降到大约70元不到,一天省十几块。这个省钱数字看起来不大,但注意这是家用10度电的小电池——如果换成工商业场景的大容量储能,一天省几千上万元都是正常的。这个模型的价值就在这里:通过充放电策略的优化,把峰谷价差转化成实际收益。
4. 新手必看:5个高频问题与排查技巧
4.1 又买又卖翻车现场:目标函数为何异常
新手跑完模型,最喜欢干的一件事就是逐时段检查结果。结果发现某个时段P_buy和P_sell同时大于0——又买又卖,这显然违反了物理直觉。问题出在哪里?
如果出现这种情况,最常见的两个原因。一是售电价设得比购电价还高,比如购电价0.6、售电价0.8,模型当然会想着低价买进来再高价卖出去,把这当成套利机会;二是模型漏写了某些约束,导致同一时段的买和卖可以毫无代价地同时发生。
在正常情况下,售电价低于购电价(电网公司不可能让你做无风险套利),模型不会同时在同一个时段买和卖。因为同时买入和卖出只会让目标函数变差——你用高价格买了电,又用低价格卖掉,这中间白亏一笔钱。所以最优解会自动避免这种行为。我在4.5会展开讲这个原理。新手遇到这个问题,先检查电价参数是不是有异常,再检查是否加了二元变量约束。
4.2 单位混用:kW与kWh的区别是一生之敌
这是我见过新手踩得最多、也最隐蔽的坑。症状是:模型能求解,但SOC变化特别离谱——一小时从0.5跳到6.8,或者SOC一直顶着边界不动。
问题通常出在SOC递推公式上。kW是功率,表示单位时间的能量流动速率;kWh是能量,表示累计的电量。电池容量是kWh,充放电功率是kW。要让两者换算,必须乘以时间。如果ΔT=1小时,数值上恰好相等,很多新手就忽略了这个时间因子,觉得“反正都一样”。可一旦你改成15分钟粒度或者2小时粒度,漏乘ΔT的模型结果立刻乱套。
我举一个典型案例。10kWh电池,充电功率3kW,充电效率0.95,充1小时,SOC增加量=3×0.95×1÷10=0.285。如果把ΔT漏掉,算出来是3×0.95÷10=0.285,数值碰巧一样(因为ΔT=1),看不出问题。可如果ΔT=0.25小时,正确值是3×0.95×0.25÷10=0.07125,漏乘ΔT算出来还是0.285,差出4倍。
我的建议是:不管ΔT是不是1,公式里都坚持写上×delta_t。一方面养成习惯防止将来改粒度时出错,另一方面代码的可读性也更好。检查单位是否正确的最快方法,是把模型输出的每一时段“充电电量×效率”加起来,除以电池容量,看和SOC总变化量对不对得上。
4.3 无可行解:SOC初始值与周期约束冲突
症状是求解器返回Status: Infeasible,也就是约束之间互相矛盾,找不到任何一个变量取值能同时满足所有条件。新手看到这个报错经常一脸懵,其实90%的情况都是数据设置问题。
最常见的冲突场景是:SOC_init设置得很低(比如0.2),SOC_min也设成0.2,同时又要求周期回置到0.2。如果某个时段负荷特别高,被迫大量放电,那么电池电量会掉到下限以上,但后续时段没有足够的低电价时段把它充回初始值,最终无法满足SOC[T-1]=SOC_init,于是模型无解。
排查方法很简单:先临时注释掉周期回置约束,重新求解。如果有解,说明问题出在周期回置和初始SOC的搭配上。解决办法要么把SOC_init设高一些,要么放宽SOC_min,要么把周期回置目标改成“期末SOC≥某个值”而不是精确等于。
还有一种情况是功率平衡约束本身无解:某个时段负荷超过了P_buy_max加上P_dis_max的总和。比如P_buy_max=10kW,P_dis_max=3kW,而这个时段负荷是15kW——无论怎么调度都不够供,模型必然无解。这个问题在3.1的数据检查阶段就应该发现:所有时段负荷都必须小于等于购电上限加放电上限。
4.4 求解器状态快速对照表
PuLP求解结束后,用pulp.LpStatus[model.status]可以查看求解状态。我把常见状态整理成一张速查表:
| 状态 | 含义 | 处理方法 |
|---|---|---|
| Optimal | 求解成功,找到全局最优解 | 正常读取结果 |
| Infeasible | 约束冲突,无可行解 | 按4.3排查,逐个放宽约束定位问题 |
| Unbounded | 目标函数无下界,变量可无限增大/减小 | 检查是否漏掉了某个变量的上界或下界 |
| Undefined | 模型未正确求解 | 检查是否有变量未定义、约束是否为空 |
| Not Solved | 模型未执行solve() | 检查是否调用了model.solve() |
出现Unbounded的一个典型原因是,某个决策变量忘了设置上界。比如P_buy如果只有lowBound=0而没有upBound,模型可能在低价时段无限买电、再在其他时段以更高价格卖电,实现“印钞”,目标函数一路降到负无穷。这种问题在变量定义环节就能避免——所有决策变量都明确设置lowBound和upBound,不要偷懒。
4.5 为什么这个基础模型可以不加二元变量
这是我最想分享的一个细节。很多教材在写购售电约束时,会加一行“P_buy[t] × P_sell[t] = 0”,或者引入0/1变量,保证同一时段不能既买又卖。但在这个基础模型里,我没有加任何这样的约束,模型依然不会出现同时买卖的情况。原因没那么玄学:只要售电价严格低于购电价,同时买电和卖电就是纯粹的亏损操作,理性优化绝不会选它。
举个例子。假设某个时段购电价0.6元、售电价0.3元。如果你同时买了1kWh又卖了1kWh,那么购电支出0.6元、售电收入0.3元,净支出0.3元,而且电池SOC没有任何变化、负荷也没有得到任何满足——这纯粹是白扔0.3元。任何这种方案,都能通过同时减少买卖电量来降低总费用。所以最优解一定会自动避开同时买卖。
但要注意一个例外:如果售电价大于购电价(比如某些地区光伏上网补贴很高,或者出现负电价),模型就会真的去套利——低价买电、存进电池、再高价卖出。这时候就必须加约束了,否则结果没有物理意义。加的方式是引入二元变量y[t]:
y = pulp.LpVariable.dicts("buy_sell_flag", range(T), cat="Binary") M = 1000 # 一个足够大的数 for t in range(T): model += P_buy[t] <= M * y[t] model += P_sell[t] <= M * (1 - y[t])y[t]=1表示这个时段只买不卖,y[t]=0表示只卖不买,M是足够大的辅助常量(保证约束不起额外界限作用)。这样模型就从纯LP变成了MILP,求解速度会变慢,但对这种小规模问题完全没影响。
这个知识点对新手理解电力市场模型很重要:很多教材里“同时买卖”约束不写成显式约束,不是因为作者疏忽,而是因为价格机制已经天然排除了这种无意义行为。理解了这一点,你读论文看到模型没有这条约束时就不会觉得奇怪了。
5. 进阶一点:把电池老化成本加进目标函数
5.1 老化成本怎么折算
前面模型的目标函数只考虑了电费,没有考虑电池本身的损耗。但实际项目里,电池是有循环寿命的。你每次充放电,都在消耗电池寿命。如果只盯着电费最低而拼命充放,可能电池两年就废了,换电池的钱比省下的电费还多。
锂电池的循环寿命一般在3000到6000次左右,具体看充放电深度。老化成本的计算思路其实很朴素:电池全生命周期能提供的总放电量是有限度的,用采购成本除以总放电量,就得到每放一度电分摊的成本。
举个例子。一块10kWh的电池,采购加安装成本2万元,厂家承诺循环寿命4000次(按100%放电深度),那么全生命周期总放电量=10kWh×4000=40000kWh。每放一度电的老化成本=20000元÷40000kWh=0.5元/kWh。
注意,这是按满载循环算的。实际使用时不可能每次都从100%放到0%,所以更稳妥的做法是用“可用容量×实际循环次数”来算。比如限制SOC在0.2到0.9之间,可用容量7kWh,循环寿命按5000次算,总放电量=7×5000=35000kWh,每度电成本=20000÷35000≈0.57元/kWh。这个数值通常落在0.1到0.8元/kWh区间,具体看电池价格和质保周期。
5.2 代码修改:目标函数加一行
把老化成本放进模型,只需要改目标函数,其他约束完全不用动。具体来说,就是在放电功率P_dis[t]上加一个成本系数:
deg_cost = 0.15 # 单位:元/kWh,每放一度电分摊的电池老化成本 model += pulp.lpSum([ P_buy[t] * price_buy[t] * delta_t - P_sell[t] * price_sell[t] * delta_t + deg_cost * P_dis[t] * delta_t for t in range(T) ]), "Total_Cost_With_De gradation"注意这里老化成本加到放电侧。你也可以加到充电侧或者按充放电两向都算,学术上有不同做法,但工程上为了简单,一般收敛在放电侧计一次费用。这个系数就是5.1算出的数值,也可以按项目实际情况调。
5.3 对比实验:加上老化成本后策略有什么变化
把整个模型再跑一遍,你会观察到很有意思的变化。在峰谷价差较大的时段,电池依然会充放电,但不再那么激进——因为每放一度电都要付出老化成本,只有当峰谷价差超过老化成本加上充放电效率损耗时,套利才有利可图。
拿前面的电价场景看,峰电价1.2元、谷电价0.3元,价差0.9元。老化成本0.15元/kWh,加上充电效率损耗,综合成本大约0.2元出头,远小于0.9元的价差,所以电池仍然会满负荷运作,策略变化不大。但如果是一个峰谷价差只有0.2元的场景,加上老化成本后,套利空间变成了负数,模型就会直接放弃调度电池——电池一整天保持不动,所有负荷全靠电网供电。
这个结论对我做实际项目很有指导意义:装不装储能、装多大储能,不只看峰谷价差,还要看综合度电成本。有些项目看起来峰谷价差挺大,但算上电池老化、运维、效率损耗之后,其实根本不赚钱。这时候模型帮我们做出了“不调度”的决策,本身就是价值所在。
我自己的习惯是把老化成本系数做成一个可配置的参数,第一次估算先用0.1到0.2元/kWh这个量级,等拿到电池厂家具体的循环寿命数据和项目采购价格后,再回代校准。这样同一个模型从课程设计到落地项目都能复用,不用改任何代码结构。
这个项目做到这里,你就已经掌握了一个完整的经济调度模型。以后不管遇到光伏、风电、柴油机还是多储能系统,建模的逻辑都是一样的:列出所有电源和负荷,定义决策变量,写出目标函数和各类约束,然后交给求解器。骨架不变,血肉往里面填就行。