模型预测控制(MPC)用在微电网调度优化里,这几年基本属于电气工程和能源方向的"标配热点",我前后用Python代码实现过好几版,从几十行的教学Demo到带预测误差、滚动修正的完整仿真都有。这篇就围绕Python代码实现展开,把MPC从"为什么需要它"到"数学模型怎么建、代码怎么落、参数怎么调、踩坑怎么排"完完整整拆一遍。适合正在做课程设计、毕业设计,或者刚接手微电网能量管理项目的同学参考,读完你至少能自己跑通一个带滚动优化的MPC调度仿真。
1. 微电网调度为什么绕不开MPC:从开环到闭环的调度逻辑
1.1 先弄清楚微电网调度到底在优化什么
微电网一般由分布式光伏、储能、本地负荷组成,有些还带柴油发电机或微型燃气轮机,可以并网运行,也可以孤岛运行。所谓调度优化,就是在一个调度周期内,确定储能充放电功率、与电网交换的功率、可控机组的出力,使得运行成本最低,同时保证功率平衡、各设备都不越界。
很多人一开始会以为调度优化等于"让光伏多出力",其实光伏属于优先消纳的可再生能源,正常情况下不存在"要不要用"的问题,真正需要动脑筋的是储能和电网交互怎么配合。中午光伏大发,是选择给电池充电,还是向电网卖电?晚上负荷爬上来了,是优先放电池的电,还是从电网买?这背后牵涉分时电价、SOC状态、预测的净负荷曲线,是一个典型的多步动态决策问题。
如果只是静态地把一个小时单独拎出来看,决策很简单:电价低就买电充电,电价高就放电。但实际的难点在于,储能是有容量约束的,今天低价充进去的电可能要到明天高价时段才放出来,中间隔着十几二十个小时,你必须站在一个较长的时间尺度上统筹安排。这就是"调度"和"单点优化"的本质区别。
1.2 一次开环优化的悲剧:预测误差如何放倒整个计划
传统的调度思路是"预测-优化-执行":先预测未来24小时的光伏出力和负荷曲线,然后一次性求解未来24小时的优化问题,把24个功率指令全部下发下去。这套逻辑在没有预测误差的假设下非常完美,但放在现实里问题马上就出来了。
预测一定会错。早上看云图觉得中午多云,结果11点太阳冲破云层;下午预报有雨,实际一滴没下。预测偏差导致的结果是:你按计划在低谷时段给电池充满电,结果真实负荷比预测低10%,电池顶着上限没地方消化多余的光伏;或者你预测晚上负荷高峰会很高,提前把电池放空,结果实际高峰推迟了两小时,高峰期你要花最高的电价从电网买电。
这种"开环"的问题在于,计划一旦生成就不再接受反馈修正。就像蒙着眼睛开车,出发前看一眼地图,规划好整个路线,然后方向盘照着规划一路打下去,完全不看实际路况。路况和地图一致还好,只要有一点点偏差,车就会越开越偏。
1.3 MPC的应对逻辑:预测、滚动、反馈一起用
模型预测控制的核心思路,就是把"看一次地图、一次性规划全程"改成"每走一小段就重新看一次路况、只规划前方一段、执行一小步后再重新来过"。具体来说有三个关键动作:
- 预测:用当前时刻的实测状态和历史数据,对未来一段时间(预测时域Np)内的光伏、负荷做出预测;
- 优化:求解从当前时刻起未来Np步的最优控制序列,目标函数是未来Np步的总成本;
- 反馈:只执行第一个时刻的控制指令,到了下一个采样周期,用实测的SOC等状态重置模型初值,再重新预测、重新优化。
这套逻辑解决了两件事:一是预测误差不会被一路放大,因为每个周期都会根据最新实测状态"纠偏";二是决策具备前瞻性,站得住未来24小时的电价和负荷变化,不会为了眼前一小时的电价便宜把后面几十个小时的路走死。
从工程实现上说,MPC不是一次大优化,而是无数次小优化串联起来的决策链路。这也是后面Python代码里的核心:优化问题本身不复杂,复杂的是怎么把它嵌入到滚动循环里,让每一轮求解都能接住上一轮的真实状态。
2. 从机理到方程:MPC调度模型的核心数学结构
2.1 状态方程与变量定义:SOC那点事
微电网MPC建模,最关键的状态量是储能系统的荷电状态SOC。选它做状态变量,是因为储能是唯一能跨时间平移能量的设备,SOC把"过去的充放电决策"和"未来的可用功率"连接在一起。
在离散时间模型里,状态方程写成:
SOC(k+1) = SOC(k) + (η_ch * P_ch(k) - P_dis(k) / η_dis) * Δt / B_cap
这里P_ch是充电功率,P_dis是放电功率,两者单位是kW;ΔT是步长,单位是小时;B_cap是电池容量,单位是kWh;η_ch和η_dis分别是充放电效率。等号两边都是无量纲的0到1之间的数,所以整个方程的单位是自洽的。
写方程时有两个细节容易被忽略。第一,充电时能量存入电池,要乘效率;放电时电池释放的能量要除以效率,才是实际从电池内部消耗的电量。第二,步长和容量的换算必须统一。比如100kWh的电池、30kW的充电功率、1小时步长,一个步长最多改变SOC 0.3;如果步长改成15分钟,一个步长最多改变SOC 0.075。量级差很多,直接影响约束的松紧和求解器的数值表现。
决策变量一般包括P_ch、P_dis、P_buy(向电网买电)、P_sell(向电网卖电),如果有柴油机还要加P_gen。光伏出力P_pv和负荷P_load在这个模型里属于"不可控扰动",出现的形式是预测值参数。
2.2 目标函数:电价、损耗与终端价值怎么平衡
MPC每个滚动窗口的目标函数,最基础的形式是未来Np步的运行成本之和:
min Σ [λ_buy(k) * P_buy(k) - λ_sell(k) * P_sell(k) + α * (P_ch(k) + P_dis(k))]
λ_buy是分时购电电价,λ_sell是上网电价。它们的差值决定了储能套利的空间。α是电池充放电的损耗惩罚系数,单位是元/kWh,这个项很容易被新手忽略,但它非常重要。
如果没有α,优化器会倾向于让电池频繁充放:电价稍微波动一下,它就充一点放一点,仿真曲线看起来花里胡哨,实际电池寿命经不起这么折腾。加一个小小的α,比如0.01到0.05元/kWh,就能抑制无谓的功率振荡,同时又不至于过度约束储能参与调度的价值。α的取值需要在"让储能干活"和"别让它乱干活"之间找平衡,后面参数调优部分我再细说。
另外一个关键设计是末端项的设定。每个MPC窗口只执行第一步,但优化结果是未来Np步的完整序列。如果目标函数里没有对末端SOC做任何限制,优化器为了省成本,会倾向于把SOC在窗口末尾压到下限——反正这一步之后就不管了。这在滚动逻辑里会带来灾难性后果:下一轮MPC开始时,SOC已经贴着下限,完全没有向上和向下的调节裕度。
常用的做法有两种:硬约束,要求SOC(Np)落在初始值附近,比如SOC(Np) ∈ [SOC(0)-0.05, SOC(0)+0.05];软惩罚,在目标函数里加"末端SOC偏离参考值"的惩罚项。硬约束简单直接,但预测极端时容易造成不可行;软惩罚更平滑,但需要求解器支持二次项,如果用的是glpk这类线性求解器,就得做分段线性化。我后面代码里默认用硬约束,工程上稳妥。
2.3 约束条件:功率平衡、边界与隐藏的互补约束
约束条件里,功率平衡是等式约束:
P_buy(k) + P_pv(k) + P_dis(k) = P_load(k) + P_ch(k) + P_sell(k)
这个式子的含义是:流入母线的功率(买电+光伏+放电)必须等于流出母线的功率(负荷+充电+卖电)。在Pyomo里写成一排等式约束非常直观。剩余的基本约束还有:
- SOC范围约束:SOC_min ≤ SOC(k) ≤ SOC_max,一般取0.2到0.9;
- 充放电功率边界:0 ≤ P_ch(k) ≤ P_ch_max,0 ≤ P_dis(k) ≤ P_dis_max;
- 并网功率边界:0 ≤ P_buy(k) ≤ P_grid_max,0 ≤ P_sell(k) ≤ P_grid_max;
- 末端SOC约束:SOC(Np)在参考值附近。
还有一些"隐藏约束"容易被忽略,但实际运行中必须处理。第一个是P_buy和P_sell不能同时为正。同一个并网计量点,不可能一边从电网买电一边向电网送电,这在物理上说不通。线性模型里如果不加限制,当上网电价高于购电电价时,优化器会玩出"低价买进、高价卖出"的套利游戏,一组P_buy和P_sell同时为正的解,功率平衡等式依旧成立,却完全不符合物理事实。解决办法是加一个二进制变量做互斥,或者用SOS1约束。不过大多数仿真场景下,上网电价低于购电电价,目标函数会自然规避这种情况。
第二个隐藏约束是P_ch和P_dis不能同时为正。和买卖电类似,同时充放电等于能量在电池里打转,只会带来损耗。如果电池损耗惩罚系数α取正值,优化器不会同时候选,因为没有任何收益;但如果α设成0,就可能出现无效循环的退化解。稳妥做法是加上互补约束,代价是引入整数变量,求解速度会慢一些。在初学阶段,我建议先把α调成正值,跑通后再考虑严格互斥。
这些细节才是MPC建模里真正拉开高下的地方:方程谁都会写,能不能把物理世界里的"边界"和"不可能事件"翻译成数学约束,决定了你的模型是"能跑"还是"真能用"。
3. Python代码实现:从Pyomo建模到滚动求解的完整拆解
3.1 技术选型:为什么选Pyomo而不是自己手写
微电网MPC调度优化,本质上是一个带约束的数学优化问题。在Python生态里,有人用cvxpy,有人用scipy.optimize,也有人用Pyomo。我自己的经验是Pyomo最适合这个场景,理由有三条。
第一,建模表达力和代码结构清晰。PowerBalance、SOC递推这类约束,用Pyomo的Constraint可以一行一个函数批量生成,下标不容易写错。第二,求解器可替换。Pyomo的SolverFactory支持glpk、cbc、gurobi、cplex、ipopt等,学术研究用免费求解器足够,项目要求高时切换到商业求解器只改一行代码。第三,调试友好。模型可以导出成lp文件,用外部工具逐行检查约束生成的对不对,这在排查infeasible问题时是救命能力。
cvxpy的DSL更短,适合快速验证;scipy.optimize.minimize能用,但写几十个约束和滚动循环会很痛苦,而且只能处理特定类型问题。微电网调度通常要面对混合整数线性规划MILP或线性规划LP,Pyomo加线性求解器的组合是这个领域目前最通用的选择。
3.2 核心模型代码:变量、约束、目标函数逐个落地
下面是我整理过的一个最小可运行版本,核心是构建单次MPC窗口的优化模型。代码里Np是预测时域,这里取24,代表一个完整的分时电价周期。
import numpy as np from pyomo.environ import * # ---------- 系统参数 ---------- B_cap = 100.0 # 电池容量 kWh SOC_min, SOC_max = 0.2, 0.9 # SOC运行范围 SOC_0 = 0.5 # 初始SOC eta_ch, eta_dis = 0.95, 0.95 # 充放电效率 P_ch_max, P_dis_max = 30.0, 30.0 # 充放电功率上限 kW P_grid_max = 50.0 # 并网功率上限 kW dt = 1.0 # 步长 小时 # ---------- 预测数据(替换成你的预测模块输出) ---------- Np = 24 P_pv_pred = np.array([ 0, 0, 0, 0.2, 0.8, 1.5, 3.0, 6.0, 10.0, 15.0, 18.0, 20.0, 18.0, 15.0, 12.0, 8.0, 4.0, 1.0, 0, 0, 0, 0, 0, 0 ]) P_load_pred = np.array([ 12, 11, 10, 10, 11, 13, 15, 18, 20, 22, 23, 22, 20, 19, 18, 17, 16, 18, 19, 18, 17, 15, 13, 12 ]) price = np.array([ 0.45, 0.42, 0.40, 0.38, 0.40, 0.45, 0.65, 0.85, 1.05, 1.20, 1.25, 1.20, 1.00, 0.90, 0.85, 0.90, 1.10, 1.15, 1.05, 0.90, 0.70, 0.55, 0.45, 0.40 ]) price_sell = price * 0.35 # 上网电价def solve_mpc_window(pv_pred, load_pred, price_buy, price_sell, soc_now): model = ConcreteModel() # 时间集合:0~Np表示状态索引,0~Np-1表示控制步索引 model.horizon = RangeSet(0, Np - 1) model.times = RangeSet(0, Np) # 决策变量 model.P_ch = Var(model.horizon, bounds=(0, P_ch_max), domain=NonNegativeReals) model.P_dis = Var(model.horizon, bounds=(0, P_dis_max), domain=NonNegativeReals) model.P_buy = Var(model.horizon, bounds=(0, P_grid_max), domain=NonNegativeReals) model.P_sell = Var(model.horizon, bounds=(0, P_grid_max), domain=NonNegativeReals) model.SOC = Var(model.times, bounds=(SOC_min, SOC_max)) # 初始SOC由实时状态给定(反馈校正的落地点) model.SOC[0].fix(soc_now) # SOC递推约束 def soc_next(m, k): return m.SOC[k + 1] == m.SOC[k] + ( eta_ch * m.P_ch[k] - m.P_dis[k] / eta_dis ) * dt / B_cap model.soc_trans = Constraint(model.horizon, rule=soc_next) # 功率平衡约束 def power_balance(m, k): return m.P_buy[k] + pv_pred[k] + m.P_dis[k] == \ load_pred[k] + m.P_ch[k] + m.P_sell[k] model.balance = Constraint(model.horizon, rule=power_balance) # 末端SOC约束:回到初始值附近,防止窗口末尾把SOC榨干 model.terminal_lb = Constraint(expr=model.SOC[Np] >= soc_now - 0.05) model.terminal_ub = Constraint(expr=model.SOC[Np] <= soc_now + 0.05) # 目标函数:购电成本 + 电池损耗惩罚 - 售电收入 def obj_rule(m): cost = 0 for k in m.horizon: cost += price_buy[k] * m.P_buy[k] cost += 0.02 * (m.P_ch[k] + m.P_dis[k]) # 电池损耗惩罚 cost -= price_sell[k] * m.P_sell[k] return cost model.obj = Objective(rule=obj_rule, sense=minimize) # 求解并返回第一条指令和SOC序列 solver = SolverFactory('glpk') solver.solve(model, tee=False) return ( value(model.P_ch[0]), value(model.P_dis[0]), value(model.P_buy[0]), value(model.P_sell[0]), [value(model.SOC[k]) for k in model.times] )这里有几个点要特别说明。
SOC[0].fix(soc_now)是MPC反馈校正的关键动作。每次滚动求解前,把上一轮实测到的SOC当作已知量写进模型,系统状态每时每刻都在被"拉回真实位置",这才是闭环。
power_balance约束里,pv和load是Python列表或numpy数组,直接传入的是当前窗口的预测值。由于它们是定值参数,优化器无法调整光伏出力,所以如果预测光伏特别大、电池充电和卖电通道又不够,这个约束就会导致模型不可行。解决办法之一是引入弃光变量,让优化器可以主动削减一部分光伏出力。代码可以先按上面这版跑通,遇到infeasible再扩展。
末端约束用的是soc_now - 0.05和soc_now + 0.05,即每个窗口结束时的SOC必须回到初始值附近。这样滚动起来之后,每一轮都有足够的调节空间,不至于把电池用成一潭死水。
3.3 滚动主循环:MPC"滚动"的动作到底长什么样
模型建好了,求解器也调用了,接下来就是MPC区别于传统优化的核心部分:滚动执行。主循环逻辑只有十几行,但每一步的含义要讲清楚。
def mpc_run(pv_actual, load_actual, price_buy_all, price_sell_all, sim_steps, soc_start=SOC_0): results = [] soc = soc_start for k in range(sim_steps): # 每个滚动窗口取未来Np步的预测值 pv_win = pv_actual[k:k + Np] load_win = load_actual[k:k + Np] pb_win = price_buy_all[k:k + Np] ps_win = price_sell_all[k:k + Np] # 求解当前窗口的优化问题,拿到控制序列 p_ch_cmd, p_dis_cmd, p_buy_cmd, p_sell_cmd, soc_seq = solve_mpc_window( pv_win, load_win, pb_win, ps_win, soc ) # 只执行第一步,然后按实际系统更新SOC soc = soc + (eta_ch * p_ch_cmd - p_dis_cmd / eta_dis) * dt / B_cap soc = np.clip(soc, SOC_min, SOC_max) results.append({ 'P_ch': p_ch_cmd, 'P_dis': p_dis_cmd, 'P_buy': p_buy_cmd, 'P_sell': p_sell_cmd, 'SOC': soc }) # 打印调试信息,观察滚动过程 print(f"t={k:3d} SOC={soc:.3f} P_ch={p_ch_cmd:6.2f} " f"P_dis={p_dis_cmd:6.2f} P_buy={p_buy_cmd:6.2f} P_sell={p_sell_cmd:6.2f}") return results每次循环做的事情,就是"求解未来Np步的最优控制序列,然后只执行第一步,更新状态,进入下一个采样周期"。注意变量名的区别:pv_actual是真实光伏出力序列,用来模拟环境推进;pv_win是当前窗口采用的预测值。如果你做理想预测仿真,让两者相等即可;如果做鲁棒性验证,就给预测值叠加上随机误差。
滚动过程中还有一个容易犯的错误:直接把所有窗口的第一步结果拼起来当成最终控制轨迹。理论上可以,但MPC的价值恰恰在于每一轮都在修正。如果预测没有误差,MPC和开环优化的结果几乎一样;一旦预测出现偏差,开环计划已经错得很远,而MPC会顺着真实状态的反馈重新找到一条不依赖当初假设的最优轨迹。
3.4 预测模型与仿真误差:真实验证MPC的价值所在
很多初学做MPC仿真的同学会遇到一个困惑:代码跑通了,但结果和普通优化看不出区别,于是怀疑自己写错了。大概率是仿真里用了"完全预测",也就是每个窗口的预测值和真实值一模一样。这种情况下MPC和开环优化确实没有本质区别,因为系统没有不确定性,不需要反馈修正。
在仿真里构造不确定性,推荐这样写:
np.random.seed(42) noise_pv = np.random.normal(0, 0.3, size=246) # 光伏预测误差 noise_load = np.random.normal(0, 0.2, size=246) # 负荷预测误差 pv_true = P_pv_pred + noise_pv load_true = P_load_pred + noise_load # 每个滚动窗口用的预测 = 真实值 + 新噪声,模拟"每次预测都有偏差" pv_forecast = pv_true[k:k+Np] + np.random.normal(0, 0.4, Np) load_forecast = load_true[k:k+Np] + np.random.normal(0, 0.3, Np)在上面的mpc_run里,pv_actual传给真实值,但solve_mpc_window里用的窗口数据是"预测值"。为了让代码结构更清晰,可以把主循环里的pv_actual[k:k+Np]替换成pv_forecast = gen_forecast(pv_true, k, Np),每次由预测模块重新生成。
加了误差之后,对比开环优化和MPC的运行成本、SOC末端位置、约束违反次数,才能体现闭环反馈的优势。这也是论文里最常见的对比实验设计,后面第4章会具体讲。
4. 算例与参数调优:预测时域、步长和末端SOC怎么定
4.1 一个典型算例长什么样
我建议初学阶段先用一个典型的24小时日曲线做验证:凌晨光伏出力为0,中午12点到14点达到峰值20kW左右,早高峰和晚高峰各有一个负荷尖峰;电价采用峰谷分时,谷段0.4元,峰段1.2元,这样储能存在明显的套利空间。电池容量100kWh,初始SOC 0.5,预测时域Np=24,步长1小时。
跑完这个算例,先看三张图:SOC时间曲线、各功率分量堆叠图、电价曲线。正常情况下应该能看到储能在电价低谷段充电、在电价高峰段放电,SOC在0.2到0.9之间平滑变化,而且每个窗口末端都能回到0.5附近。如果SOC曲线一直在某个边界上"贴地飞行",说明末端约束或者预测时域设置出了问题。
4.2 MPC和开环优化的对比怎么设计
对比实验做得好不好,直接决定你论文或报告的结论成不成立。最朴素的对照组是:用同一组预测数据,一次性求解未来24小时的最优计划,然后把24个指令全部按顺序执行,中途不再修正。实验组用MPC滚动求解,每个窗口只执行第一步。两者用同一套目标函数、同一组约束。
在完全预测的情况下,两个方案的结果理论上一致,MPC不会更差也不会更好。真正拉开差距的测试是给预测叠加误差。比如预测光伏时,每个时段的预测值在真实值基础上加上随机噪声。此时开环优化的结果是拿错误的预测算出来的,执行时功率平衡会出现偏差,SOC轨迹也会偏离计划;而MPC每个窗口都会用实测SOC重置初值,所以它始终围绕真实系统做决策,不会把预测误差一路带下去。
最后统计两个指标:总运行成本、SOC是否始终保持在可行区间。MPC在有误差场景下的成本通常低于开环,或者SOC不越界的次数更多。报告里用这个结果说明"滚动修正带来的收益",比你贴一堆公式更有说服力。
4.3 参数建议表与调参顺序
MPC里最需要调的参数不超过四个:预测时域Np、控制步长、末端SOC约束、损耗惩罚系数α。我总结了几个工程经验值。
| 参数 | 推荐范围 | 说明 |
|---|---|---|
| 预测时域Np | 24个步长(覆盖一个电价周期) | 太短看不到跨时段套利,太长预测误差累积、求解变慢 |
| 控制步长 | 仿真1h,实物15min | 步长越短,滚动修正越频繁,但求解窗口更长 |
| 末端SOC约束 | 初始值±0.05 | 先放宽到±0.1跑通,再往里收紧 |
| 电池损耗惩罚α | 0.01~0.05元/kWh | 太小导致频繁充放,太大会抹掉储能的套利价值 |
| 求解器gap | glpk默认;gurobi可设MIPGap=1% | 涉及整数变量时,gap太严会导致求解时间爆炸 |
调参顺序有讲究。我踩过的坑是上来就直接调Np,结果模型根本不可行,完全分不清是约束写错了还是参数太紧。正确的路径是:先把Np设为3步,手动检查每一步的约束表达式,确认模型逻辑正确;然后扩大到24步,观察是否有infeasible;如果infeasible,先把末端约束从±0.05放宽到±0.1,把α降一点,让优化器有更多自由度;最后再逐步收紧末端约束,找到最紧且能收敛的边界。
还有一个细节:控制时域Nc。理论课上MPC会区分预测时域Np和控制时域Nc,但在工程实现里,大多数微电网调度代码都让Nc=Np,也就是每个时刻的功率都是自由变量。只有当控制步数变多、求解压力上来之后,才会考虑让Nc<Np,让Nc之后的控制量保持不变。初学者不必一开始就纠结这个,先把Np=Nc跑通再说。
5. 实际调试中绕不过去的四个坑:现象、原因与排查链路
5.1 SOC被压到下限:终端约束与滚动衔接问题
第一个高频问题是:MPC跑了几步之后,SOC一直贴着0.2或者0.9的边界,整个系统几乎没有调节能力。很多人第一反应是"电池太小了",其实是终端约束没有设好。
如果你是先不加末端约束、直接滚动,那么每个窗口的目标函数只会考虑未来Np步的成本。平峰时段电价均衡,为了省钱,优化器会在窗口末尾把SOC放低一些,这些"低一点"在滚动中不断累积,几轮之后SOC就被压到了下限。解决办法就是在每个窗口加上末端约束,强制SOC回到当前初始值附近。这个约束会让优化器从第一步开始就考虑"未来还要留余地"这件事。
如果加了末端约束还是贴着边界,那问题可能出在约束幅度上。比如某个窗口要求SOC结束值在初始值±0.05内,但当前SOC已经0.25,预测净负荷又特别大,可能真的无法满足。这时候把约束放宽到±0.1,或者直接让末端SOC不低于SOC_min+0.1,先保证可解,再逐步收紧。
5.2 求解器报infeasible:从单步检查到约束注释法
模型报infeasible是MPC调试里最劝退的坑,但定位起来有固定套路。我在另外一个高比例光伏项目里就遇过:预测光伏30kW,电池充电上限20kW,并网交互上限10kW,而且模型里没有弃光变量,不允许卖电。功率平衡等式要求光伏多出来的10kW必须被某个变量吸收,但所有吸收通道都被上限卡住,问题当然无解。
排查链路我建议这样走。第一步,把Np改成1,只用一组已知的决策变量值用手算检查功率平衡,确认模型表达式本身没错。第二步,把末端约束注释掉,看模型是否从infeasible变成feasible,如果变了,说明是末端约束太紧。第三步,把P_buy和P_sell的上限放宽或者允许弃光,在光伏侧增加一个P_curtail变量,目标函数加上弃光惩罚,光伏多余出力可以被主动削减。第四步,还不行就用model.write('debug.lp')导出LP文件,用求解器自带的报告功能逐条看哪条约束被标记为冲突。
允许弃光的代码改动很简单,在功率平衡等式右边加一项P_curtail,变量范围0到P_pv_max,目标函数里加上弃光惩罚系数γ。这个变量物理意义是"光伏实际出力比可发出力少的部分",在高渗透率光伏场景下几乎是必须存在的。
5.3 功率指令跳变:爬坡约束与控制增量惩罚
第三个坑是滚动窗口之间的功率指令跳变。上一个时刻还在20kW放电,下一个时刻突然变成15kW充电,虽然数值上可行,但实际设备根本跟不上这么剧烈的功率变化,就算跟上了,电池寿命也受罪。
原因主要来自相邻窗口的预测曲线发生变化,或者目标函数对功率变化没有约束。解决办法有两个方向。简单方案是加爬坡约束,限制相邻两个步长的功率变化量,比如-5 ≤ P_ch(k+1) - P_ch(k) ≤ 5,单位是kW。严格方案是在目标函数里加控制增量惩罚项 β*(ΔP)^2,这需要求解器支持二次规划,glpk不行,gurobi可以。
对于课程设计或大部分工程场景,爬坡约束就够了。加约束的时候要注意,不要把爬坡限得太死,否则优化器可能无法充分利用电价低谷;一般设为设备最大允许变化率的30%到50%比较合适。调参过程和末端约束类似,先放宽到10kW跑通,再慢慢收紧到需要的水平。
5.4 数值病态与求解超时:标幺化与求解器设置
最后一个坑是数值问题,表现形式很隐蔽:同样的模型,步长15分钟、Np=48,求解时间突然从零点几秒涨到几十秒,或者求解器报数值警告。原因在于SOC是0到1的小数,功率是0到50的大数,两者出现在同一个约束里,系数差了两个数量级,求解器内部计算容易出现病态。
解决办法是在建模时做标幺化。把一个基准功率P_base定义出来,所有功率变量都除以P_base再进入模型,SOC本身就接近0到1,不用处理。这样约束系数都在相近的量级,求解器数值稳定性会好很多。
如果求解时间仍然过长,先检查模型类型。纯LP问题用glpk很快,一旦因为互斥约束引入了二进制变量变成MILP,求解时间会指数上升。这时可以设置求解器参数,比如gurobi的MIPGap设为0.01或0.02,意思是允许1%到2%的最优性gap,换取更快的求解时间。学术仿真里这个gap对结果的影响很小,但求解速度能快好几倍。
我个人的习惯是,仿真阶段先用Np=24、步长1小时把逻辑全部跑通,确认MPC行为正确后,再做15分钟步长的精细化仿真。一开始就上96步窗口,遇到问题连调试信息都看不清楚,很容易被各种数值警告带偏方向。
最后分享一点实际体会。MPC这个东西,难点从来不在理论公式上,而在"模型-预测-反馈"三条链路的衔接。模型层要物理准确,预测层要能模拟不确定性,反馈层要用实测状态不断重置初值,任何一环偷懒,跑出来的结果都会让你分不清到底是算法不行还是实现有问题。建议拿到代码后先完整走一遍单步Np=3的调试流程,把每一步的变量值、约束表达式、求解结果都打印出来核对,确认无误后再上滚动循环。这样后面调Np、调末端约束、加预测误差,每一步出了问题都能快速定位。
我整理代码时还有一个习惯:每个滚动窗口的中间结果都用DataFrame存一份,列名分别是时间、预测光伏、预测负荷、P_ch、P_dis、P_buy、P_sell、SOC。调参的时候把不同参数组的运行结果合并对比,哪些时刻功率异常、哪些时刻SOC紧张一目了然。这套"先打印,再存表,后画图"的工作流,比对着终端日志猜问题要高效得多,推荐你也试试。