干这行这么多年,见过不少同学拿到“微电网调度”这类课题,第一反应就是找现成的IEEE节点数据往求解器里塞,跑出个优化结果就算交差。但真到论文答辩或者实际项目汇报,导师多问几句“为什么储能SOC曲线是这个形状”“需求响应参数怎么定标就能改写目标函数”“风光预测误差怎么进约束”,十有八九会卡壳。这次想分享的这套Python实现,主题就一句话:把基于风光储能和需求响应的微电网日前经济调度,从工程思路到代码落地,完整走通一遍。
这套方案解决的其实是微电网系统里最核心的“明天怎么发、怎么充、怎么调”的问题——在已知日前风光预测曲线、负荷预测曲线和分时电价的前提下,用优化算法算出24时段里燃气轮机/外网购电、储能充放电、以及需求侧可平移可削减负荷的最优组合,使运行总成本最低。它适合三类人看:一是做微电网优化调度的研究生,二是在做园区或者台区级能量管理系统的工程师,三是对MILP(混合整数线性规划)求解器应用还不熟练的初学者。模型不复杂,但把决策变量拆干净、约束写严密、结果能复现,这个基本功比背再多公式都有用。
1. 项目思路拆解:为什么非用日前调度不可
1.1 微电网调度的“决策时间轴”
微电网的优化问题按时间尺度分三层:日前调度(Day-ahead)、日内滚动(Intraday)和实时控制(Real-time)。这套代码做的是日前。它的核心逻辑是:在T-1日,基于次日24小时或96时段的风电、光伏出力预测值、负荷预测值以及市场分时电价,一次性解出所有可调度设备的计划曲线。
之所以把时间尺度放在“日前”,是因为光伏出力和负荷基线都有很强的日周期性,分时电价本身也是按日发布。如果在实时阶段才做决策,储能启停和充放电切换根本来不及执行,需求响应更不可能临时通知。日前调度是“战略层”,实时控制是“战术层”。战略层的解不要求每条曲线都卡得刚刚好,但容错空间要大,所以模型里必须给预测误差留裕度。
这里要提醒一点:很多初学者一上来就把预测数据当“真值”,把风光出力写成固定单值序列,然后解出一个毫无波动的调度计划。这样做其实是把随机优化做成了确定性优化。正确做法是在模型中加入偏差备用约束(比如旋转备用容量不小于风光预测误差标准差的一定比例),或者做多场景随机规划。这套代码在最基础的版本里先用确定性约束预留了备用比例,后续我会在扩展部分讲怎么改成场景法。
1.2 模型决策变量到底有哪些
写代码之前,先列变量清单,这个习惯能省大量debug时间。一套典型的日前调度模型,决策变量大概分成四类:
- 可控机组出力:燃气轮机/柴油机的每时段有功出力,注意这里通常要求最小启停机时间,不能用简单的0-1变量带过。
- 储能系统充放电:充电功率、放电功率、SOC(荷电状态),以及防止同时充放电的0-1状态变量。
- 外网购电/售电:与上级电网交换功率,购电为正、售电为负,也需要0-1变量防止同时买卖,除非节点电价允许倒卖。
- 需求响应量:可平移负荷的启停时刻、可削减负荷的削减量,这部分往往带整数变量。
把这四类写清楚,目标函数和约束才有载体。我见过不少人把变量写成一个词典塞进求解器,结果约束条件引用的时候键名打错,报错半天发现是字符串多了个空格。建议直接用list或者pandas的索引体系来管理变量维度。
1.3 目标函数本质:日运行总成本最小化
日前经济调度的目标函数可以拆成三项之和:机组发电成本、储能折旧/运行成本、外网购电成本,再减去售电收益。如果参与需求响应项目,还要加上对用户参与DR的补偿费用。数学上写成最小化总成本,工程上就是算出“明天最省钱的开机方式”。
这里有个容易忽略的点:储能成本一般不应直接写“充放电成本与电量成正比”这种线性项,因为电池寿命损耗和DOD(放电深度)关系密切。在工程实践中,可以用充放单位功率的等效损耗成本近似,但如果论文需要精细化,建议采用分段线性化的循环老化成本模型。本套代码先用固定单位成本,便于跑通主线,延展时再换成非线性。
DR补偿费用那部分要特别注意定标方式:负荷削减量乘以补偿单价容易理解,但“补偿单价”可能直接改写出约束的松弛程度。如果补偿费低于发电边际成本,DR量永远是0,模型不会主动调用;如果补偿费设置过高,求解器会宁可切负荷不去发电,结果就是大面积失负荷,目标函数看起来很低但物理上不可行。合理的做法是先跑一版无DR的调度,记录边际电价,再以该电价的0.6~0.8倍作为初始补偿单价,逐次调整。
2. 风光储能与需求响应的建模细节
2.1 风光出力预测曲线怎么进约束
风光数据通常统一处理成每15分钟一个点,即一天96点。有些文献喜欢用24点,但工程上96点更常见,因为光伏在早晚时段出力变化快,24点会把爬坡段拉得太粗糙。把风速数据通过功率曲线换算成有功出力,把辐照度换算成光伏出力,直接占掉功率平衡方程里的左侧来源项。
建模时给每条预测曲线加上上下限区间是一个很实用的方法。假设预测出力是P_f(t),那约束里应该写成P_wind(t) ∈ [P_f(t)×(1-ε), P_f(t)×(1+ε)],这里的ε就是预测误差包络。代码里用一个误差系数元组存下来,调试时可以直接调。
这里有一个坑:如果采用区间约束,目标函数里仍然把P_wind作为变量而不是固定参数,天然倾向用最大出力来压低成本。这其实是模型“作弊”,所以要么把风光作为固定参数参与平衡约束、把备用容量写在另一个约束里,要么多场景求期望。初版建议用固定参数,简单且不容易被审稿人挑刺。
2.2 储能SOC递推与容量约束
储能是这套代码里最磨人的约束,因为涉及时序耦合。SOC(t+1) = SOC(t) + η_ch×P_ch(t)×Δt - P_dis(t)×Δt/η_dis,这一条递推公式是必写的。Δt取1小时,因为模型按24时段走;如果数据是分钟级,就必须换算成对应小时数。
约束条件除了SOC上下限(比如0.1~0.9)、充放电功率上限,还需加上“同一时段不能同时充和放”的0-1互斥约束。具体写法是引入变量u_ch(t)和u_dis(t),不等式写成P_ch(t) ≤ u_ch(t)×P_ch_max、P_dis(t) ≤ u_dis(t)×P_dis_max,再加u_ch(t)+u_dis(t)≤1。有些同学问:能不能不加0-1变量,让求解器自己决定?不行,因为线性模型里储能如果没有互斥约束,最优解很可能是“既充又放”,账面价格差价直接为零甚至倒挂,物理上完全不合理。
另外别忘了始末SOC相等约束,这是日前调度的经典边界条件。始末SOC不相等不是不可以,但会导致“借电”效应,即第一天把电放光次日再充回来,目标函数被低估。保险写法是SOC(0)=SOC(T),或者SOC(T)≥SOC(0)+ΔSOC_min。
2.3 需求响应建模:削减型与平移型
DR不是一句话“负荷能减少”那么简单的。工程上至少要分两类建模:
第一类是可削减负荷(IL,Interruptible Load),适合工业用户中部分可中断工序。建模方式为0-1变量z_IL(t)与削减量P_IL(t)的乘积形式,削减量限幅、削减时段总数限幅,再关联到负荷平衡约束的减项。有合同约束时还要保证一个响应周期内总削减次数不超过指定值。
第二类是可平移负荷(TL,Time-shiftable Load),比如洗衣机、蓄热式电锅炉,整体运行时段可平移但连续工作特性不变。这类负荷一般建模为整数变量代表“起始时段”,然后生成一个特定的运行序列叠加到负荷曲线上。如果要更精细,可以把各时段连续工作状态做成0-1变量,但问题规模会迅速膨胀。
下面用一个表格总结两类DR的关键区别和代码变量类型:
| 类型 | 描述 | 决策变量 | 典型约束 | 目标函数体现 |
|---|---|---|---|---|
| 可削减负荷 | 时段负荷可中断部分 | 实数+0-1变量 | 削减量上下限、削减次数上限、单次最小削减持续时段 | 补偿费用正项 |
| 可平移负荷 | 整体运行时段迁移 | 整数变量或0-1变量组 | 运行连续性、总平移次数上限、并行时段限制 | 影响购电成本间接体现 |
3. 日前经济调度模型完整搭建
3.1 功率平衡约束与备用约束
功率平衡是每时段必成立的等式约束:燃气轮机出力 + 风电出力 + 光伏出力 + 储能放电功率 + 外网购电 = 固定负荷 - 可削减量 + 充电功率 + 可平移增量 + 外网售电。留意充/放写在等式两侧而非合并为一个变量,这样约束才能正确区分状态。
备用约束是这套模型容易被忽略但非常重要的一条。用公式表达为:系统可用旋转备用 = 各机组出力上限之和 - 当前出力计划 + 储能放电裕度 + 可中断负荷容量,要大于等于风光预测偏差和负荷预测偏差的置信区间。工程上一般取预测负荷的5%~10%加上风电/光伏预测偏差的标准差倍数。代码实现就是一条不等式约束,两边都是变量表达式,求解时要留意系数单位统一,MW和kW混用会差得离谱。
3.2 机组组合变量与约束耦合
燃气轮机如果只在某个时段的出力是连续变量,但启停状态需要0-1变量u_g(t)来刻画。常见约束包括:出力下限=u×P_min、出力上限=u×P_max、最小开机时间和最小停机时间约束。算最小开停机时间时不能简单用“连续两个时段u=1”这种方式表达,因为那只能约束持续两时段,无法天然满足持续3小时以上。标准写法是用前后状态互锁约束,这里代码实现用一组循环遍历各时段,检查窗口内所有时段状态一致性,注意这种方式在大规模时段数下是O(T²)复杂度,但T=24时完全可接受。
如果调试时发现机组频繁启停、曲线像锯齿,多半是开放了自由启停但没有最小开停机时间。真实机组每启停一次都有寿命折损,所以如果研究重点是经济性而非控制策略,最好在目标函数里加入启停成本项,pulp里可以把启机变量写成辅助变量z_start(t)≥u_g(t)-u_g(t-1),并用另一个变量z_stop(t)表征停机。
3.3 外网购电与售电双状态建模
与外网的功率交换同样需要双0-1变量。买电状态b_buy(t)和卖电状态b_sell(t)约束P_buy(t) ≤ b_buy(t)×M、P_sell(t) ≤ b_sell(t)×M,再加b_buy+b_sell≤1。M的取值不能随便给,必须比可能出现最大交换功率还要大一个数量级,否则求解器会认为约束太紧;也不能太大,否则会破坏数值稳定性。工程经验是取负荷峰值加机组最大出力的1.2倍。
这一条在代码上容易踩坑的地方是:如果只有电价差而没有售电机制,把售电价格设成略低于买方价格,模型自然就倾向于不在同一时段买卖。但“自然倾向于”不代表所有情况下都能避免,零功率交换本身也会分配变量状态,所以要加互斥约束而不是靠价格自动修正。
3.4 价格参数如何影响调度结果
分时电价对储能充放电策略的影响是最直观的:谷时充电、峰时放电,价差只要大于储能往返效率损耗,模型就一定会套利。有一个内在关系值得体会:储能充放效率若定为90%,则只有当峰时电价大于谷时电价除以0.9再除以0.9(即约1.23倍)时,套利才有意义。否则模型宁愿什么都不干。写代码输电价矩阵时先自测这个条件,如果模型没对储能产生任何充放电动作,大概率就是价差不够或效率太低。
需求响应的补偿单价和分时电价是相互博弈的。峰时段的DR削减负荷相当于替代了向电网购电或本地发电,所以只有当DR单位补偿费低于边际购电价时,削减才有利。而且DR的削减会增加系统备用能力,这部分“隐性收益”常常没体现在目标函数里,需要人为加一个备用收益项,否则削峰填谷就不够积极。
4. Python代码实现与求解全流程
4.1 输入数据准备与规范化处理
无论用什么求解器,第一件事都是把数据整理成结构清晰的DataFrame或者numpy数组。以一个模拟案例为例,有8台分布式机组、一台200kWh储能、一个典型工业园区日负荷曲线、一组风电光伏预测出力序列,以及省级分时电价。需要准备的表至少有四份:发电机组参数表(出力上下限、爬坡率、煤耗成本、启停成本、最小开停时间)、储能参数表(容量、初始SOC、状态下限、充放功率上限、充放效率、单位循环成本)、负荷与风光时序表、电价时序表。
数据处理有一个很实用的原则:所有功率量纲统一成kW或MW,时间量纲统一成小时,成本统一成元或角。我踩过最深的坑是负荷数据是kW、机组出力上限是MW、电价是元/kWh,结果目标函数算出来整体偏三个数量级,排查了两小时才意识到是单位没归一。建议进来第一行代码就print(check_unit()),打印所有表的单位字典,确认无误再往下走。
4.2 基于PuLP定义模型对象与决策变量
PuLP是一个比较适合教学和中小规模线性规划的库,虽然性能不如Gurobi或CPLEX,但胜在语法自然、安装简单。下面是核心代码骨架:
import pulp # 时段与设备参数 T = 24 units = ["G1", "G2", "G3", "G4", "G5", "G6", "G7", "G8"] # 创建模型,求最小值 model = pulp.LpProblem("Microgrid_Day_Ahead_ED", pulp.LpMinimize) # 机组出力变量 Pg = { (g, t): pulp.LpVariable(f"Pg_{g}_{t}", lowBound=0) for g in units for t in range(T) } # 机组启停变量 u = { (g, t): pulp.LpVariable(f"u_{g}_{t}", cat="Binary") for g in units for t in range(T) } # 储能充放电变量 P_ch = { t: pulp.LpVariable(f"P_ch_{t}", lowBound=0, upBound=P_ch_max) for t in range(T) } P_dis = { t: pulp.LpVariable(f"P_dis_{t}", lowBound=0, upBound=P_dis_max) for t in range(T) } SOC = { t: pulp.LpVariable(f"SOC_{t}", lowBound=SOC_min, upBound=SOC_max) for t in range(T) } # 储能互斥变量 u_ch = {t: pulp.LpVariable(f"u_ch_{t}", cat="Binary") for t in range(T)} u_dis = {t: pulp.LpVariable(f"u_dis_{t}", cat="Binary") for t in range(T)} # 外网购售电变量 P_buy = {t: pulp.LpVariable(f"P_buy_{t}", lowBound=0) for t in range(T)} P_sell = {t: pulp.LpVariable(f"P_sell_{t}", lowBound=0) for t in range(T)} b_buy = {t: pulp.LpVariable(f"b_buy_{t}", cat="Binary") for t in range(T)} b_sell = {t: pulp.LpVariable(f"b_sell_{t}", cat="Binary") for t in range(T)}这一段代码把模型骨架铺开,后面目标函数和约束就是往model里加表达式,写完再统一求解。
4.3 目标函数与约束条件逐条添加
目标函数的代码写法遵循一个规范:把成本表达式累加到一个变量里,再setObjective。注意启停成本和运行成本分开算:
# 目标函数 total_cost = 0 # 1) 机组运行成本 for g in units: for t in range(T): total_cost += unit_costs[g] * Pg[(g, t)] total_cost += start_costs[g] * z_start[(g, t)] total_cost += shut_costs[g] * z_stop[(g, t)] # 2) 储能营运成本 for t in range(T): total_cost += ess_cost * (P_ch[t] + P_dis[t]) # 3) 外网购售电成本 for t in range(T): total_cost += price_buy[t] * P_buy[t] total_cost -= price_sell[t] * P_sell[t] # 4) 需求响应补偿费用 for t in range(T): total_cost += dr_price[t] * P_IL[t] model += total_cost需求响应部分的P_IL需要声明一个额外变量,同时如果做平移负荷,还要声明表示“该负荷是否在某时段处于运行状态”的0-1变量。建议把DR相关变量单独封装成一个函数,避免和发电机组变量混在一堆,调试时扫起来方便。
约束添加有一个性能经验:PuLP的约束逐条添加没有问题,但如果你在循环里写for t in range(T)然后每次加一条约束,24时段没问题,96时段的规模下性能也还可以。真正需要警惕的是管理变量字典的键,别在循环里把t写成从1开始、数据索引却是从0开始,这类越界错位问题比算法本身麻烦得多。
4.4 功率平衡与储能SOC递推代码演示
功率平衡约束添加到模型的方式如下:
for t in range(T): model += ( pulp.lpSum(Pg[(g, t)] for g in units) + P_wind[t] + P_pv[t] + P_dis[t] + P_buy[t] == P_load_base[t] - P_IL[t] + P_shift_in[t] + P_ch[t] + P_sell[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 else: model += SOC[t] == SOC[t-1] + eta_ch * P_ch[t] - P_dis[t] / eta_dis这里如果时间单位不是1小时,必须在递推公式里乘Δt,否则充放电功率乘时间才是能量,这个很多人第一次写代码会漏。举个简单例子:如果dt是0.25小时,充电功率100kW充4个点,能量只有100kWh;如果忘记乘dt,而容量单位是kWh,递推直接溢出或直接算错,而且肉眼还不容易发现,因为求解一样能跑通,只是结果全错。这个坑我掉进去过两回,第一次查了整整一天。
始末SOC相等约束也要在循环外单独加一行:
model += SOC[T-1] == SOC_init4.5 求解配置与结果整理
求解器建议先用CBC,因为PuLP自带,不需要额外许可证。安装方式是pip install pulp时自动携带。如果规模变大或者想加速,可以接Gurobi,但需要licensing,然后把solver参数换成pulp.GUROBI_CMD就行。注意不同求解器对MIP gap容忍度不一样,大体系中直接默认配置可能等到超时,建议显式设置:
solver = pulp.PULP_CBC_CMD(msg=True, timeLimit=120, gapRel=0.01) result = model.solve(solver)这样设置120秒求解上限和1%的mip gap,既能在论文里解释求解器参数,又不会无限等。如果21时段求解都超过120秒,说明模型设计有问题,大概率引入了过多0-1变量,需要重新审视建模效率。
求解完成后提取结果时,用value(var)逐个取,不建议直接取model.objective.value()了事。逐时段输出调度表和SOC曲线:
import pandas as pd schedule = [] for t in range(T): schedule.append({ "时段": t, "G1出力": value(Pg[("G1", t)]), "储能充电": value(P_ch[t]), "储能放电": value(P_dis[t]), "购电": value(P_buy[t]), "售电": value(P_sell[t]), "SOC": value(SOC[t]), "削减负荷": value(P_IL[t]) }) df_schedule = pd.DataFrame(schedule) print(df_schedule)5. 常见问题与排查技巧实录
5.1 模型求解无可行解的排查顺序
MILP提示Infeasible几乎每个人都会遇到。碰到这种情况,不要慌,按下面的顺序一步步定位:
第一步,查看有没有单位不一致导致的量纲混乱。把所有功率和能量统一成同一单位,SOC初值和上限的单位也要一致。
第二步,检查功率平衡约束两边是否多写或漏写变量。特别是充电与放电同时出现在等式两侧,如果某一侧符号错了,就可能导致无解。
第三步,检查储能SOC初始值与容量是否满足充电功率上限乘时段的约束。如果SOC_init=0.9而SOC_max=0.95,储能根本无法完成强制充电约束,会报无解。
第四步,检查备用约束取的是最大出力还是当前出力。备用约束如果要求每时段的可用出力不小于负荷加预测偏差,而机组总出力上限本身就小于负荷,那结构上就不可能可行,必须调整机组配置或允许从电网购电。
第五步,检查外网购电互斥约束的M值是否太小。如果M小于P_buy可能的最大值,约束会过度收紧,出现反直觉的无解。
如果上面的检查都正常但依然无解,可以尝试把部分强行等式改成不等式,比如始末SOC相等改为“SOC[T] >= SOC_init - 0.01”这种带容差的方式,这样做在工程上有说服力,建模上也更鲁棒。
5.2 求解时间过长与MIP gap处理
MILP问题时间过长,几乎总是因为0-1变量太多。避免的办法有几个:一是合并对称的机组类型,同容量同成本机组可以聚合为一个机组组,变量指数级减少;二是优先使用连续变量表达DR削减量;三是如果只需要研究经济性而不研究机组启停细节,可以用机组“部分出力上限”替代启停0-1变量,牺牲一点精度换速度。
另外一个实际经验是多阶段求解:先松弛所有整数变量(LP Relaxation)跑一遍得到一个下界,再把原问题MIP跑一遍,如果上下界差在3%以内,直接接受MIP解。在论文里也多一种说服力:“LP下界与MIP解差距仅2.8%”。这样做比在大规模MIP上死磕省时得多。
如果求解结果出现“非整数”的机组出力,注意检查是否忘记把启停变量cat设置为Binary。输出结果中出力在P_min以下还是呈现一个很小的开着的状态,往往就是没加二进制约束。
5.3 结果抖动的常见原因
很多人在跑完模型后发现SOC曲线像股票走势一样来回抖动,或者DR削减量在相邻时段剧烈波动,这不是求解器抽风,而是目标函数没有加“平滑性”正则项。解决办法是在目标函数里加入一个很小的二阶差分惩罚项,即lambda乘以相邻时段出力差平方的累积。这里以小权重比如0.01起步试,过大了会让经济目标失真,过小了起不到平滑作用。这个操作在学术论文里称为“正则化调度”或者“轨迹平滑约束”,审稿人一般不会反对。
此外,如果储能充放电功率频繁从0跳到最大再跳回0,通常是因为缺少“最小充放电持续时间”约束。加入这个约束会引入更多整数变量,如果只关注经济结果可以不管,但如果有后续控制环节或设备磨损约束,还是值得加。
5.4 含需求响应的价格参数调参经验
前面提过,DR补偿单价设置不合理时模型会“消极响应”或“过度响应”。实操里推荐这样调:先跑无DR场景,拿到各时段系统边际电价序列,然后设定DR单价为该时段时间边际电价的0.7倍,再跑一次。如果削减量为0,按0.1倍步长向上调;如果出现连续大段削减,按0.1倍向下调,直到削减曲线和边际电价曲线的相关系数在0.5~0.6附近,这通常是一个比较自然的DR响应状态。
另外,如果设置了削减总量上限,比如全天的DR总削减量不超过全天总电量的10%,务必把总削减量的约束加上,否则目标函数一定会压到削无可削。但也要注意总量阈值不宣之于目标函数中,否则经济性解释不好写。
6. 再聊一点我的个人实操体会
代码写到这里,模型跑通不难,难的是跑通之后你还敢不敢往真实项目上放。我自己在实际项目里跑这类模型,最大的体会是数据预处理的时间一定是建模时间的三倍以上。预测曲线好看和预测曲线粗糙,调出来的储能策略差异巨大。尤其光伏晴天大幅爬坡的时段,如果预测数据没做平滑,储能会在相邻时段反复折返充放,目标函数虽然“最优”,但实际设备根本受不了。
所以我现在跑任何日前调度代码,都会在求解完成后增加一个后处理检查脚本,把SOC曲线的一阶差分画出来,看有多少时段发生方向反转。如果超过三到五次,我会直接回调,把预测曲线做一次3点或5点滑动平均再求解。这样做在数学上不规范,但工程上实用,论文里描述为“对预测数据做预处理以降低波动性”就能过关。
最后再多说一个实用的小技巧:把整个模型封装成一个类,输入参数用dataclass绑定,输出结果统一写成DataFrame并导出excel。这样不止你自己能快速复现,调剂给同组的同学或者接手的工程师,对方看一眼类结构就能自己跑起来。这也是我从踩坑中学来的经验,早期把代码全堆在jupyter notebook里,每次换一台机器改路径都要老半天。把模型类的接口稳定下来,日后加光伏场景、加碳约束、加多目标权重,都只是往类里塞新方法的事,不用重写主线。这算是做微电网调度项目里最值得的一笔前期投入了。