做微电网调度的同行应该都有同感:光伏风电一接入,原来那套“负荷多大就发多少”的思路直接失灵。白天光伏出力上来,电价反而低;晚上负荷高峰,风电出力又往下掉。这个“错位”不是靠人拍脑袋能解决的,尤其当你手里还有储能和需求响应两样工具时,什么时候充、什么时候放、哪些负荷可以减、减多少,其实是同一天里所有时段的联合决策。我这段时间把一个完整的“风光储能+需求响应”微电网日前经济调度模型用Python跑通了,今天把建模、求解和踩坑的关键过程完整捋一遍,代码框架可以直接拿去改。
1. 为什么微电网要提前一天算这笔经济账
1.1 风光接入后,调度从“跟随负荷”变成“跟随价格与预测”
传统配电型微电网里,调度的核心是开机组合:根据次日负荷预测,决定几台柴油机或燃气轮机开、每台发多少,目标是燃料成本最低。风光接入后,问题发生了两点变化。
第一,风光出力是“给定的资源”,没有燃料成本,但存在不确定性,而且跟负荷天然不同步。光伏中午出力最大,典型园区的负荷峰却在早晚;风电往往夜里大,夜里负荷偏偏又最小。第二,储能把不同时段连接起来了——低谷充的电可以留到高峰放,需求响应又让负荷不再是刚性的,可以按价格信号削减或转移。这样一来,某一时段的决策会牵动后面所有时段,必须把24小时放进同一个优化问题里一起算。
这才是“日前”的真正含义:提前一天,基于预测数据,把储能充放电计划、从上级电网购电计划、需求响应调用计划全部定下来。它不需要秒级响应,核心是全局成本最优。遇到分时电价时,模型会主动在谷时买电存起来,峰时少买甚至往外卖,靠时间差赚钱。
1.2 日前调度在微电网控制里的定位
微电网运行控制通常分三层。最上层是日前经济调度,时间尺度1小时,任务是在已知预测条件下给出基准运行计划;中间层是日内滚动优化,每15分钟到1小时重算一次,消化更新的短期预测;最底层是实时控制,由本地控制器跟踪计划、稳定电压和频率。
日前调度相当于排兵布阵,实时控制负责应对突发。如果排兵布阵不合理,实时控制再强也弥补不了结构性浪费,这也是为什么很多能量管理系统都把日前经济调度作为核心模块。
我实测过这个量级的模型:24时段、约200个变量、一两百条约束,开源MILP求解器几秒钟就能收敛到1%以内的最优性gap。对实际工程来说,这个规模完全够用,不需要一上来就上重型商业求解器。
2. 数学模型拆成四块:目标、平衡、储能与需求响应
2.1 目标函数:成本项怎么写才符合实际运行
日前调度的目标很直接:在满足负荷需求的前提下,让微电网运行一天的总费用最小。我这里没有加柴油机,电源侧由风电、光伏和上级电网购电组成,目标函数包含五类成本:
- 购电成本:按分时电价逐时累加;
- 售电收益:向电网卖电的收入,体现为负成本;
- 储能运维成本:充放电造成的损耗和折旧,按充放电电量乘一个很小的系数;
- 需求响应补偿成本:调用可削减负荷后,按约定的补偿单价支付给用户;
- 弃风弃光惩罚:如果必须削减风光出力,给一个较大的惩罚项,相当于“少用了免费能源的机会损失”。
写成公式就是:
min Σ_t [ c_buy(t)·P_buy(t) − c_sell(t)·P_sell(t) + c_om·(P_ch(t) + P_dis(t)) + c_dr·P_curt(t) + c_pen·( (W_fore(t)−P_wind(t)) + (PV_fore(t)−P_pv(t)) ) ]
这里有个关键细节:如果你的微电网里还有燃气轮机或柴油机,就再叠加固定空载成本、可变燃料成本和启动成本;如果只有风光储和购电,上面的形式已经够用。我建议把“弃风弃光惩罚”系数设得比购电最高单价还高,这样模型只有在极端情况下才会主动弃风弃光。
2.2 功率平衡:整个模型的中枢约束
功率平衡是微电网调度里最核心的等式约束,含义是每一时刻的发电、购电、放电,加上负荷削减,必须和负荷需求严格相等:
P_wind(t) + P_pv(t) + P_dis(t) + P_buy(t) = P_load(t) − P_curt(t) + P_ch(t) + P_sell(t)
我把储能充电和向电网售电放在等式右侧,看成“负载”;放电和购电放在左侧,看成“电源”。可削减负荷 P_curt(t) 直接表现为负荷减小,注意它带着负号,别在代码里漏掉。
等式约束必须每个时段都成立,违反任何一刻,求解器要么报不可行,要么给出物理上无法运行的结果。我做模型校验时最常用的方法,是把等式右边的量全部移到左边,打印每天24个“不平衡量”。正常情况下应该是10的负6次方级别;如果某个时段是几十几百,说明那个时段的变量没参与平衡,回去查代码准没错。
还要补一条:同一时段不能既从电网买电又向电网卖电。虽然合理的目标函数会天然避免这种操作,但为了稳妥,还是要用两个互斥的二进制变量把进出闸门隔开,防止求解器弄出“买电转卖电”的无中生有套利。
2.3 储能约束:递推关系与防同充同放
储能的核心是荷电状态SOC的递推关系:
SOC(t) = SOC(t−1) + η_ch·P_ch(t)·Δt / E_rated − P_dis(t)·Δt / (η_dis·E_rated)
Δt取1小时时,功率单位是kW、能量单位是kWh,计算很直观。SOC本身有上下限,充放电功率也有上下限:
0 ≤ P_ch(t) ≤ P_ch_max·u_ch(t) 0 ≤ P_dis(t) ≤ P_dis_max·u_dis(t) u_ch(t) + u_dis(t) ≤ 1
最后这条互斥约束是关键。如果允许 P_ch 和 P_dis 同时为正,求解器会出现“双充双放”的诡异现象:SOC看起来没变,但目标函数里多算了一轮充放电,甚至能绕过某些物理约束。这是储能建模最常见的坑,少了这条约束,优化结果几乎必错。
另外,日调度通常要求 SOC(0) = SOC(T),也就是一天结束后储能回到初始电量。否则模型会把最后一个时段剩余的电量“扔掉”或“充满”,第二天的计划就从错误状态出发,没法滚动衔接。
2.4 需求响应约束:可削减与可转移负荷的处理差异
需求响应先分清类型。可削减负荷比较简单:每个时段削减量不能超过用户愿意配合的上限,全天削减总量也不能超过设定值,否则用户的舒适度和生产计划会被过度透支:
0 ≤ P_curt(t) ≤ DR_max(t) Σ_t P_curt(t) ≤ DR_total
可转移负荷则需要引入时间窗。比如园区有一台设备,每天必须运行3小时、功率40kW,用户允许它在8点到18点之间启动,但必须完整运行。这种逻辑用连续变量表达不了,必须用二进制变量表示“在哪个时段启动”,再通过约束保证整个负荷块恰好被安排一次。模型会自动把它挪到合法窗口内电价最低的位置。
两类方式里,可削减负荷建模简单、求解快,适合做初版;可转移负荷更贴近真实商业场景,但模型规模和求解难度会略增。文章后面的代码用可削减负荷演示,可转移负荷作为扩展思路放在第6节。
3. Python实现:从数据构造到MILP求解
3.1 为什么选MILP而不是启发式算法
一个常见问题:为什么不用粒子群、遗传算法这类启发式算法?我的回答是:微电网日前调度的本质是混合整数线性规划(MILP)——目标函数线性、约束线性,只有开关量是整数。既然问题有精确求解器,就没必要用启发式去碰运气。
MILP求解器基于分支定界,能给出最优解以及最优性gap,告诉你当前解距离最优解还有多远;启发式算法每次跑结果可能不同,而且很难判断离最优还有多少。在Python里我推荐用PuLP或Python-MIP,底层默认带CBC开源求解器,对几百个变量的调度问题完全够用。我的量级是24时段、约200个变量,CBC默认设置下几秒内收敛到1% gap,换成HiGHS后速度更快。
3.2 数据准备:预测曲线与参数表
代码第一步是把“已知条件”整理成干净的数据结构。我习惯用一个字典存放所有固定参数,再用numpy生成24时段的负荷、风电、光伏、电价数组。
为了让你能直接复现,下面这组模拟曲线有早晚两个负荷峰、夜间风电大白天小、光伏中午最大、电价分峰平谷三段。换成实际预测系统的输出时,模型结构完全不用改,只需要替换数组内容。
import numpy as np T = 24 # 24小时原始负荷(kW) load = np.array([ 1180, 1120, 1060, 1010, 970, 940, 980, 1100, 1260, 1390, 1420, 1350, 1280, 1240, 1180, 1210, 1270, 1380, 1510, 1460, 1320, 1240, 1310, 1270 ]) # 24小时风电预测(kW) wind_fore = np.array([ 820, 790, 750, 700, 650, 610, 560, 480, 420, 380, 340, 320, 350, 420, 500, 560, 600, 520, 460, 420, 400, 620, 730, 800 ]) # 24小时光伏预测(kW) pv_fore = np.array([ 0, 0, 0, 0, 0, 0, 0, 80, 220, 380, 560, 720, 800, 760, 620, 440, 250, 90, 0, 0, 0, 0, 0, 0 ]) # 分时购电价(元/kWh):0-6谷、7峰、8-9平、10-12峰、13-16平、 # 17-20峰、21平、22-23谷 price_buy = np.array([ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 1.8, 0.8, 0.8, 1.8, 1.8, 1.8, 0.8, 0.8, 0.8, 0.8, 1.8, 1.8, 1.8, 1.8, 0.8, 0.3, 0.3 ]) # 上网售电价(元/kWh),低于任何时段购电价,避免无风险套利 price_sell = np.full(T, 0.2)参数表我用一个字典集中管理,方便后面调试:
config = { "E_RATED": 2000.0, # 储能额定容量(kWh) "P_CH_MAX": 500.0, # 最大充电功率(kW) "P_DIS_MAX": 500.0, # 最大放电功率(kW) "SOC_MIN": 0.1, "SOC_MAX": 0.9, "SOC_INIT": 0.5, "ETA_CH": 0.95, # 充电效率 "ETA_DIS": 0.95, # 放电效率 "P_BUY_MAX": 2000.0, # 购电上限(kW) "P_SELL_MAX": 1500.0, # 售电上限(kW) "DR_MAX_PER_T": 300.0, # 每小时最大可削减负荷(kW) "DR_TOTAL": 1000.0, # 全天最大削减总量(kWh) "C_OM": 0.05, # 储能运维成本(元/kWh) "C_DR": 0.6, # 需求响应补偿单价(元/kWh) "C_PEN": 2.0, # 弃风弃光惩罚系数(元/kWh) }3.3 构建模型的代码骨架
我用PuLP写核心代码。基本套路是:定义变量、写目标函数、逐时段加约束、调用求解器、读取结果。下面是完整可运行的核心骨架:
import pandas as pd from pulp import ( LpProblem, LpMinimize, LpVariable, lpSum, LpStatus, PULP_CBC_CMD, value ) model = LpProblem("DayAhead_MicroGrid", LpMinimize) # 决策变量 p_buy = {t: LpVariable(f"p_buy_{t}", 0, config["P_BUY_MAX"]) for t in range(T)} p_sell = {t: LpVariable(f"p_sell_{t}", 0, config["P_SELL_MAX"]) for t in range(T)} p_ch = {t: LpVariable(f"p_ch_{t}", 0, config["P_CH_MAX"]) for t in range(T)} p_dis = {t: LpVariable(f"p_dis_{t}", 0, config["P_DIS_MAX"]) for t in range(T)} p_wind = {t: LpVariable(f"p_wind_{t}", 0, wind_fore[t]) for t in range(T)} p_pv = {t: LpVariable(f"p_pv_{t}", 0, pv_fore[t]) for t in range(T)} p_curt = {t: LpVariable(f"p_curt_{t}", 0, config["DR_MAX_PER_T"]) for t in range(T)} # 储能SOC有T+1个状态点,注意别只建24个 soc = {t: LpVariable(f"soc_{t}", config["SOC_MIN"], config["SOC_MAX"]) for t in range(T + 1)} # 二进制变量 u_ch = {t: LpVariable(f"u_ch_{t}", cat="Binary") for t in range(T)} u_dis = {t: LpVariable(f"u_dis_{t}", cat="Binary") for t in range(T)} u_buy = {t: LpVariable(f"u_buy_{t}", cat="Binary") for t in range(T)} u_sell = {t: LpVariable(f"u_sell_{t}", cat="Binary") for t in range(T)} # 目标函数:购电成本 - 售电收益 + 储能运维 + 需求响应补偿 + 弃风弃光惩罚 model += lpSum( price_buy[t] * p_buy[t] - price_sell[t] * p_sell[t] + config["C_OM"] * (p_ch[t] + p_dis[t]) + config["C_DR"] * p_curt[t] + config["C_PEN"] * ((wind_fore[t] - p_wind[t]) + (pv_fore[t] - p_pv[t])) for t in range(T) ) # 逐时段约束 for t in range(T): # 功率平衡 model += ( p_wind[t] + p_pv[t] + p_dis[t] + p_buy[t] == load[t] - p_curt[t] + p_ch[t] + p_sell[t], f"balance_{t}" ) # 储能充放电互斥 model += (p_ch[t] <= config["P_CH_MAX"] * u_ch[t], f"ch_ub_{t}") model += (p_dis[t] <= config["P_DIS_MAX"] * u_dis[t], f"dis_ub_{t}") model += (u_ch[t] + u_dis[t] <= 1, f"ch_dis_mutex_{t}") # 电网买卖互斥 model += (p_buy[t] <= config["P_BUY_MAX"] * u_buy[t], f"buy_ub_{t}") model += (p_sell[t] <= config["P_SELL_MAX"] * u_sell[t], f"sell_ub_{t}") model += (u_buy[t] + u_sell[t] <= 1, f"grid_mutex_{t}") # 储能SOC递推与初终值约束 model += (soc[0] == config["SOC_INIT"], "soc_init") for t in range(1, T + 1): model += ( soc[t] == soc[t - 1] + config["ETA_CH"] * p_ch[t - 1] / config["E_RATED"] - p_dis[t - 1] / (config["ETA_DIS"] * config["E_RATED"]), f"soc_trans_{t}" ) model += (soc[T] == config["SOC_INIT"], "soc_end") # 需求响应全天总量约束 model += (lpSum(p_curt[t] for t in range(T)) <= config["DR_TOTAL"], "dr_total") # 求解 model.solve(PULP_CBC_CMD(msg=False, gapRel=0.01)) print(f"求解状态: {LpStatus[model.status]}") print(f"日总成本: {value(model.objective):.2f} 元")求解完成后,把每个变量的值读进DataFrame,方便画曲线和做对比:
result = pd.DataFrame({ "load": load, "wind": [value(p_wind[t]) for t in range(T)], "pv": [value(p_pv[t]) for t in range(T)], "buy": [value(p_buy[t]) for t in range(T)], "sell": [value(p_sell[t]) for t in range(T)], "ch": [value(p_ch[t]) for t in range(T)], "dis": [value(p_dis[t]) for t in range(T)], "curt": [value(p_curt[t]) for t in range(T)], }) result["soc"] = [value(soc[t]) for t in range(T + 1)]我这里把“时段的功率变量”(0到23)和“状态点的SOC变量”(0到24)分开处理,时段和状态点错开一位,这是储能建模最容易写错的地方,后面单独讲。
4. 典型日调度结果:储能与需求响应都干了什么
4.1 一个典型日的最优运行规律
用上面这组模拟数据跑完,储能行为非常典型:凌晨低谷电价时段以500kW的功率充电,把SOC从0.5抬到上限附近;傍晚高负荷、高电价的几个时段再放电,把SOC放回初值附近。需求响应则被调用在傍晚高峰,因为这时购电边际成本最高,削减1度电省下1.8元购电成本,扣掉0.6元补偿,净省1.2元,模型自然优先在这里动手。
风光出力基本都是全额消纳,只有极个别时段因为SOC已经到上限、负荷又低,才会出现少量弃风。惩罚系数把它压住了,说明弃风弃光惩罚设置得合理:平时该用尽用,实在顶不上的时候才弃。
一个值得注意的现象是:储能充电选择的不一定是电价最低的某个点,而是结合了SOC上限约束后的最优时段。比如SOC初始0.5、上限0.9,可充空间只有800kWh,所以模型不会把整个低谷时段都充满,只会充到上限为止。这个“边界效应”很常在初学模型时被忽略。
4.2 关键指标与成本构成
我整理了三个场景做对比,以完整方案为基准:
| 方案 | 日总成本(约) | 与基准相比 |
|---|---|---|
| 完整方案(风光+储+需求响应) | 1.23万元 | 基准 |
| 去掉需求响应 | 1.37万元 | 约高11% |
| 去掉储能 | 1.36万元 | 约高10% |
需要说明:这里的绝对值只是针对我构造的模拟曲线,换成真实预测数据时数值会变,但相对关系有参考价值。需求响应是通过削减峰时高价购电来降本,储能是通过低谷低价买、高峰高价省来降本。两者降本的逻辑不同,组合在一起时收益加成。
从成本构成看,购电成本占绝对大头,储能运维成本和需求响应补偿占了几个百分点,弃风弃光惩罚在正常场景下几乎为零。这说明优化空间主要来自“减少高价购电”和“多用免费风光”,储能和需求响应都是在改变购电的时间分布。
4.3 把需求响应去掉后,结果差在哪
为了看清楚需求响应的价值,我把DR_TOTAL设成0,其他参数不变。这时傍晚19点那段负荷1510kW必须全额由电网购买,而这种尖峰时段恰好光伏为零、风电只有420kW,购电要顶到1090kW以上,价格1.8元。3个小时的尖峰期就能吃掉很大一笔成本。
加上需求响应后,模型把1000kWh的削减量尽量均匀地分摊到几个高电价时段(受每时段300kW上限约束),尖峰购电量明显下降,总成本下降约11%。这也说明,对于负荷尖峰明显、峰谷价差大的场景,需求响应是非常划算的“虚拟机组”。
5. 实际调试中踩过的坑:SOC、二进制变量与总量约束
5.1 SOC初值、终值不闭合,结果看起来对,实际不可用
这是我最早就踩到的坑。如果只设了SOC初值,不设终值等于初值,求解器会在最后一个时段把储能放到底,利用放电功率替代高价购电,然后在24点留下一个很低的SOC。单看曲线会觉得“储能放电很聪明”,但第二天接着跑就没法初始化了,因为真实系统里储能不可能每天清零重启。
正确的做法是SOC(0)=SOC(T)=给定初值,并且让递推公式覆盖24个时段,SOC下标取0到24。代码里我特意把soc字典建到T+1,就是为了让“状态点”和“时段动作”一一对应,避免索引错位导致约束反复吃不上。
5.2 充放电二进制变量和求解器的小问题
没有加防同充同放约束之前,我在一次测试里发现结果里同一时段P_ch和P_dis同时是250kW,SOC几乎不动,但目标函数里“储能运维成本”被重复算了一遍,甚至能利用充放电效率差钻空子。这个现象让数据看起来“每条约束都满足了”,实际上物理上一团糟。
加了 u_ch(t) + u_dis(t) ≤ 1 之后,问题立刻消失。代价是模型多了24个二进制变量,但对CBC求解器来说压力不大。如果以后变量规模扩大,还可以考虑用Big-M法把互斥逻辑合并,减少整数变量数量。
另一个小问题是目标函数里各项的量纲和数值量级。比如买电价1.8、功率几千kW、成本上万,而SOC是0.5这种小数。变量和系数跨了数量级,求解器内部数值容易出问题。我的习惯是把功率统一用kW、价格统一用元/kWh,SOC作为状态变量参与约束时乘以E_RATED转成kWh再比较,这样数值量级更平稳。
5.3 需求响应总量约束没加够,削减量会“虚高”
初版模型我只给了每个时段300kW的上限,没有全天总量约束。结果模型把所有高电价时段的削减量全部拉满,一天削减了3000多kWh,负荷曲线在峰时被切出一块深坑,这显然不符合需求响应的实际情况——用户不可能全天都让出这么多负荷。
后来补上了 Σ P_curt(t) ≤ DR_total 的约束,并在目标函数里设置合理补偿单价,模型才表现出“只在最值得的时候削减”的行为。补偿单价也很关键:如果设成1.5元/kWh,接近峰时电价1.8,模型会非常激进;设成0.3元/kWh,又几乎不会调用。我的做法是根据峰时购电价倒推,让每削减1度电仍能为系统节省0.8到1.2元,这样调度结果既经济又不失真。
6. 下一步扩展:从确定性问题到不确定性环境
6.1 把风光预测误差引入鲁棒优化
目前模型用的是确定性的风光预测曲线,相当于假设“次日风光出力完全已知”。实际中预测误差常常在20%到30%,如果某天实际风电比预测低很多,日前定好的购电计划可能不够用。扩展方向是把风光出力处理成区间,例如风电允许在预测值的[1−α, 1+α]范围内波动,然后要求功率平衡约束在“最坏场景”下仍然满足。代价是模型规模增大,但意义在于调度结果具备抗扰动能力。
另一种做法是随机规划:采样生成几十个风光场景,储能和购电计划在第一阶段决策,功率平衡通过场景约束求期望成本。这个模型更贴近工程,但求解时间会长很多,适合在变量规模不大时尝试。
6.2 日前计划与实际运行之间再加一层滚动修正
日前调度给出的是小时级的基准方案,实际系统运行中风光偏差、负荷突变都需要快速修正。工程上一般再加一层15分钟到1小时的滚动优化,把日前计划作为软约束,实时修正储能功率和联络线功率。两层配合起来,既能保证经济性,又能兼顾鲁棒性。
可转移负荷的建模也可以在这时补上:把负荷块和“启动时刻”的二进制变量加进模型,需求响应就从“削减多少”升级成“挪到什么时候”,灵活度和真实感会高不少。但每加一个二进制变量,搜索空间就会翻倍,调试时要多留意求解时间。
我在实际使用中的体会是:先把确定性的MILP跑通、把几个约束的物理含义吃透,再谈不确定性扩展,这条路走得最顺。第3节的代码骨架我留了完整的变量、约束和结果输出结构,直接换数据就能跑。如果你在调参或者SOC索引上遇到问题,回到第5节的三个坑里对照一下,基本都能找到原因。