)
我最早接触“日前-日内两阶段优化调度”的时候第一反应是这不就是先把24小时跑一遍日内再用最新预测滚一次吗真正动手用Matlab把模型搭起来又补了电价、光伏、风电、负荷四个参数的独立敏感性分析之后才发现这里面的门道远比“拆成两段”复杂得多。这篇文章就把我从建模、写代码、调求解器到出敏感性分析结果的完整过程捋一遍适合正在做微电网调度、虚拟电厂策略、或者毕业论文里需要补一组敏感性分析的同学参考。我下面用的算例以一套100kW微电网为背景含一台燃气轮机、一组储能、本地光伏和风电接入并允许从配网购售电。模型用Yalmip搭建求解器用CPLEX代码结构上把日前阶段和日内阶段分开然后在外层循环里做参数扰动最终把四个参数的敏感性结果画成曲线和表格。1. 两阶段调度到底解决什么从“一天只算一次”到“滚动修正”1.1 单阶段调度为什么扛不住新能源波动很多入门资料会把优化调度简化成一个非常“干净”的问题给定明天的负荷曲线、光伏出力曲线、风电出力曲线和分时电价一次求解出各个机组和储能的出力。思路没错但放到实际运行里单阶段调度最大的问题是它假设预测完全准确。光伏和风电的预测误差是客观存在的。天气预报给出的辐照度和实际云层遮挡之间的偏差往往远超储能一天能调节的裕量。负荷预测也一样一个临时的大型设备启动就能让功率平衡产生明显偏移。单阶段调度把所有决策都压在日前一拍一旦日内实际值偏离预测值计划就失去最优性甚至连安全约束都可能被破坏。我最早试着用单阶段模型去跑一组带强随机波动的风电数据结果功率平衡约束在午后时段频繁接近边界储能SOC也经常顶到上限。这一版模型虽然能出结果但拿到实际环境下根本不敢直接投运因为没有任何机制去修正预测误差带来的偏差。1.2 日前阶段定什么日内阶段改什么两阶段调度的核心思想是把决策拆成“确定性基础”和“不确定性修正”两层。日前阶段Day-ahead以1小时为间隔覆盖完整24小时做的是最粗粒度的决策燃气轮机的启停和出力基线、储能的充放电时段、以及从配网购电还是卖电的总体策略。由于这一阶段面临的不确定性最大它的目标函数里通常包含对未来成本的期望估计但不会去精确模拟每一个15分钟内的波动。日内阶段Intraday则更短视典型做法是每15分钟滚动一次每次优化往前看若干个小时同时把最新的光伏、风电、负荷预测值填入模型。这个阶段并不推翻日前计划而是在日前计划给出的状态基础上做增量修正。比如日前决定下午2点开始充电日内发现光伏出力比预期强就把充电功率往上提或者把燃气轮机出力往下压。你可以把两阶段调度理解为“先排班再微调”。排班阶段管的是哪些设备开、哪些设备关这类大方向问题微调阶段管的是功率到底调多少这类小但频繁的问题。两种决策的时间常数不同需要的预测精度也不同硬塞进一个单层模型要么求解时间无法接受要么对预测误差的反应过于迟钝。1.3 敏感性分析在这个框架里的位置调度模型搭好之后下一步自然要问结果靠不靠谱参数一变结果会怎么变这正是敏感性分析的工作。电价、光伏、风电、负荷这四个参数是这个模型最主要的输入变量。电价决定经济利益光伏和风电是绿色出力的来源负荷是必须要满足的硬需求。它们各自都有明显的不确定性但它们的性质完全不一样电价是人定的光伏风电靠天吃饭负荷是需求侧行为的结果。对它们分别做独立敏感性分析能告诉我们同一个调度策略到底被哪个外部变量“牵着鼻子走”也能帮我们判断是否需要为某个参数专门设计更精细的预测或应对机制。2. 数学模型目标函数和约束条件怎么落到可求解的线性规划2.1 日前阶段目标函数拆解日前阶段的优化目标通常是最小化全天总运行成本。我把目标函数拆成四块第一块是购电成本从配网购电的费用按分时电价计算。 第二块是售电收益光伏或储能多余的电上网卖钱目标函数里会以负项出现。 第三块是燃气轮机的燃料成本可以用线性化的分段函数近似。 第四块是储能充放电的运行损耗成本或者按启动次数折算的退化成本。写成一个比较通用的形式就是min ∑ [ price_buy(t)*P_buy(t) - price_sell(t)*P_sell(t) c_gas*P_gen(t) c_bat*(P_ch(t)P_dis(t)) ]注意price_sell通常是上网电价一般低于购电价否则就会出现“低买高卖”的套利漏洞导致优化结果失真。很多初学同学在模型里把购电和售电价格设成一样结果模型疯狂地同时充电和放电最后靠约束才知道不对。2.2 日内阶段滚动优化的目标修正日内阶段的目标函数结构和日前基本相同但需要额外加入“对日前计划的跟踪项”。如果不加日内优化会为了局部最优而大幅偏离日前计划导致设备启停频繁、储能滥用甚至把日前阶段的全局优化意义完全架空。我的做法是在目标函数里加入两个惩罚项penalty λ1 * ∑|P_actual(t) - P_ref(t)| λ2 * ∑|SOC(t) - SOC_ref(t)|这两个惩罚项让日内优化在“局部最优”和“保持日前计划稳定”之间取平衡。λ1和λ2不能设得太大否则日内阶段完全没有修正能力也不能太小否则滚动结果抖动得很厉害。我一般先取0.1~0.2倍的单时段成本量级再根据仿真曲线的平滑程度微调。2.3 必须写进约束集的几个关键等式与不等式无论日前还是日内阶段下面这组约束是必须的。功率平衡约束这是模型里最核心的等式P_buy(t) P_pv(t) P_wind(t) P_gen(t) P_dis(t) P_load(t) P_sell(t) P_ch(t)储能SOC动态方程SOC(t) SOC(t-1) η_ch*P_ch(t)*Δt - P_dis(t)/η_dis*Δt这里η_ch和η_dis分别是充放电效率我习惯取0.95。注意SOC上下限要留一点裕量不要顶到0或100%否则后续预测一波动就无解。机组爬坡约束-P_ramp*Δt ≤ P_gen(t) - P_gen(t-1) ≤ P_ramp*Δt购售电不能同时发生的约束0 ≤ P_buy(t) ≤ u_buy(t)*P_buy_max 0 ≤ P_sell(t) ≤ (1-u_buy(t))*P_sell_max 0 ≤ u_buy(t) ≤ 1这个约束很重要。很多初版代码里漏了这一条导致两个变量同时取正值目标函数虽然不会受影响但实际物理上是荒谬的。2.4 四个待分析参数如何作为可调接口传入模型敏感性分析需要反复改变这四个参数所以建模一开始就要把它们定义为外部输入而不是写死在模型内部。我用的方式是把所有场景数据放在一个params结构体里params.price.forecast price_forecast; % 1×24 或 1×96 params.pv.forecast pv_forecast; params.wind.forecast wind_forecast; params.load.forecast load_forecast; params.scenario base;模型的每个约束和每个目标函数项都只引用params结构体里的字段。这样改参数的时候完全不用动模型代码只要在外层循环里往结构体里塞不同数据即可。3. Matlab代码实现Yalmip建模和滚动时域框架3.1 文件结构把数据、模型、求解、分析拆开我自己的项目代码通常分成这几个文件main.m流程控制决定是跑基准场景还是跑敏感性分析。load_scenario.m加载电价、光伏、风电、负荷曲线生成params结构体。day_ahead_model.m构建日前优化模型输出决策变量和约束。intraday_model.m构建日内滚动优化模型。run_sensitivity.m外层扰动循环调用单个场景并汇总指标。plot_results.m画曲线和敏感性柱状图。这样拆的好处是改模型逻辑不会污染数据分析调参数也不会误碰约束方程。对做研究的人来说代码可读性直接决定后续能不能继续用。3.2 日前阶段的核心代码段下面这段是我常用的日前模型写法变量名尽量和数学符号保持一致function [result] day_ahead_model(params) T params.horizon_hours; % 通常为24 dt 1; % 单位小时 % 决策变量 P_buy sdpvar(T,1); P_sell sdpvar(T,1); P_gen sdpvar(T,1); P_ch sdpvar(T,1); P_dis sdpvar(T,1); SOC sdpvar(T1,1); u_buy binvar(T,1); C []; C [C, SOC(1) params.soc_init]; % 初始SOC for t 1:T % 功率平衡 C [C, P_buy(t) params.pv.forecast(t) params.wind.forecast(t) ... P_gen(t) P_dis(t) ... params.load.forecast(t) P_sell(t) P_ch(t)]; % 储能动态 C [C, SOC(t1) SOC(t) params.eta_ch*P_ch(t)*dt ... - P_dis(t)/params.eta_dis*dt]; % 额定容量约束 C [C, 0 SOC(t1) params.soc_max]; C [C, 0 P_ch(t) params.p_ch_max]; C [C, 0 P_dis(t) params.p_dis_max]; % 机组出力上下限 C [C, params.p_gen_min P_gen(t) params.p_gen_max]; % 购售电互斥约束 C [C, 0 P_buy(t) u_buy(t)*params.p_buy_max]; C [C, 0 P_sell(t) (1-u_buy(t))*params.p_sell_max]; end Objective sum(params.price.buy .* P_buy - params.price.sell .* P_sell ... params.c_gas * P_gen ... params.c_bat * (P_ch P_dis)); ops sdpsettings(solver,cplex,verbose,0); optimize(C, Objective, ops); result.P_buy value(P_buy); result.P_sell value(P_sell); result.P_gen value(P_gen); result.P_ch value(P_ch); result.P_dis value(P_dis); result.SOC value(SOC); result.cost value(Objective); end注意我在功率平衡约束里直接用了params.pv.forecast(t)等值这些值是预测出的最大可用出力。实际优化中光伏和风电可以低于预测值也就是说它们在约束里应该是不等式上限而不是固定等式。我这里的写法是为了让模型保持线性简洁真正做日内阶段时通常会引入弃光弃风变量让模型决定是否丢掉一部分新能源出力。3.3 日内阶段滚动更新的写法日内阶段最核心的动作是“滚动”。我的实现思路是外层循环每个时段触发一次优化每次优化只取该时段之后的一段预测窗口并以上一步求得的SOC作为当前状态初值。function [result] intraday_rolling(params, horizon_steps) T params.horizon_minutes / params.step_minutes; % 例如 96 result.P_buy zeros(T,1); result.P_ch zeros(T,1); result.P_dis zeros(T,1); result.P_gen zeros(T,1); SOC_now params.soc_init; for k 1:T % 生成从k时刻开始的预测窗口 window build_window(params, k, horizon_steps); % 以SOC_now为初值求解窗口内的最优控制序列 [x_opt] intraday_model(window, SOC_now); % 只取窗口第一个时刻的控制量 result.P_buy(k) x_opt.P_buy(1); result.P_ch(k) x_opt.P_ch(1); result.P_dis(k) x_opt.P_dis(1); result.P_gen(k) x_opt.P_gen(1); SOC_now x_opt.SOC(2); % 更新状态 end end两个细节特别容易翻车。一是滚动窗口长度太短看不到负荷变化趋势太长又会把误差拉进来。我通常取4小时也就是16个15分钟时段。二是每次优化结束后只保留第一个时刻的控制量因为下一个时刻会有更新的预测进来不要用旧窗口里的第二个时刻结果。3.4 求解器设置和结果导出求解器配置直接决定求解速度和可行性。对于线性混合整数问题我习惯用sdpsettings(solver,cplex)并且设置两个关键参数ops sdpsettings(solver,cplex, verbose, 2, ... cplex.mip.tolerances.mipgap, 0.001, ... cplex.mip.tolerances.absmipgap, 1e-3);mipgap设为0.001意味着允许0.1%的次优性。如果你在跑敏感性分析循环次数很多完全没必要追求严格的整数最优那样会非常慢。结果导出我习惯用一个表结构把每天的功率曲线、SOC曲线、成本和购售电量都存下来。后面做敏感性分析时每次扰动都往这个大表里追加一行最后一次性汇总。4. 电价、光伏、风电、负荷的独立敏感性分析怎么做才不踩坑4.1 “独立”二字的含义保持其他参数不动一次只动一个敏感性分析最容易犯的错是把几个参数揉在一起同时改。比如把电价上调10%的同时把负荷下调到90%最后成本变化既说不清是电价的影响还是负荷的影响那就不是“独立”敏感性分析了。正确做法是设立一个基准场景然后一次只变动一个参数其他参数全部保持基准值。对电价做扰动时光伏、风电、负荷都用基准曲线对光伏做扰动时电价和负荷又是另一套固定值。这样才能画出真正意义上的单变量敏感性曲线。我的实现用的是这样一个外层循环param_names {price, pv, wind, load}; scale_factors [0.8, 0.9, 1.0, 1.1, 1.2]; results_table []; for i 1:length(param_names) for j 1:length(scale_factors) params_tmp params_base; switch param_names{i} case price params_tmp.price.buy params_base.price.buy * scale_factors(j); params_tmp.price.sell params_base.price.sell * scale_factors(j); case pv params_tmp.pv.forecast params_base.pv.forecast * scale_factors(j); case wind params_tmp.wind.forecast params_base.wind.forecast * scale_factors(j); case load params_tmp.load.forecast params_base.load.forecast * scale_factors(j); end result run_full_schedule(params_tmp); results_table [results_table; {param_names{i}, scale_factors(j), result}]; end end这样就得到一个20组4个参数×5个扰动水平的敏感性样本。如果要做更精细的曲线可以把扰动水平设成-30%、-20%、-10%、0、10%、20%、30%。4.2 扰动范围和步长的选择扰动范围和步长没有绝对标准但有几个实际经验。第一步先跑一个±20%左右的扰动观察结果曲线是否单调。如果单调性明显说明在这个范围内系统行为稳定可以继续缩小步长寻找转折点。如果曲线出现急剧变化或突变说明参数可能已经逼近某个约束边界这时需要加密步长比如在1.0到1.2之间再插几个点。对于电价我习惯把购电价和售电价按同一个比例缩放因为现货市场中两个价格通常同向变化。对于光伏和风电我按预测出力曲线整体缩放这种方式相当于模拟晴转多云或风速整体增强的情况。对于负荷除了整体缩放还可以做时段差异化扰动比如把晚高峰段单独拉高看调度策略如何响应。4.3 用哪些指标衡量敏感度敏感性分析不只看总成本。总成本是最直接的指标但它会掩盖系统内部行为的变化。我通常同时记录下面几类指标总运行成本包括购电成本、燃料成本和储能退化成本。网购电量从配电网净购入的电量反映对外部电力的依赖度。光伏/风电弃电量新能源限电的比例敏感性分析中很关键的指标。储能循环次数或累计充放电量反映储能被调用的强度。燃气轮机平均出力反映可控机组的基载或调峰角色变化。有了这些指标就能回答“电价上涨10%为什么成本只涨了5%”“光伏出力翻倍为什么成本只降了一点点”这类更深层的问题。4.4 一组参考结果与解读下面给出一组基于我的基准算例的参考结果里面数字的具体绝对值取决于你的系统参数但趋势很有代表性。参数扰动幅度总成本变化率网购电量变化率弃能情况电价10%6.2%-3.1%基本无弃能电价-10%-5.8%2.7%基本无弃能光伏20%-2.4%-8.3%出现轻微弃光光伏-20%4.1%9.2%无弃光风电20%-1.7%-6.6%出现轻微弃风风电-20%3.5%7.8%无弃风负荷10%8.9%5.6%无弃能负荷-10%-7.2%-4.8%出现轻微弃光从这组数据可以明显看出负荷对总成本的影响最大电价次之光伏和风电相对温和。原因是负荷是刚性的必须满足负荷增加直接拉高购电和燃料成本而光伏和风电只要并网能力够其边际成本接近零但在高渗透场景下受弃能约束对总成本的边际改善会递减。这个结论在不同系统里方向一致但具体数值差异会很大。比如在储能容量更大的系统里光伏敏感性会更强因为储能可以把多余的光伏电量转移到晚高峰弃能更少。5. 实测中容易翻车的细节和我的处理经验5.1 求解器无解时先查哪里我第一次跑两阶段模型时隔三差五遇到“Problem is infeasible”当时第一反应是去调求解器参数折腾了半天都没用。后来把约束逐条输出查看才发现问题出在功率平衡约束的初值上。具体来说日前阶段用了SOC(1)作为初值但滚动日内阶段更新SOC状态时没有同步更新约束里对应的初始SOC。结果某一时刻的SOC初值超出容量上限模型直接无解。排查无解问题我的建议是按这个顺序来用check(C)查看Yalmip给出的每条约束残差定位是哪条约束不满足。检查SOC初值和末值看是否有违反容量上下限。检查功率平衡等式尤其是新能源出力高但负荷低的时候是否需要引入弃光弃风变量。检查购售电互斥约束看u_buy有没有合理定义。不要一上来就怀疑求解器。CPLEX和Gurobi在线性规划求解上的稳定性极高绝大多数无解问题都是模型本身约束写坏了。5.2 日内阶段的“预测更新”不能全量替换很多初版日内滚动代码是每个滚动步直接用最新预测数据替换整个窗口的所有预测值。这在预测系统稳定的前提下工作得很好但一旦某次预测出现明显偏差就会导致滚动计划剧烈抖动。我的处理方式是做“平滑预测更新”。具体做法是给预测曲线加一个更新权重forecast_new alpha * forecast_latest (1 - alpha) * forecast_old;alpha取0.3到0.5之间相当于新预测只承担三分之一的权重。这个技巧是从模型预测控制MPC的实践里搬过来的能有效抑制控制量抖动代价是预测响应速度稍微变慢但换来的是调度曲线稳定很多。5.3 储能SOC的传递方式最容易出错日内滚动优化的状态传递是整个代码里最隐蔽的bug来源。我踩过的坑是在滚动循环里求解完窗口优化后直接用了x_opt.SOC(2)作为下一轮的初值但没有检查这个SOC是否满足下一轮窗口内储能功率约束的最低需求。更稳妥的做法是在每次滚动求解前把当前SOC代入约束并检查当前时段允许的充电/放电功率范围如果SOC接近上限顺带把充电功率上限压下去。available_charge min(params.p_ch_max, (params.soc_max - SOC_now)/dt); available_discharge min(params.p_dis_max, SOC_now/dt);这个检查对长期仿真的稳定性非常重要否则系统跑着跑着会出现“明明SOC只有40%却要求放电30kW持续半小时”这种物理上不可行的指令。5.4 大规模算例怎么压缩求解时间如果你把日前阶段做成96时段日内滚动做成96次优化再加上20组敏感性分析总求解次数会非常可观CPLEX默认配置下可能要等很久。我常用的加速方法有三个。第一个是热启动。Yalmip支持用assign初始化变量再配合sdpsettings(usex0,1)让每次敏感性分析场景从上一组相近场景的可行解出发可以省去大量分支定界时间。assign(P_buy, result_prev.P_buy); assign(SOC, result_prev.SOC); ops sdpsettings(solver,cplex,usex0,1,verbose,0);第二个是减少二进制变量。如果购售电价格曲线在某个时段已经明显高于日常水平可以通过预处理直接固定u_buy(t)不需要让求解器去搜索。第三个是约束分层。功率平衡、储能SOC这类硬约束放在主模型里燃料成本的分段线性化可以放到目标函数里用整数变量近似但尽量避免大量辅助变量。如果一个线性约束就够了就不要引入额外布尔变量。5.5 最后分享一个调整敏感性分析结果的小技巧敏感性分析做完别急着只画一条总成本曲线。我的习惯是把四个参数的敏感性曲线放在同一张图里横轴是参数缩放比例纵轴是总成本对应基准成本的比例。这张图能非常直观地比较哪个参数对系统影响最大。如果发现某条曲线斜率特别大我还会顺带看储能充放电曲线和购电曲线判断到底是哪个机制在放大敏感性。比如负荷曲线斜率大通常是因为高峰时段要大量购电或启机这时可以考虑增加储能容量是否划算。电价曲线斜率大则说明系统对价格信号高度敏感优化策略是否存在套利空间也值得再检查一遍。写到这里我想起实际算例里第一次看到电价敏感性曲线时的心情。我以为电价上涨10%成本应该至少跟涨10%结果只有6%出头原因在于系统会自动调整购电和储能策略来对冲。这种“系统自主调适”的现象恰恰是做敏感性分析最有价值的部分。它能让你看到模型内部的调节机制而不是只盯着一个总成本数字。如果你正在做类似的两阶段调度项目建议先把基准场景跑通、曲线画顺再开始做敏感性分析。不要一上来就铺开20组扰动那样只会浪费时间。代码里一定把四个参数作为外部接口传进来模型逻辑和参数数据彻底分离这样后续扩展需求也会轻松很多。