做科研代码复现的人都有一种共同的体验:论文里的数学模型看着清楚,真正落到Matlab的时候全是细节问题。这篇博客想复现的题目是"考虑用户充电负荷-最优分时电价互动的光储充换电站优化模型研究"。听起来很绕,其实核心就一句话——充换电站怎么在用户会根据电价改变充电习惯的前提下,把自己的收益做到最优。如果你正在研究光储充换电站的调度优化、需求响应、分时电价设计,或者你需要一个能跑的Matlab优化模型作为论文基线,这篇文章值得从头读到尾。我会把模型的物理背景、数学骨架、代码实现,以及复现时最容易踩的坑一起讲清楚。
1. 光储充换电站的优化难题:为什么"充放电策略"远不够用
1.1 电站的物理结构与收益来源
光储充换电站是光伏、储能、充电桩、换电柜的组合体。物理上它有四个最主要的组成部分:屋顶或车棚的光伏板、一组储能电池柜、多个直流充电桩、以及电池更换设备。换电站与普通充电站最大的区别是它自带一个电池仓,仓里存放着一批可更换的动力电池,用户到站后直接换上满电电池就走,而不是在原地等充电完成。因此换电站的调度不仅要考虑"今天发多少电、存多少电",还得考虑"仓库里有多少块满电电池、多少块正在充电的电池、什么电价下充电更划算"。
那收益从哪来?梳理一下主要收入项:
- 充电服务费:给电动车充电,按电量或按次数收服务费
- 换电服务费:用户换一次电池,收取服务费用
- 峰谷套利:低价时段从电网购电存入储能,高价时段放出或供站内负荷使用,赚取价差
- 光伏自发自用:光伏出力优先供给站内负荷,多余部分存储能或反送电网
支出项也很直观:向电网购电的费用、储能电池循环寿命损耗折算成本、设备维护成本、以及一部分用户需求响应激励成本。优化模型的本质,就是在这个收支框架里找一条让日净收益最大的运行曲线。很多人在复现时只盯着储能充放电策略,忽略了充电电价这个可以主动调节的杠杆,结果模型变成了"给定负荷下的储能套利",这跟题目里"互动"两个字完全不沾边。
1.2 为什么必须把"用户负荷响应"放进模型
很多初版模型把用户充电负荷当作固定曲线,算出来一套储能充放电策略,看着很完美,实际用不了。原因很简单:你设置峰时电价高、谷时电价低,用户不傻,他会把原本在峰时充的电挪到谷时去充。你的"优化"前提是假设负荷不变,结果电价一变负荷就变了,整个调度方案就失效了。
所以必须要做"互动"。这里的互动有两层含义:
第一层,用户负荷受电价影响。电价高,充电需求下降;电价低,充电需求上升。这叫需求弹性。而且不同时段的弹性不一样,白天网约车司机赶着接单,电价涨了也得充;凌晨私家车主不着急,电价便宜就多充一点。用一个固定弹性系数是全模型简化的做法,但不同时段分组设定能大大提升贴近度。
第二层,电价本身又是电站可以优化的对象。在满足一定约束(比如电价上下限、平均电价水平)时,电站可以通过调整峰、平、谷时段的充电电价来引导用户,让负荷分布更有利于自己的购电成本和储能收益。比如低谷时段电价压低,吸引更多用户过来充电,储能也正好在这时段充电,既满足了用户需求,又利用了低价电。
这两层耦合在一起,就是一个互动的闭环。只做其中任何一层,都是把问题简化了,最后贴出来的曲线大概率跟实际运营对不上。这就是题目中"互动"二字的份量。复现的时候,如果模型跑出来电价曲线是一根平线,或者用户负荷完全没随电价波动,那多半是把耦合环节写丢了。
1.3 分时电价在模型里是"决策变量"还是"输入参数"
复现这个模型之前,先把概念搞清楚。分时电价在模型里的角色,不同论文处理方式完全不同:
- 作为输入参数:电价是给定的,模型只管储能充放电和购电计划
- 作为决策变量:电站自己定充电电价,但要保证用户响应后的总充电需求仍然匹配,且合规
- 作为交互变量:上层定电价,下层用户响应,上下层循环迭代到均衡
这篇题目里写了"用户充电负荷-最优分时电价互动",通常走的是第三种路线。我的复现版本采用了下层用户响应以价格弹性系数刻画、上层电站以收益最大化为目标的双层迭代结构。为什么选这个结构?因为它在保持物理清晰度的同时,计算量可控,也比较贴近真实运营中的滚动决策节奏。如果你想做成真正的双层优化,用KKT条件把下层问题转化为约束,也是一种标准做法,但代码复杂度会高一个量级,对新手不太友好。先跑通互动迭代版本,再考虑要不要严格化,性价比更高。
2. 用户充电负荷对电价的响应:从"弹性系数"到"迭代均衡"
2.1 价格弹性怎么用
需求价格弹性在经济模型里是个经典概念,放到充电场景里其实就是一句话:电价上涨百分之一,充电量下降百分之几。写成公式:
ΔQ / Q = ε × ΔP / P
其中ε是弹性系数,通常取负值,比如-0.3,表示电价涨10%,该时段充电量降3%。不同用户群体弹性差别很大:网约车司机赶时间,弹性小,ε可能只有-0.1;私人车主可以晚上慢慢充,弹性大,ε可能到-0.5。复现时我建议把用户分成两类:快充刚需用户和可转移充电用户,分别给不同的弹性系数,比统一一个系数贴出来的结果更有说服力。你甚至可以进一步把换电用户单独建模,因为换电用户对电价的敏感度体现在"是否来换电池"而不是"充电多少",逻辑上不太一样。
实际数据处理时,弹性系数不一定是常数。可以做成随时间和SOC变化的函数,比如电池电量越低,用户越难转移充电行为,弹性就越小。这个细节论文里不一定写,但加入之后模型更真实,审稿人也更认可。
2.2 负荷转移的数学表达
有了弹性系数,就能构造分时电价下的负荷响应模型。假设优化前的基础充电负荷曲线是P0(t),分时电价是price(t),基准价是price_base,那么响应后的负荷是:
P_user(t) = P0(t) × (1 + ε × (price(t) - price_base) / price_base)
这个式子的意思是:当price(t)高于基准价时,该时段负荷下降;低于基准价时,负荷上升。不过要注意,简单地逐点计算会破坏总充电需求守恒。用户把充电需求从峰时挪走了,总电量并不会凭空消失,只是换了个时间点充电。所以完整模型里要加一个电量守恒修正,把所有时段多出来的降下去的负荷差额按合理比例重新分配到谷时或平时段。
这一步如果不做,优化结果会出现一个很明显的问题:峰时负荷越降越低,谷时负荷却没有涨回去,全站日充电量加起来比实际需求量少了一大截,整个收益算出来虚高,把问题做成了"压低负荷就能赚钱",这显然不符合物理规律。我见过好几个复现代码栽在这个细节上,跑出来的电价越高、收益越大,明显是模型有问题,根源就是没有做电量守恒修正。
还有一个细节:需求转移不是即时的。今天电价高,用户可能明天才来充电,跨日转移在日尺度模型里不好处理。如果只做24小时静态优化,可以把每天的总需求设为固定值,把跨日影响通过初始条件体现。要是做多日滚动,就需要加入队列模型或延迟变量,模型复杂度和求解时间都会上涨。我的建议是先做单日版本,跑通逻辑再扩展。
2.3 互动迭代的收敛判据
当电站调整电价、用户响应负荷之后,电站的调度计划又要变,而调度计划变化可能反过来影响电价决策(比如储能在谷时段存储能力有限,电价压得太低可能造成储能充满也消化不完,反而浪费),所以需要迭代。
我的复现里迭代流程是这样的:
- 初始化一组分时电价,通常用电网的分时购电价加服务费作为初值
- 用当前电价代入用户响应模型,更新各时段充电负荷
- 把更新后的负荷代入电站优化模型,求解储能充放电、购电、换电策略和收益
- 用更新后的运行结果重新校正电价信号,检查是否还有提升空间
- 重复步骤2到4,直到目标函数变化量小于阈值或达到最大迭代次数
收敛判据我直接看相邻两次迭代的目标函数值之差,设定一个相对误差阈值,比如1e-4。迭代通常会波动几轮再稳定,你要是不加阻尼就直接更新电价,很容易振荡。我在代码里给电价更新加了一个0.5的松弛因子,工程上叫"阻尼系数",效果很明显,能少迭代四五轮。别小看这个细节,论文里不会写,但复现时你就知道它要命。
如果振荡一直停不下来,建议检查电价更新步长和负荷响应幅度是否匹配。有一种常见情况是:电价稍微变动,负荷响应太大,下一轮电价又过度校正,形成等幅振荡。这时候把阻尼系数降到0.3甚至0.2,基本都能稳住。
3. 优化模型的数学骨架:目标函数、约束与求解思路
3.1 目标函数的构成
目标函数我按"日净收益最大化"来写,时间粒度取1小时,一天24个时段,这是论文复现最常用的配置。目标函数展开后如下:
max 总收益 = Σ(充电服务费收入 + 换电收入 + 光伏上网补贴) - Σ(购电成本 + 储能循环损耗成本 + 换电电池充电成本)
每一项都需要展开:
- 充电服务费收入:Σ price_user(t) × P_user(t) × dt,这里price_user就是模型决策的分时充电电价
- 换电收入:每块满电电池的换电服务费乘以换电数量
- 光伏上网补贴:光伏出力大于站内消耗时,多余电量按上网电价售出
- 购电成本:Σ price_buy(t) × P_buy(t) × dt,price_buy是电网分时购电价,给定不变
- 储能循环损耗成本:按放电深度折算,常用等效循环次数乘以单次循环成本
- 换电电池充电成本:换电站电池仓里电池充电消耗的电费
注意,充电服务费收入和购电成本这两项都跟充电负荷互动相关,如果负荷随电价变化,那这两项就是耦合在一起的非线性项。为了让模型保持线性,我采用迭代求解的方式:在每一轮迭代里把用户负荷固定为上一轮响应后的值,这样当前轮次的目标函数是线性的,可以用线性规划求解。这就是把双层互动问题"外层迭代+内层LP"的常用处理。
关于目标函数选择,多说一句:为什么不直接最小化成本?因为充电站运营商更关心收入-成本差,在光伏补贴存在、电价有上下限约束的情况下,最大化收益和最小化成本会给出不同策略。比如光伏补贴力度大时,最大化收益模型会倾向于多发电多上网,最小化成本模型则可能选择少购电少运行。复现时一定要先确认原论文的目标函数是哪种,别拿自己习惯的一套硬套。
3.2 核心约束条件清单
约束条件我列一份清单,复现时逐条核对,少了任何一条求解结果都会跑偏:
- 全站功率平衡约束:光伏出力 + 电网购电 = 充电桩负荷 + 换电站负荷 + 储能充电功率 + 站内基础负荷,放电时储能功率作为负的充电功率放在等式左侧
- 储能SOC递推约束:SOC(t+1) = SOC(t) + (η_c × P_ch(t) - P_dis(t) / η_d) × dt / E_B
- 储能SOC上下限:SOC_min ≤ SOC(t) ≤ SOC_max,普遍取0.1到0.9
- 储能充放电功率上下限,以及避免同时充放电的约束(可以用互斥变量,也可以直接用LP松弛,绝大多数情况下不会出现同时充放电的解,因为同时充放电在经济上明显不合理)
- 购电功率上限:受配电容量限制,这个上限设多少直接影响峰时段购电行为,要对照变压器容量来设
- 分时电价上下限约束:用户充电电价不能超过政府指导价上限,也不能低于某个下限;同时为了避免电价过高导致用户全跑光,通常还会加平均电价水平约束
- 换电站电池仓约束:任意时刻仓内存有满电电池的数量,要满足下一个时段换电需求的预期值;电池充电功率受充电机数量限制
关于换电站约束,我再展开一点。换电站的电池仓可以用一个离散状态方程描述:
N_full(t+1) = N_full(t) - N_swap(t) + N_charge_finish(t)
其中N_full(t)是t时段开始时满电电池数量,N_swap(t)是t时段换出的电池数量,N_charge_finish(t)是t时段内完成充电的电池数量。复现时最容易忽略的是N_charge_finish取决于电池充电的启动时间,如果电价低就集中充电,充电机会出现排队。很多简化模型直接假设电池随到随充,这个假设会导致低谷时段充电功率虚高,实际中充电机数量就那么多,物理上不允许。我建议至少加一个充电功率总上限约束,这个上限等于充电机数量乘以单台充电功率。
关于SOC递推,我要特别提醒一句:dt的统一。上课时很多同学把dt写成1小时,但数据里的光照和负荷可能按15分钟一个点,时间粒度不一致直接导致储能SOC曲线跳变。复现时统一用小时做基准,所有15分钟数据都聚合成1小时,或者全部用分钟甚至秒,但目标函数里的价格乘电量也要同步换单位。这是最普遍的"低级错误",但也是最容易让人卡一晚上的问题。
3.3 求解器选型与代码结构选择
这个模型在单轮迭代内是线性规划问题,规模很小,24个时段,加上储能等变量,也就几百个变量,根本不需要大规模求解器。我的经验是:先用Matlab自带的linprog跑通全流程,确认模型逻辑正确、结果合理,再按需切换到Gurobi或CPLEX。为什么建议这样?因为linprog内置于Matlab Optimization Toolbox,不需要额外授权,代码复现的第一要务是让程序无痛跑起来。等你确认模型无误,再追求速度也不迟。不过如果论文要求对比商业求解器性能,那就在代码里留一个求解器选择开关,用YALMIP包装一下最方便。
我自己选择用YALMIP定义模型,因为它的语法跟数学表达式几乎一一对应,出错时能直接看到是哪条约束不满足。YALMIP对linprog、Gurobi、CPLEX都支持,换求解器只需改一行的设置,复现不同论文里的参数时到处改也省心。
如果模型里加了整数变量(比如储能同时充放电互斥,或者换电站充电机启停),问题就从LP变成MILP,linprog就无能为力了。这时候要么用YALMIP调Gurobi/CPLEX,要么想办法把整数约束松弛掉,用LP近似求解。以我的实践来看,24小时尺度的调度模型里,把同时充放电的整数约束去掉,几乎不会影响收益结果,因为同时充放电在成本端是明显亏的,优化器天然会避开。如果审稿人不深究,LP版本完全够用。
4. Matlab代码实现:模块划分、关键片段与可视化
4.1 文件组织
复现代码我做成四个文件,结构简单,改动起来清清楚楚:
- main.m:参数设置、调用求解、打印结果
- load_data.m:读取光伏出力、基础负荷、分时购电价、用户分组等输入数据
- build_model.m:用YALMIP定义目标函数和所有约束,返回优化问题对象
- plot_results.m:画SOC曲线、负荷对比图、电价曲线和收益分布图
为什么这样拆分?因为复现论文时,你要反复改参数、换场景。比如只改分时电价上下限,不应该去翻目标函数文件;只更新光伏出力,不应该影响约束定义。拆成独立模块后,每次改动一行参数文件就能把不同场景的结果跑出来。做科研的人最怕模型代码改成"意大利面条",后期根本不敢动。真到写论文时,你要跑敏感性分析、换数据、调约束,代码组织得好不好直接决定你加班到几点。
4.2 参数与输入数据组织
初始化部分我用一个struct统一管理,比散落一地的全局变量好用太多。举个例子:
param.H = 24; param.dt = 1; param.EB = 600; % 储能额定容量 kWh param.SOC0 = 0.2; param.SOC_max = 0.9; param.SOC_min = 0.1; param.P_charge_max = 200; % 储能充电功率上限 kW param.P_discharge_max = 200; param.eta_c = 0.95; param.eta_d = 0.95; param.price_buy = [0.35, 0.8, 0.5]; % 谷、峰、平购电价 元/kWh param.price_base = 0.6; % 用户基准充电电价 param.epsilon = -0.3; % 用户价格弹性 param.price_user_min = 0.5; param.price_user_max = 1.2;
这样改参数只需要动这一块,而且配合Matlab的table或struct,代码可读性非常高。我在实际做的时候还加了一个Config注释块,把每个参数的物理含义和参考来源写在旁边,免得隔一个月回来想不起来。
光伏出力曲线可以直接用Matlab的readtable读取Excel或CSV,也可以按文献生成典型曲线。复现时如果给的原始数据只有日发电量,我会按Beta分布模拟出24小时的光伏出力形状,再乘上日发电量。因为分时电价互动效果最敏感的是峰谷时段的光伏出力差异,所以曲线的形状比总量更重要。数据缺失时,也可以直接用Matlab内置的PV模块生成,但要注意那通常是用于Simulink的,纯脚本里还是自己拟合一个S型曲线更直接。
4.3 核心代码片段:电价迭代与LP求解
核心求解部分代码如下:
P_load_base = load_data(1:24); % 基础负荷 price_user = param.price_user_max * ones(1, 24); % 初始电价 for iter = 1:max_iter % 用户响应 load_ratio = 1 + param.epsilon * (price_user - param.price_base) / param.price_base; P_load_resp = P_load_base .* load_ratio; % 电量守恒修正 total_base = sum(P_load_base); total_resp = sum(P_load_resp); P_load_resp = P_load_resp * total_base / total_resp; % 求解电站优化 [x, obj(iter)] = solve_station_opt(P_load_resp, param); % 电价更新(阻尼) price_new = update_price(x, price_user, param); price_user = price_user * (1 - alpha) + price_new * alpha; if iter > 1 && abs((obj(iter) - obj(iter-1)) / obj(iter-1)) < tol break; end end
这段代码里最关键的就是"电量守恒修正"那个乘系数。很多人在参数转移后不修正总电量,导致模拟一天下来总充电需求比实际少了10%以上。加了这一行之后,负荷曲线才真正反映了"移动"而不是"消失"。
solve_station_opt函数内部就是YALMIP建模加求解。变量包括24个时段的储能充放电功率、储能SOC、购电功率、用户充电电价等。约束按前面的清单逐条写进YALMIP,然后调用optimize。我习惯在约束列表里给每组约束命名,比如constraints = [constraints, ...] 之后每个约束都有它的名字,调试时能直接定位到是哪条约束出了问题。别省这个功夫,模型复杂起来你一定会感谢当时的自己。
关于电价更新函数update_price,我的做法是把当前负荷下系统的边际价值算出来,然后朝边际价值方向微调电价。最朴素的版本是:如果某时段光伏充足、购电成本低,就适当降低该时段电价吸引负荷;如果某时段容量紧张、购电成本高,就提高电价抑制负荷。这个启发式虽然不如严格双层最优那么严谨,但配合迭代能很快逼近合理区间。你要是想做得更数学化,可以用对偶变量作为电价更新依据,但初版不建议这么弄,容易把自己绕晕。
4.4 结果输出:让论文图表动起来
跑完模型之后,输出四张图几乎是论文标配:
第一张图,分时电价曲线和响应前后用户充电负荷的对比。这张图直接说明"互动"的效果,峰时段负荷降了没有、谷时段负荷涨了没有,一目了然。
第二张图,储能SOC和充放电功率曲线。看SOC是否都在上下限内、有没有突变、充放电是否和电价峰谷时段对着来。如果电价谷时储能反而不充电,那调度逻辑就有问题了。
第三张图,光伏出力、购电、负荷的功率平衡堆叠图。这张图用来验证功率平衡约束是否正确实现,也是审稿人最爱看的一张图。
第四张图,收益构成柱状图,分解出充电收入、换电收入、购电成本、损耗成本,看看哪个环节贡献最大。这张图是敏感性分析的基石,后面改参数全靠它定位。
Matlab绘图我用最朴素的plot和stairs就够了,别上花里胡哨的3D图。学术复现图的审美标准是清晰、可对比、标注完整,不是炫技。每条曲线加图例,坐标轴写清单位,标题用英文或者中文都行,但全篇保持统一。如果你要投稿英文期刊,图里文字最好用英文,图片格式导出为PDF或EPS,Matlab的exportgraphics命令一行就能搞定。
5. 复现路上的坑:从"能跑"到"结果合理"
5.1 典型坑一:SOC初值与末值不一致
储能约束里通常会要求SOC(24)回到SOC(0),也就是日循环守恒。如果你直接不加这个约束,优化器会倾向于把储能里的电全部放光,因为那样能降低购电成本,收益虚高。加SOC末值等于初值约束后,结果马上正常。这个约束的必要性我在第一次复现时完全没意识,直到看到SOC一路从0.9降到0.1,才反应过来储能被"白嫖"了。
实际处理时,SOC末值约束可以写成SOC(24) ≥ SOC(0),也可以写成等式。等式更严格,但如果你的光伏出力日发电量特别大,等式约束可能导致无解。这时可以加一个松弛变量,让SOC(24)在SOC(0)附近的一个小区间内浮动,比如SOC(0) ± 0.05,这样既避免储能白嫖,又给优化器留了余地。
5.2 典型坑二:用户弹性系数符号搞反
弹性系数ε是负值。很多初稿写成+0.3,结果电价越涨用户越充电,峰时电价高负荷也高,优化器就拼命抬价,最后电价顶到上限。检查方法很简单:单独拎出响应模型,给电价一个扰动,看负荷是不是向着相反方向变。这种符号问题是最难排查的,因为整个程序能跑通、能收敛、收益还"很高",但结果跟论文对不上。我排查过两小时才定位到是这个符号写反了,从那以后我养成了一个习惯——每个关键公式先用一组假数据手动验算一遍。
补充一个相关现象:如果弹性系数设得过大(绝对值超过0.8),会出现负荷极端转移,谷时段负荷暴增到超过变压器容量,这时候约束就会被触发,求解结果反而不合理。弹性系数不是越大越好,要结合实际用户行为数据标定。文献里常见的取值区间是-0.1到-0.5,你在这个范围内调参基本安全。
5.3 典型坑三:求解器数值精度与单位
如果用了Gurobi或CPLEX,注意它们的参数容差会影响SOC末值约束是否严格满足。有些求解器默认的约束容差较松,SOC(24)和SOC(0)差0.02,看起来不明显,但日收益误差会累积。解决办法是把约束容差调严,比如gurobi.setparam('FeasibilityTol', 1e-6),同时把收益目标也检查一下。单位方面,kWh和kW、元/度还是元/MWh,稍不注意数值会差一千倍。我在代码里统一用kW和kWh,价格用元/kWh,跑出来的结果直观,调试时不用换算。
YALMIP还有一个容易踩的坑:如果你用binvar定义了二进制变量,求解器默认调用B & B算法,速度慢且可能内存溢出。24小时尺度下,二进制变量越多,求解越慢。能不用整数变量就不用,这是复现阶段的高效法则。
5.4 复现结果与原始论文数值对不上怎么办
这是复现类项目最常问的问题。我的经验是:先比对"趋势",再比对"数值"。如果你画出来的曲线形态——峰谷走势、SOC形状、收益构成比例——跟论文一致,但具体数字差20%以内,大概率是输入参数微调导致的,可以接受。如果趋势都对不上,先检查负荷响应模型和约束清单是否漏项,再看目标函数里哪些项的正负号是否正确。复现论文时,不要指望参数没给全就能一比一还原数字,那不可能的。你只要能展示出相同的机制和相近的量化结果,就已经是合格的复现了。
还有一个务实建议:把论文里的"基础工况"结果先做成基准曲线,每次改动一个参数就对比一次基准,这样任何异常变动都能第一时间定位。别一口气把模型全改完再跑,那样出问题根本不知道是哪一步引入的。我在复现时习惯用git保留每个版本的代码,改坏了就回退,省了很多重复劳动。
最后把我在这个项目里最深的体会说一下吧。复现这类光储充换电站互动优化模型,真正耗时的大头往往不在建模本身,而是那些论文里一笔带过的环节——用户负荷怎么响应、SOC初末值怎么约束、迭代怎么才收敛。把这几个点想清楚,Matlab代码就是水到渠成的一件事。如果你也在复现类似模型,建议先从24小时的LP版本跑通,再逐步加复杂度,这个顺序不会错。等哪天你把互动闭环跑出漂亮的曲线,再回头看这段过程,会发现那些踩过的坑全都是值得的。