微电网日前经济调度建模与Python实现:从目标函数到MILP求解
1. 日前经济调度到底在解决什么从一次“午后断电风险”说起我第一次给某园区的微电网做日前经济调度时心里最大的疑问其实是这不是一个优化问题吗为什么市面上大家讲的都是“求解”而不是“建模”后来把模型跑通、把代码交出去、看着现场调度员按这张曲线表操作之后我才明白风光储能需求响应的日前经济调度真正的难点从来不在优化算法本身而在如何把一个现场问题翻译成一个数学上可解、工程上可用、经济上合理的问题。为什么这件事重要直接说场景某园区有屋顶光伏、两台风机、一组储能电池还有一批负荷。第二天天气预报告诉你中午光照强、下午风速不错但傍晚负荷高峰会上来而且电网晚高峰电价很贵。你要决定的是储能什么时候充、什么时候放、充多少风力和光伏发的电是自己用、卖给电网还是存起来哪些柔性负荷可以挪到后半夜去跑。这一整套决策要在前一天做出来就是“日前调度”。它和实时调度的区别在于日前是“按预测做预案”实时是“按偏差做修正”。如果日前预案做得烂实时怎么调都费劲。所以这篇博文我就围绕这个场景展开把从建模到 Python 代码实现的完整链路讲清楚重点讲几个我在实际项目里被反复问到、也反复踩过的地方目标函数怎么写才对、需求响应怎么约束才不会出荒谬结果、储能约束怎么处理才不会“同时充又同时放”以及最终跑出来的调度曲线到底该怎么看。如果你正在做微电网的优化调度或者刚接触相关课题/项目这篇文章能给你一套可以直接抄作业的框架。2. 目标函数与决策变量设计把“省钱”这件事量化成数学语言2.1 目标函数各项的物理含义与经济解释日前经济调度的目标听起来很简单明天的运行成本最小。但落到数学上你要先想清楚“成本”到底由哪几部分构成。我一般习惯分四块向上级电网购电的费用售电给电网的收益如果有余电上网的条件储能充放电带来的电池损耗成本调用需求响应的补偿成本不管是可削减负荷还是可平移负荷动用户的东西是要花钱的。于是目标函数的基本形态是[ \min \sum_{t1}^{24} \left[ C_{\text{buy},t} \cdot P_{\text{buy},t} - C_{\text{sell},t} \cdot P_{\text{sell},t} C_{\text{ess}} \cdot (P_{\text{ch},t} P_{\text{dis},t}) C_{\text{dr}} \cdot P_{\text{cut},t} \right] ]这里 (P_{\text{buy},t}) 是从电网买电的功率(C_{\text{buy},t}) 是对应时段的购电价(P_{\text{sell},t}) 是反送电网的功率电价按上网电价算储能损耗成本我习惯按充放电功率的线性函数算因为工程上不需要精确到电化学模型但完全忽略损耗会让结果偏向“频繁充放”不符合电池寿命的真实情况。需求响应成本按调用量算量越大补偿越高这样模型才不会无缘无故去“折腾”用户负荷。2.2 为什么目标函数里要同时出现“购电”和“售电”什么时候该控制不让两者同时发生很多初学者会问既然能买电也能卖电为什么不把两者合并成“净交互功率”答案是电价不对称。电网的购电价是工商业峰谷电价而售电价一般是燃煤基准价或者市场化交易价通常比购电价低不少。如果你合并成净功率模型会在“高价时段买入、低价时段卖回”这种现实中不存在的套利机会上钻空子得出来的调度方案根本没法落地。所以建模时必须把买、卖两个功率分别设成非负连续变量同时加一条约束同一时段不能既买又卖。否则线性规划会为了数值上“好看”而同时置两个变量非零虽然在最优解里这通常不会发生但加上约束更稳妥。此外在分布式光伏渗透率高的场景下还要考虑变压器容量约束[ P_{\text{buy},t} P_{\text{pv},t} P_{\text{wind},t}] 不能超过并网点的允许容量这条限额我在第五节会单独讲。2.3 需求响应成本不要只看补偿单价还要看“用户是否愿意配合”需求响应在目标函数里的处理关键是“用什么价格激励用户”。我见过不少文献把 (C_{\text{dr}}) 设成一个很高的大数意思是“尽量不动负荷”。这其实混淆了“能不能动”和“值不值得动”——如果补偿成本高到几乎永远不用需求响应那建模就失去了意义。合理的做法是参考实际工程中的补偿协议比如可削减负荷的补偿标准为元/kWh 的某个值可平移负荷按“平移到哪个时段”给出不同的积分成本或者固定补偿。总之目标函数要能真实反映“调用需求响应是省了购电费还是多花了补偿费”的权衡。3. 约束条件逐条拆解功率平衡、储能 SOC 与需求响应边界3.1 功率平衡约束微电网一切运行方式都必须满足的“硬底线”不管怎么优化每个时段的电力都必须守恒[ P_{\text{pv},t} P_{\text{wind},t} P_{\text{dis},t} P_{\text{buy},t} P_{\text{load},t} - P_{\text{cut},t} P_{\text{ch},t} P_{\text{sell},t} ]左右两边分别是“发电侧/供给侧”和“用电侧/需求侧”。这里有个细节容易被忽略可削减负荷 (P_{\text{cut},t}) 写在右边并且取负号表示实际用电量减少了。而如果模型里还有可平移负荷它的处理就更复杂一些我放到 3.3 节专门说。3.2 储能 SOC 递推方程与充放电功率限制储能是微电网里唯一具备“时间耦合”的设备前一个时段的决策会直接影响后一个时段的状态所以必须用递推方程描述[ SOC_{t1} SOC_t \eta_{\text{ch}} \cdot \frac{P_{\text{ch},t} \Delta t}{E_{\text{ess}}} - \frac{P_{\text{dis},t} \Delta t}{\eta_{\text{dis}} E_{\text{ess}}} ]其中 (\eta_{\text{ch}}/\eta_{\text{dis}}) 是充放电效率(E_{\text{ess}}) 是储能额定容量(\Delta t) 取 1 小时。同时每个时段还有上下限约束 [ P_{\text{ch},t} \le u_{\text{ch},t} \cdot P_{\text{ch,max}}, \quadP_{\text{dis},t} \le u_{\text{dis},t} \cdot P_{\text{dis,max}}, \quadu_{\text{ch},t} u_{\text{dis},t} \le 1 ]最后一条“同时性约束”是关键。我在第六节的踩坑实录里会详细讲如果漏了这条求解器可能给出一个时段内一边充电一边放电的荒谬结果SOC 曲线看着正常实际储能却在做“无用功循环”白白磨损电池。3.3 需求响应约束的三种做法可削减、可平移、可转移需求响应不能只写一个“负荷上限”否则模型会在电价最高的时段把所有柔性负荷全部砍掉产生不合理的“舒适度灾难”。我做项目时一般把需求响应分成三类处理可削减负荷在任意时段削减量不超过允许值且一天内削减总量设上限避免透支用户的配合意愿。可平移负荷负荷块只能在允许的时间窗口内整体平移。例如某个工业流程需要连续运行 3 小时不能在中间断开。处理方式是用“启动时刻唯一”约束 [ \sum_{s} x_{\text{start},s} 1 ] 然后再把该负荷块在启动后的 3 个时段内以固定功率叠加到用电侧。可转移负荷最常见的是电动汽车充电桩。“今天总共需要充 20 kWh可以分时段充”但每个时段有最大充电功率限制。很多人写论文时只做第一种可削减因为这个约束最简单。但实际工程价值最大的是第二、第三种因为它们直接改变负荷曲线形态是真正的“削峰填谷”手段。所以我在自己的代码里三类需求响应都做了并且分别设置了不同的成本。3.4 风光出力上限预测值就是调度的“输入天花板”每个时段的光伏、风电出力预测值 (P_{\text{pv},t})、(P_{\text{wind},t}) 一旦给定实际利用的功率不得高于预测值 [ P_{\text{pv,use},t} \le P_{\text{pv},t}, \quad P_{\text{wind,use},t} \le P_{\text{wind},t} ] 之所以写成“小于等于”而不是“等于”是为了给“弃光弃风”留出空间。有些场景下比如中午光伏大发而负荷很低少用一点光伏、多从电网买便宜电反而整体更划算答案通常是“不划算”但也要看当地的考核政策。如果分布式光伏没有补贴压力一般不会主动弃光但如果微电网有并网功率限制、或者储能已充满弃风弃光是完全可能出现的。这个约束的存在让模型具备了“主动弃风/弃光”的能力是否触发完全由经济性决定。4. 求解路线与 Python 代码实现为什么选 MILP 而不是启发式算法4.1 模型类型判断离散变量出现在哪里先捋一遍模型的数学性质目标函数是线性的大部分约束也是线性的。但需求响应里的“启动时刻唯一”、储能充放电的“同时性约束”都需要 0/1 变量。这意味着模型是一个混合整数线性规划标准的求解器是 CBC、GLPK商业环境常用 Gurobi 或者 CPLEX。为什么不选遗传算法、粒子群之类的启发式算法核心原因是微电网日前调度的规模完全可控24 小时、几十个变量、几十条约束根本不需要启发式优化。MILP 求解器不仅能给出全局最优解还能给出最优性间隙gap这对工程验收非常重要。启发式算法在这个问题上属于“用大炮打蚊子”且打不打得中还没保证。4.2 使用 Pyomo 的建模骨架数据集、变量、约束、求解下面是我常用的 Pyomo 建模代码框架节选关键部分完整工程在文末说明import numpy as np from pyomo.environ import * # ---------- 数据预处理 ---------- T list(range(96)) # 4个时段/小时96个15min时段也可以直接用24小时 # 实际项目中用典型日/实测数据替代 pv_forecast np.array([0,0,0,0,0,0,0, ...]) # 光伏预测, kW wind_forecast np.array([...]) # 风电预测, kW load_forecast np.array([...]) # 基础负荷预测, kW price_buy np.array([...]) # 购电价, 元/kWh price_sell np.array([...]) # 售电价, 元/kWh # 储能参数 E_ess 600.0 # 额定容量, kWh P_ch_max 150.0 # 最大充电功率, kW P_dis_max 150.0 # 最大放电功率, kW eta_ch 0.95 # 充电效率 eta_dis 0.95 # 放电效率 SOC_min, SOC_max 0.2, 0.9 SOC_init 0.5 # 可削减负荷参数 P_cut_max 50.0 # 每时段最大削减量, kW Q_cut_total 200.0 # 一天累计最大削减量, kWh # ---------- 构建模型 ---------- model ConcreteModel() model.t Set(initializeT) # 连续变量 model.p_buy Var(model.t, domainNonNegativeReals) model.p_sell Var(model.t, domainNonNegativeReals) model.p_pv_use Var(model.t, domainNonNegativeReals) model.p_wind_use Var(model.t, domainNonNegativeReals) model.p_ch Var(model.t, domainNonNegativeReals) model.p_dis Var(model.t, domainNonNegativeReals) model.p_cut Var(model.t, domainNonNegativeReals) model.soc Var(model.t, bounds(SOC_min, SOC_max)) # 0/1变量 model.u_ch Var(model.t, domainBinary) # 充电标志 model.u_dis Var(model.t, domainBinary) # 放电标志 model.u_sell Var(model.t, domainBinary) # 售电标志防止同时买卖 # ---------- 目标函数 ---------- def obj_rule(m): return sum( price_buy[t] * m.p_buy[t] - price_sell[t] * m.p_sell[t] c_ess * (m.p_ch[t] m.p_dis[t]) c_dr * m.p_cut[t] for t in m.t ) model.obj Objective(ruleobj_rule, senseminimize) # ---------- 功率平衡约束 ---------- def balance_rule(m, t): return (m.p_pv_use[t] m.p_wind_use[t] m.p_dis[t] m.p_buy[t] m.p_cut[t] m.p_ch[t] m.p_sell[t] load_forecast[t]) # 注意这里我把负荷移到等式右边更容易理解 model.balance Constraint(model.t, rulebalance_rule) # ---------- 储能SOC递推 ---------- def soc_rule(m, t): if t 0: return m.soc[t] SOC_init eta_ch * m.p_ch[t] / E_ess - m.p_dis[t] / (eta_dis * E_ess) else: prev T[T.index(t) - 1] return m.soc[t] m.soc[prev] eta_ch * m.p_ch[t] / E_ess - m.p_dis[t] / (eta_dis * E_ess) model.soc_constr Constraint(model.t, rulesoc_rule) # 最后一个时段的SOC要与初始SOC一致日循环约束 def soc_cycle_rule(m): last T[-1] return m.soc[last] SOC_init model.soc_cycle Constraint(rulesoc_cycle_rule) # ---------- 储能充放互斥关键 ---------- def ch_dis_exclusive(m, t): return m.u_ch[t] m.u_dis[t] 1 model.ch_dis_ex Constraint(model.t, rulech_dis_exclusive) def ch_link(m, t): return m.p_ch[t] P_ch_max * m.u_ch[t] model.ch_link_c Constraint(model.t, rulech_link) def dis_link(m, t): return m.p_dis[t] P_dis_max * m.u_dis[t] model.dis_link_c Constraint(model.t, ruledis_link) # ---------- 光伏/风电使用上限 ---------- def pv_limit(m, t): return m.p_pv_use[t] pv_forecast[t] model.pv_bound Constraint(model.t, rulepv_limit) def wind_limit(m, t): return m.p_wind_use[t] wind_forecast[t] model.wind_bound Constraint(model.t, rulewind_limit) # ---------- 需求响应约束 ---------- def cut_limit(m, t): return m.p_cut[t] P_cut_max model.cut_max_c Constraint(model.t, rulecut_limit) def cut_total(m): return sum(m.p_cut[t] for t in m.t) Q_cut_total model.cut_total_c Constraint(rulecut_total) # ---------- 求解 ---------- solver SolverFactory(cbc) # 或者 gurobi / cplex results solver.solve(model, teeTrue) # ---------- 结果导出 ---------- p_buy_opt [value(model.p_buy[t]) for t in T] p_dis_opt [value(model.p_dis[t]) for t in T] p_ch_opt [value(model.p_ch[t]) for t in T] soc_opt [value(model.soc[t]) for t in T]这里我特别把 SOC 递推的两种写法都保留了。如果你用 24 小时模型且把“最后 SOC 回到初始值”设为日循环约束那么递推从 (t0) 开始计算即可如果你不要求日循环也可以用“初始 SOC 给定 末端自由”的方式工程上这取决于第二天的调度计划是否衔接。4.3 约束写法的两个设计心得整数变量为什么要配合“大 M”或显式关联上面第 4.2 节里我用了u_ch[t] u_dis[t] 1再加p_ch P_ch_max * u_ch这种“0/1 标志 功率上限关联”的写法比单纯在变量上加“互补约束”要稳健得多。原因是求解器处理线性约束远比处理非线性约束高效用了 0/1 变量之后互补互斥关系直接变成线性不等式整数规划的性能和数值稳定性都更好。同理防止“同时买电又卖电”也可以用p_buy[t] M * u_buy[t]和p_sell[t] M * u_sell[t]配合u_buy[t] u_sell[t] 1。M 可以取一个足够大的数比如变压器容量但也不要取太大否则数值稳定性变差——这是很多 MILP 模型调优的通用经验。5. 典型日算例调度结果里藏着经济学逻辑5.1 算例设置一个 600 kWh 储能的园区微电网为了让你对“跑出来的结果长什么样”有直观感受我设计了一个典型算例参数如下参数数值光伏额定功率300 kW风电额定功率500 kW储能额定容量600 kWh储能最大充放功率150 kW基础负荷峰值约 700 kW购电峰/平/谷价1.05 / 0.63 / 0.32 元/kWh上网售电价0.45 元/kWh需求响应补偿0.8 元/kWh可削减负荷上限50 kW/时段累计 200 kWh用一个夏季典型日的数据光伏中午最高约 260 kW风电凌晨与傍晚较强负荷呈现“早晚双峰”形态。5.2 看结果的三条主线储能在低谷充、高峰放柔性负荷往谷段挪跑完求解器输出功率平衡表和 SOC 曲线你第一眼要关注三件事储能 SOC 曲线的整体趋势应该是谷电价时段充电、峰电价时段放电。比如在这个算例里凌晨 1-4 点 SOC 从 0.5 充到接近 0.9中午光伏大发时也稍微补了一点下午 17-20 点晚高峰阶段 SOC 一路放回到 0.2 左右。这条曲线的形状如果不符合电价结构基本可以断定模型出问题了。购电功率是否避开了尖峰时段正常情况下午间光伏出力充足时应减少购电甚至出现少量售电高峰时段购电功率会被压到较低水平。可削减负荷的分布我的求解结果里削减量基本集中在晚高峰 18-20 点累计削减大约 180 kWh。这说明模型确实在经济最优的位置使用了需求响应而不是为了削减而削减。需要说明的是这个算例没有考虑网络潮流和电压约束属于典型的“单节点微电网”模型。如果要扩展到多节点拓扑需要增加潮流方程和节点电压上下限约束模型规模和求解难度会明显上升。6. 实测最容易踩的坑SOC 初值、日循环约束与需求响应的“假削峰”6.1 坑一SOC 初值给错或者忘了日循环约束调度曲线直接“全线崩溃”很多人在开发环境里随便把 SOC_init 设成 0.8然后跑出来的结果就是储能一整天基本不充电到了傍晚把 0.8 放到 0.2 就走完了购电成本看起来低得离谱但第二天根本没有可用的储能来重复这个方案。工程上这叫“方案不可复现”。解决办法是加日循环约束[SOC_{T} SOC_{0}]。这样做的经济学含义是储能电量在当天形成一个完整循环所有决策都是可持续的。如果你遇到更复杂的场景比如两天以上的调度周期可以把初始 SOC 作为决策变量并把首末 SOC 的一致性纳入约束。6.2 坑二可削减负荷的总量约束缺失导致“高峰全削、低谷全买”的极端结果如果不加“一天内累计削减量上限”模型在峰谷价差大的地方会倾向于把所有高峰负荷全砍掉、低谷时段大量购电存起来甚至出现负荷削减之后用电量低于实际需求的荒谬平衡。实际需求响应是有“用户耐受度”的一天内连续削 8 个小时用户早就投诉了。所以在代码里一定要保留类似sum(p_cut[t] for t in T) Q_cut_total的约束。我调参的时候也发现把 Q_cut_total 从 500 降到 200结果里的削减时段从“几乎全天削”变成了“集中在最贵的 3~4 个小时”这才是符合真实工程预期的形态。6.3 坑三光伏/风电“不可用功率”没有建模结果里出现“预测 500 kW 风电却用了 500 kW”的假象可能你会觉得这不是好事吗但问题在于如果没有给p_pv_use和p_wind_use设置“小于等于预测值”的约束模型理论上可以用超过预测值的功率。实际代码里如果变量上界没设置好求解器会用预测值的上限去凑平衡——解看起来是“最优”的但现场根本没法执行。这个坑在数据质量较差的项目里特别容易出现。还有一个小坑很多人在对比“弃风弃光率”的时候直接用 1 - P_use/P_forecast 计算但忘了加“实际负荷可能低于风光总出力”这一物理学前提。如果日内负荷本身就小于风光总量弃掉一部分是合理的不应被算作调度方案的缺陷。6.4 数值调优经验CBC 求解器在小规模问题上完全够用但要注意整数变量的规模增长用 CBC开源的 COIN-OR 分支定界求解器求解 24 时段的小模型通常几秒到几十秒就能达到 1% 的最优间隙以内。我自己测试过大约 200 个连续变量加 50 个整数变量的问题规模CBC 完全能胜任。但如果把时段缩短到 15 分钟96 个时段、加入几十类可平移负荷、再叠加滚动优化的多次求解整数变量会迅速膨胀到上千个CBC 可能就慢得让人抓狂了。这时候有两个选择一是换用 Gurobi 或 CPLEX二是缩小问题规模比如把需求响应聚合为“负荷集群”而不是逐个设备建模。工程项目的经验是能用 1 小时分辨率解决的问题不要用 15 分钟去折腾自己除非真的有实时控制层面的需求。7. 从“能跑”到“能用”下一步可以怎么扩展最后说几个我特别有体感的扩展方向不一定覆盖所有场景但都是真实工程里会碰到的问题。第一是不确定性处理。日前调度的最大软肋是预测不准光伏突然被云遮了风电阵风来了实测功率跟日前预测差一截。工程上没有那么高深的随机优化最朴素也最常用的做法是“场景法”——取晴天、多云、阴天三套光伏预测数据各跑一遍调度最后加权平均或者滚动更新修正。把确定性模型升级成两阶段鲁棒优化或者分布鲁棒优化那是研究课题的方向不是工程项目的第一需求。第二是与实时控制的衔接。日前调度给出的是“明天每小时跑多少功率”的指导曲线执行层面还需要 AGC/就地控制器跟踪。这个衔接环节我见过太多人忽略调度说 14:00 储能放 100 kW结果实际温度超过阈值、电池保护性停机控制器就不管了整个系统回到无序状态。好的做法是在调度模型里给储能留 10%~20% 的功率余量并在实时层加“越限报警 自动降级”逻辑。第三是多微电网互联。园区和园区之间如果可以共享储能和备用容量日前调度的优化边界就从“单节点”扩展到“区域多节点”需要引入联络线功率约束和可能存在的内部结算电价。这个方向上模型规模会变大但目标函数的形态和约束框架跟本文是完全一致。我个人在实际项目里的体会是把基础版本做扎实、把约束写清楚、把结果能解释给非技术背景的运营人员听比堆一大堆高级算法有用得多。希望这篇文章能帮你把那套基础版本真正跑起来。