首页 / 资讯中心 / 文章详情

基于充电预约的光储充电站能量调度:LP建模与滚动时域优化

基于充电预约的光储充电站能量调度:LP建模与滚动时域优化 ★ FEATURED ARTICLE
简介《面向汽车充电预约的光储充电站能量调度策略》论文配套源码包聚焦电动汽车充电预约场景下的光储充电站能量调度问题面向智能电网优化、电动汽车充电管理等领域的研究者与工程师。资源基于Python实现多种优化算法与仿真模型能够帮助读者快速理解充电预约机制、储能充放电策略与负荷预测方法。压缩包共43个文件约597KB含31个Python源文件、5个XML配置、2个CSV数据文件以及Git忽略文件、IDEA项目文件、PNG结果图和License说明。Python代码覆盖时间槽划分、模型构建、粒子群优化PSO、遗传算法GA及改进算法CSV文件提供EV充电时间与收费要求、光伏出力等实验数据。目前已有304人学习下载。资源还包含结果可视化、动画生成、数据导出等脚本及实验验证代码可对照论文逐步复现实验、对比不同调度策略效果适合用于课题研究、毕业设计与工程预研。1. 预约驱动的光储充电站能量调度解决的到底是什么问题一座装了光伏和储能的光储充电站午后电价便宜、光伏满发的时段桩位冷清傍晚电价翻倍时七八台车同时进场变压器顶到上限只能看着车排队。基于电动汽车充电预约的光储充电站能量调度策略要解决的就是这个时间错配把每辆车的到达时间、离开时间和需求电量当作已知边界配合光伏出力预测与分时电价在每一个控制周期重新决定储能充放、电网购电和每台充电桩的功率分配。它面向充电站运营者、微网调度工程师和做能源系统优化的学生核心价值是让你从“当下缺多少补多少”的被动响应变成“未来几小时怎么安排”的主动决策。源码层面来说这是一套可以直接改参数、替换数据源就跑起来的决策骨架。2. 调度策略的数学底子LP 建模、目标函数与约束整定2.1 决策变量与时间粒度15 分钟一个时点按车建模还是按总功率建模我先给结论我一般用 15 分钟粒度、按车建变量。这个选择不是个人偏好是被预约信息逼出来的。做能量调度第一步是选时间粒度和决策变量这一步定错后面很难补。15 分钟一个时点意味着一天 96 个时点充电行为本身是分钟级的15 分钟足够表达“错峰”动作96 个时点的规模对线性规划又非常友好。粒度细到 5 分钟变量多三倍收益却不明显粒度粗到 1 小时傍晚高峰里“半小时抢电”的行为就直接丢失了。决策变量分三组电网购电功率P_grid[t]、储能充电与放电功率P_ch[t]/P_dis[t]、每辆车在每个时点的充电功率P_car[i,t]。前两组好理解第三组是整套策略的关键。按车建变量而不是只建一个“充电桩总功率”因为充电预约的核心约束是每辆车在它自己的到达/离开窗口内充完指定电量只有按车建变量才能把这个窗口写进约束。聚合总功率的模型在车少时看着也“差不多”一旦要表达某辆车必须在几小时内充完就露馅了——你不知道总功率在该窗口内到底给谁用。变量规模可以估算8 辆车的场景是 8×96768 个充电变量加上储能、电网、弃光变量约 1200 个极小规模 LP。就算扩到 50 辆车也就 5000 多个变量开源求解器都能秒级解完。所以这个方案不需要过度担心性能第一要务是把模型写对而不是写“快”。2.2 目标函数购电成本最小化、违约惩罚与权重设定经验一个可用的最小目标函数是min Σ price[t] * P_grid[t] * dt Σ w_i * e_i第一项是全天购电成本第二项是预约违约惩罚e_i是第 i 辆车“答应充电但没充够”的电量w_i是单位缺电量的惩罚系数。注意这里默认充电服务收入是常数——每辆车需求电量固定、充电单价固定收入项就不影响最优解方向可以丢掉。如果站点实行分时充电价或按功率收费就要把收入项Σ r_charge * P_car[i,t] * dt加回来目标从“最小化成本”变成“最大化净收益”。违约惩罚系数w_i怎么定我的经验是w_i至少要高于单度电的服务费损失否则优化器会选择违约。少充一度电省下的购电成本可能只有 0.35 元而服务费损失是 0.8 元惩罚系数取服务费的 1.5~2 倍才压得住。加了优先级的车辆比如会员、或即将临近离开时间的车权重可以取普通车的 2~3 倍。注意w_i不是越大越好过大会让软约束退化成硬约束高峰时段直接把问题逼到无解这层关系我在第 5 章展开。还有一项容易被忽略储能电池的循环寿命成本。把每次充放折算成损耗常见经验值在 0.1~0.3 元/kWh加入目标优化器就不会在可充可放时反复折腾电池。我初期一般不加这一项先看 SOC 曲线再决定否则目标函数里参数太多现场调不过来。2.3 约束条件功率平衡、SOC 递推与预约窗口的软硬约束约束分四组每一组在源码里对应一段独立的循环。功率平衡是每个时点的等式约束P_grid[t] P_pv[t] P_dis[t] P_ch[t] P_cur[t] P_load[t] Σ P_car[i,t]P_cur[t]是弃光功率。光伏满发、电网不想买电、储能又满了的时候必须有地方把多余的电“扔掉”否则平衡等式无解。这个变量很多初学者会漏掉漏掉的结果是中午光伏出力一大整个模型直接 infeasible。SOC 递推是储能状态的核心soc[t1] soc[t] (η_ch * P_ch[t] * dt - P_dis[t] / η_dis * dt) / E_bat单位必须统一P是 kWdt是小时乘起来是 kWh再除以容量E_batkWh得到 SOC 变化量。SOC 本身有上下界我一般设 0.1~0.95并加一条soc[T-1] SOC_0保证储能不在一日之内被掏空第二天还能接着用。预约窗口约束用软约束写法Σ P_car[i,t] * dt ≥ E_i - e_ie_i ≥ 0物理含义是“每辆车在其到达/离开窗口内至少充到需求电量充不够的量进入e_i由目标函数惩罚”。这是整套策略里最接近业务表达的一条约束。最后是边界约束单台车功率不超过接口上限总充电功率不超过max_chg电网购电不超过变压器容量P_trans。这里有个关键取舍值得说透储能充放电互斥。物理上一个时刻不能又充又放但我不加二值变量而是用P_ch[t] P_dis[t] P_bat这条线性约束替代。原因是效率小于 1 时同时充放只会白白损失能量最优解天然不会这么干线性化互斥足以保证。一旦你引入真正意义的 0-1 互斥变量问题就从 LP 变成 MILP求解时间差十倍起步。这套源码刻意压成纯 LP就是为了让 CBC 这类开源求解器在秒级出解现场控制器也跑得动。3. 用 Python 把模型跑起来PuLP 最小可运行实现与滚动时域3.1 最小可运行代码96 时点、8 辆车、光伏加储能的一天最优调度下面这段是我会放进源码骨架里的核心函数依赖只有numpy、pandas和pulp。示例数据用正弦模拟实际使用时替换成气象预报、负荷预测和真实预约记录。import numpy as np import pandas as pd import pulp as lp # ------- 基础参数按实际站点改 ------- T 96 # 一天 96 个时点每个 15 分钟 dt 0.25 # 时点时长小时 E_bat 200.0 # 储能容量 kWh P_bat 50.0 # 储能最大充/放功率 kW SOC_0, SOC_min, SOC_max 0.50, 0.10, 0.95 eta_ch eta_dis 0.95 # 充/放电效率 P_trans 400.0 # 配电变压器容量 kW max_chg 300.0 # 充电桩总功率上限 kW # ------- 示例预测数据实际替换为气象/负荷预测输出 ------- hours np.arange(T) / 4.0 P_pv np.where((hours 6) (hours 19), 250 * np.sin(np.pi * (hours - 6) / 13.0), 0) P_load 30 5 * np.sin(2 * np.pi * hours / 24.0) price np.full(T, 0.35) price[0:28] 0.22 # 0:00-7:00 谷价 price[32:44] 0.90 # 8:00-11:00 平段 price[72:88] 1.20 # 18:00-22:00 尖峰 # ------- 预约表arrive/leave 为时点序号0-95------- bookings pd.DataFrame({ car_id: range(8), arrive: [2, 8, 10, 12, 13, 16, 18, 20], leave: [10, 12, 14, 16, 17, 20, 22, 24], energy: [40, 55, 60, 70, 45, 50, 60, 40], max_power: [60, 60, 60, 60, 60, 60, 60, 60], weight: [1, 1, 2, 2, 1, 2, 3, 3], }) # 生成窗口布尔矩阵win[i,t]True 表示车 i 在时点 t 在站 win np.zeros((len(bookings), T), dtypebool) for i, r in enumerate(bookings.itertuples()): win[i, r.arrive:r.leave] True def build_and_solve(win, weights, P_pv, P_load, price): 把预约窗口与预测数据编译成 LP 并求解 n len(win) prob lp.LpProblem(pv_storage_charging, lp.LpMinimize) # 决策变量 P_grid lp.LpVariable.dicts(pg, range(T), 0, P_trans) P_ch lp.LpVariable.dicts(pc, range(T), 0, P_bat) P_dis lp.LpVariable.dicts(pd, range(T), 0, P_bat) P_cur lp.LpVariable.dicts(cur, range(T), 0, None) soc lp.LpVariable.dicts(soc, range(T), SOC_min, SOC_max) P_car lp.LpVariable.dicts( ph, [(i, t) for i in range(n) for t in range(T)], 0, None) e_var lp.LpVariable.dicts(ev, range(n), 0, None) # 目标购电成本 违约惩罚 prob (lp.lpSum(price[t] * P_grid[t] * dt for t in range(T)) lp.lpSum(weights[i] * e_var[i] for i in range(n))) # 功率平衡 for t in range(T): prob (P_grid[t] P_pv[t] P_dis[t] P_ch[t] P_cur[t] P_load[t] lp.lpSum(P_car[i, t] for i in range(n))) # SOC 递推 prob soc[0] SOC_0 for t in range(T - 1): prob (soc[t 1] soc[t] (eta_ch * P_ch[t] * dt - P_dis[t] / eta_dis * dt) / E_bat) prob soc[T - 1] SOC_0 # 跨日不掏空 # 储能充放互斥的线性化 for t in range(T): prob P_ch[t] P_dis[t] P_bat # 预约软约束与单台车功率窗口 for i in range(n): prob (lp.lpSum(P_car[i, t] * dt for t in range(T) if win[i, t]) bookings.loc[i, energy] - e_var[i]) for t in range(T): if win[i, t]: prob P_car[i, t] bookings.loc[i, max_power] else: prob P_car[i, t] 0 # 总充电功率上限 for t in range(T): prob lp.lpSum(P_car[i, t] for i in range(n)) max_chg # 求解 status prob.solve(lp.PULP_CBC_CMD(msgFalse, timeLimit30)) if lp.LpStatus[status] ! Optimal: print(求解失败:, lp.LpStatus[status]) return None return (np.array([P_grid[t].value() for t in range(T)]), np.array([soc[t].value() for t in range(T)]), np.array([e_var[i].value() for i in range(n)]), prob.objective.value()) grid, soc_arr, e_arr, obj build_and_solve( win, bookings[weight].values, P_pv, P_load, price) print(购电成本:, round(obj, 2), 元) print(违约电量:, np.round(e_arr, 2), kWh) print(储能终值SOC:, round(soc_arr[-1], 3))这段代码的逻辑链条很直接先定义变量再写目标再把功率平衡、SOC 递推、预约窗口逐条追加为约束最后求解。PULP_CBC_CMD(msgFalse, timeLimit30)表示关闭求解器日志、单次求解上限 30 秒现场跑的时候这两项都要控制住否则日志刷屏会拖慢整个滚动循环。参数说明dt0.25与T96必须配套改粒度时两个一起改SOC_00.5留给跨日调度一点缓冲效率eta0.95是锂电池储能系统的典型值P_bat/E_bat50/2000.25C这个功率容量比在工商业储能里很常见。把示例数据换成真实数据后只要 Solar 曲线长度仍是 96函数不用改。3.2 滚动时域MPC预测永远不准所以每 15 分钟重算一次离线算好一整天的调度方案只能做研究演示现场跑必翻车因为光伏预测和预约到达都有误差。光伏出力预测在 10:00 和 14:00 刷新时可能从 250 kW 掉到 30 kW离线方案里安排的“午后储能充电”全成了空话。滚动时域的做法是每 15 分钟或每小时基于最新预测和最新预约状态只对未来一段窗口重建模型、重新求解然后只执行第一个时点的决策到下一个周期再重算。我一般把预测窗口设为 8 小时32 个时点、控制区间设为 1 小时4 个时点既覆盖傍晚高峰又不会因为窗口太长而引入太多不准确的远期预测。def rolling_schedule(pv_forecast_all, price_all, load_all, booking_per_step, horizon32, step4): pv_forecast_all 为全天96时点的预测序列 每 step 个时点滚动一次每次只看未来 horizon 个时点 T_total len(pv_forecast_all) exec_grid [] # 实际会执行的购电功率轨迹 exec_soc [] for t0 in range(0, T_total - horizon 1, step): pv pv_forecast_all[t0:t0 horizon] pr price_all[t0:t0 horizon] ld load_all[t0:t0 horizon] # 预约侧筛选窗口内仍在站的车辆生成局部 win 矩阵 win_local build_window(booking_per_step, t0, t0 horizon) res build_and_solve(win_local, weights, pv, ld, pr) if res is None: continue grid_w, soc_w, _, _ res # 只取本窗口第一个时点的控制量加入执行轨迹 exec_grid.append(grid_w[0]) exec_soc.append(soc_w[0]) return np.array(exec_grid), np.array(exec_soc)这里的关键是“只执行第一个时点”。窗口末端的决策只是参考因为下一次重算时预测已经更新旧方案里后面的部分作废。已执行部分则作为历史值固定下来不参与下一次优化否则模型会试图“修改过去”。3.3 预约进模型的三种方式硬约束、软约束与优先级权重预约信息进模型有三种常见写法选哪种取决于站点繁忙程度。建模方式约束/目标写法优点缺点适用场景硬约束去掉 e_var直接要求充够完成率 100%高峰重叠时无解桩位充裕、预约率低软约束均匀惩罚e_var 进目标w 全相同总有解可量化违约权重难调常规运营软约束分级权重按优先级设不同 w重要用户先保调参工作量稍大含会员/快充服务我实际项目里默认用第三种普通车w1.0会员车和临近离开的车w2.0超时风险最高的车w3.0。这样做的好处是高峰时段优化器会先砍普通车的电量而不是所有车一起砍业务上也说得通。分级权重的具体数值在第 4 章会再展开。4. 充电预约进模型预约表到调度窗口的转换与参数整定4.1 预约表字段与时间窗生成什么时候到、最晚几点走、要充多少电预约表设计成DataFrame最顺手核心字段就六个car_id、arrive_min、leave_min、energy_kwh、max_power_kw、priority。arrive_min和leave_min我用“当日从 0 点起算的分钟数”而不是 ISO 时间字符串因为后续要整除 15 转换成时点索引字符串解析容易出时区问题。窗口生成时有个半开区间的约定车在[arrive, leave)时点在站到达时点可以充电离开时点已经拔枪。这个约定必须全代码统一否则会出现一辆车在离开那一瞬间被分配大功率的荒诞结果。def normalize_bookings(raw_df, T96): 把分钟数转成时点索引并生成窗口布尔矩阵 df raw_df.copy() df[arrive_idx] (df[arrive_min] // 15).astype(int) df[leave_idx] (df[leave_min] // 15).astype(int) # 校验窗口必须非空离开必须晚于到达 assert (df[leave_idx] df[arrive_idx]).all(), 存在空预约窗口 assert (df[leave_idx] T).all(), 离开时间超出当日时点范围 win np.zeros((len(df), T), dtypebool) for i, r in enumerate(df.itertuples()): win[i, r.arrive_idx:r.leave_idx] True # 人为制造一个空窗口马上能发现 assert win[i].any(), fcar {r.car_id} 窗口为空 return df, win参数说明里最重要的一条是arrive_min // 15用向下取整。9:30 到站对应570 // 15 38但如果用ceil就会变成 39等于把车到站后的第一个 15 分钟白白丢掉。窗口边界差一个时点软约束还能靠e_i兜底硬约束就直接违反预约了。4.2 批次滚动接入预排与增量重排跨天预约怎么处理实际操作中我把预约接入分成两层每日预排和滚动重排。每天 16:00 左右做一次次日预排此时用次日的光伏预测和电价曲线输给build_and_solve算一遍全天方案目的是提前看到“明天哪个时段压力大、要不要限功率”。真正执行则交给 15 分钟滚动调度——每 15 分钟重新拉取一次最新预约列表、剔除已经离开的车、修正还在站内车辆的剩余电量需求再重算窗口。跨天预约是常见的坑。24 点以后到的车在“当日 96 时点”的坐标系里没有位置。我一般把坐标系从“自然日”改成“未来 N 小时滑窗”当前时刻为原点窗口长度 32 个时点8 小时跨天的预约自然落进窗口。日出表的时候再把滑窗映射回 96 时点结构给求解器用。def advance_sliding_window(now_idx, horizon, all_bookings): 从全量预约里筛出 [now_idx, now_idxhorizon) 内有效的车辆 cond (all_bookings[leave_idx] now_idx) \ (all_bookings[arrive_idx] now_idx horizon) active all_bookings[cond].copy() # 已经把部分电充掉的车辆需求电量要扣减已充量 active[energy_left] active[energy_kwh] - active[charged_kwh] active[energy_left] active[energy_left].clip(lower0) return activeenergy_left这个字段很关键。滚动重排时不能把整车的energy_kwh再塞给优化器否则一辆已经充了 80% 的车会被当成“还要再充 60 kWh”重新安排总量需求虚高储能和购电策略全被带偏。4.3 优先级与违约权重的量化权重不是拍脑袋按收益损失来定weight字段最终会直接变成目标函数里的w_i它的物理含义是“欠充 1 kWh 造成的综合损失”。普通车欠 1 kWh损失的是 0.8 元服务费会员车欠 1 kWh损失的是服务费加会员权益折算可能到 1.5 元临近离开时间的快充车再叠一层超时补偿。我常用的初始参数表如下实际值按站点服务费水平线性缩放。优先级场景违约权重 w_i含义1普通预约1.0基准约等于服务费损失的 1.5 倍2会员 / 快充2.0双倍补偿优先保证3临近离开时间3.0超时风险高几乎不允许违约权重的整定路径是先全部设 1.0 跑一周回测统计各优先级违约率再按违约率倒挂比例上调某个等级的权重。不要一开始就调成 3.0、5.0原则是“够用就好”权重过大的坏处在第 5 章讲。5. 调度源码落地的 5 个高频踩坑与排查方法这套策略跑起来之后前两周遇到的问题基本集中在下面五类。我按现象、原因、解决的顺序记方便你直接对照。5.1 SOC 递推单位错乱储能变“永动机”一天充进去 300 kWh现象回测日志里储能 SOC 曲线整体向上漂一天累计充电量远大于储能容量甚至出现 SOC 大于 1 的情况。原因单位没统一。最常见的是dt忘写15 分钟的时点按 1 小时参与计算充电量虚增 4 倍其次是储能功率用 MW、容量用 kWh量纲直接错位。解决SOC 递推统一按 kWh 核算之后加断言。我自己会在每次滚动求解前校验eta * P_ch * dt / E_bat的物理上限并打印单日能量平衡表Σ充电量 Σ放电量 Σ损耗 ΔSOC * E_bat不平衡超过 1% 就报警。这个校验建议放进源码的 CI 或者每日回测里。5.2 预约全部写成硬约束高峰时段直接 infeasible现象滚动调度跑到傍晚 18:00 到 20:00求解器返回Infeasible整个后续窗口的控制都停摆。原因七八辆车同时预约且都要在两小时内充满总需求功率超过max_chg或变压器容量。硬约束把所有“必须充够”锁死LP 找不到可行解。这是把第 2 章软约束换成硬约束后最典型的翻车现场。解决先定位瓶颈——把max_chg临时放大到理论极大值再求解如果此时有解说明就是容量瓶颈如果仍无解问题出在功率平衡或 SOC 约束本身。确认是容量瓶颈后把预约约束全面改成软约束并保证至少高优先级车辆的w_i足够大让优化器在极端情况下优先砍普通车。5.3 光伏预测突变控制量来回横跳现象某天上午 10:00 预测光伏 250 kW策略安排储能大功率充电11:00 预测刷新成 30 kW策略又立刻把储能改成放电。一个小时内储能方向翻转多次设备寿命和电网考核都受不了。原因光伏预测是黑匣子晴转阴时出力预测剧烈波动而优化器没有记忆能力每次都按最新预测重新最优化控制量自然横跳。解决给储能功率加变化率限幅约束|P_ch[t1] - P_ch[t]| ΔP_max我一般取储能额定功率的 30% 作为单时点变化上限。同时可以对光伏预测做一阶指数平滑平滑系数 0.3 左右然后再喂给求解器。这条约束会让策略牺牲一点经济性但换来的是现场设备愿意接受的控制轨迹。5.4 时间窗边界差一个时点在站的车永远充不进电现象回测里某辆车e_i始终大于 0但日志显示它明明在站内待了四个小时最优调度却从不给它分配功率。原因时间转换时取整不一致。到达时间是 9:30用ceil转成时点 39但调度窗口从 38 开始窗口重叠区被吃掉或者leave用了floor把离开前最后一个 15 分钟也剔除了。解决全代码统一用向下取整转换到达时间、半开区间划定窗口并在源码里加一条硬校验assert win[i].any()任何车辆只要预约过就必须存在可充电时点。这条断言能在数据接入阶段就把问题拦下来而不是等调度结果出来再看违约率。5.5 滚动调度跑几天后越来越慢模型堆积与热启动问题现象第一天单次求解 0.8 秒第三天涨到 5 秒以上超过 15 分钟控制周期调度开始滞后。原因滚动循环里每个周期都从零重建模型且prob.solve()返回后模型对象没释放在 Linux 上用 cron 跑每日预排时日志输出没有重定向也会拖慢求解。CBC 的默认参数在小规模 LP 上没做热启动每次都是冷启动。解决把求解器参数固定下来msgFalse关日志、timeLimit30设熔断、求解完主动删除模型引用。如果规模确实变大切到 HiGHS 或 Gurobi 这类更快求解器并保留上一次的可行解作为初值。工程上更稳妥的办法是控制滚动频率每日预排跑一次 96 时点全窗口执行层的 15 分钟重算只跑 8 小时短窗口长期运行稳定很多。6. 上线前的最后一步用历史数据回测整套策略把策略接真实控制器之前我习惯先跑两周离线回测。回测框架很小核心逻辑是把历史光伏数据、历史电价、历史预约记录逐日回放每天调用滚动调度最后统计几个关键指标。def backtest(days, pv_hist, price_hist, load_hist, booking_hist): rows [] for d in range(days): win booking_hist[d] weights booking_hist[d][weight].values res build_and_solve(win, weights, pv_hist[d], load_hist[d], price_hist[d]) if res is None: rows.append({day: d, 完成率: np.nan, 购电成本: np.nan}) continue grid, soc_arr, e_arr, obj res total_energy booking_hist[d][energy].sum() rows.append({ day: d, 完成率: 1 - e_arr.sum() / max(total_energy, 1e-6), 购电成本: round(obj, 2), 终值SOC: round(soc_arr[-1], 3), }) return pd.DataFrame(rows)回测指标我只看四个充电完成率、单位电量购电成本、储能终值 SOC、求解失败次数。完成率掉到 95% 以下说明权重或容量配比有问题终值 SOC 持续偏离 0.5 说明跨日约束没有真正生效求解失败次数大于 0 就必须回到第 5 章的排查清单。跑完两周回测和“无储能、无优化”的基线方案对比你会看到傍晚高峰的购电成本明显下降光伏弃光率收窄这就是这套策略值不值得上线的依据。我个人的习惯是回测通过后先让策略以“建议模式”跑一周——只出方案不下发执行人工对比方案与实际操作的差异确认没有离谱动作再切自动。这套流程帮我挡掉过不止一次因预测异常导致的调度横跳。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站