1. 这个程序解决的是什么问题:从一道典型调度题说起
先别急着看代码,我们先把这道题的"物理世界"搞清楚。我见过太多新手拿到这类题目就扑向MATLAB或Python,结果模型建了一半,连"我到底在优化什么"都没想明白。
这个标题里的几个关键词,其实构成了一套完整的微电网——或者说园区级综合能源系统——日前经济调度问题。我拆开来讲:
- 蓄电池:系统里的储能环节,作用是"搬运"能量。它可以在电价低的时候充电,电价高的时候放电,赚取峰谷价差;也可以在光伏或风电大发、电网消纳不了的时候充电,避免弃风弃光。
- 市场购电售电约束:系统和大电网之间存在功率交换,可以从电网买电(购电),也可以向电网卖电(售电)。这不是无限制的,通常有最大购电功率、最大售电功率的限制,甚至可能规定不能同时购电和售电。
- 功率平衡约束:这是整个模型的骨架。任意时刻,系统内所有电源出力、储能充放电、负荷消耗、与电网交换的功率,必须满足"发用电平衡"。用大白话说:电不能凭空产生,也不能凭空消失。
- 目标函数为总费用最低:也就是让系统在满足所有约束的前提下,运行一天(通常是24小时,步长1小时)的总成本最小。
这类模型的本质,是一个**线性规划(Linear Programming, LP)问题,或者如果加入蓄电池的充放电效率、SOC递推方程,就是一个混合整数线性规划(MILP)**问题——具体取决于蓄电池模型怎么建。新手阶段,我强烈建议先做LP版本,把逻辑跑通,再往MILP扩展。
那这个程序到底能干什么用?最典型的应用是微电网经济调度、园区能源管理系统(EMS)的日前调度模块、电力市场环境下的用户侧储能优化。无论你是做毕设、做课程设计,还是刚进公司做能源规划,这套建模思路都是最基础、最核心的骨架。可以说,你把这个模型吃透,后面无论加光伏、加风电、加柴油机、加需求响应,都只是在骨架上添砖加瓦。
提示:本文针对新手,重点讲清楚"为什么要这样建模",而不是堆一堆花哨的算法。粒子群(PSO)、遗传算法(GA)这类智能算法,在高维非线性问题上确实有优势,但在这种纯线性模型上用它们,属于"用大炮打蚊子",不仅慢,而且结果不稳定。先用求解器把LP模型跑通,比用什么算法重要一百倍。
2. 模型构建的四个核心模块:蓄电池、购售电、功率平衡、目标函数
2.1 目标函数:总费用最低的钱从哪省出来
我们要优化的总费用,一般由这几项组成:
- 向电网购电的费用:
sum(购电功率 × 分时电价),这是最主要的成本项。 - 向电网售电的收入:
sum(售电功率 × 上网电价),注意这是收入,在目标函数里是负项,也就是减去它。 - 蓄电池的折旧成本或运行维护成本:很多新手会漏掉这一项。如果你完全不考虑蓄电池的损耗,那么模型会疯狂地让电池每天满充满放,只为了赚几毛钱的峰谷价差——这在实际中显然不合理。所以通常加一个很小的单位充放电成本系数,比如每千瓦时0.02元,让模型自己权衡。
所以目标函数写成:
min C = Σ_t [ λ_buy(t) × P_buy(t) - λ_sell(t) × P_sell(t) + c_bat × (P_ch(t) + P_dis(t)) ]其中t是时间索引(1到24),λ_buy是分时购电价,λ_sell是上网电价,P_buy和P_sell分别是购电和售电功率,P_ch和P_dis是蓄电池的充电和放电功率,c_bat是单位充放电成本。
2.2 功率平衡约束:一切约束的核心
这个约束的物理含义是:在任意时刻,系统的发电能力必须等于用电需求。
P_buy(t) + P_dis(t) = P_load(t) + P_ch(t) + P_sell(t)注意这里的正负号约定。我习惯把等式左边看成"供给",右边看成"需求":
- 供给:从电网买的电
P_buy,蓄电池放的电P_dis - 需求:负荷
P_load,蓄电池充电P_ch(充电也是一种用电),卖给电网的电P_sell
如果你的系统里有光伏或风电,只需要在供给侧加上P_pv(t)和P_wt(t)即可,而且它们通常是已知的预测出力曲线,不是优化变量。
2.3 蓄电池建模:看似简单,细节不少
蓄电池的建模是整道题最容易出错的地方。我总结出四个必须写的约束:
第一,充放电功率上限。
0 ≤ P_ch(t) ≤ P_ch_max × u_ch(t) 0 ≤ P_dis(t) ≤ P_dis_max × u_dis(t)其中u_ch和u_dis是0-1变量,表示充电和放电状态。如果不加这两个状态变量,模型会出现一个荒谬的结果:同一时刻既充电又放电,凭空创造能量,把目标函数"做没"。虽然线性规划中因为目标函数是费用最小,一般不会出现同充同放,但加上状态变量更加严谨,也让模型可以扩展到更复杂的场景(比如蓄电池不能频繁切换状态)。
第二,同一时刻只能充或只能放。
u_ch(t) + u_dis(t) ≤ 1第三,SOC(荷电状态)递推方程。
SOC(t+1) = SOC(t) + η_ch × P_ch(t) - P_dis(t) / η_dis其中η_ch和η_dis分别是充电和放电效率,通常在0.9到0.95之间。这个递推关系是时序强耦合约束,也是整个模型"难"的地方——它把24个小时的决定串成一串,牵一发而动全身。
第四,SOC上下限和始末状态。
SOC_min ≤ SOC(t) ≤ SOC_max SOC(1) = SOC_init SOC(25) = SOC_end最后一条很重要。很多程序跑出来一天结束蓄电池SOC是0,第二天从头再算,这种结果在实际中没法用。通常我们让一个调度周期结束时的SOC等于初始SOC,这样才能循环滚动调度。
2.4 购电售电约束:和电网的边界条件
这部分相对简单,但边界条件必须写清楚:
0 ≤ P_buy(t) ≤ P_buy_max 0 ≤ P_sell(t) ≤ P_sell_max有些模型还会加一条"同一时刻不能既购电又售电",逻辑上这合理——你不可能左手从电网买电、右手把电卖回给电网,白白缴纳过网费。加上这个约束需要引入另一个0-1变量,把模型升级为MILP:
P_buy(t) ≤ P_buy_max × u_buy(t) P_sell(t) ≤ P_sell_max × u_sell(t) u_buy(t) + u_sell(t) ≤ 1经验之谈:如果是刚开始学,建议先不加购售互斥约束。因为电价结构合理时(购电价总是高于上网电价),模型天然不会同时购售电,加了互斥变量只是多了几个0-1变量,对结果影响不大,但求解时间会增加。先跑通,再升级,这是学习这类问题的最佳路径。
3. 求解器的选型与代码实现:MATLAB还是Python
3.1 MATLAB + YALMIP + 求解器
如果你用的是MATLAB,我推荐YALMIP工具箱配合Cplex或Gurobi。YALMIP的建模语言非常接近数学表达式,对新手极其友好。核心代码如下:
%% 参数定义 T = 24; % 调度周期 P_load = [60 55 50 45 45 50 55 65 75 80 85 90 95 100 105 100 95 90 85 80 75 70 65 60]; % 负荷曲线 lambda_buy = [0.5 0.5 0.5 0.4 0.4 0.5 0.6 0.8 0.9 1.0 1.1 1.2 1.2 1.1 1.0 0.9 0.8 0.7 0.6 0.5 0.5 0.4 0.4 0.5]; % 分时购电价 lambda_sell = lambda_buy * 0.8; % 上网电价按购电价的80% %% 变量定义 P_buy = sdpvar(1, T); % 购电功率 P_sell = sdpvar(1, T); % 售电功率 P_ch = sdpvar(1, T); % 充电功率 P_dis = sdpvar(1, T); % 放电功率 SOC = sdpvar(1, T+1); % 荷电状态,多一个时刻方便递推 %% 蓄电池参数 P_ch_max = 30; P_dis_max = 30; SOC_min = 0.2; SOC_max = 0.9; SOC_init = 0.5; SOC_end = 0.5; eta_ch = 0.95; eta_dis = 0.95; c_bat = 0.02; % 单位充放电成本 %% 约束集合 Constraints = []; % 功率平衡约束 for t = 1:T Constraints = [Constraints, P_buy(t) + P_dis(t) == P_load(t) + P_ch(t) + P_sell(t)]; end % 蓄电池SOC递推方程 for t = 1:T Constraints = [Constraints, SOC(t+1) == SOC(t) + eta_ch * P_ch(t) - P_dis(t) / eta_dis]; end % 蓄电池SOC上下限 for t = 1:T+1 Constraints = [Constraints, SOC_min <= SOC(t) <= SOC_max]; end Constraints = [Constraints, SOC(1) == SOC_init, SOC(T+1) == SOC_end]; % 充放电功率上下限 for t = 1:T Constraints = [Constraints, 0 <= P_ch(t) <= P_ch_max]; Constraints = [Constraints, 0 <= P_dis(t) <= P_dis_max]; end % 购电售电功率上下限 for t = 1:T Constraints = [Constraints, 0 <= P_buy(t) <= 100]; Constraints = [Constraints, 0 <= P_sell(t) <= 50]; end %% 目标函数 Objective = sum(lambda_buy .* P_buy - lambda_sell .* P_sell + c_bat * (P_ch + P_dis)); %% 求解 ops = sdpsettings('solver', 'gurobi', 'verbose', 1); optimize(Constraints, Objective, ops); %% 结果可视化 figure; subplot(3,1,1); bar([value(P_buy); value(P_sell)]', 'stacked'); legend('购电', '售电'); title('与电网交互功率'); subplot(3,1,2); bar([value(P_ch); value(P_dis)]', 'stacked'); legend('充电', '放电'); title('蓄电池充放电功率'); subplot(3,1,3); plot(0:24, value(SOC), 'o-'); xlabel('时间/h'); ylabel('SOC'); title('蓄电池荷电状态变化');这段代码是完整的LP模型(没有购售互斥变量),我故意没有加购售互斥,你可以先跑通看结果,然后自己试着加上0-1变量看看有什么变化。跑通之后,你会发现几个有意思的现象:
- 蓄电池总是在电价高峰时段放电、低谷时段充电,这就是峰谷套利的直观表现。
- 在电价最高的一两个时段,购电功率可能降到很低,因为放电满足了大部分负荷。
- SOC曲线是一条平滑的锯齿波,起始和终止都在0.5。
3.2 Python + Pyomo / PuLP
如果你习惯Python,我推荐Pyomo搭配Gurobi或CBC求解器。Pyomo的语法比YALMIP略啰嗦,但胜在免费、开源生态好,而且和机器学习工作流的对接更顺畅。
import numpy as np import pyomo.environ as pyo from pyomo.opt import SolverFactory # 基础数据 T = 24 P_load = [60, 55, 50, 45, 45, 50, 55, 65, 75, 80, 85, 90, 95, 100, 105, 100, 95, 90, 85, 80, 75, 70, 65, 60] lambda_buy = [0.5, 0.5, 0.5, 0.4, 0.4, 0.5, 0.6, 0.8, 0.9, 1.0, 1.1, 1.2, 1.2, 1.1, 1.0, 0.9, 0.8, 0.7, 0.6, 0.5, 0.5, 0.4, 0.4, 0.5] lambda_sell = [x * 0.8 for x in lambda_buy] model = pyo.ConcreteModel() model.T = pyo.RangeSet(1, T) model.TS = pyo.RangeSet(1, T+1) # 变量 model.P_buy = pyo.Var(model.T, within=pyo.NonNegativeReals) model.P_sell = pyo.Var(model.T, within=pyo.NonNegativeReals) model.P_ch = pyo.Var(model.T, within=pyo.NonNegativeReals) model.P_dis = pyo.Var(model.T, within=pyo.NonNegativeReals) model.SOC = pyo.Var(model.TS, bounds=(0.2, 0.9)) # 参数 model.P_load = pyo.Param(model.T, initialize=dict(enumerate(P_load, 1))) model.lambda_buy = pyo.Param(model.T, initialize=dict(enumerate(lambda_buy, 1))) model.lambda_sell = pyo.Param(model.T, initialize=dict(enumerate(lambda_sell, 1))) P_ch_max = 30 P_dis_max = 30 eta_ch = 0.95 eta_dis = 0.95 c_bat = 0.02 # 约束:功率平衡 def power_balance_rule(m, t): return m.P_buy[t] + m.P_dis[t] == m.P_load[t] + m.P_ch[t] + m.P_sell[t] model.power_balance = pyo.Constraint(model.T, rule=power_balance_rule) # 约束:SOC递推 def soc_update_rule(m, t): return m.SOC[t+1] == m.SOC[t] + eta_ch * m.P_ch[t] - m.P_dis[t] / eta_dis model.soc_update = pyo.Constraint(model.T, rule=soc_update_rule) # 约束:始末SOC model.soc_init = pyo.Constraint(expr=model.SOC[1] == 0.5) model.soc_end = pyo.Constraint(expr=model.SOC[T+1] == 0.5) # 约束:充放电和购售电上下限 def ch_lim_rule(m, t): return m.P_ch[t] <= P_ch_max model.ch_lim = pyo.Constraint(model.T, rule=ch_lim_rule) def dis_lim_rule(m, t): return m.P_dis[t] <= P_dis_max model.dis_lim = pyo.Constraint(model.T, rule=dis_lim_rule) def buy_lim_rule(m, t): return m.P_buy[t] <= 100 model.buy_lim = pyo.Constraint(model.T, rule=buy_lim_rule) def sell_lim_rule(m, t): return m.P_sell[t] <= 50 model.sell_lim = pyo.Constraint(model.T, rule=sell_lim_rule) # 目标函数 def objective_rule(m): return sum(m.lambda_buy[t] * m.P_buy[t] - m.lambda_sell[t] * m.P_sell[t] + c_bat * (m.P_ch[t] + m.P_dis[t]) for t in model.T) model.objective = pyo.Objective(rule=objective_rule, sense=pyo.minimize) # 求解 solver = SolverFactory('gurobi') result = solver.solve(model, tee=True) # 输出 for t in model.T: print(f"t={t}: buy={model.P_buy[t].value:.2f}, sell={model.P_sell[t].value:.2f}, " f"ch={model.P_ch[t].value:.2f}, dis={model.P_dis[t].value:.2f}, SOC={model.SOC[t].value:.3f}")3.3 求解器选型建议:新手不要在这个环节纠结
我强烈建议新手直接装Gurobi,学术版免费,安装简单,求解速度快得惊人——这种24时段的小模型,它能在0.1秒内解完。如果装不上Gurobi,CBC作为开源求解器也完全够用,就是输出信息没那么详尽。
注意:国内部分高校用户访问Gurobi官网可能需要一点网络技巧,如果你所在的环境下载不便,可以考虑用
scipy.optimize.linprog先做最简单版本的LP,但那个只能处理纯LP,处理不了MILP。新手还是优先把求解器装好,一劳永逸。
4. 建模中容易出错的六个坑:逐个避雷
讲完代码,我觉得有必要把新手最容易踩的坑单独拎出来说。这些问题我帮人调试时见过无数次。
4.1 功率平衡约束的正负号搞反
这是最常见的错误。记住一句话:等式左边是流入系统的能量,右边是流出系统的能量。蓄电池放电是流入(供给),充电是流出(消耗),别搞混。我建议你写约束之前,先在纸上画一个简单的能量流图,标清楚每个变量的方向,再写公式。
4.2 SOC递推方程里效率的位置放错
充电效率应该乘在充电功率上,放电效率应该除在放电功率上。也就是:
SOC(t+1) = SOC(t) + η_ch × P_ch(t) - P_dis(t) / η_dis这个公式的含义是:充进去1度电,实际只能存进0.95度(因为有损耗);要放出1度电,实际需要消耗1/0.95 ≈ 1.05度的存储能量。如果你把效率位置放反,会出现"电池越用越多"的笑话——当年我调试时看到SOC曲线一路向上,就是从这找出来的问题。
4.3 SOC变量维度少了一个
注意我的代码里SOC的维度是1到T+1,也就是25个点,不是24个。因为SOC(1)是初始状态,SOC(t+1)才是第t个时段结束后的状态。搞成24个点,递推方程最后一步会越界,报错都不好找。
4.4 目标函数里漏掉蓄电池成本
如果你不加c_bat这一项,模型对蓄电池的使用是"免费"的,它会让电池在每一个峰谷周期都满充满放。虽然从数学上看没错,但从工程上看不合理。加上一个极小的成本项,整个决策会变得理性得多。
4.5 直接复制代码不检查数据和变量规模
很多新手拿到一段能跑的代码,直接把自己的负荷数据替换进去,结果发现结果异常。原因往往不是代码问题,而是数据本身的问题——比如负荷数据包含了尖峰毛刺,导致购电功率顶到上限。建议先画出负荷曲线、电价曲线的图,确认数据和实际场景匹配了,再去跑优化,这样出了问题也好定位。
4.6 忽略"购售互斥"但又在结果里发现同购同售
前面提过,在购电价始终高于上网电价的前提下,模型不会同购同售,但我见过有人把售电价设置得比购电价还高(或者为了简化随便填数据),导致模型"套利"——低价买电、高价卖电,利润无限,购电功率顶到上限,售电功率也顶到上限。这种结果一出来就知道是数据错了。
经验之谈:任何优化模型跑出"反直觉"的结果,先怀疑数据,再怀疑模型,最后才怀疑求解器。这个排查顺序能帮你节省大量时间。
5. 结果分析:怎么看懂求解器给出的答案
跑通模型只是第一步,能看懂结果、能从结果里发现模型的合理性,才算真正入门。我建议至少画下面几张图:
第一张:堆叠条形图展示系统各时刻的功率构成。横轴是24小时,纵轴是功率,用堆叠柱状图分别显示购电、放电、充电、售电、负荷。这样可以直观看到每个时刻能量从哪来、到哪去。
第二张:SOC曲线。这是一条锯齿状曲线,低谷时段充电SOC上升,高峰时段放电SOC下降,起始和终止都在0.5。如果SOC曲线出现突变、跳变,或者超出了上下限,那一定是约束写错了。
第三张:电价与购电量的对比图。把分时电价和购电量画在同一张图上,你会清楚地看到购电量在电价低谷时段高、高峰时段低。这就是经济调度的直观体现。
以我上面给出的负荷和电价数据为例,典型结果应该是这样的:
- 凌晨1点到5点(电价0.4到0.5元),蓄电池充电,购电功率除满足负荷外还有富余。
- 上午9点到12点(电价0.9到1.2元),蓄电池放电,购电功率明显降低。
- 下午14点到18点(电价0.8到1.0元),蓄电池继续放电,承担部分负荷。
- 晚上19点到22点(电价0.5到0.6元),蓄电池开始回充,恢复SOC到0.5。
如果你跑出来的结果完全符合这个规律,说明模型建对了。如果发现某个时段的行为"不理智",比如电价最高时还在充电,那就要回头检查约束和逻辑了。
6. 从入门到进阶:下一步可以在这个模型上做什么
如果你已经能独立跑通这个模型,并且能看懂结果,那恭喜你,你已经掌握了经济调度问题的核心骨架。接下来有三条进阶路线,按推荐程度排序:
路线一:加入新能源出力。在功率平衡方程左侧加上光伏出力P_pv(t)和风电出力P_wt(t),设定为已知的预测曲线。你会发现蓄电池的角色从"纯峰谷套利"变成了"平抑新能源波动"。当光伏大发时充电,避免向电网反送功率受限;当光伏不足时放电,补充负荷缺口。这一步改动不大,但能让你理解储能和新能源的配合逻辑。
路线二:加入可平移负荷或需求响应。把部分负荷变成可调节变量,比如从高峰时段平移到低谷时段。模型需要增加"平移量守恒"约束——被平移出去的负荷,必须在其他时段被补回来。这一步会让模型更有趣,也更贴近实际电力市场的需求响应场景。
路线三:加入网络拓扑约束(多节点潮流)。目前是单节点模型,也就是一个"能量池",没有线路容量限制。如果扩展到多节点,需要引入直流潮流方程或交流潮流方程,复杂度陡增。这一步适合研究生阶段或者实际工程项目需求。
我个人最推荐路线一。因为新能源接入是当前行业的绝对主流,你把这个模型吃透,加上光伏和风电,已经可以应对许多实际项目的前期方案评估了。
7. 写在最后的实操心得
最后分享几点我个人在调试和教学过程中积累的心得,希望对新手有帮助。
第一,建模之前先画能量流图。哪怕只是用笔在纸上画个方框,标清楚哪些箭头进、哪些箭头出,也比直接写数学公式靠谱得多。我见过太多人公式写得漂亮,结果一检查发现物理方向都是反的。
第二,小规模测试永远比直接跑完整模型高效。别一上来就是24小时、100个变量。先设定10个时段跑通,确认SOC递推没问题、目标函数没报错,再扩展到24小时或168小时。这个习惯能让你从"被报错折磨"的状态中解脱出来。
第三,求解器报"infeasible"(不可行)时,先查约束之间是否互相矛盾。常见原因是某个时刻负荷太大,而购电上限和电池放电上限加起来都不够满足——这时候应该调整的是购电上限,而不是去调其他参数。YALMIP可以用check(Constraints)查看哪些约束不满足,Python侧可以用model.pprint()逐一检查。
第四,关于我为什么不推荐新手一上来就用强化学习或者启发式算法。原因很简单:这类模型本质上是凸优化问题,有高效且精确的求解器。先用数学优化的方法理解问题结构,等把约束和变量关系吃透了,再去尝试那些"更高级"的算法不迟。本末倒置的结果往往是:算法调了好几天,不如一个LP跑得快、来得稳。
这个模型虽然小,但它五脏俱全——目标函数、等式约束、不等式约束、时序耦合,一个不少。把它的每一个细节都琢磨透,你以后遇到任何调度优化问题,都会觉得似曾相识。