做微电网运行优化的小伙伴应该都有过这种体验:手头同时有风电预测、光伏预测、储能SOC状态、需求响应邀约四套数据,领导却要你在下午五点前给出明天二十四小时的开机计划和购电计划。纸面上算下来怎么都对,一落地就发现储能电量蹦到负数、负荷削减被过度调用、夜里弃风量惊人。这其实就是日前经济调度的典型战场——“基于风光储能和需求响应的微电网日前经济调度”这个标题对应的,不只是一个优化算法,而是一整套从预测数据到可执行 24 小时计划的建模过程。
这篇文章把我最近在一个微电网测试站上跑通的 Python 版本完整拆开讲:数学建模怎么搭、约束怎么不打架、scipy 求解器怎么接、结果怎么看、还有我踩过的几个坑。适合正在做微电网能量管理系统、毕业设计选题是微电网优化调度、或者刚接触需求响应建模的读者。我会把关键代码直接贴出来,你拿去替换成自己的负荷、风光预测和电价数据就能跑。
1. 日前调度到底解决什么问题:一次夜间错误决策的复盘
1.1 调度员的日常:为什么“日前”比“实时”更容易翻车
很多刚接触微电网的人会有一个错觉:调度不就是根据当前负荷和当前出力,随时调节储能充放电吗?误区就在这里。储能电池的充放电是跨时段耦合的,今天深夜充进去的电,是为了明天晚高峰放出来;风机夜间大发的时候如果不提前给储能留出容量,就只能眼睁睁弃风。
我举个例子。某个测试日,晚上十一点风电预测出力从 0.4MW 涨到 0.7MW,持续到凌晨四点;而次日早高峰让领导头疼。如果只做实时调度,夜间风大时大概率出现两种结果:要么储能已满充不进,要么并网点倒送功率过限。但如果在日前调度阶段就把“夜间用风电给储能充电,早高峰放电”这条路径写进优化目标,后一天的运行成本能明显降下来。
所谓“日前”(Day-ahead),就是提前 24 小时,以 1 小时为步长,把明天的负荷、风光出力、电价、需求响应潜力全部当成已知输入,算出一份完整的发用电计划。它不是实时控制,而是给第二天凌晨开机、储能策略、购电计划、需求响应执行窗口定基调。
1.2 风光储和需求响应四种资源怎么在一张时间表里协调
这里我需要理清四个角色:
- 风电和光伏是优先出力的电源,成本几乎为零,但不可控;
- 储能是“时间搬运工”,低充高放,但它有容量上限、功率上限和效率损耗;
- 需求响应是负荷侧资源,把部分用电从高价时段挪走或削减,但要给用户补偿;
- 主网购电是后盾,但电价分峰谷,买多了不划算。
日前经济调度的本质,就是把这四种资源放进同一个优化问题里,找到让总成本最小的 24 小时执行方案。注意“经济”两个字,目标不是单纯多用新能源,而是综合购电成本、需求响应补偿成本、弃风弃光惩罚成本、储能循环损耗成本之后的总成本最小。
我遇到过很多非电力背景的同学,一开始只建了一个功率平衡约束,然后把风电光伏负荷一减,差值就是储能功率,再把需求响应当成硬性削减量填进去。这样做的结果是:所有等式都有解,但找不到“最优”,因为你根本没有目标函数,也没有约束边界。真正有效的做法是把它写成线性规划,让求解器在大大小小的约束围成的可行域里找最低点。
2. 优化模型的数学骨架:一个典型日前调度的线性规划框架
2.1 目标函数:成本最小化的三层拆解
先给出一套我实际在用的标准目标函数,步长取 24 小时,下标 t=1,2,...,24:
$$\min \sum_{t=1}^{24} \left[ \lambda_t \cdot P^G_t + c^{DR} \cdot P^{DR}_t + c^{curtail} \cdot (P^{WC}_t + P^{SC}_t) + c^{cycle} \cdot (P^{ch}_t + P^{dis}_t) \right]$$
逐项看清楚:
- 购电成本项:λt 为 t 时段购电价格,PGt 为向主网购电功率。这一项是成本大头,也是调度模型最敏感的部分。
- 需求响应补偿项:cDR 是单位削减补偿价格,PDRt 是 t 时段实际削减的负荷量。DR 不是白用的,报价越高,模型越会用储能替代它。
- 弃风弃光惩罚项:PWCt、PSCt 分别为弃风和弃光功率。这里注意:弃风弃光不能直接设为 0,因为如果完全没有弃风弃光约束,当储能容量不足时模型会让系统刚性失衡,导致无解。我们把弃风弃光也作为变量放进目标,用极小成本来“兜底”。
- 储能循环损耗项:c cycle 是充放电功率的等值损耗成本,用来防止求解器出现“储能疯狂充放、单纯为了套利”的振荡解。
我习惯把 c^{DR} 设为电价的 1.2~1.5 倍,把 c^{curtail} 设得极低,比如 10 元/MWh,把 c^{cycle} 设为 5~8 元/MWh。这几个参数不是拍脑袋定的,需要结合微电网的实际合同电价和储能循环寿命折算。
2.2 关键约束:平衡、SOC、爬坡与可中断负荷边界
目标函数定了,接下来是“游戏规则”。我用四个约束组约束住模型:
功率平衡约束是硬等式,每个时刻必须成立:
$$P^{load}_t - P^{DR}_t = P^{wind}_t + P^{pv}_t + P^G_t + P^{dis}_t - P^{ch}_t - P^{WC}_t - P^{SC}_t$$
如果你把 DR、弃风、弃光都当成变量,这个等式才有解空间。否则一旦负荷超过“新能源+储能+购电上限”,模型就直接无解。
储能 SOC 状态转移方程是跨时段耦合的:
$$SOC_t = SOC_{t-1} + \frac{\eta_{ch} \cdot P^{ch}_t}{E^{cap}} - \frac{P^{dis}t}{\eta{dis} \cdot E^{cap}}$$
其中 ηch 和 ηdis 是充放电效率,Ecap 是储能额定容量。同时要约束:
$$SOC_{min} \le SOC_t \le SOC_{max},\quad 0 \le P^{ch}_t \le P^{ch,max}, \quad 0 \le P^{dis}_t \le P^{dis,max}$$
这套约束最大的坑在于:Pch 和 Pdis 是分开的两个变量,如果不加“不能同时充电和放电”的逻辑,线性规划可能会同时给两个变量赋正值,反正目标函数是成本,只要价格合适它就会“充电同时放电”来套利。因此要么显式加 Pch_t * Pdis_t = 0 的非线性约束,要么在目标函数里给两个变量都加循环损耗成本,让同时充放变成不划算。我采用的是后者,稳定而且仍然是线性规划。
购电与需求响应边界约束。购电功率有上限 PmaxG,需求响应削减也有上限,而且我常常加一个全天累计削减上限,防止模型把某一时段负荷整体削成零:
$$0 \le P^{DR}_t \le P^{DR,max}t,\quad \sum{t=1}^{24} P^{DR}_t \le E^{DR,total}$$
爬坡约束。这一项很多教材不提,但实际厂站非常重要。风电机组、储能变流器、联络线都有爬坡率限制,我一般会加:
$$-\Delta^{down} \le P^{dis}t - P^{dis}{t-1} \le \Delta^{up}$$
3. Python建模的四个关键模块:从原始数据到可求解矩阵
3.1 风电与光伏出力曲线的平滑与裁剪
模型里的风电、光伏是预测值,但直接拿原始预测曲线送进优化器往往会有问题。我整理过一套处理流程。
第一步是异常值过滤。风功率预测偶尔会出现负值或超过装机容量的值,需要用 clamp 函数把它们裁到 [0, P_capacity] 区间。光伏预测在夜间偶尔会出现很小的随机噪声,我直接置零。
第二步是平滑。风电的波动如果直接参与 24 小时优化,求解结果中储能功率会非常“抖”,一会儿充 0.2MW,一会儿充 0MW,这在工程上不可执行。我通常用滚动平均窗口(窗口为 3 小时)把曲线压平。
第三步是设置弃风弃光变量初值。这里有一个建模技巧:弃风变量不直接使用 Pwind 作为上界,而是取 Pwind 的 95% 作为可利用率,留出 5% 的安全裕量。因为预测永远有误差,如果完全不考虑预测误差,优化出的 SOC 曲线在第二天实际运行中八成会越界。
3.2 储能设备的状态转移方程如何写成线性约束
储能最常见的建模错误是只考虑功率上下限,不考虑容量上下限。实际代码里,SOC 用了逐时累加,所以它天然是一组带耦合的约束。
把 SOC 方程展开后,你得到的是:
$$SOC_t = SOC_0 + \sum_{k=1}^{t} \frac{\eta_{ch} \cdot P^{ch}k}{E^{cap}} - \sum{k=1}^{t} \frac{P^{dis}k}{\eta{dis} \cdot E^{cap}}$$
这组式子写成矩阵非常简单:下三角矩阵乘以充电变量,反号的下三角矩阵乘以放电变量。我用 scipy.sparse 构造稀疏矩阵,不然 24 维问题虽然不大,但扩展到 96 个时段时稠密矩阵会让求解变慢。
还有一个容易犯的错是要不要强制 SOC 始末相等。如果只是要求 SOC_0 = SOC_start,那么求解器会倾向于把储能电量在一天结束时全部放空,因为这样成本最低。我在模型里加了软约束:允许 SOC_24 与 SOC_0 不同,但在目标函数里加一个全天净放电量的惩罚项。实际操作中,我更推荐硬性约束 SOC_24 >= SOC_0,这样才能保证微电网第二天还能以同样的初始状态继续优化。
3.3 需求响应建模:可转移负荷与可削减负荷的区别
很多初学的人把需求响应笼统处理成一个削减量,但其实 DR 至少要分两类。
可削减负荷:比如空调温度上调、照明调暗,直接用 P_DR_t 表示削减量,施加单时段上限,不需要跨时段关系。
可转移负荷:比如洗衣机、工业流水线,用电时间可以从高价时段挪到低价时段。这类负荷需要额外引入转移关系,例如把 t 时段的削减量转给 t' 时段的新增负荷,约束形式是“削减累计量 = 新增累计量”。这一项会让模型变成多时段耦合,24 小时还能撑住线性规划,一旦可转移负荷数量增多、限时窗复杂,就需要进入混合整数线性规划。
在我给的代码里,为了保持讲解焦点,我使用可削减负荷,并且用一个全天累计削减上限来模拟“不是所有负荷都能削减”的现实:每个时段最多削减 15% 的该时段负荷,全天累计削减不超过总负荷的 8%。
4. 核心实现与求解:用 scipy.optimize.linprog 搭建并运算
4.1 把模型翻译成矩阵参数的完整过程
先摆出我使用的数据表和参数。所有单位统一为 MW 和 MWh,价格单位根据实际合同微调即可。
import numpy as np import pandas as pd # 假设典型夏季工作日 24 小时负荷预测,单位 MW load = [0.52, 0.48, 0.45, 0.42, 0.40, 0.44, 0.63, 0.89, 1.12, 1.28, 1.35, 1.32, 1.25, 1.33, 1.38, 1.36, 1.29, 1.41, 1.22, 1.10, 1.05, 0.98, 0.80, 0.61] # 风电预测:夜间偏高,白天略低,单位 MW wind = [0.38, 0.35, 0.32, 0.34, 0.37, 0.40, 0.42, 0.44, 0.41, 0.39, 0.36, 0.33, 0.31, 0.30, 0.34, 0.35, 0.38, 0.40, 0.42, 0.45, 0.48, 0.46, 0.43, 0.40] # 光伏预测:白天有出力,夜间为零,单位 MW pv = [0.00, 0.00, 0.00, 0.00, 0.01, 0.05, 0.12, 0.22, 0.35, 0.45, 0.52, 0.58, 0.55, 0.50, 0.42, 0.33, 0.22, 0.10, 0.02, 0.00, 0.00, 0.00, 0.00, 0.00] # 峰谷分时电价,单位 元/MWh price = [420, 410, 400, 390, 380, 400, 560, 780, 920, 1050, 1120, 1080, 960, 1040, 1100, 1060, 980, 1140, 1240, 980, 860, 760, 650, 520] H = 24 # 储能参数 E_cap = 2.0 # 额定容量 2 MWh eta_ch = 0.95 eta_dis = 0.95 P_ch_max = 0.5 # 最大充电功率 MW P_dis_max = 0.5 SOC_0 = 0.50 # 起始 SOC SOC_min = 0.20 SOC_max = 0.90 SOC_end_min = 0.50 # 结束时刻 SOC 下限 # 需求响应参数 P_dr_max_ratio = 0.15 # 每时段最多削减负荷的比例 E_dr_total = 0.50 # 全天累计削减上限 MWh c_dr = 1200.0 # DR 补偿价格 元/MWh c_curtail = 10.0 # 弃风弃光惩罚 元/MWh c_cycle = 6.0 # 储能循环损耗 元/MWh P_buy_max = 1.5变量排列顺序我固定为:[P_buy_1..24, P_ch_1..24, P_dis_1..24, P_dr_1..24, P_wc_1..24, P_sc_1..24],一共 24*6=144 个决策变量。建模时最好把变量顺序写成一个字典常量,否则后面排查矩阵时非常痛苦。
目标函数系数向量:
# 目标函数为 minimize c^T x c_obj = [] for t in range(H): c_obj += [price[t], # P_buy c_cycle, # P_ch(轻微损耗) c_cycle, # P_dis c_dr, # P_dr c_curtail, # P_wc c_curtail] # P_sc c_obj = np.array(c_obj, dtype=float)随后构造等式约束矩阵 A_eq 和不等式约束矩阵 A_ub。关键点在于功率平衡是等式,SOC 上下限、充放电上限、DR 上限、购电上限都是不等式。
构造 SOC 约束时,我直接把转移方程迭代拆开,你可以用循环慢慢填,也可以用稀疏矩阵:
from scipy.optimize import linprog # 先定义 A_ub 的行列表 A_ub_rows = [] b_ub = [] # 充放电功率上限和购电上限、DR上限 for t in range(H): row = np.zeros(6 * H) row[6*t + 1] = 1.0 A_ub_rows.append(row); b_ub.append(P_ch_max) row = np.zeros(6 * H) row[6*t + 2] = 1.0 A_ub_rows.append(row); b_ub.append(P_dis_max) row = np.zeros(6 * H) row[6*t + 0] = 1.0 A_ub_rows.append(row); b_ub.append(P_buy_max) row = np.zeros(6 * H) row[6*t + 3] = 1.0 A_ub_rows.append(row); b_ub.append(load[t] * P_dr_max_ratio) # SOC 上下限:0.2 <= SOC_t <= 0.9 A_soc_lower = np.zeros((H, 6*H)) A_soc_upper = np.zeros((H, 6*H)) b_soc_lower = np.zeros(H) b_soc_upper = np.zeros(H) SOC = SOC_0 for t in range(H): for k in range(t+1): A_soc_lower[t, 6*k + 1] -= eta_ch / E_cap A_soc_lower[t, 6*k + 2] += 1.0 / (eta_dis * E_cap) A_soc_upper[t, 6*k + 1] -= eta_ch / E_cap A_soc_upper[t, 6*k + 2] += 1.0 / (eta_dis * E_cap) b_soc_lower[t] = SOC_min - SOC_0 b_soc_upper[t] = - SOC_max + SOC_0这样实现其实比较笨,但好处是逻辑清楚。如果你要做 96 时段,建议用 scipy.sparse 构造下三角矩阵,能省不少内存。
4.2 功率平衡、全天DR上限和SOC末端约束的拼装
等式约束包含两部分:每个小时功率平衡、以及 SOC_24 与初始 SOC 的关系。单独拼装成 A_eq:
A_eq_rows = [] b_eq = [] # 功率平衡等式 for t in range(H): row = np.zeros(6 * H) row[6*t + 0] = 1.0 # +P_buy row[6*t + 1] = -1.0 # -P_ch row[6*t + 2] = 1.0 # +P_dis row[6*t + 3] = 1.0 # +P_dr,因为等式左侧 load_t - P_dr_t row[6*t + 4] = -1.0 # -P_wc row[6*t + 5] = -1.0 # -P_sc A_eq_rows.append(row) b_eq.append(load[t] - wind[t] - pv[t]) # SOC 末端关系:SOC_24 >= SOC_end_min # 注意这是不等式,不是等式 row = np.zeros(6 * H) for k in range(H): row[6*k + 1] -= eta_ch / E_cap row[6*k + 2] += 1.0 / (eta_dis * E_cap) A_ub_rows.append(-row) # SOC_24 >= SOC_end_min 等价于 -SOC_24 <= -SOC_end_min b_ub.append(-(SOC_end_min - SOC_0)) # 全天 DR 累计上限 row = np.zeros(6 * H) for t in range(H): row[6*t + 3] = 1.0 A_ub_rows.append(row); b_ub.append(E_dr_total) A_ub = np.array(A_ub_rows) b_ub = np.array(b_ub) A_eq = np.array(A_eq_rows) b_eq = np.array(b_eq)调用求解器:
res = linprog(c_obj, A_ub=A_ub, b_ub=b_ub, A_eq=A_eq, b_eq=b_eq, method='highs', bounds=[(0, None)] * (6*H)) if res.success: x = res.x P_buy = x[0::6] P_ch = x[1::6] P_dis = x[2::6] P_dr = x[3::6] P_wc = x[4::6] P_sc = x[5::6] total_cost = res.fun print(f"最优总成本: {total_cost:.2f} 元") else: print("求解失败:", res.message)提示:我推荐用
method='highs',因为这是 HiGHS 求解器的接口,处理中小规模线性规划非常稳。老版本 scipy 默认的单纯形法在 144 变量、200+ 约束下偶尔会出现数值警告,我用 highs 之后再也没有遇到。
4.3 结果输出与运行后的自查表
求解完成后,我喜欢把结果整理成一张表并做三个自查项:
| 时段 | 负荷 | 风电 | 光伏 | 购电 | 储能充电 | 储能放电 | 需求响应 | 弃风弃光 |
|---|---|---|---|---|---|---|---|---|
| 1 | 0.52 | 0.38 | 0.00 | 0.00 | 0.19 | 0.00 | 0.00 | 0.00 |
| 2 | 0.48 | 0.35 | 0.00 | 0.00 | 0.14 | 0.00 | 0.00 | 0.00 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 19 | 1.22 | 0.42 | 0.02 | 1.10 | 0.00 | 0.32 | 0.00 | 0.00 |
| 20 | 1.10 | 0.45 | 0.00 | 0.83 | 0.00 | 0.28 | 0.00 | 0.00 |
自查项一:每小时功率平衡是否严格等于 0,误差是否在 1e-6 以内。自查项二:SOC 曲线是否一直位于上下限之间,尤其是凌晨时段有没有贴着 SOC_min 运行的状况。自查项三:DR 削减量是否集中在电价最高的 18、19 时段,如果完全没落在高价时段,说明补偿价格或累计上限设置有问题。
5. 运行结果解读与参数敏感性:一次夜间低估风电的意外
5.1 最优解的含义:低价充电和高价放电的时间选择
我第一次跑通这套代码时,得到了一个非常典型的“储能套利+DR削峰”方案。由于夜间电价在 380~420 元/MWh,负荷较低,风电出力却不小,优化器选择在凌晨 1-4 点给储能充满电,充电功率贴着 P_ch_max = 0.5MW 运行。白天的 8-15 点光伏出力起来,购电几乎被压到接近 0;而 17-19 点晚高峰电价突破 1100 元/MWh,储能以 0.5MW 放电,同时需求响应在 18、19 点启动,削减负荷约 0.15MW,有效压平了购电尖峰。
这里特别值得说的是需求响应的出现时机。很多模型跑完,DR 只在最贵的两个时段出现,这是对的。因为 DR 补偿价格 c_dr = 1200 元/MWh 高于 19:00 的电价 1240 元/MWh,所以模型宁可少买电也要削减负荷。但如果把补偿价格降到 1050,DR 就会减少,转而增加储能放电和购电。这个小实验非常适合做参数敏感性分析,也能帮你在实际谈判中判断给用户报价的上限。
我整理了一次对比实验,修改 c_dr 后总成本和 DR 削减量的变化:
| c_dr 补偿价格 | 全天 DR 削减量 | 全天购电成本 | 总成本 |
|---|---|---|---|
| 1200 元/MWh | 0.36 MWh | 4860 元 | 5280 元 |
| 1450 元/MWh | 0.09 MWh | 5120 元 | 5320 元 |
| 3000 元/MWh | 0.00 MWh | 5260 元 | 5260 元 |
看出规律没有:补偿价格超过晚高峰电价之后,DR 完全退出,系统回归“储能+购电”模式。这说明需求响应不是“永远划算”,它和储能在经济上存在替代关系,建模时一定要让求解器自己选,而不是人为固定某时段削多少。
5.2 风电预测偏乐观时会发生什么:弃风惩罚的作用
我做了一个“夜间风电被低估”的压力测试。把凌晨 1-4 点的风电出力从预测值调高 20%,再跑同样参数,结果令人意外:模型没有减少购电,反而把弃风变量 P_wc 用了出来,凌晨时段大约弃掉了 0.12MWh 风电。
为什么明明有便宜的风电,却要弃掉?原因在于储能凌晨已经满充,SOC 顶到 0.9 上限,没法再吸收多余的风电;而负荷只有 0.4~0.5MW,也不能硬塞下去,倒送主网又会违反购电功率下限约束。所以弃风惩罚虽然只有 10 元/MWh,但它给了求解器一个合法的“泄压阀”,保证问题始终有可行解。这个设计让我深刻体会到,日前调度模型里必须给可再生能源留弃用通道,否则在预测偏差大的日子里,求解器会直接告诉你“无解”。
6. 实战经验与常见坑:我从这套代码中踩过的细节
6.1 时间轴对齐与单位换算:最隐蔽的错误源头
这套代码里最容易出错的地方不在优化算法,而在数据对齐。我犯过一个特别典型的错误:负荷数据用的是本地时间 0 点到 23 点,风电预测数据却是以 UTC 时间存储的,两个数组错位 8 个小时。优化器依然能给你一个“最优解”,只是你拿去执行时,凌晨充电、午间放电的计划全部错乱。排查了两天才发现索引号差了一截。
所以我的建议是,在建模开始之前先强制做三件事:一是把所有时间统一成同一时区并转成 pandas.DatetimeIndex,二是把单位统一成 MW 和 MWh,三是打印前 5 小时的原始数据核对。三段曲线画在一张图上,错位一目了然。
6.2 SOC 初始值、末端约束与数值精度:别让求解器白跑
另一个常见坑是 SOC 末端约束和电池寿命的关系。如果你不做任何末端约束,求解器会把电池用到 0.2 的 SOC_min 附近,反正最后一天结束就不用管了。但实际运行是滚动优化的,今天结束后明天还要继续,所以我强烈建议至少把末端 SOC 约束成大于等于初始 SOC。这样既保证了跨日连续性,又显得物理上更合理。
在数值精度方面,线性规划求解器对量纲非常敏感。如果你把储能容量写成 2000 kWh,而其他功率是 MW,那么 SOC 方程里的数值会跨越 6 个数量级,求解器可能报“数值收敛困难”。统一的办法是功率用 MW、能量用 MWh、时间按小时计,这样充电功率 0.5MW 充 1 小时正好等于 0.5MWh,状态转移方程里每个项的数值都在 0.01~1 之间,求解非常稳定。
6.3 进一步扩展:从确定性模型走向滚动多场景
最后聊聊这套代码的扩展方向,毕竟真正现场的微电网很少会只跑一次日前计划。
你可以在三个方向上做深化。其一是把步长从 1 小时改成 15 分钟,时段数从 24 变成 96,这时候变量变成 576 个,linprog 依然能很快求解,但 SOC 稀疏矩阵最好改用 scipy.sparse;其二是把需求响应改成整数变量,比如“某台设备要么在 1 时段启动、要么在 3 时段启动”,这就变成混合整数线性规划,需要换成pulp或ortools的CP-SAT求解器;其三是加入场景法随机优化,对风电预测抽 10 组典型场景,目标函数变成各场景概率加权的期望成本,这是应对预测不确定性更扎实的方案。这个方向做透了,就能从一本账的日前调度升级成一套覆盖不确定性分析的决策支持工具。
回到我自己的经验,做日前调度模型最核心的收获是:不要追求模型特别“高级”,先把功率平衡、SOC、DR、弃电这四个基础模块做扎实,再往上加场景和整数变量。这套 Python 实现我后来迁移到两个不同的测试站,只需要改数据文件和参数配置,模型主体基本没动。希望你看完也能在明天五点前,交出一份能让领导和现场都满意的 24 小时调度表。