ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

电动汽车协调充电调度:考虑不同需求的MILP建模与代码实现

电动汽车协调充电调度:考虑不同需求的MILP建模与代码实现 电动汽车充电调度这个问题我在复现论文算法的时候踩了不少坑今天把整个思路和代码实现过程完整梳理一遍。标题里说的“考虑不同充电需求的协调充电调度方法”核心就是要解决一个现实问题不同车主对充电的需求完全不一样——有人赶时间必须一小时充满有人停一晚上随时可充有人只想补个电够开到公司就行。如果充电站只是“先到先充”或者“平均分配功率”那急用车的用户就会被慢充车主拖死电网侧也可能出现高峰拥挤。协调调度的意思就是把每一辆车的需求差异、时间约束、电价变化、变压器容量限制统统放进一个优化模型里求出全局最优的充电时序和功率分配方案。这篇文章我把它定位成一份代码复现笔记加实操解析。无论你是做电网调度的研究生、写充电桩管理系统的工程师还是刚开始接触优化调度算法的新手都可以照着这份流程把算法从论文变成能跑的程序。我会先拆解建模思路再对比方案选型然后给出核心代码和参数标定最后专门整理一份踩坑清单——这些都是论文附录里不会写的东西。1. 整体设计与思路拆解1.1 核心需求解析为什么“不同需求”是问题的关键大多数入门教程里讲的充电调度都是把车抽象成“一辆车、一个固定的充电曲线、最大化充电量”这种建模在单桩场景下成立一旦放到一个拥有几十个充电桩、同时服务上百辆车的场站就完全不够用了。我复现的这篇方法把“需求”拆成了三个维度时间需求每辆车有到达时间、期望取车时间。紧急用户可以给短时间窗普通用户给长时间窗。电量需求用SOC荷电状态表示车到达时有初始SOC车主期望离开时SOC达到某个阈值。不同车阈值不同比如网约车要90%通勤车只要50%。功率需求车辆充电机支持的最大功率不同快充车型能到120kW老车型只能到7kW。不同的MaxPower直接决定了调度时的可选策略空间。这个建模的关键不是把需求“定义得多复杂”而是要让优化器知道每一个约束到底是硬约束还是软约束。比如网约车司机说“我必须在8点前充满”这就是硬约束普通车主说“最好明天早上满电”这就可以作为目标函数里的惩罚项而不是强约束。复现过程中很容易犯的错就是把所有需求一视同仁地变成约束结果求出来的解要么不可行要么牺牲了整体效率。1.2 协调调度的目标函数设计成本和满意度之间的取舍协调调度不同于单台车“插上就充”它需要在全局层面做权衡。我复现的模型用了双目标加权第一项是总充电成本最小化第二项是用户满意度最大化。成本部分考量分时电价充电功率乘以对应时段电价累加求和。满意度部分用“最终SOC与期望SOC的差值”来表示差值越小满意度越高。两个目标加起来形式化地写就是min Σ_t Σ_i (电价_t * P_i,t * Δt) λ * Σ_i (SOC期望_i - SOC最终_i)λ是权重系数用来调节“省钱优先”还是“体验优先”。这个参数在实际场景里往往由运营商决定居民区充电站可能更看重体验商业快充站更看重成本。这个双目标结构复现起来不复杂但要注意量纲问题——成本单位是元SOC差值是百分比量纲差了几十倍如果不做归一化λ很难调节。我建议先把成本项除以单日最大可能成本把SOC差值除以100%再乘权重这样λ的取值才有实际意义。1.3 约束条件设计不可逾越的物理边界没有约束的优化是无源之水。在这个模型里约束大致可以分成五类复现时务必逐条对照总功率约束任意时刻充电站总消耗功率不能超过变压器上限。单车最大功率约束每辆车每个时段的充电功率不能超过车辆自身的最大充电功率和充电桩额定功率的较小值。时间窗约束只能在车辆到达之后到期望离开之前充电。SOC动态约束SOC按时间累加但不能越过100%也不能低于0%。充电连续性约束可选部分场景要求一旦开始充就不能中断复现时可以用二进制变量来建模。这里最需要注意的就是第5条。我一开始没有加入连续性约束求出来的解是“功率一会儿60kW一会儿10kW再一会儿80kW”从优化器角度看成本最低但现实中没有任何一款车或者充电桩能接受这种频繁功率跳变。加了连续性约束后模型就从线性规划变成混合整数线性规划求解时间明显增加但结果才真正可用。2. 算法选型为什么选这一条路线2.1 几类主流调度方法对比在这个领域逛一圈主流方案基本可以分为四类启发式规则法、元启发式算法GA/PSO、模型预测控制MPC、混合整数规划MILP。启发式规则最简单比如“先到先充”“最短作业优先”“电价最低时段充”实现起来半小时搞定但效果不稳定尤其面对强约束场景很容易违反变压器容量限制。元启发式算法如遗传算法、粒子群在国内论文中出现频率很高思路上适合非线性、非凸的目标函数但复现时最烦的是参数调优种群大小、交叉率、变异率哪个不匹配都会导致结果波动。MPC适合在线滚动调度但对场景的状态预测要求高初版复现不建议直接上。我最终选择的是MILP路线。原因很直接充电调度问题本质上是一个变量规模可控的整数规划问题充电车辆几十到上百辆时间窗切成96个时段15分钟粒度或24个时段小时粒度用MILP完全能在秒级到分钟级内求出全局最优解而且MILP求解器比如开源的CBC、商用免费的Gurobi学术版会给一个最优性证明——这是启发式算法给不了的。2.2 复现之前必须做的“需求建模三问”在动手写代码之前我建议先回答三个问题否则代码写一半一定会推翻重来。第一问时间颗粒度切多细我首次复现用了1分钟粒度结果一个24小时的调度模型一小时就有60个时段一天1440个时段再乘以车辆数决策变量规模直接爆炸。后来改成15分钟粒度一天96个时段模型规模缩小到原来的1/15求解速度从几十秒降到两三秒精度损失却很小——因为实际充电桩调度本身也是15分钟级别的操作。第二问优化周期是开环还是滚动开环一次性算出全天计划计算简单但抗扰动差滚动则每15分钟重新求解一次把“刚发生的变化”比如来了新车、某辆车提前走了加进去效果好但工程复杂度高。复现时建议先做开环跑通后再升级为滚动。第三问目标函数用单目标还是多目标加权这个前面说过了建议先做单目标再扩展成双目标加权不要一开始就搞Pareto前沿分析那是论文里好看工程里难落地。3. 代码架构与核心实现解析3.1 工程结构与数据类定义我复现时用的Python环境核心库是pulp做MILP建模pandas做数据处理matplotlib画调度甘特图。整个工程目录结构如下ev_charging_scheduling/ ├── data/ # 原始数据 │ ├── vehicles.csv # 车辆需求信息 │ └── price.csv # 分时电价 ├── src/ │ ├── model.py # MILP模型构造与求解 │ ├── preprocess.py # 数据清洗和时间窗切分 │ └── visualize.py # 结果可视化 ├── output/ │ └── schedule_result.csv # 调度结果 └── main.py # 主流程车辆数据表我设计了这些字段每行是一辆车vehicle_id车辆编号arrive_time到达时间小时leave_time期望离开时间小时init_soc初始SOC0~1target_soc期望离开时SOC0~1battery_capacity电池容量kWhmax_charge_power最大充电功率kW电价数据表比较简单就两列time_interval和price表示每个时段的电价元/kWh。3.2 核心建模代码逐段解读先贴预处理部分的代码。预处理的核心任务是把每辆车的“时间窗”转换成时段的索引数组。import pandas as pd import numpy as np # 时间粒度设为15分钟一天96个时段 TIME_SLOTS 96 SLOT_MINUTES 15 SLOT_HOURS SLOT_MINUTES / 60.0 def time_to_slot(time_hour): 将小时数转换为时段索引如 8.25 - 33 return int(time_hour * 60 // SLOT_MINUTES) def load_and_preprocess(vehicle_file, price_file): df pd.read_csv(vehicle_file) price_df pd.read_csv(price_file) vehicles [] for _, row in df.iterrows(): arrive_slot time_to_slot(row[arrive_time]) leave_slot time_to_slot(row[leave_time]) # 可充电时段就落在 [arrive_slot, leave_slot) 区间内 available_slots list(range(arrive_slot, leave_slot)) vehicles.append({ id: row[vehicle_id], available_slots: available_slots, init_soc: row[init_soc], target_soc: row[target_soc], capacity: row[battery_capacity], max_power: row[max_charge_power], energy_needed: (row[target_soc] - row[init_soc]) * row[battery_capacity], }) # 电价数组长度96 price_array np.zeros(TIME_SLOTS) for _, row in price_df.iterrows(): idx time_to_slot(row[time_interval]) price_array[idx] row[price] return vehicles, price_array这里有个细节值得说一下energy_needed字段我直接算出来了因为后续模型里的核心约束是“累计充电量大于等于所需电量”提前算好可以减少模型内部的计算量还能方便发现问题——比如如果出现负数就说明初始SOC已经高于目标SOC这辆车实际不需要充电应该在第一轮清洗时直接过滤掉。下面进入模型构造。使用pulp.LpProblem创建最小化问题决策变量定义为二维数组import pulp def build_model(vehicles, price_array, transformer_limit500.0, lambda_weight0.1): prob pulp.LpProblem(EV_Charging_Scheduling, pulp.LpMinimize) # 决策变量P[v][t] 表示车辆v在时段t的充电功率非负连续变量 P {} for v in range(len(vehicles)): for t in vehicles[v][available_slots]: P[v, t] pulp.LpVariable(fP_{v}_{t}, lowBound0) # 目标函数由两部分组成见下 total_cost pulp.lpSum(price_array[t] * P[v, t] * SLOT_HOURS for v in range(len(vehicles)) for t in vehicles[v][available_slots]) # 用户满意度损失用最终SOC与目标SOC的负偏差来惩罚 # 新建变量缺失SOC量 miss[v] miss {} for v in range(len(vehicles)): miss[v] pulp.LpVariable(fmiss_{v}, lowBound0) # 累计充电量 初始电量 目标电量 - miss prob (pulp.lpSum(P[v, t] * SLOT_HOURS for t in vehicles[v][available_slots]) vehicles[v][init_soc] * vehicles[v][capacity] vehicles[v][target_soc] * vehicles[v][capacity] - miss[v]) # 目标成本 惩罚项 penalty lambda_weight * pulp.lpSum(miss[v] for v in range(len(vehicles))) prob total_cost penalty注意我用了miss[v]这个松弛变量——这是这个模型复现中的精华所在。为什么不用硬约束强制每辆车都必须达到目标SOC因为当变压器容量不够、或者充电时间实在太短时硬约束会导致整个问题无解。工程上一定要允许“部分车辆无法达成目标”让优化器自己去权衡哪些车可以牺牲、牺牲多少。lambda_weight就是给这种“牺牲”定价的。然后加上约束条件变压器总功率限制和单车最大功率限制。# 约束1任意时刻总功率不超过变压器上限 for t in range(TIME_SLOTS): prob (pulp.lpSum(P[v, t] for v in range(len(vehicles)) if (v, t) in P) transformer_limit, ftransformer_limit_{t}) # 约束2单车最大功率 for v in range(len(vehicles)): for t in vehicles[v][available_slots]: prob (P[v, t] vehicles[v][max_power], fmax_power_{v}_{t}) return prob, P, miss到这里一个可以求解的MILP模型就构造完成了。3.3 求解与结果解析求解就一行代码def solve_model(prob, P, vehicles): solver pulp.PULP_CBC_CMD(msgTrue, timeLimit120) prob.solve(solver) if pulp.LpStatus[prob.status] ! Optimal: print(警告未求得最优解状态为, pulp.LpStatus[prob.status]) # 提取调度结果 schedule [] for (v, t), var in P.items(): if var.value() and var.value() 0.01: schedule.append({ vehicle_id: vehicles[v][id], time_slot: t, power_kw: round(var.value(), 2), }) return schedulePULP_CBC_CMD是pulp自带的开源求解器虽然性能不及Gurobi但对这个规模和结构的模型完全够用。timeLimit120是防止极端情况下求解卡死——我在复现时遇到过非常复杂的时间窗组合导致分支定界树异常庞大加了这个限制至少能保证程序在两分钟内退出不至于一直挂在那里。拿到schedule之后我会转成透视表行是车辆列是时段值是充电功率然后画成热力图或者堆叠面积图。这样能很直观地看到各车辆充电功率的时序分配情况。4. 实证场景与参数标定4.1 仿真场景设置我用一个模拟的小型充电站做测试变压器上限500kW10辆车时间范围是早上8点到晚上8点一共12个小时48个15分钟时段。电价设计成峰谷电价8点到12点和17点到21点为峰时1.2元/kWh其余时段为谷时0.4元/kWh。车辆参数如下表这里面刻意设置了不同类型的用户车辆ID到达时间离开时间初始SOC目标SOC电池容量kWh最大功率kW用户类型V18:009:000.200.806060紧急快充V28:0012:000.501.0075120上班补电V38:3018:000.400.906060全天停车V49:0011:000.100.708080网约车V510:0016:000.601.00100120长时停车V610:3015:000.300.856060普通通勤V711:0012:300.701.007575吃饭补电V813:0020:000.200.908080下午停车V914:0019:000.500.956060普通通勤V1015:0017:000.150.75100100紧急补电注意V1的设定8点到9点只有1小时要从20%充到80%60kWh电池需要充36kWh以60kW功率充1小时刚好够。这是一个典型的时间窗极紧的用户协调调度算法必须优先给它分配功率。4.2 求解结果解读跑完模型后我重点看了三个输出。第一个是目标函数值的分解总充电成本、总惩罚值。当λ取0.1时成本项约为320元惩罚项为0——意思是所有车都达到了目标SOC没有任何车辆被牺牲。当λ降为0.01时成本降到约280元但出现了惩罚值——V10没有完全充到目标SOC差了大约12kWh。第二个是逐时段总功率曲线。优化结果清楚地把大部分充电量推到了谷电时段峰时段的功率被压缩到刚好满足紧急用户的需求。V1在8点~9点满功率60kW充电V2和V3的充电功率被打散到了12点之后的谷电时段V4在9~11点也是优先充因为11点前要离开。第三个是每辆车的SOC变化轨迹。这个数据很重要——它能验证模型是否违反了SOC上限。我检查了所有车辆的SOC曲线最大值没有超过100%最小值没有低于0%。4.3 关键参数敏感性分析光跑通还不够参数不调结果只能自嗨。我重点做了两个参数的敏感性实验。先调λ。λ从0.01到1.0每隔0.05测一次一共20次求解记录总成本和惩罚值。得到的规律是λ在0.05到0.2之间是一个比较合适的区间总成本比完全不顾体验的方案只高5%到8%但所有车都能达到目标SOC。λ超过0.5以后优化器对SOC差值的惩罚过重导致为了给某一辆车“硬充”到100%不得不在峰时高价购电总成本急剧上升。λ低于0.02几乎等于不关心用户满意度默认情况下所有车都会被牺牲到最低SOC。再调变压器上限。把变压器上限从500kW降到300kW再降到200kW观察解的变化。500降到300总成本和惩罚值几乎不变说明裕量充足300降到200惩罚值开始出现个别车在峰时段无法被满足200降到150惩罚值急剧上升几乎一半的车达不到目标。这说明在这个10辆车的场景里变压器容量临界点在250kW左右。5. 常见问题与避坑指南5.1 典型的“论文代码”陷阱这节我列一下复现过程中最常遇到的问题建议收藏。问题现象根本原因解决方案求解器报“Infeasible”时间窗不足或总功率不够硬约束过多引入松弛变量miss[v]求解时间非常长时间粒度太细、时段数量多调整为15分钟粒度减少决策变量结果中功率频繁跳动缺少连续性约束或最小充电功率约束加入充电状态二进制变量求解结果总是不稳定目标函数量纲不一致归一化后再加权所有车都在峰时充电电价价格数组没设对检查电价索引和时段对应关系5.2 我在复现中踩过的三个具体的坑坑一电价索引错位。第一次写price_array的时候我用了time_to_slot(row[time_interval])而电价表里time_interval是浮点数小时比如8.25代表8点15分。但我的时段索引从0开始代表0点~0点15分这本身没问题问题出在我把time_interval理解成“起始时间”而不是“时间点”导致8.25对应的时段被算到338点15分~8点30分而实际上8.25是8点15分那个时刻应该属于328点~8点15分。虽然只差一个时段但峰谷电价切换时段一错所有优化结果全错。排查方法就是在求解前画出价格曲线检查一下。坑二遗漏了SLOT_HOURS换算。我知道充电量等于功率乘时间但写代码时有一版直接把P[v,t]当成“该时段充入的电量”来用没有乘0.25小时。这样算出来的SOC变化比实际快了4倍。复现论文公式的时候一定要警惕变量含义是“功率”还是“能量”这是最隐蔽的错误。坑三目标函数里惩罚项量纲没统一。λ0.1的时候成本项的数值大约在300元附近而SOC差值用kWh表示miss[v]在一辆车没充满的情况下可能达到十几kWh惩罚项才1块多完全不起作用。所以我在代码里给miss[v]也乘了一个unit_cost_per_kwh参数相当于对“每度电短缺”定一个价格比如5元/kWh这样λ调起来就直观多了。# 改进后的惩罚项给短缺电量定价 shortage_cost 5.0 # 元/kWh自行设定 penalty lambda_weight * shortage_cost * pulp.lpSum(miss[v] for v in range(len(vehicles)))5.3 从离线复现走向在线部署的一点经验最后想聊一下怎么把离线复现的代码应用起来。我之前在实验室里用这个MILP模型做离线调度效果很好但到了真实场站发现一个问题车辆到达时间是不确定的实际SOC可能和上报值有偏差还有充电桩故障等突发情况。这时候就需要把调度从“一天一次的开环优化”改成“每15分钟滚动一次的开环优化”每次求解只执行下一个时段的调度指令然后重新读入最新状态再次求解。这个改动在不改变模型核心的前提下加一个循环就行但健壮性提升非常明显。如果追求更快的求解速度可以把pulp换成gurobipy同样的模型规模求解时间基本能缩短10倍以上。Gurobi有学术免费license申请也不复杂学生和科研用户可以直接用。回看整个复现过程最重要的经验可以浓缩成一句话先花大量时间理解需求建模再动手写代码。模型比代码值钱——miss松弛变量的引入、量纲统一、时段粒度选择这些才是硬功夫。希望这份解析能帮你少走一些弯路。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进