资讯动态

从复现到实践:用Python+PuLP实现电动汽车协调充电调度

发布时间:2026/9/14 7:51:33 来源:尧图企业网站定制
上周我把手头的一篇《考虑不同充电需求的电动汽车协调充电调度方法》论文复现任务跑通了前后折腾了大约三个晚上。这篇文章记录我复现的全部过程从数学模型怎么读、怎么转成代码到用 Python PuLP 搭出完整调度系统再到结果怎么核验、怎么把无序充电和有序充电的差异画出来。如果你也在复现这类论文或者准备自己尝试写一个有序充电调度的小工具这篇文章应该能帮你省不少时间。先说清楚这篇论文在解决什么。电动汽车越来越多大家下班回到家插上充电枪就开始充电结果就是晚上七八点钟形成一个巨大的用电尖峰。小区变压器容量是固定的所有车同时开足功率充电轻则电费飙升重则跳闸、变压器过载。传统的“即插即充”模式完全没有任何协调谁先插枪谁先充毫无秩序。而所谓协调充电调度核心思路就是在不影响车主用车需求的前提下通过优化算法决定每辆车什么时候开始充、用多大功率充、是否需要中途暂停尽可能避开用电高峰和贵电价时段实现削峰填谷和费用优化。这类问题在学术界有个典型的建模框架——混合整数线性规划MILP。复现的关键也在这里怎么把论文里“考虑不同充电需求”这句抽象的话变成可求解的数学约束再变成能跑的 Python 代码。下面按我的复现路线逐步展开。1. 复现之前先把问题本身搞清楚1.1 它到底在优化什么这篇论文最核心的设定是“不同充电需求”。如果你理解成“每辆车充电速度不同”那就偏了。论文里的“需求差异”指的是车辆抵达充电站后的时间紧迫程度、停留时长、期望电量不同。我做了个归纳实际建模时通常会面对这几类车主紧急型电池快耗尽SOC 低于 20%停留时间很短1~2 小时希望尽快充到 80% 以上常规通勤型晚上到早上离开停留时间 10 小时以上对充电时长不敏感只要第二天早上电量足够即可价格敏感型同样停留一晚上但希望推迟到谷电时段充电把账单压到最低商业/快充场景型停留在商场有固定离场时间充电时长约 2~3 小时。不同类型车主对“何时充电”的约束不同这直接影响模型的可调度空间。紧急型的可调度空间很小因为必须立刻充通勤型的可调度空间很大因为可以等到半夜再充。协调调度的本质正是利用这些“弹性”需求去填补负荷低谷、避开高峰。1.2 复现前需要看清的三个优化目标论文的目标函数一般不是单目标常见的有三个方向最小化总充电费用峰时电价高、谷时电价低让充电尽量跑到谷时去最小化负荷峰值/峰谷差即使电价一样也要避免所有车集中在同一时刻充电造成变压器过载最大化充电完成率或用户满意度不能为了削峰填谷把某辆车电充得太少导致车主第二天开不走车。实际复现时我建议先做双目标费用最小 负荷峰谷差最小再用加权系数合成一个单目标。这样既能看到调度效果又不用处理复杂的多目标求解更适合验证核心逻辑。如果论文里明确是三目标你可以在双目标基础上加一辆车的“最低完成电量”软约束等价于牺牲一点经济性换满意度。1.3 “协调”到底体现在哪里很多新手容易忽略“协调”的作用机制。单辆车自己做优化是没有意义的因为每台车都是独立决策负荷会被推到同一个便宜时段——比如大家都等到晚上 11 点谷电开始再充结果 11 点仍然是一个新的峰值。真正的协调是所有车共享一个总功率上限或者共享一个目标函数在优化各自需求的同时相互制约、错峰分配。这个思想对应到代码上就是约束条件中必须有一个全局耦合约束例如所有车在任意时刻的充电总功率不能超过变压器上限或者优化目标里有一个关于总负荷曲线的正则项。没有这个全局约束你的模型就不是“协调调度”而只是“单车充电优化”。这个区分一定要在复现前想清楚。2. 数学模型拆解如何把论文变成可计算的公式2.1 参数、集合与决策变量复现文献时最忌直接跳去写代码。我习惯先在自己本子上列一张符号表把论文模型的关键参数整理出来。以下是我在这篇论文复现中用的核心符号符号含义(N)充电的电动汽车数量算例中取 10(T)调度周期内时间槽数量取 96对应 24 小时 × 15 分钟(i)车辆索引(i 1, 2, ..., N)(t)时间槽索引(t 1, 2, ..., T)(P_{\max,i})第 (i) 辆车的最大充电功率kW(E_{\text{cap},i})第 (i) 辆车的电池容量kWh(SOC_{\text{init},i})第 (i) 辆车的初始电量比例(SOC_{\text{target},i})第 (i) 辆车期望达到的电量比例(\eta)充电效率通常取 0.9~0.95(p_t)第 (t) 个时间槽的电价元/kWh(P_{\text{trans}})变压器/线路最大允许负荷kW(t_{\text{arr},i}, t_{\text{dep},i})第 (i) 辆车到达和离开的时间槽(C_{\text{peak}})总负荷的峰值辅助变量(x_{i,t})决策变量第 (i) 辆车在 (t) 时刻的充电功率kW(y_{i,t})0-1 决策变量1 表示第 (i) 辆车第 (t) 时刻在充电决策变量里(x_{i,t}) 可以是连续的可调功率充电桩也可以是离散档位快充/慢充/关闭。论文复现中我选择了连续功率变量配合 0-1 开关变量 (y_{i,t})这样最接近实际中“充电桩可调节输出”的场景。2.2 目标函数费用 削峰怎么合成一个论文原模型通常是多目标加权我的复现代码如下表示[ \min \quad \omega_1 \sum_{t1}^{T} p_t \cdot \Delta t \cdot \sum_{i1}^{N} x_{i,t} \omega_2 \cdot C_{\text{peak}} ]其中第一项是总充电费用第二项是充电总负荷峰值(\omega_1)、(\omega_2) 是权重系数。(\Delta t) 是一个时间槽的时长15 分钟就是 0.25 小时。如果你不想用“峰值”这种 max 型目标也可以用总负荷方差或平均绝对偏差。但要注意直接用方差函数在 PuLP 这类线性求解器里不好处理——方差带平方项非线性。峰值的优势是可以线性化[ C_{\text{peak}} \geq \sum_{i1}^{N} x_{i,t}, \quad \forall t 1, 2, ..., T ]这样只要保证 (C_{\text{peak}}) 大于等于每个时间槽的总负荷且目标函数在最小化它求解器就会自动把峰值压下来。这种方法代码简单、求解稳定是我复现时首选的方案。2.3 约束条件论文的核心工作量在这SOC 连续性约束这是调度的“物理底线”。车辆的电池电量变化必须满足[ SOC_{i,t1} SOC_{i,t} \frac{\eta \cdot x_{i,t} \cdot \Delta t}{E_{\text{cap},i}} ]这里 (SOC_{i,t}) 本身就包含在等式里了代码里可以通过累加约束来实现。注意(\eta) 别漏了很多复现半天调不对的原因就是忘了效率。充电功率上下限每辆车每个时间槽的充电功率要满足[ 0 \leq x_{i,t} \leq P_{\max,i} \cdot y_{i,t} ]如果 (y_{i,t}0)功率强制为 0如果 (y_{i,t}1)功率最大可到 (P_{\max,i})。离场时电量目标约束对每辆车要求离开时达到期望电量[ SOC_{i,T_{\text{dep},i}} \geq SOC_{\text{target},i} ]另一种写法是“累计充电电量满足需求”[ \sum_{tt_{\text{arr},i}}^{t_{\text{dep},i}} \eta \cdot x_{i,t} \cdot \Delta t \geq (SOC_{\text{target},i} - SOC_{\text{init},i}) \cdot E_{\text{cap},i} ]我复现时推荐用累计形式因为不需要为每辆车单独建 SOC 变量省内存、求解更快尤其适合稍大规模算例。时间窗约束车辆只能在到达和离开之间的时间槽内充电[ x_{i,t} 0, \quad t t_{\text{arr},i} \quad \text{或} \quad t t_{\text{dep},i} ]这个约束其实可以融合到功率上下限里通过只对可充电时段添加变量来实现减少无效决策变量数量。全局负荷约束[ \sum_{i1}^{N} x_{i,t} \leq P_{\text{trans}}, \quad \forall t ]如果不想硬约束总功率也可以把它放进目标函数。但建议保留硬约束因为它体现出“变压器容量有限”这个真实物理限制。比如小区变压器额定 250 kVA理论最大只能给所有车同时输出 250 kW设置 (P_{\text{trans}}) 为 200 kW 更保守。3. 复现环境选型工具链为什么这么选3.1 为什么用 Python 而不是 MATLAB论文复现有很多人习惯用 MATLAB YALMIP但这几年我基本坚持用 Python。理由有三个一是 PuLP/OR-Tools 等建模库很容易安装pip 装一下就是环境了二是数据生成、结果画图、调参全在同一个语言闭环里不用来回切换三是社区资料多遇到问题随便一搜就有现成案例。如果论文自带 MATLAB 代码我会先读明白逻辑再移植到 Python。如果是从零复现Python 绝对更省心。MATLAB 不是不能用只是它的线性规划工具箱在学术许可之外价格不菲Python 的开源求解器足够打 90% 的场景。3.2 求解器选 CBC 还是 Gurobi我第一步用的是 PuLP 默认的 CBC 求解器原因很简单免费、安装零配置、对 10 辆车 × 96 个时间槽这种规模毫无压力。规模上去之后例如 100 辆车以上时间粒度到 5 分钟CBC 的求解时间会明显增加这时候可以切到 Gurobi。Gurobi 对学生和学术用途免费但需要申请许可证。日常复现我用 CBC 就够了只有跑大数据量才切 Gurobi。3.3 数据怎么造论文复现中拿不到原始数据很正常这时候需要自己生成合理的模拟数据。我造数据的原则是真实场景均值 均匀分布扰动。比如 10 辆车的数据我这样生成初始 SOC在 20%~50% 之间均匀分布目标 SOC统一设为 90%结合各车情况可调整电池容量40~60 kWh 之间随机模拟不同车型最大充电功率慢充桩取 7 kW快充桩取 50 kW到达时间集中在 17:00~20:00离开时间通勤型为次日 7:00~8:00紧急型为到达后 1~2 小时电价采用典型居民分时电价峰时 0.9 元/kWh8:00-22:00谷时 0.4 元/kWh22:00-次日8:00。生成完数据后我会先用 Excel 或 Pandas 粗略统计一下总需求电量是多少、全部车辆按无序充电会在哪个时段形成峰值。这个“基准值”对后面验证模型结果非常关键因为有序充电的效果本质上就是跟无序充电对比出来的。3.4 完整代码的骨架设计我写这类建模代码的习惯是数据区、模型区、求解区、结果区四段分离。数据区和结果区用 Pandas 处理模型区用 PuLP避免全部塞在一个循环里。下面是我的骨架import pandas as pd import numpy as np import pulp # 1. 数据生成 np.random.seed(42) N 10 T 96 delta_t 0.25 # 小时 # 车辆基础数据 vehicles pd.DataFrame({ capacity: np.random.uniform(40, 60, N), soc_init: np.random.uniform(0.2, 0.5, N), soc_target: 0.9, p_max: np.random.choice([7, 50], N, p[0.8, 0.2]), arrival: np.random.randint(68, 80, N), # 17:00-20:00 - 第68-80个时间槽 departure: np.random.randint(88, 96, N), # 22:00-24:00 - 第88-96个时间槽 }) vehicles[energy_demand] (vehicles[soc_target] - vehicles[soc_init]) * vehicles[capacity] # 分时电价 price np.array([0.9] * 96) price[88:] 0.4 # 22:00-24:00 为谷时 price[0:32] 0.4 # 0:00-8:00 为谷时这里我把时间定为 17:00 到次日 8:00其实也可以用完整 24 小时。论文如果跑的是 24 小时调度就把 96 个时间槽铺满 0:00 到 24:00电价曲线按真实分时来定义。关键是时间段和电价要对上。4. 核心代码实现与逐步解析4.1 建模型和决策变量有了数据和骨架下一步就是真正建模。PuLP 里创建模型和变量非常直观prob pulp.LpProblem(EV_Coordinated_Charging, pulp.LpMinimize) # 决策变量充电功率 x pulp.LpVariable.dicts(charge_power, ((i, t) for i in range(N) for t in range(T)), lowBound0) # 0-1 状态变量是否在充电 y pulp.LpVariable.dicts(charge_status, ((i, t) for i in range(N) for t in range(T)), catpulp.LpBinary) # 峰值辅助变量 peak pulp.LpVariable(peak_load, lowBound0)这里我用了LpVariable.dicts好处是后续可以用x[i, t]直接索引代码可读性很高。lowBound0已经覆盖了功率非负约束不需要额外再加。4.2 目标函数费用和峰值怎么权衡目标函数的代码要跟前面的数学公式一一对应omega1 1.0 omega2 0.5 # 充电费用 charging_cost pulp.lpSum(price[t] * delta_t * x[i, t] for i in range(N) for t in range(T)) # 目标费用 权重 * 峰值 prob omega1 * charging_cost omega2 * peak这里 (\omega_1) 和 (\omega_2) 的取值会影响调度结果。如果峰值的权重拉太高求解器会把所有车辆充电都尽量分散开即使电价贵一点也无所谓如果费用权重太高峰值则可能压不住。我最终用的经验值是费用权重给 1.0、峰值权重给 0.5在这个配置下费用和削峰效果都能兼顾。建议你复现时先用这两个值再根据自己的数据微调。4.3 核心约束一条条加上去约束 1峰值定义for t in range(T): prob pulp.lpSum(x[i, t] for i in range(N)) peak这个约束确保 peak 至少是任意时刻的总负荷。目标函数里在最小化 peak求解器会自动把 peak 压到最大负荷附近。约束 2充电功率与状态变量的关系for i in range(N): for t in range(T): prob x[i, t] vehicles.loc[i, p_max] * y[i, t] prob x[i, t] 0状态变量 (y_{i,t}) 在这里的作用是“开关”。如果不想要离散变量也可以直接把 (x_{i,t}) 的上下限定为 (0) 和 (P_{\max,i})但那样模型就成了纯线性规划没有“充/不充”这种逻辑选择在论文复现中反而不够精确。约束 3累计充电量满足需求for i in range(N): arr int(vehicles.loc[i, arrival]) dep int(vehicles.loc[i, departure]) demand vehicles.loc[i, energy_demand] prob pulp.lpSum(eta * x[i, t] * delta_t for t in range(arr, dep)) demand这是整个模型里最关键的约束。它只允许车辆在到达和离开的时间窗内充电同时保证离开前能充到目标电量。这个约束一旦写错求解出来的调度方案会出现“车还没到就开始充”或者“到点了电量还没充满”的荒唐结果。约束 4紧急型车辆的时间窗限制如果某辆车是紧急型它必须在到达后的前 1~2 小时内完成充电这个约束需要单独加urgent_vehicles [0, 3, 7] # 假设这些是紧急型车辆 for i in urgent_vehicles: arr int(vehicles.loc[i, arrival]) dep int(vehicles.loc[i, departure]) demand vehicles.loc[i, energy_demand] # 到达后的前 8 个时间槽2小时内必须满足需求 prob pulp.lpSum(eta * x[i, t] * delta_t for t in range(arr, min(arr 8, dep))) demand如果你不想在代码里硬编码车辆类型可以在 vehicles DataFrame 里加一列type然后根据类型动态生成约束。这样更接近论文“考虑不同充电需求”的表述。4.4 求解与状态检查solver pulp.PULP_CBC_CMD(msgTrue, timeLimit60) result prob.solve(solver) print(求解状态:, pulp.LpStatus[result]) print(目标函数值:, pulp.value(prob.objective))求解状态有Optimal、Infeasible、Unbounded三种情况要重点关注。如果出现Infeasible最常见的原因是累计电量需求太大、时间窗不够如果出现Unbounded说明目标函数或约束有问题很可能某个约束漏加了。遇到这两种情况不要急着改数据先检查约束逻辑。4.5 调度结果提取schedule np.zeros((N, T)) for i in range(N): for t in range(T): schedule[i, t] pulp.value(x[i, t]) or 0 total_load schedule.sum(axis0)这里要留意一点pulp.value(x[i, t])可能返回None所以用or 0保底。提取完数据后我会立刻做一个“合理性校验”——逐辆车检查累计充电量是否满足需求以及总负荷曲线是否出现负值或不合理尖峰。这一步能提前拦截大部分模型错误。5. 结果怎么看调度效果必须和“无序充电”对比5.1 先生成无序充电基准有序充电的效果不是看绝对数字而是看“相对无序充电”改善多少。所以复现时我会用同样的车辆数据跑一个“即插即充”模式作为基准。无序充电的逻辑最简单车辆到达后立刻以最大功率充电直到充满为止。baseline_total_load np.zeros(T) for i in range(N): arr int(vehicles.loc[i, arrival]) dep int(vehicles.loc[i, departure]) demand vehicles.loc[i, energy_demand] remaining demand t arr while remaining 1e-6 and t dep: power min(vehicles.loc[i, p_max], remaining / (eta * delta_t)) baseline_total_load[t] power remaining - power * eta * delta_t t 1这个简单循环就模拟了“车辆一到达就开充充满即停”的真实行为。我跑出来的无序充电基准通常在晚上 18:00-20:00 出现一个又高又窄的峰值这正好对应“晚高峰充电拥堵”。5.2 图表怎么画能说明问题我习惯画三张图无序充电 vs 有序充电的总负荷曲线直接看削峰效果每辆车的充电时序热力图横轴是时间纵轴是车辆编号颜色表示充电功率看调度器把充电任务分配到了哪些时段累计充电费用的柱状对比图直观展示省钱额度。总负荷曲线那部分代码大致是这样import matplotlib.pyplot as plt time_axis np.arange(0, 24, 0.25) plt.figure(figsize(12, 5)) plt.plot(time_axis, baseline_total_load, labelUncoordinated, linewidth2) plt.plot(time_axis, total_load, labelCoordinated, linewidth2) plt.xlabel(Time (h)) plt.ylabel(Total Load (kW)) plt.legend() plt.grid(alpha0.3) plt.show()我第一次跑出对比图时效果非常明显有序充电的负荷曲线像是一排被均匀拉开的砖块峰值降了差不多 35%同时费用降了接近 20%。这个结果也验证了模型逻辑没有大问题。5.3 量化指标复现论文结论的验证方式表格是论文复现里最有力的表达方式。我给自己定了三个必算指标指标无序充电有序充电改善比例总充电费用元78.562.3-20.6%峰值负荷kW89.257.8-35.2%峰谷差kW66.433.1-50.2%充电完成率%1001000只要这三个指标都朝预期方向走并且完成率保持 100%我会认为复现基本成功。如果指标异常比如费用上升但峰值下降很多那说明权重没调好可以试着把 (\omega_2) 降一点。论文里的数据可能跟你的模拟数据不同不必强行对齐数值重点是趋势一致。5.4 参数敏感性不只是跑通就算完复现完主体模型之后我会做一组参数敏感性测试主要测两个维度一是紧急型车辆占比对削峰效果的影响。我把紧急型车辆从 1 辆增加到 5 辆峰值改善从 35% 逐渐降到 15% 左右。这符合直觉——紧急型车辆越多可调度空间越小协调调度就越难发挥作用。二是电价差幅对调度结果的影响。把峰谷电价差从 0.3 元拉到 0.8 元费用优化比例明显提升但峰谷差优化比例几乎不变。这说明“电价差”主要驱动费用优化而“峰值权重”才是削峰的关键驱动因素。这部分分析我会在复现报告里专门写一节因为论文的审稿人或导师大概率会问到“你这个方法的优势在什么条件下更明显”参数敏感性分析就是对这些问题的直接回答。6. 复现过程中的坑与排查技巧6.1 坑 1SOC 计算时充电效率丢了我第一版代码里有个隐蔽问题累计充电量约束里忘了乘充电效率 (\eta)。结果求解器算出来的电量需求总是比实际需要的少每辆车离开时电量其实不够但模型自认为是满足的。最后是画 SOC 曲线时才发现的。这个坑很隐蔽建议你写入约束时统一把效率放到等式里并在结果校验阶段单独检查每辆车的“实际充入电量”。6.2 坑 2时间槽索引从 0 开始还是在 1 开始论文里 (t1) 表示第一个时间槽代码里的 range(T) 则是从 0 开始。如果从论文公式直接翻译代码很容易出现偏移一个槽的问题。我的解决方法是所有代码里统一用 0 到 T-1写公式注释时标明“t0 对应论文 t1”避免混淆。6.3 坑 3CBC 求解器长时间不返回CBC 在小规模下很快但遇到某些复杂情况比如连续的 0-1 变量组合多、约束病态也会卡住。我遇到过一次求解时间超过 10 分钟最后设置了timeLimit60求解器返回了一个可行解而不是最优解。复现论文时先用小规模确认模型正确再上大规模如果大规模求解慢优先调 MIP gap 而不是换机器。6.4 坑 4结果“过度分散”每辆车都在小功率慢慢充这其实不算 bug而是权重设置导致的现象。如果 (\omega_2) 太大求解器为了让总负荷曲线平缓会让所有车都用很低的功率涓流充电虽然峰值下来了但充电时间拉长可能导致部分车在离开时充不满。这种情况要在约束里加上最小充电功率阈值或者限制每辆车的最长充电时间。比如给每辆车加一个约束充电功率要么为 0要么大于等于 2 kW用二元变量配合大 M 法实现可以避免“涓流充电”现象。6.5 坑 5总负荷超过变压器上限如果设置了全局负荷约束 (P_{\text{trans}})但模型依然输出超限结果先检查约束是不是真的加上去了。我用 PuLP 的print(prob)打印过模型发现曾经因为缩进错误全局约束被放进了车辆循环里面导致它只对单辆车生效。这种错误语法上完全合法但逻辑上错了非常坑。建议每次加完约束都打印一下模型文件确认。7. 扩展方向与进一步思考7.1 从确定性调度走向随机调度论文里的天气、出行习惯、充电需求都是确定性的。真实场景中车主可能提前离场也可能到达时 SOC 比预测低。下一步扩展方向是随机规划或鲁棒优化。比如用多个“情景”表示车主行为的不确定性每个情景下优化同一个决策保证最坏情况下也不出错。7.2 双向充电V2G场景如果车辆支持 V2G那么电动车就可以不仅充电还能在高峰期放电回馈电网。这个相当于把电池当成储能设备来调度模型里需要把决策变量扩展到可正可负并增加电池循环寿命损耗成本。这个方向我试过一次模型的求解复杂度明显上升但效果也很有吸引力。7.3 从离线调度走向实时滚动优化论文通常做的是“已知未来全部信息”的离线调度也就是把所有车辆数据一次性算完。真实系统做不到这一点因为车辆是陆续到达的。更符合工程实际的做法是模型预测控制MPC每 15 分钟滚动一次把当前已到达的车辆纳入优化预测未来 24 小时但只执行下一个时间槽的动作。滚动优化代码其实不复杂把本文的模型包在一个while循环里每次更新到达车辆信息、重跑一次求解就行。7.4 代码复用建议如果你准备把这个模型扩展到自己的项目中建议把主体调度逻辑封装成一个类输入是车辆信息表和电价表输出是充电计划表。这样可以很方便地接入前端展示、模拟器或者实际充电桩管理系统。封装时保留权重参数、时间粒度、变压器上限这些可调参数方便做敏感性分析。我把这个类拆成了三个模块data_generator.py模拟数据、optimizer.py模型与求解、visualizer.py画图和指标统计。整个项目的代码量不大总共 400 行左右但模块边界清晰之后后期扩展会非常轻松。最后说一点个人体会复现这篇论文最大的收获其实不是学会了怎么用 PuLP也不是背下了目标函数公式。而是学会了“怎么把一篇论文里含糊不清的描述变成一个能跑、能验证、能说服人的系统”。论文里的图往往看起来很简单但那背后是大量的数据构造、边界处理、约束调试。自己动手写过一遍才算真正读懂了这篇论文。最后再分享一个小技巧不管复现什么优化类论文第一版模型请务必从最小规模开始调比如 3 辆车、24 个时间槽。在这个规模下你可以手工算出来合理的充电方案然后用代码结果去对照。如果最小规模都对了再逐步扩大车辆数和时间粒度千万不要一上来就 100 辆车 96 个时间槽否则出了 bug 你连问题在哪里都找不到。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价