这篇文章想聊的是微电网日前经济调度里最容易被低估的一层:当你把风机、光伏、储能和需求响应揉进同一个优化模型时,真正难的其实不是"把约束写出来",而是把调度问题想清楚、把代码结构搭稳、把每个参数调得有理有据。标题里提到的这几个词,单个拿出来都能写很多,但合在一起要跑出一个靠谱的日前调度计划,我踩过的坑比想象中多。这篇我从一个可复现的项目视角展开:问题怎么抽象、模型怎么建、Python代码怎么组织、求解中常见的坑怎么排,最后怎么解读调度结果。目标读者是正在做微电网调度研究的同学、刚接触综合能源优化的工程师,以及任何想用Python把"风光储+需求响应"调度跑明白的人。
1. “日前”到底在调度什么:问题的边界与真正难点
先把这个问题的边界说清楚。所谓的"日前经济调度",是在未来一天(通常是24小时,以1小时为步长,也可以15分钟)的时间尺度上,预先决定每一个时段的机组出力、储能充放电功率、与主网的交换功率,以及需求侧可以削减或平移的负荷量。它和日内调度、实时控制最大的区别在于:所有决策都建立在预测数据上——明天风电出多少、光伏出多少、负荷是多少、电价曲线长什么样——都是用预测值来算的。
这个"预测"是理解整篇文章的关键。很多人初学时会误解,以为日前调度是在做精确的物理推演,其实它本质上是在"预测值已知的前提下,找一个成本最低且满足所有运行约束的方案"。所以模型里出现频率最高的约束不是物理方程,而是不等式边界:风电出力不能超过预测值,光伏出力不能超过预测值,需求响应削减量不能超过合同约定的上限。预测值在这里不是期望,而是作为边界条件出现的。
那这个问题的难点到底在哪里?我自己的体会是三点。
第一,时间耦合。储能设备的SOC(荷电状态)把24个时段串成了链条:凌晨多充一度电,晚高峰就可能少买一度高价电。需求响应里的可转移负荷也是同理,一个洗衣任务挪到哪个时段,影响的是两个以上时段的总负荷分布。这种跨时段耦合不能靠事后拍脑袋,必须用联立方程组表达。
第二,整数变量带来的复杂度。储能不能同时充放电,可转移负荷一旦开始就要持续若干小时,这些逻辑在数学上都要变成0-1整数变量。模型从LP退化成MILP,求解难度成倍上升。
第三,经济信号的设计。成本函数里的每一项系数(惩罚因子、DR补偿单价、储能折旧系数),本质上是在传达调度偏好。同样的风电预测曲线,惩罚系数设不同,弃风率可能差出一倍。
还有一个易被忽视的工程细节:联络线功率约束。微电网和主网的交交换功率有上限,而且很多场景下不允许倒送电(或者倒送价格很低)。这个约束直接决定了储能"要不要充""该充多满",也决定了需求响应"是不是必须削"。我在实际项目里见过不止一次,模型无解不是因为储能或DR建模出错,而是联络线约束和功率平衡之间写成了矛盾逻辑。这类问题在后面排查章节会详细展开。
从技术路径上来讲,日前经济调度现在有确定性优化、鲁棒优化、随机优化三条主流路线。这套代码先落在最成熟也最实用的确定性框架上:预测值作为已知参数,用混合整数规划求解。随机和鲁棒是在这个基础上的扩展。
2. 把物理系统写成数学问题:调度模型的核心拆解
这一节是整个项目的地基。我的习惯是先把数学建模写成文档,再动代码,因为代码只是模型的翻译,模型错了代码再漂亮也没用。下面按"目标函数-功率平衡-储能约束-风光约束-需求响应约束"五块来讲。
2.1 目标函数:调度成本里到底扣了哪些钱
经济调度的目标函数是"最小化总成本"。简单版本只考虑购电成本,但真实的微电网调度需要考虑至少五项:
- 从主网购电的费用,以及向主网售电的收益(如果有倒送允许);
- 储能设备的运行损耗成本,按充放电电量折算,常用"等效折旧系数"来近似;
- 弃风、弃光的惩罚成本,这个值很关键;
- 需求响应的补偿成本,每调用1 MWh的可削减负荷,要给用户一定补偿;
- 如果模型里有可中断负荷或切负荷,还需要对应的失负荷价值惩罚。
写成数学形式,大概是这样:
min Σ_t ( 购电单价[t] * P_buy[t] * Δt - 售电单价[t] * P_sell[t] * Δt + α * (P_ch[t] + P_dis[t]) * Δt + β_wind * (P_wind_pred[t] - P_wind[t]) * Δt + β_pv * (P_pv_pred[t] - P_pv[t]) * Δt + γ_DR * P_dr_cut[t] * Δt )这个目标函数里面有几个需要特别解释的"经济信号"。
弃风弃光惩罚系数 β的设置逻辑是:只有当弃风弃光的代价足够高时,优化器才会优先消纳新能源。习惯上 β 要高于购电电价的峰值,否则风电和光伏在预测出力特别大的时段会被白白丢弃,因为"弃掉比买电更划算"——这在逻辑上就歪了。我见过有人把所有惩罚一律设成100,结果在光伏午间大发时出现大量弃光,原因就是没有和电价曲线做对比。
储能折旧系数 α不是物理定律,而是管理上的取舍。α 越高,储能越"舍不得用",模型倾向于少充少放,靠购电满足负荷;α 过低则储能过度频繁地充放电,实际运行中电池寿命会很快衰减。一般做法是用"电池全生命周期总充放电量 ÷ 电池投资成本"来折算每 MWh 的损耗成本,再乘一个安全系数。这个系数可以根据项目运营目标上下调整。
DR补偿单价 γ_DR要参考电网的峰谷价差来设定。如果补偿单价高于峰时购电价,优化器会倾向削减负荷而不是买电,这在经济上不合理。合理的区间是介于平均购电价和峰值购电价之间,让DR在尖峰时段被使用,平段和谷段不轻易动用。
2.2 功率平衡:全系统都绕不开的那条等式
微电网调度的核心约束是一条任意时刻必须成立的等式:
P_grid[t] + P_wind[t] + P_pv[t] + P_dis[t] - P_ch[t] - P_dr_cut[t] = P_load_fixed[t] + P_dr_shift[t]每一项的含义:
P_grid[t]:与主网交换功率,正值表示购电,负值表示售电;P_wind[t]、P_pv[t]:风力和光伏的实际调度出力;P_dis[t]、P_ch[t]:储能放电和充电功率,两个不能同时为正;P_dr_cut[t]:该时段被削减的负荷量;P_load_fixed[t]:不可调的刚性负荷;P_dr_shift[t]:可转移负荷中实际被安排到该时段的用电量。
这个等式看着简单,但写代码时最容易出三个问题。
第一个问题是单位不一致。功率单位常用MW,时间步长 Δt 是小时,算电量要乘以 Δt。如果不统一,等式两边平衡了但能量不守恒。
第二个问题是 P_grid 正负号的处理。有人习惯把购电和售电分别设成两个非负变量,加互斥约束;也有人直接让 P_grid 可正可负。前者逻辑清晰但变量翻倍,后者模型紧凑但表达式要小心。我的建议是:如果售电价格机制简单(比如固定上网电价),直接用可正可负的 P_grid;如果购售电价不对称且不允许同时购售,就用两个变量加上互斥约束,后面求解器处理起来也不难。
第三个问题,也是最隐蔽的问题:需求响应的削减量放在了等式的左侧。很多人会写成"右侧负荷減小",这在表达式上等价,但会让后面的 DR 约束(削减上限、总量限制)挂在负荷侧,阅读性差一些。我习惯把所有可控量放在左侧,刚性负荷放右侧,这样排查功率平衡问题会快很多。
2.3 储能约束:SOC递推与不可同时充放电
储能是微电网调度里时间耦合最强的设备,它的核心是SOC(State of Charge,荷电状态)递推方程:
SOC[t] = SOC[t-1] + η_ch * P_ch[t] * Δt / E_rate - P_dis[t] * Δt / (η_dis * E_rate)其中E_rate是电池额定容量(MWh),η_ch和η_dis分别是充放电效率。SOC 是标幺量,取值在 0 到 1 之间。
和SOC递推配套的约束还有四组:
- SOC 上下限:
SOC_min ≤ SOC[t] ≤ SOC_max,为了防止过度充电和深度放电,通常取 0.1~0.9,也有保守场景取 0.2~0.8; - 充放电功率上限:
0 ≤ P_ch[t] ≤ P_ch_max * u_ch[t],0 ≤ P_dis[t] ≤ P_dis_max * u_dis[t]; - 充放电互斥:
u_ch[t] + u_dis[t] ≤ 1,这个约束用两个0-1变量表达"不能同时充和放"; - 周期始末SOC平衡(可选):
SOC[0] = SOC_end,保证调度周期结束时电池电量回到初始值,方便日内滚动使用。
这里要特别强调互斥约束的必要性。如果没有u_ch + u_dis ≤ 1,优化器会在目标函数的驱动下做出"同时充放电"的荒谬决策——因为充放电成本是 α * (P_ch + P_dis),如果同时进行,等式两侧可能互相抵消而且还能套取某种"成本对称"的漏洞。实际工程中更严重的问题是:如果没有适配的求解器,这种逻辑可能不会报错,但结果的 SOC 曲线会波动异常,物理上完全不可行。
还有一个建模细节:SOC 递推方程的右边涉及SOC[t-1],这意味着 t=1 时用到的是初始 SOC 参数。这个初值在日前调度中有两种处理方式:一种是固定为当前实测值,另一种是让它成为一个决策变量并约束SOC[0] = SOC[23],这样模型会自然规划出一整天的电池策略。我推荐用第二种方式,特别是在和日内调度衔接时,能避免"第一天初始SOC偏高导致整天不充电"这类问题。
2.4 风、光约束:预测边界和下限
风电和光伏在日前调度模型里不是自由决策,而是受预测值约束的:
0 ≤ P_wind[t] ≤ P_wind_pred[t] 0 ≤ P_pv[t] ≤ P_pv_pred[t]有的模型会加一个最低出力约束,比如"若预测大于某一阈值则必须至少调度某个比例",这是为了模拟机组最小技术出力或者为了保证消纳目标。大部分场景下,上下限边界就够了。
风光的成本在目标函数中没有显式项,它们的消纳是通过惩罚项来驱动的。当预测出力大于负荷加储能吸收能力时,超出部分会被迫削减,对应惩罚成本进入目标函数。
这个边界约束看似简单,但在实际项目中容易和预测数据的质量挂钩。如果预测数据本身有误(比如某时段给了负值,或超大的尖峰),模型就会算出不符合物理的调度结果。我写的前处理流程里,有一个专门的校验步骤:检查风电预测和光伏预测的数值范围、缺失值和异常尖峰,这部分虽然跟优化算法无关,却经常决定模型能不能跑出合理结果。
2.5 需求响应约束:用户侧的"软约束"与硬逻辑
需求响应是微电网经济调度里最灵活也最容易建模出错的环节。它分两类,建模方式完全不同。
可削减负荷(Load Curtailment):这一类在日前调度里是最常用的。用户允许微电网运营方在特定时段削减一部分负荷,作为补偿,运营方支付 DR 单价。约束通常包括:
- 每小时削减上限不能超过该时段可削减负荷总量的一定比例(比如 20%);
- 全天削减总量有上限(比如全天总负荷的 5%),防止过度充电用户的舒适度,这一点容易被忽略;
- 不能连续削减太多小时(比如最多连续 4 小时),这是为了公平性,避免同一个用户被反复削减。
写成约束大概是:
0 ≤ P_dr_cut[t] ≤ cut_limit * P_load[t] Σ_t P_dr_cut[t] ≤ total_dr_cap Σ_t u_dr[t] ≤ max_hours P_dr_cut[t] ≤ M * u_dr[t]其中u_dr[t]是该时段是否启用 DR 的 0-1 变量,max_hours是全天累计最大调用时长。
可转移负荷(Load Shifting):这一类建模更复杂。比如某个工业流程或家用负荷,必须运行 2 小时,但可以安排在一天中的任意连续时段。这种负荷建模有两种常用方法:
方法一是"分块平移模型":把总可转移负荷拆成若干个任务块,每个块是一个固定的连续用电曲线,可以整体落到某个起始时段,用二元变量表示。例如:
Σ_t y[k,t] = 1 # 任务块k只能选择一个起始时段 P_block[k,t] = Σ_s y[k,s] * profile_k[t - s + 1]这里y[k,t]是0-1变量,profile_k是该任务块的功率曲线。这种模型很直观,但任务块数量多时二进制变量爆炸。
方法二是"总量转移模型":只约束负荷总用电量守恒,允许负荷在任何时段弹性增减。这种方式相对粗糙,但模型更轻量,适合不需要精确模拟单个设备行为的场景。实际项目里,我通常先用总量模型粗算,再用分块模型精确复算,两个结果对比可以快速发现异常。
值得多说一句:需求响应约束本质上是"软约束",因为它不是物理定律,而是商业契约。因此 DR 相关的惩罚和上限参数需要在模型外做灵敏度分析——不是跑一次就算完,而是把 DR 上限从 0%、5%、10%、15% 依次扫一遍,看总成本如何变化。这个分析做下来,往往比单个优化结果更有决策价值。
3. Python代码结构:如何让模型"看得懂、改得动、跑得通"
模型建完,进入编码环节。这里很多人容易一上来就堆公式、堆优化变量,最后代码变成一坨只有自己能看懂的脚本。我在这类调度程序中摸索出的结构是"数据层-模型层-求解层-结果层"四层分离,下面细讲。
3.1 代码骨架:从数据到决策变量的清晰分界
我的代码目录长这样:
scheduler/ ├── data/ # 风电预测、光伏预测、负荷、分时电价 ├── model/ │ ├── parameters.py # 读数据并封装成参数类 │ ├── variables.py # 定义决策变量 │ ├── constraints.py # 按模块组装约束 │ └── objective.py # 组装目标函数 ├── solve.py # 构建并求解 └── result/ ├── plot_curve.py # 绘图 └── report.py # 生成报告我不建议把所有东西都塞进一个 Jupyter Notebook。调度模型在开发过程中要反复调试,notebook 里变量作用域混乱,改一个约束要滚动半天,效率太低了。用 Python 类封装更有工程性。
参数类是最基础的。我一般这样组织:
class SchedulingParams: def __init__(self, df_data, config): self.T = config["horizon"] # 24 self.dt = config["dt"] # 1 小时 self.load_fixed = df_data["load_fixed"].values self.load_shiftable = df_data["load_shiftable"].values self.p_wind_pred = df_data["wind_pred"].values self.p_pv_pred = df_data["pv_pred"].values self.price_buy = df_data["price_buy"].values self.price_sell = df_data["price_sell"].values # 储能参数 self.E_rate = config["bat_capacity_mwh"] # 容量 MWh self.P_ch_max = config["ch_max_mw"] self.P_dis_max = config["dis_max_mw"] self.eta_ch = config["eta_ch"] # 充电效率 self.eta_dis = config["eta_dis"] # 放电效率 self.soc_min = config["soc_min"] self.soc_max = config["soc_max"] self.soc_init = config["soc_init"] # 或使用自由起始 # DR 参数 self.dr_limit_ratio = config["dr_limit_ratio"] self.dr_total_cap = config["dr_total_cap_mwh"] self.dr_price = config["dr_price_per_mwh"] # 惩罚系数 self.beta_wind = config["beta_wind"] self.beta_pv = config["beta_pv"] self.alpha = config["alpha"] # 联络线 self.p_line_max = config["p_line_max"]变量层用 cvxpy 定义即可,非常直观:
import cvxpy as cp P_grid = cp.Variable(T, name="P_grid") # 购电为正,售电为负 P_wind = cp.Variable(T, name="P_wind") P_pv = cp.Variable(T, name="P_pv") P_ch = cp.Variable(T, name="P_ch", nonneg=True) P_dis = cp.Variable(T, name="P_dis", nonneg=True) SOC = cp.Variable(T, name="SOC") P_dr_cut = cp.Variable(T, name="P_dr_cut", nonneg=True) u_ch = cp.Variable(T, boolean=True, name="u_ch") u_dis = cp.Variable(T, boolean=True, name="u_dis") u_dr = cp.Variable(T, boolean=True, name="u_dr") # 如果允许售电,可以加两个非负变量拆开购电和售电 P_buy = cp.Variable(T, nonneg=True, name="P_buy") P_sell = cp.Variable(T, nonneg=True, name="P_sell") P_grid = P_buy - P_sell这里有个小技巧:用P_grid = P_buy - P_sell而不是直接让 P_grid 的正负性依赖求解器。这样无论最后是买是卖,表达式都明确拆分,目标函数里也能分别给不同电价,更重要的是方便加"不同时购售电"的互斥约束:
u_buy = cp.Variable(T, boolean=True) u_sell = cp.Variable(T, boolean=True) constraints += [P_buy <= params.p_line_max * u_buy] constraints += [P_sell <= params.p_line_max * u_sell] constraints += [u_buy + u_sell <= 1]3.2 约束组装的顺序:保持“可读性”比省代码更值钱
约束按模块写,不要挤在一个大列表里。我常用的写法是分模块构建后合并:
constraints = [] # 功率平衡 constraints += [ P_buy[t] - P_sell[t] + P_wind[t] + P_pv[t] + P_dis[t] - P_ch[t] - P_dr_cut[t] == load_fixed[t] + load_shift_sched[t] for t in range(T) ] # 风光出力边界 constraints += [0 <= P_wind[t] <= wind_pred[t] for t in range(T)] constraints += [0 <= P_pv[t] <= pv_pred[t] for t in range(T)] # 储能系统 constraints += [SOC[0] == soc_init] constraints += [ SOC[t] == SOC[t-1] + eta_ch * P_ch[t] * dt / E_rate - P_dis[t] * dt / (eta_dis * E_rate) for t in range(1, T) ] constraints += [soc_min <= SOC[t] <= soc_max for t in range(T)] constraints += [P_ch[t] <= P_ch_max * u_ch[t] for t in range(T)] constraints += [P_dis[t] <= P_dis_max * u_dis[t] for t in range(T)] constraints += [u_ch[t] + u_dis[t] <= 1 for t in range(T)] # 需求响应 constraints += [P_dr_cut[t] <= dr_limit_ratio * load_fixed[t] for t in range(T)] constraints += [P_dr_cut[t] <= dr_total_cap * u_dr[t] for t in range(T)] constraints += [cp.sum(u_dr) <= max_dr_hours]这里load_shift_sched是可转移负荷的排程结果——如果用的是分块模型,需要额外引入任务块变量;如果用的是总量模型,则load_shift_sched[t]本身就是决策变量,加上总量守恒约束Σ load_shift_sched[t] = Σ load_shiftable_demand[t]。
需要特别指出一点:千万不要图省事把u_ch[t] + u_dis[t] <= 1换成P_ch[t] * P_dis[t] == 0。乘积形式的互补约束是非线性的,把问题从MILP变成了非凸问题,常规求解器很难收敛。正确做法必须通过整数变量来互斥。
3.3 求解与结果导出:一个出口函数搞定
求解部分我封装成一个solve()函数:
def solve(params): prob = build_problem(params) prob.solve(solver=cp.GUROBI, mip_tol=1e-4, TimeLimit=300) if prob.status != cp.OPTIMAL: print("求解失败,状态:", prob.status) return None # 结果打包 res = pd.DataFrame({ 'P_grid_buy': P_buy.value, 'P_grid_sell': P_sell.value, 'P_wind': P_wind.value, 'P_pv': P_pv.value, 'P_ch': P_ch.value, 'P_dis': P_dis.value, 'SOC': SOC.value, 'P_dr_cut': P_dr_cut.value, }) return res, prob.value求解器的选择也值得说两句。如果只是跑通流程且数据规模不大,开源求解器足够:连续LP用CLARABEL或OSQP,MILP用CBC(cvxpy内置支持)。但我在实际项目中,涉及储能二进制变量加DR二进制变量,24小时总共有几十个0-1变量,CBC可能会比较慢或gap较大。这时候换商用求解器会有明显改善。如果你的环境不方便装商用求解器,也可以用HiGHS,它是目前开源MILP求解器里综合素质很不错的。
导出结果时有个人人都会踩的坑:prob.value返回的是目标函数最优值,但要注意把Δt和时间单位捋清楚。当电价单位是元/MWh,功率是MW,Δt是h,目标值单位就是元。有人到了写报告环节发现成本数值大得离谱,往往是Δt没有乘进去。
4. 实测求解中的坑与调试经验:无解、慢收敛与不合理结果
代码能跑通不等于跑对。这一节全部来自我实际调试中的经验,每一条背后都是花过时间排查的。
4.1 无解(Infeasible)的第一排查链路
模型报infeasible是最常见的灾难现场。排查顺序很重要,我的链路是:
先单独检查功率平衡约束:把所有系统参数代入,看看在时间序列上有没有"天生不可能平衡"的时段。比如负荷峰值大于联络线最大值+储能最大放电+风电预测+光伏预测之和,这种物理不可行是无解的根因。速查方法是做可行性分析脚本,逐时段算"可用最大出力-负荷需求",看有没有负值。
逐段放松约束:我有一套"由简到全"的调试策略——先把DR削量和可转移负荷全部去掉,只保留风、光、储、联络线;这个模型如果可解,再把DR逐项加回来。这样能快速定位是哪一组约束导致的无解。
检查SOC递推的初值问题:当
SOC[0]强制等于soc_init且还要求SOC_end = SOC_init时,如果储能容量太小或充放电功率有限,可能一天内无法完成"充电-放电-回充"循环。这种无解很隐蔽,因为它不是某单一约束显而易见地矛盾,而是全链条循环跑不通。遇到这种情况,优先去掉终点SOC约束跑一次试试。检查DR时段上限:如果把
max_dr_hours设得太小,同时dr_total_cap又比较大,就会发生"总量允许削减但小时级不允许调用"的矛盾。这是参数组合问题,不是约束逻辑错误。
4.2 求解慢与gap太大的处理
MILP求解慢是常态。压箱底的经验是:
- 减少整数变量数量:SOC递推里可以不引入SOC的0-1变量;DR分块模型里,如果任务块能合并就合并。每一个0-1变量都会让分支定界的搜索树膨胀。
- 给二进制变量一个好的初始可行解:先用连续松弛结果做启发式,再把得到的二进制取值作为
warm_start传给求解器。cvxpy里通过给变量.value赋值后再调用solve(warm_start=True)实现。 - 设置合理的MIP gap上限:项目中通常不需要真正的全局最优,MIPGap=1%或0.5%就足够工程决策。我见过有人直接默认gap跑几个小时,其实0.1%和0.5%的成本差异对决策毫无影响。
- 检查约束的量级是否悬殊。功率约束是几十MW级别,SOC是0到1,DR削减量可能是零点几MW——这些量级混在一个模型中,如果没有适当缩放,求解器数值会出问题。我会统一把功率单位设成MW、能量单位设成MWh,SOC保持标幺,并避免目标函数里的惩罚系数出现1e5这类极端值。
4.3 调度结果“看起来不对”的三种典型情况
有些时候模型明明有解,结果却不符合工程经验,这种隐蔽错误更危险。
储能“莫名”充电又放电。如果SOC曲线在同一相邻时段出现"充电-放电-充电"的高频抖动,大概率是折旧系数α设得太低,导致优化器把储能当作"无成本缓冲器"来平息不同时段的微小电量不平衡。对策是提高α,或者给充放电状态变化次数加惩罚(即缩小储能启停次数),也可以在SOC递推中加最小充放电持续时间约束。
DR被过度调用。如果结果里每天DR削减都顶着上限跑,先别急着说模型错了,要检查负荷曲线和峰谷电价差是不是真的那么大。如果峰谷价差远高于DR补偿单价,DR全部被调用反而合理。真正的问题是DR补偿单价设得比峰价还高,导致优化器宁愿削负荷也不买电,这在现实中是反了的。调整原则:DR单价不应高于峰时段购电价。
SOC长期顶在99%或压在10%。这说明储能建模里的SOC限值约束可能在起作用,但经济信号不足。常见原因是电价曲线太平滑,储能没有"低充高放"的套利空间;或者α太高,放电阻碍。排查时先画出分时电价曲线和SOC曲线对比图,一眼就能看出储能是否有套利动力。
4.4 预测误差的值不值得花大力气处理
日前调度用的是预测数据,实际运行时风电光伏和负荷都会漂移,所以纯粹把日前结果直接用于实时执行是不现实的。在单层日前调度的代码里,我通常会在结果分析中加入一个鲁棒性指标:把所有时段的备用需求作为硬约束加进模型。比如:
Σ(备用来源) ≥ 备用需求[t] 备用来源 = 联络线余量 + 储能可放空间 + DR未调用容量 + 风/光可增发空间这个约束不算复杂,却能显著提高日前计划在实时执行时的可行性。等到后续扩展成"日内滚动修正",才有必要引入场景法或鲁棒优化。
5. 典型结果长什么样:从调度曲线到指标评估
代码跑通只是第一步,能看懂结果、能从结果反推数据质量才是这个项目的真价值。我以一个典型的"某地区夏季典型日"为例,把结果梳理一遍,顺便说清楚几个关键指标的计算方式。
5.1 一个典型日的调度结果解读
假设输入数据大致是:光伏预测中午峰值约15 MW,风电预测在凌晨和傍晚较高约8 MW,负荷在晚高峰19点达到22 MW,分时电价谷段0.3元/kWh、峰段1.1元/kWh,储能容量10 MWh、最大充放电功率3 MW,联络线限制10 MW。
跑完模型后,典型的合理调度曲线会呈现这么几个特征:
- 凌晨1点到5点:电价谷段,风电出力相对充足,储能以接近额定功率充电,SOC从初始值逐步爬升;
- 上午9点到下午3点:光伏大发,负荷处于平段,光伏除了直接供负荷外,多余电量优先给储能续充或卖给主网;
- 傍晚17点到22点:负荷进入晚高峰,电价升高,此时光伏出力下降、风电波动,储能开始放电,同时DR在这个时段被部分调用;
- 全天SOC曲线呈现"谷段充电-峰段放电"的经典形态,SOC保持在20%~90%的安全区间内。
从功率平衡图上,你会发现一个有趣的现象:DR削减量通常恰好落在"联络线功率到达上限"的时段——这是经济信号的自然结果,因为给DR付费比越限受罚更便宜。
5.2 用表格量化不同配置的收益
很多研究报告只给出一个最终配置结果,但工程决策通常需要对比。我习惯跑三组方案:
| 方案 | 配置说明 | 日运行成本(元) | 峰时购电(MWh) | DR调用量(MWh) | 弃风弃光率 |
|---|---|---|---|---|---|
| A | 无储能无DR | 18520 | 11.2 | 0 | 4.8% |
| B | 有储能无DR | 17280 | 8.6 | 0 | 2.1% |
| C | 有储能有DR | 16650 | 7.9 | 1.5 | 1.6% |
注意,这个表格里的数值只是示意,重点在于趋势:储能的主要贡献是改变购电时段分布(多买谷电少买峰电),DR的贡献是削掉尖峰购电需求,两者合起来既降低了成本,又降低了联络线越限风险。如果项目以"降低碳排放"为目标,还需在目标函数里加入碳排放因子系数,那是另一个扩展方向。
5.3 画图与报告:一张图比十个表格更有说服力
我最终的可视化通常是三张图。
第一张是功率平衡堆叠图:横轴24小时,纵轴功率,把购电、风电、光伏、储能放电画成堆叠柱,负荷和DR削减叠加成线。这张图能直观看到"每一度电从哪来、用到哪去了"。
第二张是SOC曲线加分时电价的双纵轴图:SOC曲线和电价曲线放在同一个坐标系里。你会立刻看到SOC的低谷是否对应电价谷段、SOC峰值是否出现在电价峰值前。如果没有对应关系,说明模型的经济信号有问题或者储能参数不合理。
第三张是DR调用时段图:把各时段DR削减量和补偿单价画出来,同时叠加联络线功率曲线,可以直接看清楚DR是在联络线饱和时被调用。
绘图代码用matplotlib就够了,核心片段如下:
fig, ax1 = plt.subplots(figsize=(10, 5)) ax1.stackplot(hours, P_grid.values, P_wind.values, P_pv.values, P_dis.values - P_ch.values, labels=['Grid', 'Wind', 'PV', 'Battery']) ax1.set_ylabel('Power (MW)') ax2 = ax1.twinx() ax2.plot(hours, SOC.values, 'k--', label='SOC') ax2.set_ylabel('SOC') ax1.legend(loc='upper left') ax2.legend(loc='upper right')5.4 几个评估指标怎么算
调度结果出来后,除了成本数值,我还会看这四个指标形成报告:
- 可再生能源消纳率= 实际调度风电+光伏总量 / 预测风电+光伏总量。消纳率太低就要检查弃风弃光惩罚系数和储能容量是否匹配。
- DR调用率= 实际DR削减量 / DR可削减总量。这个指标过低说明DR定价没有吸引力,过高说明系统真的缺电,需要复核你的备用容量配置。
- 储能日均循环次数= 日充放电总量 / (额定容量 × 2)。超过1.2次/天基本意味着电池在加速老化,得考虑提高α。
- 联络线峰值利用率= 联络线峰值功率 / 联络线容量。低于60%说明微电网配置偏保守,高于95%则说明接近极限,要重点检查DR策略。
这四个指标不需要额外求解,只是在结果数据上做后处理,但对调度方案的工程判断非常有用。
6. 参数敏感性分析与方案决策
这部分不是必须的,但如果你要把这套程序从"作业代码"变成"决策工具",就一定要有敏感性分析的意识。
最简单的做法是单参数扫描。比如把dr_total_cap从 0、1、2、3、4 MWh 逐一跑一遍,看总成本和DR调用率的变化趋势。最常见的结论是:DR上限在0→2 MWh区间成本下降很快,2 MWh之后边际收益递减,甚至由于DR总量增大导致电价信号失真而成本反弹。这种曲线是给上级决策者最直观的论据。
再比如储能容量扫描:E_rate从5、10、15、20 MWh逐一跑。很多人预设"越大越好",但实际结论往往是在某个容量之后边际收益趋平,因为联络线约束和负荷峰值有限,储能再大也"充不满放不完"。这个分析能帮项目省下不少电池投资。
敏感性分析在代码层面很好实现,最外层套一个循环即可:
results = [] for dr_cap in [0, 1, 2, 3, 4]: config["dr_total_cap_mwh"] = dr_cap params = SchedulingParams(df_data, config) res, obj = solve(params) results.append({"dr_cap": dr_cap, "cost": obj, "dr_used": res["P_dr_cut"].sum()})有一点需要提醒:参数的扫描区间要结合工程实际,不能纯拍脑袋。比如DR总量按"可削减负荷总量"的一定比例来取,而不是“从0到500任意取”——后者出来的结论没有物理参考意义。
最后,验证模型正确性的一个常用手段是"对偶检验":跑一个极端场景,比如把购电价全部设为0,储能和DR参数全部失效,此时调度结果应该完全跟随负荷曲线——购电满足所有负荷即可,储能不充不放,DR不调用。如果这个极端场景下模型还在充放电或调用DR,说明目标函数或约束里有没清理干净的强制项。这是非常好用的"模型自检"。
说实话,做完这个项目之后我的感触是:微电网日前经济调度的代码实现,真正消耗精力的地方不在求解算法,而在数据清洗、建模粒度选择、参数物理含义校准和结果解读。Python生态里cvxpy加开源求解器已经足够支撑中小规模的日前调度,关键是你要对模型里的每个系数都有"这个数是怎么来的、调大调小会怎样"的把握。
我现在的习惯是每次跑完模型,先不急着看总成本,而是把SOC曲线、DR调用时段、功率平衡堆叠图放一起扫一眼,看到结构符合预期后再去抠数值。如果SOC曲线呈现"谷充峰放"的清晰节奏、DR发生在联络线吃紧时段、弃风弃光集中在预测尖峰但全局比例不高,这套模型基本就站得住脚了。接下来你可以按同样的思路往日内滚动修正、多场景鲁棒优化方向扩展,这套代码的框架还能继续用。