最近在Matlab里做了一版基于多时段动态电价的电动汽车有序充电策略优化,把思路、模型和可运行的代码一起整理出来。起因是帮朋友评估一个住宅小区的充电桩规划,发现下班回家即插即充的模式太典型了:电动车主18:00左右到家,正好撞上晚高峰,充电功率又把小区配变顶到红线,电费账单也难看。后来引入多时段动态电价作为调度信号,把充电负荷平移进谷段,问题才真正变成“什么时候充、充多少”的优化问题,而不是简单一句“晚点再充”。
这个方向适合正在做电动汽车有序充电、需求侧响应、小区充电桩容量评估的读者。Matlab代码可以直接跑,参数也给了默认值,你拿到手里先复现,再根据自己的电价和车辆参数去改。下面我会把为什么要这么建模、约束怎么列、linprog和quadprog怎么用、以及实测中容易踩的坑一起讲清楚。
1. 有序充电到底在优化什么:从无序到有约束的自主决策
1.1 无序充电为什么又贵又挤
无序充电的核心特征是:车主到家时间决定充电开始时间,充电功率固定,直到充满或者第二天出行才停止。看起来没什么问题,但把大量车辆放在同一个场景里看,问题就出来了。
傍晚18:00到21:00本来就是生活用电高峰,空调、热水器、电磁炉都在工作。电动车这时候接入,相当于在峰值负荷上又叠了一层大功率负荷。一台7kW慢充桩的电流接近32A单相,一个单元十几台车同时充,配变很容易过载。电网侧的矛盾先不说,车主自己的电费账单也不好看——因为多时段动态电价下,傍晚恰恰是一天中最贵的时段。
无序充电还有一个隐性成本:它把“充电负荷”当成一个不可调度的刚性负荷,导致配网容量必须按“最坏情况”去设计。你原本只需要为夜间基础负荷配的变压器容量,因为无序充电,可能要多扩容30%到50%,而扩容成本最终会分摊到每个用户头上。
1.2 动态电价是“用价格换时间”的调度信号
多时段动态电价的本质,是把不同时刻的供需紧张程度转化成价格差异。凌晨负荷低、发电出力宽裕,电价就低;傍晚负荷尖峰、备用紧张,电价就高。电动车的充电负荷天然具备“可平移”属性——只要在出发前完成充电,具体哪一小时充、功率多大,是可以灵活调整的。
所以在优化模型里,电价不是简单的一个成本系数,而是整个调度逻辑的信号源。价格曲线越陡,优化出来的充电策略就越倾向于把负荷挤到谷段;价格曲线平缓,优化结果则更接近均匀充电。这也是为什么我建议你先别急着调算法,先把电价序列和数据格式搞清楚,后面所有结果都依赖它。
这里说的动态电价,可以理解为“分时电价”的扩展版:分时电价通常是固定的峰平谷三档,而动态电价可能是日前预测值、实时激励系数,甚至是市场出清价格的近似。模型处理起来没什么区别,都是给每个时段一个price(t)。我会用一组典型的分时价格做演示,你换成96点日前电价曲线也一样能跑。
1.3 有序充电能控制什么、不能控制什么
建模之前最忌惮把问题扩大化。有序充电策略能控制的变量是有限的:
- 每个时段的充电功率
P_t,可以是连续值,也可以是离散档位; - 充电开始时间和结束时间;
- 目标SOC和是否需要参与反向馈电(V2G扩展);
- 是否考虑变压器容量约束,限制同时充电的车数或总功率。
不能控制的是:电池的初始SOC、出行时间、基础负荷曲线、电价曲线。这些在模型里一律当作已知输入,而不是决策变量。
很多初学者容易把模型写成“既优化充电又优化电价”甚至“优化出行时间”,这会把问题变成非线性混合整数规划,求解难度和工程落地难度完全不同。我先讲清楚边界,下面所有约束都不越界。
2. 模型设计:目标函数、约束条件与参数约定
2.1 目标函数的两层设计
在进行Matlab实现前,先把数学模型写清楚。我用的决策变量是一天N个时段的充电功率x,长度为N的列向量,单位kW。
第一层目标是最小化充电费用:
min sum(price(t) * x(t) * dt)dt是每个时段的时长,如果按小时分段就为1,按15分钟分段就为0.25。这一层目标直接对应用户最关心的“省钱”。
第二层目标是平抑负荷波动。单纯追求电费最低,可能出现所有电动车都在凌晨0点一起开始充的情况,这在单辆车模型上看不出来,但放到100辆车的小区里就是新的负荷尖峰。所以我加入一个二次惩罚项:
min sum(price(t) * x(t) * dt) + lambda * sum((baseLoad(t) + x(t) - mean(baseLoad + x))^2 * dt)baseLoad(t)是基础负荷曲线,lambda是权重系数。lambda=0就是纯费用最优;lambda越大,充电负荷越倾向于避开基础负荷的波峰,把总负荷曲线拉平。
在Matlab里,第一层目标用linprog就能解,因为目标函数和约束都是线性的。加上第二层目标后,目标函数变成了二次型,要用quadprog。我会分别给出两版代码,方便你对比。
2.2 约束条件:电池、功率和时间窗
约束条件看起来多,拆开其实就四类。
功率上下限约束:
0 <= x(t) <= Pmax % Pmax为充电桩最大充电功率,比如7kW如果某个时段不允许充电,直接把该时段ub设成0即可,比如出行前1小时禁止充电。
SOC递推关系:
SOC(t+1) = SOC(t) + x(t) * eta * dt / Capeta是充电效率,取值0.9到0.95,我默认用0.92;Cap是电池容量,单位kWh。注意这里功率单位是kW、时间单位是小时,乘出来就是kWh。
SOC上下限约束:
SOCmin <= SOC(t) <= SOCmax实际程序里我不会每个时段都写SOC递推等式,因为如果x是变量、SOC也是变量,约束数量会翻倍。更简洁的做法是只写“前缀和”形式的不等式:任意时刻累计充入电量不能超过SOCmax - SOC0对应的电量,也不能低于保障出行的下限。
终端电量约束:
SOC(N) >= SOC_target也就是出发时电量要达到设定目标。这个约束是刚性的,达不到目标说明充电时间或功率不够,需要调整参数。
2.3 示例参数:24时段还是96时段
我选择用24时段做演示,每个时段1小时,直观、好调试,画图也容易看。实际项目里建议用96时段,每段15分钟,能更好描述分时电价拐点。两者在代码上的区别只是N和dt变了,约束矩阵的结构完全一样。
我这组仿真参数如下:
| 参数 | 值 | 说明 |
|---|---|---|
| 电池容量 Cap | 60 kWh | 主流纯电车型范围 |
| 初始SOC | 0.25 | 下班到家剩余电量 |
| 目标SOC | 0.95 | 第二天出发期望电量 |
| 最大充电功率 | 7 kW | 单相慢充桩典型值 |
| 充电效率 eta | 0.92 | 含AC/DC损耗 |
| 时段数 N | 24 | 每段1小时 |
| 电价谷段 | 0.40 元/kWh | 23:00-6:00 |
| 电价平段 | 0.70 元/kWh | 6:00-8:00, 10:00-18:00, 21:00-23:00 |
| 电价峰段 | 1.20 元/kWh | 8:00-10:00, 18:00-21:00 |
所需充入电量 =(0.95-0.25)*60 / 0.92 = 45.652 kWh,以7kW连续充电需要6.52小时。而谷段只有6小时,所以最优解必然会有少量充电落在平段,这种“不够完美”的不对称,反而更能看出优化算法的分配能力。
3. Matlab实现:从电价数组到调度曲线
3.1 先把电价和基础负荷做成数组
Matlab里做优化,第一件事不是调优化器,而是把所有输入数据整理成向量。我建议用脚本开头一次性定义参数,后面所有计算都引用这些变量,改参数只改一处。
%% 基本参数 N = 24; % 24个时段 dt = 1; % 每段1小时 Cap = 60; % 电池容量 kWh soc0 = 0.25; % 初始SOC socTar = 0.95; % 目标SOC socMax = 0.95; % 允许最大SOC Pmax = 7; % 充电功率上限 kW eta = 0.92; % 充电效率 %% 构造24小时电价数组,注意长度必须等于N % 时段划分:0-5谷,6-7平,8-9峰,10-17平,18-20峰,21-22平,23谷 price = [0.4*ones(1,6), 0.7*ones(1,2), 1.2*ones(1,2), ... 0.7*ones(1,8), 1.2*ones(1,3), 0.7*ones(1,2), 0.4*ones(1,1)]; price = price(:); % 转成列向量 %% 基础负荷曲线,kW baseLoad = [2.5, 2.3, 2.2, 2.1, 2.0, 2.0, 2.2, 3.0, 3.8, 4.2, ... 4.0, 3.5, 3.6, 3.8, 4.0, 4.2, 4.8, 6.0, 6.8, 6.5, ... 5.5, 4.5, 3.5, 2.8]; baseLoad = baseLoad(:); %% 充电所需总电量 needEnergy = (socTar - soc0) * Cap / eta;这段代码没有技术难点,但price数组的长度很容易出错。我习惯写完以后立刻检查length(price),如果和N对不上,后面所有约束矩阵都会报维度错误。
3.2 版本A:用linprog求最低电费充电策略
先做纯费用最优版本。约束矩阵的思路是:用前缀和表达“任意时刻累计充电量不能超过SOC上限允许值”,用总和表达“最终电量必须达到目标”。
%% 约束矩阵 % 决策变量 x 是N维列向量,x(t)为第t时段充电功率 % 1) 任意时刻SOC <= socMax: % soc0 + chargeEff * sum_{i=1}^t x(i) <= socMax % 对每个t分别写一行,形成上三角矩阵 chargeEff = eta * dt / Cap; A_ub1 = triu(ones(N,N)) * chargeEff; b_ub1 = (socMax - soc0) * ones(N,1); % 2) 最终SOC >= socTar: % soc0 + chargeEff * sum(x) >= socTar % 改写为 -chargeEff * sum(x) <= soc0 - socTar A_ub2 = -chargeEff * ones(1,N); b_ub2 = soc0 - socTar; A = [A_ub1; A_ub2]; b = [b_ub1; b_ub2]; % 3) 功率上下限 lb = zeros(N,1); ub = Pmax * ones(N,1); %% 线性规划求解 f = price * dt; % 目标函数系数:min f'*x options = optimoptions('linprog', 'Display', 'iter', 'Algorithm', 'dual-simplex'); x_lin = linprog(f, A, b, [], [], lb, ub, options); %% 后处理 SOC_lin = soc0 + cumsum(x_lin) * eta * dt / Cap; cost_lin = price' * x_lin * dt; peakDiff_lin = max(baseLoad + x_lin) - min(baseLoad + x_lin);这里A_ub1每一行都是前缀和,含义是“到达当前时段结束时,累计充入的电量不许突破SOC余量”。由于充电功率非负,SOC只会单调上升,下限约束自动满足,不需要额外写。
跑出来的结果符合直觉:linprog会把充电功率尽可能放到23:00到6:00的谷段,但谷段总容量只有7*6=42kWh,离需要的45.652kWh还差3.652kWh,这部分会放到电价第二低的平段。注意它不是放到早晨6点,而是可能放到21:00-22:00的平段,因为还要受SOC上限约束,晚充比早充更安全,且价格一样的情况下,优化器倾向选择更靠后的时段来避免触发SOC上限。
这版代码解出来的电费大约在19.4元左右。如果采用无序策略,18:00到家直接充,费用在35.7元左右,节省接近46%。
3.3 版本B:用quadprog实现费用与波动的折中
只有费用目标是理想化的。实际项目里,电网侧和运营商更关心负荷曲线是否平滑。加入二次惩罚项后,需要用quadprog。
先把波动项写成标准二次型。令中心化矩阵:
M = eye(N) - ones(N,N)/N;那么总负荷baseLoad + x的方差乘以N就等于(baseLoad+x)'*M*(baseLoad+x)。展开后,二次项系数矩阵是lambda*M,线性项除了电价还要加上2*lambda*M*baseLoad。因为quadprog的模型是0.5*x'*H*x + f'*x,所以H = 2*lambda*M。
%% 多目标:费用 + lambda * 总负荷波动平方 lambda = 1.0; M = eye(N) - ones(N,N)/N; % 二次项矩阵 H = 2 * lambda * M; % 线性项 = 电价 + 交叉项 f_q = price * dt + 2 * lambda * M * baseLoad; % 约束与版本A一致 x_quad = quadprog(H, f_q, A, b, [], [], lb, ub, [], options); %% 后处理 SOC_quad = soc0 + cumsum(x_quad) * eta * dt / Cap; cost_quad = price' * x_quad * dt; peakDiff_quad = max(baseLoad + x_quad) - min(baseLoad + x_quad);quadprog对H矩阵要求是对称半正定,M是幂等对称阵,半正定没问题。lambda默认取1.0,你可以当成灵敏度开关去调。
加了波动惩罚后,充电功率不再完全集中在同一个谷段,而是会往平段稍微摊开一些。代价是电费从19.4元上升到21元左右,但总负荷峰谷差会明显变小。这体现的就是多目标优化的本质:没有免费的全都要,只有看你怎么权衡。
3.4 两种策略的结果对比
我建议把无序、纯费用最优、费用加波动最优三条曲线画在同一张图里。
%% 对比绘图 t = 0.5:1:23.5; % 时段中点,用于横轴 figure('Color', 'w'); subplot(2,1,1); stairs(0:24, [baseLoad; baseLoad(end)], 'k-', 'LineWidth', 1.2); hold on; stairs(0:24, [baseLoad + x_lin; baseLoad(end) + x_lin(end)], 'b--', 'LineWidth', 1.2); stairs(0:24, [baseLoad + x_quad; baseLoad(end) + x_quad(end)], 'r-.', 'LineWidth', 1.2); legend('基础负荷', '费用最优总负荷', '费用+平抑总负荷'); xlabel('时刻 (h)'); ylabel('功率 (kW)'); grid on; subplot(2,1,2); plot(t, SOC_lin, 'b-o', 'LineWidth', 1.2); hold on; plot(t, SOC_quad, 'r-s', 'LineWidth', 1.2); yline(soc0, '--'); yline(socTar, '--'); xlabel('时刻 (h)'); ylabel('SOC'); grid on; title('SOC曲线对比');从我的算例看,无序策略从18:00开始充,SOC一路爬升,到23:30左右才到目标;纯费用最优则从23:00才开始明显爬坡,清晨前充满;加了lambda的版本,会在21:00-22:00多充一点,后半夜充电曲线更平缓。这个SOC曲线的形状就是策略的“指纹”,一眼能看出算法在做什么。
4. 调试时最容易出的问题与下一步扩展方向
4.1 约束矩阵拼接:变量顺序和维度不匹配是头号bug
Matlab里跑优化报错,90%出在约束矩阵上。linprog要求A的列数等于决策变量个数,也就是N。如果你的price是行向量,x_lin是列向量,计算price'*x时一时看不出问题,但f如果是行向量也可能被自动转置掩盖掉。
我的排查习惯是三步走:先size(A)确认维度是(N+1)*N;再打印x_lin前几个值,看是不是全0或全在边界上;最后把约束手写一两条,和矩阵对应行对比。
还有一个隐蔽问题:triu(ones(N,N))生成的是对角线及以上的前缀和矩阵,表示第t行的非零元素是1:t。如果某天你想改成“只允许在指定窗口充电”,正确做法是修改对应时段的ub,而不是去改A矩阵。
4.2 lambda怎么定:别拍脑袋,画一下权衡曲线
lambda是费用和波动的折中系数。实测中我见过两种极端:lambda设得太大,充电功率被拉成一条水平线,完全失去动态电价的意义;lambda设得太小,又跟纯费用最优没区别。
建议做一次简单的灵敏度扫描:
lambdaList = 0:0.1:3; costList = zeros(size(lambdaList)); peakDiffList = zeros(size(lambdaList)); for i = 1:length(lambdaList) lambda = lambdaList(i); H = 2*lambda*M; f_q = price*dt + 2*lambda*M*baseLoad; x_q = quadprog(H, f_q, A, b, [], [], lb, ub, [], options); costList(i) = price'*x_q*dt; peakDiffList(i) = max(baseLoad+x_q) - min(baseLoad+x_q); end plot(costList, peakDiffList, 'o-');画出来通常是一条下凸曲线,拐点附近就是比较合理的lambda。没有绝对正确答案,但能看到你的“省钱”和“降峰谷差”边界在哪。实际操作里我还会打印每次求解是否收敛,quadprog有时会因为数值问题提前退出,表现为exitflag不为1,这时候需要检查数据量级或调ConstraintTolerance。
4.3 从单辆车扩展到聚合充电的几步走
单辆车模型跑通后,常见的方向是扩展到多车聚合。这时一个充电桩的Pmax约束要换成“充电桩群总功率约束”,比如一个配变下允许的最大充电总功率;每辆车的SOC递推和终端约束都各自独立,可行解域会变成多组变量的笛卡尔积。
如果加入V2G,决策变量x_t就变成双向的,负值表示放电,功率下限从0改成-Pmax,目标函数里电价项也变成“放电收益”。这时模型仍然可以保持线性,只是约束条件里需要额外处理“不能同时充放”这样的逻辑约束,可以引入二进制变量,也可以用互补约束表示。这部分我还没有展开做,但基础版本跑通以后,扩展路径是清晰的。
4.4 Matlab版本与求解器接口的兼容性
最后提醒一个容易劝退新手的点:不同Matlab版本的linprog和quadprog参数名有细微差异。老版本可能不支持'dual-simplex'算法名,新版本则可能把MaxIterations改成了MaxIterations和ConstraintTolerance。不要背参数名,直接用doc linprog查当版本签名。
如果你用的是Matlab R2021a之后的版本,optimoptions设置算法名的写法有时会被警告“忽略算法选项”,这不影响结果,但说明该算法已被自动选择。只要exitflag返回1,就说明问题求解成功。
就个人经验,我还会在代码最后加一个断言:
assert(abs(SOC_quad(end) - socTar) < 1e-6, '终端SOC未达到目标');别小看这一行,它能帮你迅速区分“策略差异”和“约束根本没满足”。毕竟,优化模型最尴尬的结局不是算得慢,而是算完了才发现终端电量不够,第二天车主开不走车。