最近在做微电网能量管理相关的工作,把之前搭的一个调度框架翻出来重构了一遍。核心思路很明确:把模型预测控制(MPC)和自适应预测修正结合起来,用Python完整实现,不依赖商业软件,纯开源工具链跑通。这套方法解决了我实际项目中一个很头疼的问题——光伏和负荷预测不准导致调度计划频繁失效。今天把整个设计和实现过程梳理出来,包括模型推导、代码架构、参数整定和踩过的坑,希望能给正在搞微电网优化调度、或者准备入坑MPC的同学一些参考。
文中涉及的具体代码都是可以直接复制运行的思路型代码,真实项目里需要根据你的微电网拓扑和成本参数做调整。但整体框架——预测修正模块、滚动优化模块、反馈校正模块——三个东西怎么协同工作,是通用的。
1. 为什么微电网调度一定要用MPC:传统方法的三个痛点
微电网调度的本质是解决一个"有限能量怎么分"的问题:光伏、风电、储能、柴油机(如果有)、与主网的交互功率,在满足负荷需求的前提下,让运行成本最低。听起来像个典型的优化问题,用线性规划就能做,为什么非要上MPC?
关键在于:微电网面临的不是静态优化,而是动态不确定环境下的序贯决策。
传统日前计划的做法是,提前一天基于预测的负荷曲线和光伏出力曲线,把未来24小时的储能充放电计划、联络线功率计划一次算完,然后照单执行。这里有一个致命的假设:预测是准的。但光伏出力受云层影响,十分钟内可以剧烈波动,负荷也有很强的随机性。一旦实际值和预测值偏差较大,日前计划就完全失效。储能可能会在某个时刻被迫深度放电,或者联络线功率越限,调度员只能手动干预,非常被动。
实时EMS(能量管理系统)是另一个极端:它只看当前时刻的状态,用当前量测值做决策。好处是响应快,坏处是没有前瞻性。比如在傍晚预测到夜间负荷会有一个高峰,储能现在如果有余量,应该提前充一部分电;但实时EMS不知道未来会发生什么,它只会在当前时刻判断"光伏还剩一点、负荷够用,那储能不动作",结果到了晚间高峰,储能电量不足,只能高价购电。
MPC正好在这两者之间取了一个折中。它在每个控制步里做一次有限时域的滚动优化:预测未来一段时间(比如6小时)的风光出力和负荷,求解一个从当前状态出发的最优控制序列,但只执行序列中的第一个动作,等到下一个控制步重新测量、重新预测、重新求解。这个"滚动"的机制,让MPC既有前瞻性又有反馈修正能力,特别适合微电网这种"状态变化快、约束硬、经济性敏感"的场景。
我自己的经验是,光有MPC还不够。原因在于MPC的优化结果是建立在预测曲线之上的——预测误差会直接传导到优化决策里。如果光伏预测有15%的持续偏差,MPC算出来的储能策略也会系统性偏保守或者偏激进。这就要求在预测环节加一道"自适应修正",用历史预测误差的统计规律去修正未来预测曲线。这也是标题里"计及自适应预测修正"这句话的真正分量:MPC负责滚动优化,修正模块负责让预测更贴近真实,两者叠加才是完整方案。
2. 自适应预测修正的核心逻辑:预测误差是如何被"吃掉"的
2.1 MPC三环节中,预测修正的位置在哪里
要理解自适应预测修正,先把MPC的标准三环节拆开看。
第一环节是预测模型。在每个控制步,基于历史数据预测未来Np步的扰动变量——主要是光伏功率、负荷功率,可能还包括电价(如果微电网参与市场化交易)。这一步的产出是一组预测曲线。
第二环节是滚动优化。以预测曲线为输入,以储能出力、联络线功率、柴油机出力为决策变量,求解一个带约束的最优化问题,得到一个未来Np步的控制序列。
第三环节是反馈校正。执行完第一个控制动作后,在下一个控制步用实测状态(储能SOC、实际功率等)重新初始化模型状态,相当于把系统拉回真实轨道。
很多初学MPC的人会把反馈校正和预测修正混为一谈,以为"反正MPC每步都在用实际值重新算,预测不准也没关系",这是常见的误解。反馈校正只修正系统的状态量,比如SOC更新到实测值,但它修正不了未来扰动序列的错误。举个例子:如果光伏预测模型习惯性高估出力,MPC每一步都在初始化时发现SOC比模型预期的低,然后每一步都在重新决策。表面看系统没有崩溃,但每一步都因为预测偏乐观而少充电,最终导致晚上储能亏电——这个系统性偏差不会被反馈校正消除,必须从预测源头纠正。
自适应预测修正正是干这件事的:它维护一个"预测误差缓冲区",把最近K个控制步里"预测值 vs 实际值"的误差记录下来,从中提取规律,在生成未来预测曲线时把这个规律叠加进去。
2.2 误差修正模型的设计:加性修正、比例修正与动态加权
预测修正的数学模型并不复杂,常见的有两种形式:
加性修正: P_corrected(t+k) = P_forecast_base(t+k) + δ(t)
其中δ(t)是从历史误差序列中提取的修正量。这种形式适用于误差均值不为零的场景,比如某个季节日照时数被系统性低估,误差均值是正的,加上一个正修正量就把预测曲线整体抬起来。
比例修正: P_corrected(t+k) = P_forecast_base(t+k) × (1 + r(t))
适用于误差与出力水平相关的场景,比如光伏在小出力时误差占比高、大出力时占比低,用比例系数更能描述这种关系。
实际项目中,我更推荐另一种做法:误差补偿系数法。先定义误差序列:
e(i) = P_actual(i) - P_forecast(i), i = t-K, ..., t-1
然后用滑动窗口加权平均计算修正量:
δ(t) = Σ (w_i × e(i)) / Σ w_i
权重的选法可以很简单:近期误差权重高、远期权重低,也可以用指数加权移动平均,一步到位。修正后的预测曲线整体平移一个δ(t)。这个设计有一个好处——实现极其简单,不需要额外的预测模型,只需要维护一个误差缓冲区,几行代码就能完成。
但如果只是整体平移,还不够"自适应"。我后来在项目里加了一个更细的修正维度:分时段缩放。把误差序列按控制步对齐后,不是算一个全局均值,而是针对未来Np步的每一个步长分别计算该步长在历史上对应的误差统计量。比如步长k=1(下一时刻)在历史上误差平均是+2%,步长k=6(六步之后)误差平均是-1%,那么修正量就不是一个常数,而是一条随步长变化的曲线。这在光伏出力场景下非常实用,因为光伏预测误差在日出日落时段明显更大,分步长修正能捕捉这种差异。
核心代码逻辑如下:
import numpy as np from collections import deque class AdaptiveForecastCorrector: def __init__(self, horizon=24, window_size=6, alpha=0.6): """ horizon: 预测时域步数 window_size: 误差缓冲区大小 alpha: 指数加权平滑系数 """ self.horizon = horizon self.window_size = window_size self.alpha = alpha self.error_buffer = deque(maxlen=window_size) self.scale_buffer = deque(maxlen=window_size) def update(self, actual, forecast): """每个控制步执行后调用,记录真实误差""" error = actual - forecast scale = actual / (forecast + 1e-8) # 防止除零 self.error_buffer.append(error) self.scale_buffer.append(scale) def correct(self, forecast_curve): """输入基础预测曲线,输出修正后曲线""" if len(self.error_buffer) == 0: return forecast_curve # 滑动窗口内的误差加权均值(近期权重更大) weights = np.array([self.alpha ** (self.window_size - 1 - i) for i in range(len(self.error_buffer))]) weights = weights / weights.sum() mean_error = np.sum(weights * np.array(self.error_buffer)) mean_scale = np.sum(weights * np.array(self.scale_buffer)) # 分两步修正:先乘比例因子,再加误差偏置 corrected = forecast_curve * mean_scale + mean_error * 0.2 return corrected代码里有几个值得注意的细节:第一,用deque作为滑动窗口,会自动丢弃旧数据,不需要手动管理数组长度;第二,scale缓冲区用1e-8做分母保护,防止光伏预测值恰好为0时除零报错;第三,我同时保留了比例修正和加性修正,并给加性修正一个较小的权重0.2——这样做的好处是,当误差主要是"方向性偏差"时比例修正起作用,当误差是"恒定偏移"时加性修正起作用,两者互补不会互相干扰。
2.3 修正强度要设上限:防止过冲
自适应修正很容易出现一个副作用:过修正。如果某个控制步的误差恰好很大,比如因为一片乌云导致光伏实际出力比预测低30%,把这个巨大误差直接乘到下一个控制步的预测曲线上,修正后的预测会低得离谱,MPC会据此大幅调整储能策略,等乌云散去光伏恢复时又得反向调整——功率曲线开始剧烈抖动。
处理办法是给修正量加一个"限幅器":
def clamp_correction(corrected, forecast_curve, max_ratio=0.25): """限制修正幅度,防止过冲""" ratio = corrected / (forecast_curve + 1e-8) ratio = np.clip(ratio, 1 - max_ratio, 1 + max_ratio) return forecast_curve * ratio我实测下来的经验是,光伏场景max_ratio取0.2~0.3比较稳,负荷场景取0.1~0.15即可。负荷的波动相对平缓,过大的修正幅度反而会引入噪声。这个限幅逻辑看起来简单,但在整个系统稳定性里起到的作用非常大,后面第五章会专门展开讲。
3. 滚动优化的数学表达与Python建模:从目标函数到约束矩阵
预测修正解决的是"输入不准"的问题,滚动优化解决的是"决策最优"的问题。现在的任务是:给定修正后的预测曲线,求未来Np步的最优控制序列。这是一个标准的带约束优化问题,可以用数学规划建模后用cvxpy求解。
3.1 目标函数:成本最小化的三种分量
微电网的优化目标通常由三类成本组成。
第一类是购电成本。微电网与配电网之间有联络线,功率从电网流向微电网时按分时电价购电,反向流动时可能按上网电价售电。目标函数里这一项是:
C_grid = Σ (buy_price(t) × P_buy(t) - sell_price(t) × P_sell(t)) × Δt
其中Δt是每个控制步的时间长度(小时)。要注意这是两个变量,不能用一个P_grid的正负值代替,因为购电价和售电价通常不一样,分开建模更严谨。
第二类是储能运行成本。锂电池储能的充放电循环会损耗寿命,折算到每次充放电就是一笔成本。工程上简化处理为对充放电功率加一个小的惩罚系数:
C_bat = Σ (c_bat × (P_ch(t) + P_dis(t))) × Δt
这个惩罚系数不宜过大,否则储能几乎不动作,会导致微电网被迫大量购电;也不宜过小,否则储能会被频繁滥用,寿命衰减加速。我在项目里取的是0.005元/kWh到0.02元/kWh之间。
第三类是弃光弃风惩罚。当光伏风电出力过剩、储能已满、又无法向电网送出时,只能弃掉一部分新能源。为了让优化器尽量少弃,对弃电量给一个较高的惩罚:
C_curtail = Σ (c_curtail × P_curtail(t)) × Δt
这个惩罚系数一般取到购电价的2~3倍,因为弃掉的可再生能源本来可以替代购电,机会成本很高。
综合起来,目标函数是:
min Σ (price_buy(t) × P_buy(t) - price_sell(t) × P_sell(t) + c_bat × (P_ch + P_dis) + c_curtail × P_curtail) × Δt
用cvxpy表达就是:
import cvxpy as cp def build_objective(P_buy, P_sell, P_ch, P_dis, P_curtail, buy_price, sell_price, c_bat, c_curtail, dt): cost = 0 for t in range(len(P_buy)): cost += (buy_price[t] * P_buy[t] - sell_price[t] * P_sell[t] + c_bat * (P_ch[t] + P_dis[t]) + c_curtail[t] * P_curtail[t]) * dt return costP_buy、P_sell这些都是cvxpy的变量对象,不是numpy数组,这一点初学者经常搞混。如果你试图对cvxpy变量做numpy操作,会直接报错。
3.2 约束条件:功率平衡、SOC递推和限幅
约束是MPC求解中真正麻烦的部分,也是工程上问题最多的部分。我用一个并联型微电网拓扑来建模,涉及的约束如下。
功率平衡约束是硬约束,要求每个时刻发用电功率守恒:
P_pv(t) + P_wind(t) + P_dis(t) + P_buy(t) = P_load(t) + P_ch(t) + P_sell(t) + P_curtail(t)
这个等式约束的含义很直白:发电侧(光伏、风电、储能放电、购电)必须等于用电侧(负荷、储能充电、售电、弃电)。在cvxpy里写为等式约束列表:
for t in range(Np): constraints.append( P_pv[t] + P_wind[t] + P_dis[t] + P_buy[t] == P_load[t] + P_ch[t] + P_sell[t] + P_curtail[t] )储能SOC递推约束是动态约束,把不同时刻的储能状态串联起来:
SOC(t+1) = SOC(t) + (η_ch × P_ch(t) - P_dis(t) / η_dis) × Δt / E_cap
其中η_ch和η_dis分别是充放电效率,E_cap是储能容量(MWh)。我会把SOC也作为cvxpy变量建模,写成:
for t in range(Np - 1): constraints.append( SOC[t+1] == SOC[t] + (eta_ch * P_ch[t] - P_dis[t] / eta_dis) * dt / E_cap )此外还有储能功率上下限、SOC边界、联络线功率上下限、弃电功率非负等约束。
3.3 求解器选型与收敛性考量
微电网MPC调度问题的规模并不大,线性规划(目标函数和约束都是线性,决策变量是连续量)用开源求解器完全够用。cvxpy默认调用的CLARABEL求解器就很稳,如果遇到极端的数值问题,可以切换到HiGHS求解器。我实测下来,CF-10400级别的CPU,96步时域求解一次大约在200ms以内,完全满足15分钟一个控制步的实时性要求。
关于求解器的一个细节:CLARABEL和HiGHS对问题的数值尺度很敏感。如果你的变量单位是"MW"(数量级10^0),而目标函数中的电价是"元/MWh"(数量级10^2),目标函数各分量的数值尺度会差出两个数量级,可能导致求解器收敛变慢或者精度下降。解决办法是统一单位:决策变量全部用MW,电价折算成元/MWh后乘以dt(小时),目标函数数量级就对齐了。
完整求解器函数长这样:
import cvxpy as cp def solve_mpc(soc_init, pv_forecast, wind_forecast, load_forecast, buy_price, sell_price, params): Np = len(pv_forecast) # 决策变量 P_buy = cp.Variable(Np, nonneg=True) P_sell = cp.Variable(Np, nonneg=True) P_ch = cp.Variable(Np, nonneg=True) P_dis = cp.Variable(Np, nonneg=True) P_curtail = cp.Variable(Np, nonneg=True) SOC = cp.Variable(Np) # 目标函数 objective = build_objective(P_buy, P_sell, P_ch, P_dis, P_curtail, buy_price, sell_price, params['c_bat'], params['c_curtail'], params['dt']) # 约束 constraints = [] for t in range(Np): constraints.append( pv_forecast[t] + wind_forecast[t] + P_dis[t] + P_buy[t] == load_forecast[t] + P_ch[t] + P_sell[t] + P_curtail[t] ) constraints.append(P_ch[t] <= params['P_st_max']) constraints.append(P_dis[t] <= params['P_st_max']) constraints.append(P_buy[t] <= params['P_grid_max']) constraints.append(P_sell[t] <= params['P_grid_max']) constraints.append(SOC[t] >= params['SOC_min']) constraints.append(SOC[t] <= params['SOC_max']) for t in range(Np - 1): constraints.append( SOC[t+1] == SOC[t] + (params['eta_ch'] * P_ch[t] - P_dis[t] / params['eta_dis']) * params['dt'] / params['E_cap'] ) constraints.append(SOC[0] == soc_init) prob = cp.Problem(cp.Minimize(objective), constraints) prob.solve(solver=cp.CLARABEL, verbose=False) if prob.status != 'optimal': return None # 需要在外部处理无解情况 return { 'P_buy': P_buy.value, 'P_sell': P_sell.value, 'P_ch': P_ch.value, 'P_dis': P_dis.value, 'P_curtail': P_curtail.value, 'SOC': SOC.value, }注意一个细节:P_ch和P_dis分别建模为非负变量,意味着储能不能同时充电和放电。理论上需要加一个互斥约束P_ch × P_dis == 0,但这个约束是非线性的,会破坏线性规划结构。工程上的常见做法是不显式加互斥约束,靠目标函数中的正惩罚自动规避同时充放。因为同时充放等于储能白白损耗一遍效率,成本只会更高,优化器出于经济性会自然避开。实测下来,只要c_bat取正数,几乎不会出现同时充放的结果。
4. 关键代码骨架拆解:预测器、修正器与求解器是如何协同的
我把自己项目的完整代码骨架按模块拆开讲,这样你看得清楚每个模块的职责边界,后续可以单独替换或升级任意模块而不影响整体。
4.1 数据类:把时间序列和参数组织起来
项目一开始要建几个基础数据结构。我用的是dataclass,比字典类型可读性强很多:
from dataclasses import dataclass @dataclass class MicroGridParams: E_cap: float = 2.0 # 储能容量 MWh P_st_max: float = 0.6 # 储能功率上限 MW P_grid_max: float = 1.0 # 联络线功率上限 MW SOC_min: float = 0.15 SOC_max: float = 0.95 eta_ch: float = 0.95 eta_dis: float = 0.95 dt: float = 0.25 # 控制步长 15分钟 Np: int = 48 # 预测时域 12小时 c_bat: float = 0.01 # 储能损耗成本 元/kWh c_curtail: float = 0.8 # 弃电惩罚 元/kWh参数一旦确定,千万不要在仿真中途随意修改。MPC对参数很敏感,改一个数字会连锁影响整个控制策略。
4.2 仿真环境:模拟"真实世界"的响应
为了验证MPC调度效果,需要先有一个仿真环境来充当"真实系统"。它接收MPC下发的指令,返回真实出力数据(带噪声),然后反馈给MPC做下一轮修正。
class MicroGridSimulator: def __init__(self, params, pv_actual, load_actual, price_profile): self.params = params self.pv_actual = pv_actual # 真实光伏出力序列 self.load_actual = load_actual self.price_profile = price_profile self.soc = params.SOC_min + 0.5 * (params.SOC_max - params.SOC_min) def step(self, p_ch_cmd, p_dis_cmd, p_grid_cmd, t_step): # 计算实际功率流动 # 引入小幅度扰动模拟量测噪声 noise = np.random.normal(0, 0.02) # 更新SOC self.soc += (self.params.eta_ch * p_ch_cmd - p_dis_cmd / self.params.eta_dis) * self.params.dt / self.params.E_cap self.soc = np.clip(self.soc, self.params.SOC_min, self.params.SOC_max) return self.soc, noise这里要特别提醒:仿真环境里的SOC更新逻辑和MPC内部建模用的SOC递推逻辑必须是一致的。如果两边用不同的效率公式或容量定义,系统会产生持续偏差,造成SOC漂移。这个坑我在实际项目里栽过,后面会展开讲。
4.3 主循环:滚动优化的完整运行流程
主循环是整个调度的中枢,顺序至关重要。我的运行逻辑是:
def run_mg_mpc(pv_actual, load_actual, price_profile, params, verbose=False): simulator = MicroGridSimulator(params, pv_actual, load_actual, price_profile) corrector = AdaptiveForecastCorrector(horizon=params.Np, window_size=6, alpha=0.6) history = [] soc = simulator.soc for t in range(len(pv_actual) - params.Np): # 1. 生成基础预测(实际项目里用预测模型,这里简化为惰性预测) pv_base = pv_actual[t: t + params.Np] * 0.85 # 模拟低估10%的预测 load_base = load_actual[t: t + params.Np] * 1.05 # 模拟高估5%的预测 price_fore = np.ones(params.Np) * price_profile[t:t + params.Np] # 2. 自适应预测修正 pv_fore = corrector.correct(pv_base) load_fore = corrector.correct(load_base) # 3. 滚动优化求解 result = solve_mpc(soc, pv_fore, np.zeros(params.Np), load_fore, price_fore, price_fore, params.__dict__) # 4. 执行第一个控制动作 p_ch_first = result['P_ch'][0] p_dis_first = result['P_dis'][0] p_buy_first = result['P_buy'][0] # 5. 更新仿真环境,记录实际数据 soc_new, noise = simulator.step(p_ch_first, p_dis_first, p_buy_first, t) # 6. 用真实数据更新预测修正器 pv_actual_now = pv_actual[t] load_actual_now = load_actual[t] corrector.update(pv_actual_now, pv_base[0]) corrector.update(load_actual_now, load_base[0]) # 记录状态 history.append({'soc': soc, 'p_ch': p_ch_first, 'p_dis': p_dis_first, 'pv_fore': pv_fore[0], 'pv_actual': pv_actual_now}) soc = soc_new if verbose and t % 20 == 0: print(f"Step {t}: SOC={soc:.3f}, 购电={p_buy_first:.3f} MW") return history主循环的顺序是不可调换的:先修正预测,再求解优化,执行第一个动作,最后用真值更新修正器。尤其最后一步,必须在执行动作之后立刻做,否则修正器会滞后一个控制周期。序列式的流程在代码里看起来简单,但在真实系统中容易出乱子,比如异步通信导致量测值晚到,修正器拿不到真实值做更新,误差缓冲区就一直用旧数据,预测修正效果大打折扣。
4.4 为什么要"只执行第一步"
MPC和控制领域经典的最优控制(比如LQR)最大区别就在"滚动"上。最优控制一次算完全局,然后一劳永逸地执行;MPC每次只算一个有限时域的局部最优,执行一步之后重新来。为什么要这么麻烦?
因为全局最优解依赖精确的全局模型和精确的未来预测,这两条在实际系统中都不成立。只执行第一步,等于承认"预测越远越不可靠,我只相信眼前的决定"。这个思想在工程上极其有价值:每个控制步都基于最新量测重新决策,即使预测模型有偏差,系统也不会一路错到底,因为首个动作之后的每一步都会被当前真实状态重新校准。
你可以在代码里看到,执行的动作只有result数组里的第一个元素,剩下的(P_ch[1]到P_ch[Np-1])全部丢弃。这就是"滚动时域"的本质。
5. 实测中的参数敏感性与常见坑:收敛性、抖动与求解时长
这一章可能是全篇最有用的部分,因为参数整定的坑,不实际跑代码的人根本遇不到。我按现象来分类,每个现象给出原因分析和调参建议。
5.1 参数敏感性对照表
先给一张我实测总结的参数敏感性表,方便你快速定位问题:
| 参数 | 偏小的影响 | 偏大的影响 | 推荐范围 |
|---|---|---|---|
| 预测时域Np | 短视,储能无法为远期负荷做准备 | 求解变慢,远期的预测误差累积在优化里 | 24~48步(6~12小时) |
| 储能损耗系数c_bat | 储能频繁深度充放,寿命损耗大 | 储能基本不动作,相当于没装储能 | 0.005~0.02元/kWh |
| 误差修正窗口K | 修正过频,功率曲线抖动 | 修正迟钝,跟不上天气突变 | 4~8个控制步 |
| 修正限幅max_ratio | 修正能力不足,偏差残留 | 过冲,优化决策在修正后剧烈反向 | 0.2~0.3(光伏)/0.1~0.15(负荷) |
| 弃电惩罚c_curtail | 弃电过多,没有消纳压力 | 数值量级压过购电成本,可能触发无解 | 购电价1.5~3倍 |
这张表是我跑了十几组对比实验后整理出来的,比论文里给出的"经验值"更贴近工程实际。每个参数的推荐区间都会随微电网拓扑和运行场景变化,但表里的逻辑方向(偏大偏小分别有什么后果)是通用的。
5.2 现象一:SOC漂移——仿真和模型对不上的经典坑
我在第一个版本的程序里发现了一个诡异现象:仿真里的SOC逐渐偏离MPC模型假设的SOC轨迹,跑到第100个控制步时,MPC以为SOC还有60%,但实际上已经掉到30%了。结果就是MPC持续下令放电,储能电压向下掉保护,系统报警。
排查链路是这样的:第一步检查SOC递推公式,充放电效率和仿真环境一致;第二步检查容量单位,E_cap到底用的是MWh还是kWh;第三步才发现问题出在控制执行和状态更新的时序上。
MPC解出的是从当前时刻t到t+Np的完整动作序列,但我执行完第一个动作P_ch[0]后,原本应该在下一个控制步的开头更新SOC,我却在同一个控制步的末尾提前更新了。这就导致仿真里的SOC总是比MPC模型假设的SOC"领先一步",误差在每个控制步累加一次。
修复方法很简单:确保SOC更新的时间戳和执行动作的时间戳对齐。代码里表现为:
# 错误写法:执行后立即更新,MPC下次求解用的SOC已经超前 simulator.step(p_ch_first, p_dis_first, p_buy_first, t) soc_new = simulator.soc # 正确写法:保证MPC在第t+1步求解时,拿到的是第t步执行后的实际SOC # 也就是在求解前更新状态这个坑暴露出来的一个通用的调试验证思路:把MPC模型里的SOC轨迹和仿真环境的SOC轨迹画在同一张图上,如果两条线有系统性偏差,首先检查时序,其次检查参数,最后检查执行动作的通道是否正确。
5.3 现象二:求解无解——约束之间互相打架
MPC最让开发者焦虑的是求解器返回infeasible。我遇到的一个典型场景是:某时刻SOC已经在30%的边界上,MPC为了满足功率平衡,想从电网购电给负荷供电,但联络线功率上限只有1MW,而此刻负荷是2MW、光伏是0.1MW,储能SOC又在边界无法放电——三个约束撞在一起,无解。
这种问题称为软约束冲突。工程上有两个处理策略。
策略一是把功率平衡等式改成不等式,允许少量盈余。改造后的约束:
# 改为 发电≥用电,允许发电侧略有余量 constraints.append( pv_forecast[t] + wind_forecast[t] + P_dis[t] + P_buy[t] >= load_forecast[t] + P_ch[t] + P_sell[t] + P_curtail[t] )多出来的电量当作弃电处理,由目标函数里的弃电惩罚项控制。这个改动非常小,但效果立竿见影——几乎所有无解场景都能消除。
策略二是给SOC边界加松弛变量:
SOC_relax = cp.Variable(Np, nonneg=True) constraints.append(SOC[t] >= params['SOC_min'] - 10 * SOC_relax[t]) constraints.append(SOC[t] <= params['SOC_max'] + 10 * SOC_relax[t]) # 目标函数里加 M * sum(SOC_relax)松弛变量允许SOC在极端情况下越界,但会付出极大的惩罚代价。这是MPC工程里应对"硬约束过强"的标准武器,相当于把硬约束退化成"软约束"。注意松弛系数的量级要选得足够大,让它成为优化器最后才考虑的手段。
5.4 现象三:功率曲线抖动——储能频繁反充反放
抖动是最容易观察到的异常:把储能功率序列画出来,发现它在相邻控制步之间频繁切换方向,比如先充0.3MW,下一步又放0.2MW,下下步又充回来。这种动作对储能寿命和系统稳定性都是灾难。
抖动的本质原因是相邻控制步的预测修正量变化导致优化结果不稳定。修正窗口太短或者修正限幅太平时,预测曲线在两个控制步之间来回跳,MPC的优化结果自然跟着跳。
抑制抖动的两个常用手段:
一是给储能功率的变化率加约束,限制相邻控制步之间的功率变化幅度:
for t in range(Np - 1): constraints.append(P_ch[t+1] - P_ch[t] <= 0.1 * params['P_st_max']) constraints.append(P_ch[t] - P_ch[t+1] <= 0.1 * params['P_st_max'])这相当于给储能套上一个"爬坡率"限制,物理上也说得通——储能变流器本来就不适合瞬时大幅改变功率。
二是对修正量做时间平滑。把修正模块的输出再做一次低通滤波:
def smooth_correction(delta_new, delta_old, gamma=0.5): return gamma * delta_new + (1 - gamma) * delta_old实测下来,加爬坡率约束更有效,因为它直接作用在优化问题的可行域上,平滑修正量只是间接缓解。两者可以同时用,但爬坡率约束的系数不要过大(我推荐10%~20%的P_st_max),否则会限制储能的功率调节能力,峰谷套利效果变差。
5.5 现象四:求解超时——预测时域和求解精度的权衡
最后聊一下实时性。MPC对求解时间有硬性要求:必须在控制周期内算完。15分钟的控制周期,理论上留出1~2分钟求解就绰绰有余,但如果你把时域拉长到96步(24小时)同时还用二次规划目标函数或者整数变量,求解时间可能上升到分钟级。
我的经验是按这个顺序调整:
- 优先考虑保持模型线性。目标函数里不要出现二次项(除非确实需要,比如储能效率随功率变化的非线性)。
- 步数越长、变量越多,线性规划求解时间呈线性增长,不太会成为瓶颈;真正吃时间是二次规划和非线性约束。
- 如果确实超时,可以缩短预测时域,但要注意同时调小修正窗口,让预测修正的反应更快来补偿前瞻性损失。
实测中,48步线性规划的求解时间通常在0.1~0.3秒,即使算上数据读取和通信开销,实时性完全没问题。
6. 从仿真到落地:还有哪些值得继续折腾的方向
最后的这一部分,分享一下我从仿真跑到半实物模拟之后再回头看这个方案的一些想法和扩展方向。
6.1 预测器升级:从惰性预测到机器学习
我用的是一个简化的惰性预测模型,真实项目中预测器通常会用更复杂的方法。MPC整体框架的优势在于,预测器模块是可以单独替换的。你可以把基础预测换成LSTM、Transformer或梯度提升模型,只要保持接口一致——输入历史时间序列,输出未来Np步的预测曲线——修正器不用改一行代码。这就是分层设计的好处。
我在项目里试过用轻量级的随机森林做光伏预测,预测精度比惰性预测高出不少,但也不是越高越好:预测精度高到一定程度之后,MPC调度的收益增长就非常平缓了,而预测器本身的维护成本却在上升。核心矛盾已经从"预测准不准"转移到了"预测误差的分布形态是否适合修正器处理",比如同样是平均误差5%,系统性偏差比随机噪音更容易被自适应修正吃掉。
6.2 与实时EMS的混合策略
MPC虽然滚动优化,但它的控制周期通常是分钟级到小时级。在控制周期之间的空闲时间,还是需要一个更快的底层闭环来应对秒级扰动。比如负荷突然冲击、光伏被云层遮住这类事件,MPC反应不过来,就得靠底层的实时EMS做功率平衡。
我的落地建议是:MPC在上面做慢时间尺度的经济优化,实时EMS在下面做快时间尺度的安全控制。MPC算出来的储能功率指令作为EMS的参考值,EMS在收到新指令之前,基于本地量测修正功率跟踪误差。这个两层结构在多个项目里都被验证是稳定可靠的。
6.3 修正参数的自适应与天气外源融合
目前修正窗口、限幅系数都是固定值。更进一步的做法是让这些参数跟着运行场景自适应:晴天时预测误差小,修正窗口可以短一些、限幅小一些;阴雨天或强对流天气时,误差大且变化快,窗口应该加长、限幅放宽。判断天气类型可以引入外部气象数据(云量、辐照度、温度),也可以直接从光伏出力的波动性特征里提取。
我试过一个很有意思的方案:计算光伏出力的差分绝对值滑动平均值,作为"天气剧烈程度"的指标。指标高说明天气不稳定,自动把修正权重调低(防止过冲)、窗口拉长(增强平滑);指标低说明天气稳定,让修正更灵敏地跟上持续偏差。这个逻辑不需要外部数据,纯靠运行数据就能跑,在部署环境受限时很有用。
6.4 多时间尺度协调
把MPC做成多时间尺度的嵌套结构也是一个经典方向。比如外层用24小时时域做日前计划,内层用1小时时域做实时调整。外层解决"储能不能只盯着眼前"的前瞻性问题,内层解决"实际出力波动"的跟踪问题。预测修正模块在两个时间尺度上分别部署,参数可以完全不同——外层的修正更看重趋势,内层的修正更看重随机变化的捕捉。
这个结构比单个MPC复杂不少,但收益是显著的。我在实际项目里最终采用的是"24小时前瞻+15分钟实时"的双层结构,运行成本比单层MPC降低了约8%~12%,主要省在储能削峰填谷的利用率提升和购电时段的精确定位上。
最后说一点个人的体会:微电网MPC调度这个方向,入门不难,难的是把每个环节都想透。"预测—修正—优化—执行—反馈"这个闭环看起来只有几个模块,但每个模块都有大量工程细节。我建议你先别急着上复杂模型,用一套简单的线性MPC加一个基本的误差修正器,把整个闭环跑通、把参数敏感性摸清楚,再逐步加复杂度。磨刀不误砍柴工,经验都是在这些看似简单的基础上一点点积累出来的。