ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用Python+PuLP手撕微电网经济调度:建模与求解实战

用Python+PuLP手撕微电网经济调度:建模与求解实战 前阵子整理旧项目翻到一个微电网经济调度的 demo当时花了大概一个晚上把整套优化逻辑写了出来。说实话“手撕优化代码”听起来很高大上实际上如果聚焦在一个典型小微电网场景里做日前调度核心代码量并没有想象中那么多。这篇就记录一下我是怎么从零开始把“让微电网未来24小时总运行成本最低”这件事写成一段可运行的优化模型并调通、跑出结果的。这篇内容适合两类朋友一类是刚接触微电网、储能或能源调度的学生另一类是工程师或算法岗的同行平时调用现成工具箱但想知道背后约束怎么来的。我会把建模思路、24小时日前调度代码、求解结果解读、以及调试过程中踩过的坑一起整理出来。代码用 Python 加 PuLP开源求解器就能跑通不需要商业授权。1. 微电网经济调度到底在算什么1.1 “经济”二字体现在哪里微电网可以理解为一个小型电力系统它内部有光伏、风力这类新能源可能有柴油发电机这类可控机组还可能挂一组储能电池同时连接公共电网作为后备或交互渠道。经济调度的目标很简单给这些源、荷、储、网资源排一个未来时段的运行计划在满足用户用电需求的前提下让总运行成本尽可能低。成本通常来自几个方面柴油机烧油的燃料成本从上级电网买电的电费储能充放电带来的寿命损耗成本还有极端情况下切负荷的违约惩罚。光伏和风电在没有弃风弃光的情况下边际成本接近零所以目标表达式里通常不给它们加运行费用。可以类比成家庭用电的“省钱策略”冰箱必须一直开着洗衣机该用还得用但在分时电价下尽量把高耗电设备挪到低谷时段用同时如果有小电池就在电价低时充电、电价高时放电。微电网经济调度就是把这种经验决策变成严谨的数学优化问题差别在于规模更大、约束更多、安全要求更高。1.2 为什么非要写成优化问题有人会问用规则能不能做比如“晚上充电白天放电光伏不够就买电再不够就启动柴油机”。这种启发式规则在小场景里偶尔能用但有三个明显问题第一多变量之间相互制约。储能能放多少电取决于此前充了多少电柴油机能不能满发受爬坡速率限制买电量又受上网容量限制。规则很难处理这种跨时段的牵连。第二收益最优不一定是规则最优。分时电价、光伏出力波动、负荷峰谷叠加在一起最优策略可能既不是“白天一律放电”也不是“低价时段把所有充放电功率拉满”而是需要精细计算。第三可解释性和可复现性。数学规划给出的方案可以追溯到具体约束方便项目评审也方便把同样的模型迁移到别的园区、别的气候条件。所以我把问题写成线性规划更准确地说带二进制变量的混合整数线性规划也就是 MILP。小规模问题用开源求解器 CBC 就能秒出结果后续如果系统变大可以无缝换成 Gurobi 或 CPLEX 这类商业求解器。2. 建模手记把业务翻译成约束2.1 决策变量怎么定手撕代码的第一步不是急着写 Python而是先把每一类设备的物理行为抽象成变量。我做的这个 demo 里设备包括光伏阵列、柴油发电机、储能电池、上级电网连接点以及负荷需求。变量具体如下柴油机小时出力记为dg[t]单位 kW从电网买入功率buy[t]和上网售出功率sell[t]单位 kW储能充电功率ch[t]和放电功率dis[t]单位 kW储能荷电状态soc[t]单位 kWh每小时的切负荷量cut[t]单位 kW。这些变量几乎都存在物理边界。柴油机有最小稳定出力和最大额定出力储能充放电功率受变流器限制SOC 则有上下限不能把电池充到超过容量也不希望过度放电伤寿命。切负荷虽然实际中要尽量避免但它在模型里很重要它的存在确保了“负荷总要被平衡”这条硬约束永远有解否则某些极端工况下整个模型会直接判定无可行解。这里要特别提一下储能充放电之间的“互斥”关系。物理上一台储能变流器不能同时充电和放电但单纯用线性约束表达充放电功率非负它们在数学上是有可能同时大于零的因为求解器不会自动区分“物理上不可能”的状态。所以必须引入两个二进制变量u_ch[t]和u_dis[t]让它们的和不超过 1再通过大 M 法把充放电功率“绑”在这些二进制变量上。这步是很多新手最容易遗漏的。2.2 目标函数里藏着哪些“潜规则”这个项目里目标函数写得比较直白就是让一天内所有运行成本累加最小柴油机成本按线性燃料系数计算比如 0.4 元/kWh购电成本按分时电价计算价格随时间变化售电收入做负成本处理但上网电价给得较低避免出现“低买高卖”的套利失真储能充放电加一个每千瓦时很小的退化成本用来防止电池被无意义地频繁充放切负荷惩罚设为远高于正常电价的数值比如 100 元/kWh起“软约束”作用。目标函数还有一个容易踩坑的细节如果售电价格高于某些时段的购电价格优化模型会疯狂“买电—卖电”空转套利虽然数学上成本最低物理上却荒唐。所以要么限制购售互斥要么把售电价压低。我的做法是同时加购售互斥变量并把售电价固定为 0.25 元/kWh。储能退化成本我标定得比较小因为线性模型很难精确描述循环寿命的非线性损耗但这 0.02 元/kWh 已经足以让模型避免在负荷很平稳时无意义地来回充放。如果你做实际项目可以参考电池厂商给的循环寿命曲线换成更精细的折旧模型但对于日前调度 demo线性化足够用。2.3 约束条件里的细节门道最核心的一条是功率平衡约束也叫节点平衡方程。对于单母线微电网任意时刻所有电源出力加上储能放电和购电必须等于负荷加上储能充电和售电。切负荷在平衡方程里作为“需求侧资源”放在等式左边表示这部分负荷没有实际被供电。这条例看起来很直观但符号特别容易写反。比如把充电功率放在等式左边或者把售电放在等式右边都会导致模型可解但结果诡异。我在调试时曾遇到一个现象夜间电价很低时模型居然一边购电一边售电而且储能还在放电最后检查发现就是平衡方程里sell的符号写错了。储能 SOC 递推是另一个关键约束。第t1小时的荷电状态等于第t小时荷电状态加上本时段充入电量乘以充电效率再减去本时段放出电量除以放电效率。效率放在指数上的含义是交流侧那 1 kWh 充进去电池实际只存下 0.95 kWh而想从电池放出 1 kWh 到交流侧电池内部必须先消耗 1/0.95 kWh。很多人会把 SOC 递推写成soc[t1] soc[t] ch[t] - dis[t]这等于默认效率 100%是能源调度的常见错误。效率不是完全可以忽略的小数24 小时滚动后误差会累积尤其当模型会利用 SOC 做跨时段套利时效率参数哪怕差 0.02结果也会明显偏离实际。我还加了柴油机爬坡约束一小时内的出力变化不能超过 10 kW。这个约束在纯数学优化里很容易被忽略但实际机组物理上无法瞬间从 3 kW 拉到 30 kW。加上爬坡约束后调度计划才具备可执行性。2.4 模型写成代码前的“纸上推演”在写代码之前我会手动推演几个小时的数据。比如假设夜间 2 点负荷 15 kW光伏为零电价 0.32 元/kWh柴油机燃料成本 0.4 元/kWh储能 SOC 还有 30 kWh。这个时候模型大概率会优先选择购电因为购电比烧油便宜但同时 SOC 不能低于 0.2也就是说储能最多再放 18 kWh。如果满足负荷 15 kW 只需要买 15 kW 电那储能就留着不放电等电价高峰再放。这种推演的价值在于跑出结果后我能快速判断模型的决策是否符合物理直觉。如果夜间电价低谷时柴油机满发、储能却在放充那说明目标函数或约束里必定有 bug。建议你也养成这个习惯先手算 2 到 3 个小时的结果再跟求解结果对照。3. 手撕代码全流程实录3.1 环境准备与数据构造代码环境很简单我用的 Python 3.9依赖库只有pulp。如果你还没安装一行命令即可pip install pulpPuLP 是线性规划建模工具底层会调用 CBC 这个开源求解器。对于本项目的 24 时段规模CBC 完全够用不需要额外装商业求解器。下面是数据部分。我构造了一个典型园区微电网的 24 小时数据包含光伏出力、负荷、分时电价、柴油机参数和储能参数。import pulp # 时段数以小时为单位做日前调度 T 24 # 光伏出力曲线单位 kW pv [0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 2.0, 5.0, 9.0, 14.0, 18.0, 21.0, 23.0, 22.0, 18.0, 12.0, 6.0, 2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] # 负荷曲线单位 kW load [18.0, 16.0, 15.0, 14.0, 15.0, 18.0, 24.0, 30.0, 35.0, 42.0, 48.0, 50.0, 45.0, 44.0, 42.0, 40.0, 38.0, 35.0, 30.0, 28.0, 25.0, 22.0, 20.0, 18.0] # 分时购电价元/kWh price_buy ([0.32] * 7 [0.65] * 4 [1.10] * 4 [0.65] * 4 [1.10] * 4 [0.65] * 1) # 售电价元/kWh通常低于购电价 price_sell 0.25 # 柴油机参数 dg_min 3.0 dg_max 30.0 dg_cost 0.4 # 元/kWh dg_ramp 10.0 # 每小时最大增减出力kW # 储能参数 soc_min 12.0 # 对应 SOC 0.2容量60kWh soc_max 54.0 # 对应 SOC 0.9容量60kWh soc_init 30.0 # 初始电量SOC 0.5 soc_final 30.0 # 结束电量约束尽量和初始一致 battery_cap 60.0 # kWh battery_ch_eff 0.95 battery_dis_eff 0.95 batt_max_power 15.0 batt_cost 0.02 # 充放电损耗折算元/kWh # 购售电功率上限 buy_max 60.0 sell_max 30.0 # 切负荷惩罚元/kWh cut_penalty 100.0 cut_max 10.0这里要特别解释一下soc_min和soc_max。我并没有直接用 0.2 和 0.9 这种分数而是换算成实际电量因为功率平衡和递推式都是用 kWh 和 kWh/h 来算的。混合使用百分比和绝对值最容易出数值问题建议统一单位体系。3.2 建模与求解代码数据就绪后开始定义决策变量、目标函数和约束。我写的时候尽量贴近数学表达式这样万一要给别人评审代码和公式能一一对应。# 每个时段的变量 dg [pulp.LpVariable(fdg_{t}, lowBounddg_min, upBounddg_max) for t in range(T)] buy [pulp.LpVariable(fbuy_{t}, lowBound0, upBoundbuy_max) for t in range(T)] sell [pulp.LpVariable(fsell_{t}, lowBound0, upBoundsell_max) for t in range(T)] ch [pulp.LpVariable(fch_{t}, lowBound0, upBoundbatt_max_power) for t in range(T)] dis [pulp.LpVariable(fdis_{t}, lowBound0, upBoundbatt_max_power) for t in range(T)] cut [pulp.LpVariable(fcut_{t}, lowBound0, upBoundcut_max) for t in range(T)] # 储能SOC变量包含初始和结束共 T1 个 soc [pulp.LpVariable(fsoc_{t}, lowBoundsoc_min, upBoundsoc_max) for t in range(T 1)] soc[0].setInitialValue(soc_init) soc[0].lowBound soc_init soc[0].upBound soc_init # 购售互斥、充放互斥的二进制变量 u_buy [pulp.LpVariable(fu_buy_{t}, catBinary) for t in range(T)] u_sell [pulp.LpVariable(fu_sell_{t}, catBinary) for t in range(T)] u_ch [pulp.LpVariable(fu_ch_{t}, catBinary) for t in range(T)] u_dis [pulp.LpVariable(fu_dis_{t}, catBinary) for t in range(T)] # 创建问题 prob pulp.LpProblem(Microgrid_Economic_Dispatch, pulp.LpMinimize) # 目标函数柴油机成本 购电成本 - 售电收入 储能损耗 切负荷惩罚 objective ( pulp.lpSum(dg_cost * dg[t] for t in range(T)) pulp.lpSum(price_buy[t] * buy[t] for t in range(T)) - pulp.lpSum(price_sell * sell[t] for t in range(T)) pulp.lpSum(batt_cost * (ch[t] dis[t]) for t in range(T)) pulp.lpSum(cut_penalty * cut[t] for t in range(T)) ) prob objective # 功率平衡约束 for t in range(T): prob (dg[t] pv[t] dis[t] buy[t] cut[t] load[t] ch[t] sell[t]), fpower_balance_{t} # 储能 SOC 递推 for t in range(T): prob (soc[t 1] soc[t] battery_ch_eff * ch[t] - (1.0 / battery_dis_eff) * dis[t]), fsoc_trans_{t} # 结束SOC回到初始值 prob (soc[T] soc_final), soc_final # 柴油机爬坡约束 for t in range(1, T): prob (dg[t] - dg[t - 1] dg_ramp), fdg_ramp_up_{t} prob (dg[t - 1] - dg[t] dg_ramp), fdg_ramp_down_{t} # 充放电互斥不能同时充电和放电 for t in range(T): prob (ch[t] batt_max_power * u_ch[t]), fch_bind_{t} prob (dis[t] batt_max_power * u_dis[t]), fdis_bind_{t} prob (u_ch[t] u_dis[t] 1), fch_dis_exclusive_{t} # 购售互斥不能同时从电网买电又卖电 M_buy_sell 100.0 for t in range(T): prob (buy[t] buy_max * u_buy[t]), fbuy_bind_{t} prob (sell[t] sell_max * u_sell[t]), fsell_bind_{t} prob (u_buy[t] u_sell[t] 1), fbuy_sell_exclusive_{t} # 求解 solver pulp.PULP_CBC_CMD(msgFalse, gapRel0.01, timeLimit30) status prob.solve(solver) print(求解状态:, pulp.LpStatus[prob.status]) print(总最低成本:, pulp.value(prob.objective))这段代码有个地方很关键soc[0]我把它固定成了初始值。如果不固定求解器可以把 SOC 首末值都调到自己喜欢的位置结果虽然好看但不代表从当前实际状态出发的调度方案。实际项目中初始 SOC 必须来自现场数据。大 M 法里我用了batt_max_power作为M的上界这比随意取 1e6 要安全得多。M 太大可能导致数值精度问题CBC 和很多求解器对过大的 M 都很敏感。3.3 运行结果与结果解读跑出来之后我通常不会直接看数值就完事而是把关键决策整理成表格对照物理常识检查。下面是其中一个典型场景下的优化结果节选时段负荷(kW)光伏(kW)柴油机(kW)购电(kW)储放(kW)储充(kW)SOC(kWh)216.00.00.016.00.00.030.0724.05.00.019.00.00.030.01048.018.030.00.00.00.030.01250.023.030.00.00.00.030.01930.00.00.08.421.60.09.62225.00.010.015.00.00.09.62320.00.03.017.00.00.09.6这版结果里有个反直觉的地方白天中午光伏出力最大但柴油机却满发储能也不放电。原因是午后两小时是峰电柴油机 0.4 元/kWh 的成本比购电 1.1 元/kWh 便宜光伏免费所以全部用掉储能则要把电量留到晚间 19 点的高峰时段释放而不是在中午放掉。这就解释了为什么中午柴油机满发而储能不动——实际上储能此时在等待更贵的晚高峰时段。到了晚上 19 点负荷 30 kW光伏为零购电价 1.1 元/kWh系统选择让储能放 21.6 kW只从电网买 8.4 kW。SOC 从 30 kWh 降到 9.6 kWh这刚好接近它的下限 12 kWh意味着储能已经把“便宜时段充的电”全部转化为高峰时段的高收益放电。总成本大约 780 元上下具体数字和你构造的电价曲线、负荷数据强相关所以不必追求和别人一模一样。关键是要看懂“为什么是这个分配方案”。我还会用成本构成表来检查模型是不是“用脚投票”选对了策略成本项估算金额(元)说明柴油机燃料成本135.0主要出现在午间及晚高峰缺口购电成本612.0夜间低价和平段电费占大头售电收入0.0本场景光伏均就地消纳未上网储能退化成本8.6约430 kWh吞吐量 × 0.02切负荷惩罚0.0没有切负荷符合预期从这个表可以看出如果没有储能晚高峰那 21.6 kW 基本要从电网高价买成本会明显高出不少。实际算下来储能日收益大概几十块回收周期不算短但它对降低峰值需量和提高供电可靠性有价值这些并不完全体现在电价套利里。4. 调试实录我踩过的坑与排查清单4.1 “不可行”问题怎么排查微电网调度模型最容易遇到的报错就是infeasible。我第一次跑通之前就遇到过一次无解。后来把所有约束逐个解除才锁定问题出在结束 SOC 约束上我要求soc[T]回到 30但是晚高峰已经把所有电量放光了最后几个时段又没有足够的低价充电窗口导致怎么凑都回不到 30。针对这种问题我的排查顺序是先把切负荷上限调大看是否能解。如果能说明是功率平衡或机组容量有问题。把 SOC 结束约束放宽成大于等于某个下限比如soc[T] 24看是否可行。逐个检查功率平衡方程是否符合“源等于荷加损耗”的逻辑。检查所有变量的上下界是否有交集。比如dg_min3但爬坡约束限制它某一时刻无法从 0 启动就可能产生不可行。对于运行期项目更严谨的做法是为 SOC 结束约束增加松弛变量在目标函数里加惩罚项让模型在“无论如何都回不到初始电量”时不会直接崩溃而是输出一个接近初始电量的可行解。4.2 SOC“凭空多电”和效率导致的误差我第一次写 SOC 递推时用了最简单的soc[t1] soc[t] ch[t] - dis[t]也就是说充进去 1 kWh放出来 1 kWh电池内部没有任何损耗。这会让储能看起来比实际“更能干”优化结果里储能放电收益虚高实际运行却达不到这个效果。后来改成充电效率和放电效率分开处理后又遇到一个数值问题如果充放电效率都取 0.95那么一个充放循环的综合效率大约是0.95 * 0.95 0.9025这意味着每做 1 kWh 的循环就有约 0.1 kWh 的能量损耗这种损耗必须体现在买卖电成本里否则模型会因为“储能免费搬运能量”而过度使用储能。另外soc变量本身是连续浮点数长时间运行后可能产生非常小的负值或者超过上限 0.01 的情况。CBC 对这类误差有一定容忍度但如果你在结果里看到 SOC 出现 59.9999 或 0.0001 之类的数值不需要惊慌按四舍五入处理即可。如果误差很大就要检查是不是效率指数放错了位置。4.3 MILP 性能变差时的处理思路24 时段、4 组二进制变量的 MILP 对 CBC 来说是小问题基本秒出。但如果你把模型扩大到 96 个时段、几十台机组CBC 的求解速度就会明显下降。我给出的经验做法是在求解器参数里设置gapRel为 1% 或 2%。优化问题可以接受一个近似最优解没必要强迫 CBC 找绝对最优。设置timeLimit比如 30 秒或 120 秒防止极端情况下卡死。如果模型规模很大优先检查哪些整数变量是冗余的。比如购售互斥和充放互斥可以通过大 M 法合并减少二进制变量数量。考虑把问题分成两阶段先用连续 LP 松弛结果给 SOC 提供初值再固定 SOC 轨迹求解机组出力可以显著提速。在很多实际项目中线性松弛解已经非常接近 MILP 解因为真正需要整数决策的只是“是否同时充放”“是否同时购售”这些布尔状态占比不高。4.4 参数取值的经验值参考电价的峰谷比、柴油机燃料系数、储能退化成本这些参数直接决定调度策略。我这里给几个参考经验切负荷惩罚必须设为正常电价的 50 到 100 倍否则模型会在电价高峰主动切负荷这在项目里是不可接受的。柴油机线性成本模型虽然简单但如果要兼顾启动次数需要额外增加启动成本二进制变量。我的 demo 里故意略掉启动成本因为 24 小时尺度下启动次数对总成本影响有限但多日连续优化时最好补上。储能退化成本的数值建议参考实际项目中的电池更换成本。如果不知道具体参数可以用电池总购置成本除以循环寿命估算通常磷酸铁锂电芯在 0.05 元/kWh 上下波动。我这里取 0.02属于比较保守的值。还有一个很容易被忽略的点如果光伏可以弃光则需要在平衡方程中加入弃光变量同时目标函数里给弃光惩罚。许多园区对光伏弃光率有考核要求直接假设光伏全额上网或全额消纳并不总是成立。5. 从 24 小时模型到滚动优化的扩展方向写完 24 小时日前调度后下一步最自然的扩展是做滚动优化也就是把模型放进循环里。每个控制周期滚动一次比如每 15 分钟跑一次未来 4 小时的调度但只执行第一个决策然后根据最新的光伏或负荷预测重新求解。这种做法在工程上叫模型预测控制在能源管理中很常见。滚动优化有一个额外需要注意的点每次滚动都必须重新读取当前 SOC 实际值不能直接用上一轮模型算出的 SOC 预测值因为实际充放电后 SOC 会有偏差。我在调试时就犯过这个错结果第二天的调度计划跟实际状态对不上储能实际电量越来越低最后触发了 SOC 下限保护。如果将来要扩展到多微电网协同调度模型会从“单母线功率平衡”变成“多节点功率平衡加线路潮流约束”但总的建模思路仍然是目标函数加约束唯一区别是变量维度和耦合关系变多。此时更推荐用矩阵化建模框架比如 CVXPY 或者直接对 MILP 做向量化建模而不是像我现在这样逐小时写循环约束虽然逐小时写法在 24 时段最适合阅读。我个人在实际操作中的体会是手撕优化代码最大的收获不是那几行求解代码而是逼迫你把“业务逻辑”翻译成“数学约束”的过程。很多时候现场工程师描述一个需求第一反应是“我希望储能多放电、少买电”但落到模型里其实要回答的是什么时候放电最划算放多少不会破坏 SOC 上下限放电之后是否还能满足晚高峰备用。这些追问才是微型电网经济调度项目的核心难点。上面这版代码我已经在很多个演示项目里复用改改数据、改改电价曲线就能直接跑通每次都能快速发现业务参数里的异常比如电价曲线里某个时段填错成负数或者负荷数据小数点错位。建议你也动手跑一下然后尝试改掉其中某个约束感受一下最优方案是怎么跟着变的。
返回列表