简介:这是一套基于内点法求解实时最优电价的MATLAB实现方案,面向电力市场研究者、电气工程专业学生以及从事电网调度优化的技术人员。资源以30节点电力网络为例,完整展示了分时电价(TOU)策略下如何通过内点法迭代求解实时电价,兼顾供需平衡与运营成本最小化。压缩包共7个文件,包括4个MATLAB脚本(主程序、数据输入、初始化及辅助计算)、2个fig格式的迭代过程图(分别展示不同向心参数及预测-校正环节的影响)和1个说明文档,整体仅40KB,结构紧凑。该资源已有118人学习,适合希望掌握最优潮流计算与电价建模的读者参考。通过运行模型,可以直观对比不同参数下的收敛行为,并结合实际电价数据验证算法效果,为电力市场定价机制设计提供可复现的实验基础。
1. 基于现货的实时最优电价:为什么固定分时电价越来越不够用
现货市场每 15 分钟出一个价格,凌晨风光大发时可能只有几分钱,晚高峰却能冲到一块五以上。而多数用户签的还是那种一年不变的峰谷分时电价(Time of Use, TOU),晚高峰照收高价,深谷时段也照收固定低价。售电公司被现货价格和固定 TOU 价差两头挤,用户也没拿到现货红利。更麻烦的是,光伏渗透率越高的地区,午间现货价被压得越低,固定 TOU 却还在收"平段价",这等于把市场信号完全扭曲了。要做实时最优电价,就得把现货价格、用户需求响应和电网约束一起扔进一个优化问题,再用内点法在可接受的秒级时间内求出每个时点的最优电价。这套东西不算新,真正让人头疼的是建模和调参——特别是 spot.rar 这类数据包里时间戳、缺失值和弹性系数,任何一个没处理好,解出来的电价曲线就是一张废纸。
2. 从现货价格到 TOU 电价:目标函数、约束与内点法选型
2.1 先把问题写成数学形式:需求响应函数与电价决策变量
做实时最优电价,第一步不是写代码,而是把问题定义清楚。决策变量很简单:未来一天 96 个时点(15 分钟粒度)的零售电价 (p_t)。输入是现货价格 (c_t),以及每个时点用户对电价的响应关系。
用户需求不是固定的,会随电价变化。常用的线性需求响应模型是:
[ D_t(p_t) = A_t - b_t p_t ]
其中 (A_t) 是用户在该时点的基线需求,(b_t) 是价格弹性斜率。(b_t) 越大,用户对电价越敏感。实际建模时,(b_t) 往往由弹性系数 (\eta) 换算而来:(b_t = \eta \cdot A_t / p_{\text{ref}}),(\eta) 一般取 0.1~0.3,工业用户高一些,居民低一些。
目标函数我一般这么写:让售电公司从现货市场购电的总成本最小,同时尽量避免电价曲线剧烈跳动,再叠加一个削峰惩罚项。
[ \min \sum_t c_t D_t(p_t) + \frac{\rho}{2} \sum_t D_t(p_t)^2 + \frac{\alpha}{2} \sum_t (p_t - p_{t-1})^2 ]
第一项是买电成本,第二项是削峰软约束,第三项是相邻时段电价平滑项。约束条件包括:零售电价上下限 (p_{\min} \le p_t \le p_{\max})、平均电价不低到亏本、响应后的负荷不能超过基线负荷的 1.2 倍。这个模型里目标函数是非线性的,因为 (p_t) 和 (D_t) 相乘,约束又是一堆不等式,用内点法处理最顺手。
2.2 内点法 vs 线性规划、梯度下降:实时电价场景为什么选它
很多同行第一反应是用线性规划,但把需求响应写进去,目标不再是线性的,单纯形法用不了。用普通梯度下降也能跑,可碰上 (D_t \ge 0)、削峰限值这类不等式约束,梯度下降没法保证解始终在可行域里,罚函数法又得反复调惩罚系数,时间都耗在调参上了。
内点法的思路是把所有不等式约束 (g_i(x) \ge 0) 以对数障碍项的形式并入目标函数,然后对一系列衰减的障碍参数 (\mu) 做牛顿迭代。它兼顾了非线性目标和大量不等式约束,收敛速度和稳定性都远好于手动罚函数。
| 方法 | 非线性目标 | 大量不等式约束 | 秒级求解 | 实现难度 |
|---|---|---|---|---|
| 线性规划/单纯形 | 不支持 | 支持 | 快 | 低 |
| 梯度下降+罚函数 | 支持 | 勉强 | 看调参 | 低 |
| 内点法 | 支持 | 支持 | 快 | 中 |
实时电价的求解规模通常只有 96~336 个变量(一天 96 点或一周 336 点),内点法在这种情况下几十次迭代就能收敛。cvxpy 自带的 CLARABEL 求解器本质就是内点法实现,直接调用即可,不需要自己造轮子,除非你在做研究或者要嵌入 C 程序。
2.3 拿到 spot.rar 先核四件事:数据体检与时间戳对齐
spot.rar 这类压缩包,解压后通常是一堆 CSV 或 Excel 文件,里面放的现货电价、负荷、温度数据。我拿到手不会急着跑模型,先做四件事:看目录结构、看字段名、看时间间隔、看缺失值。
$ unar spot.rar -o ./spot_data/ $ ls -R ./spot_data解压后先扫一眼文件布局,确认有没有说明文档。接着用 pandas 读取,重点核对时间戳。
import pandas as pd df = pd.read_csv("./spot_data/spot_price.csv", parse_dates=["time"]) print(df.head()) print(df.info()) print(df.isna().sum()) # 缺失检测 print(df["time"].diff().value_counts()) # 时间间隔是否均匀这一步能筛出很多暗坑:时间戳是不是按 15 分钟对齐的、有没有重复行、现货电价单位是元/MWh 还是元/kWh。我见过把功率单位 MW 和电量单位 MWh 混在一起的数据,直接在价格上差了两个数量级。单位不核对清楚,后面算出来的最优电价谁都不敢用。
3. 用内点法求解实时最优电价:最小可复现代码
3.1 用 cvxpy 三分钟跑通第一版:建模代码与求解器选型
把数学问题翻译成 cvxpy 代码,逻辑非常直接。下面是一天 96 个时点的完整建模:
import cvxpy as cp import numpy as np T = 96 p = cp.Variable(T) # 待求的零售电价 c = cp.Parameter(T, nonneg=True) # 现货电价 A = cp.Parameter(T, nonneg=True) # 基线需求 b = cp.Parameter(T, nonneg=True) # 需求弹性斜率 rho = 0.001 # 削峰惩罚系数 alpha = 0.5 # 相邻时段平滑系数 p_min_s = 0.10 # 零售电价下限,单位元/kWh p_max_s = 1.50 # 零售电价上限 D = A - cp.multiply(b, p) # 需求响应后的负荷 cost_power = cp.sum(cp.multiply(c, D)) # 现货购电成本 cost_peak = 0.5 * rho * cp.sum_squares(D) cost_smooth = 0.5 * alpha * cp.sum_squares(p[1:] - p[:-1]) objective = cp.Minimize(cost_power + cost_peak + cost_smooth) constraints = [ p >= p_min_s, p <= p_max_s, cp.mean(p) >= 0.35, # 平均电价下限,防整体压价 D >= 0, # 负荷不能为负 D <= 1.2 * A # 削峰:响应后负荷不超过基线1.2倍 ] prob = cp.Problem(objective, constraints) prob.solve(solver=cp.CLARABEL, verbose=False) price_opt = p.value print(price_opt[:10])这段代码的rhos和alpha值得仔细调。rho太大,最优解会牺牲经济性强行压平负荷;alpha太大,电价曲线变成一条直线,等于把实时信号抹掉了。我一般先跑一组固定参数,rho=0.001作为起点,然后做灵敏度扫描再定。
cp.multiply(b, p)是按位相乘,D是 96 维的变量表达式。这里的关键是c、A、b用 Parameter 而不是直接的 numpy 数组,这样后续做灵敏度分析和滚动优化时,只需更新参数再重新solve(),不用改模型结构。
3.2 手写一个简化内点法:KKT 条件、障碍参数与牛顿迭代
cvxpy 适合落地,但要调试内点法的收敛问题,还是得理解底层在干嘛。最经典的是原始-对偶内点法,这里给一个更直白的障碍法实现,适合用来理解障碍参数 (\mu) 的作用。
import numpy as np class BarrierIPM: """简化的障碍内点法,适用于目标为二次、约束为线性的问题。 约束形式:A @ x <= b,即每个约束写成 g_i(x) = b_i - (A_i)·x >= 0 """ def __init__(self, Q, q, A, b, mu0=0.1, mu_ratio=0.5, tol=1e-8): self.Q, self.q, self.A, self.b = Q, q, A, b self.mu = mu0 self.mu_ratio = mu_ratio self.tol = tol def g(self, x): return self.b - self.A @ x def phi(self, x): # 障碍目标:原目标 - mu * sum(log(g_i)) return 0.5 * x @ self.Q @ x + self.q @ x - self.mu * np.sum(np.log(self.g(x))) def grad(self, x): gv = self.g(x) return self.Q @ x + self.q - self.mu * (self.A.T @ (1.0 / gv)) def hess(self, x): gv = self.g(x) return self.Q + self.mu * (self.A.T @ np.diag(1.0 / gv**2) @ self.A) def solve(self, x0): x = x0.copy() for outer in range(50): if self.mu < 1e-8: break for inner in range(100): gv = self.g(x) if np.min(gv) <= 0: raise RuntimeError("约束被冲破,检查初始点或步长策略") dx = -np.linalg.solve(self.hess(x), self.grad(x)) alpha = 1.0 # 回溯线搜索:先保证可行,再保证目标下降 while np.min(self.g(x + alpha * dx)) <= 0: alpha *= 0.5 x_new = x + alpha * dx while self.phi(x_new) > self.phi(x) + 1e-4 * alpha * self.grad(x).dot(dx): alpha *= 0.5 x_new = x + alpha * dx x = x_new if np.linalg.norm(dx, np.inf) < self.tol: break self.mu *= self.mu_ratio # 障碍参数衰减,逐步逼近原问题 return x核心逻辑就两件事:外循环衰减 (\mu),内循环对当前障碍目标做牛顿迭代。grad和hess我用的都是解析式,因为目标二次、约束线性,求导不难。障碍项 (-\mu \ln g_i(x)) 在约束边界附近形成一个势垒,无论怎么迭代,解都不会越过边界。
实际调试时,mu0=0.1,mu_ratio=0.5是比较稳的组合。mu_ratio再小一点(比如 0.2)收敛更快,但约束刚好在边界上时容易震荡甚至发散。这个手写版本因为没加原始-对偶残差校正,对初始点要求较高,必须严格可行,也就是说所有约束初始值都大于 0。
3.3 内点法必调的三个参数:障碍因子、收敛阈值、回溯步长
内点法的调参经验,基本能直接套用到 cvxpy 那些黑匣子求解器上。三个参数最重要。
| 参数 | 经验值 | 作用 | 调参方向 |
|---|---|---|---|
| 障碍初始值 (\mu_0) | 0.1 | 决定最早迭代时障碍项的强度 | 约束多时减小,防止初始迭代太钝 |
| 障碍衰减比 (\mu_{\text{ratio}}) | 0.5 | 外循环收敛速度 | 0.2~0.5,太大收敛慢,太小易震荡 |
| 收敛阈值 | 1e-8 | 牛顿步长小于该值时认为内循环收敛 | 调度场景 1e-6 也够用 |
回溯线搜索的系数,我习惯用1e-4判断 Armijo 条件,步长衰减用 0.5。这里有个翻车点:如果初始点刚好落在边界附近,(g_i(x)) 接近 0,障碍项梯度会变得巨大,第一轮牛顿步就可能直接冲出可行域。解决办法是初始化时尽量取可行域的"中点",或者先用一个很小的 (\mu_0) 起步。
另外,如果求解报告提示 "Numerical problems" 或者 "Terminated because of numerical difficulties",九成是数据量纲问题。现货电价用 0.2 和 800 这种不同量纲去喂同一个模型,海森矩阵会病态。先把所有输入归一化到 0~1 之间,求解完再映射回实际电价,这是最省事的后悔药。
4. 把最优解落成实时电价曲线:时段划分与后处理
4.1 把 96 个时点的电价聚成峰谷平:KMeans 与连续时段合并
实时最优电价求解出来是 96 个连续数值,但用户侧套餐不能 15 分钟一个价,否则根本没法对外解释。常见做法是聚成峰、平、谷三段。用 KMeans 把电价归成 3 类,按类别均值排序,再映射回时段。
from sklearn.cluster import KMeans price_flat = price_opt.reshape(-1, 1) km = KMeans(n_clusters=3, n_init=10, random_state=0) labels_raw = km.fit_predict(price_flat) # 按类中心从小到大排序:0=谷,1=平,2=峰 center_rank = np.argsort(km.cluster_centers_.flatten()) labels = np.empty_like(labels_raw) for new_label, old_label in enumerate(center_rank): labels[labels_raw == old_label] = new_label for label in range(3): idx = np.where(labels == label)[0] print(f"时段{label}: 时点 {idx.min()}-{idx.max()}, " f"平均电价 {price_opt[idx].mean():.3f}")KMeans 的坑是标签号不是按价格高低排列的,必须用cluster_centers_排序重贴标签,否则你可能把峰段标成谷段。另外,聚类结果常出现"峰-谷-峰"这种断裂时段,一个小时高峰、两个小时平段、又一个小时高峰,用户侧没法用。我的处理办法是:少于 2 小时(8 个时点)的时段段落直接合并到相邻类别,再重新计算边界。
这种分段方法有个玄学点:K 到底取几。有人用肘部法则,有人直接按政府目录电价设峰谷平三段。我一般先跑 3 段看结果,如果峰段和谷段价差小于 0.2 元/kWh,就换 4 段,说明现货波动已经细化到需要尖峰段了。
4.2 电价修正三步:平滑、削尖、约束回填
聚类只是第一轮后处理,真正能落地还得过三关。第一关是平滑:KMeans 分类边界处两个时点的电价可能从 0.2 直接跳 1.1,这种尖刺对用户不友好。第二关是削尖:把超过现货价 2 倍或低于现货价 0.5 倍的时点电价往回收。第三关是校验约束:回填后重新检查削峰约束是否被破坏。
def smooth_and_clip(p, c, win=5, k=3): w = np.ones(win) / win p_s = np.convolve(p, w, mode="same") # 滑动平均 p_s = np.minimum(p_s, 2.0 * c) # 不高过现货2倍 p_s = np.maximum(p_s, 0.5 * c) # 不低于现货0.5倍 p_s = np.maximum(p_s, p_min_s) # 回填上下限 p_s = np.minimum(p_s, p_max_s) return p_s滑动平均会削掉真正的现货尖峰信号,所以c(现货价)参与截断很关键。边界处np.convolve(mode="same")会引入边缘效应,前两个点数值偏低,我常手动回填这两个点为原始值,或者干脆对首尾时点不做平滑。最后一步是把平滑后的电价拿回去跑一遍需求响应函数,确认D <= 1.2 * A没有被破坏,如果削峰约束还差一点,就把rho调大重新求解,这不是后处理能救回来的。
4.3 输出格式:给营销系统和账单系统的电价表
输出电价表要同时满足两拨消费方:一是营销系统,要按峰谷平时段展示给用户;二是账单系统,按时间戳和价格计算电费。字段设计一句话说清:一天 96 个时点、每个时点一个价格、标注时段类型、时间戳用 ISO8601。
import pandas as pd df_out = pd.DataFrame({ "time": pd.date_range("2024-01-01", periods=96, freq="15min"), "price": price_opt, "period": labels }) df_out["period_name"] = df_out["period"].map({0: "谷", 1: "平", 2: "峰"}) df_out.to_csv("tou_price_20240101.csv", index=False)账单系统最怕两件事:时间戳没有时区、时段边界不连续。我建议文件里直接写2024-01-01 00:00:00+08:00这种带时区的格式,让下游系统自己解析。时段边界要检查没有空档,比如 0:00~6:00 是谷,那么 6:00 必须接上"平",中间不能漏时点。曾有同事输出文件里 23:45 和 00:00 之间断了 15 分钟,账单系统当成无电价时段,用户投诉一堆。
5. 内点法做实时最优电价最常见的四个坑:现象、原因、解决
5.1 迭代发散或震荡:目标函数出现 NaN
现象:求解器报Numerical problems,或者手写内点法跑到第 10 轮目标函数变成 NaN。
原因:(\mu) 衰减太快,牛顿方向在约束边界附近产生巨大的障碍梯度,一步跨过可行域。另一个常见原因是初始点不可行,比如用全零向量初始化,而约束里有 (p \ge 0.1),零点在边界外。
解决:(\mu_0) 从 0.1 起步,衰减比别小于 0.5;初始点取可行域中点,比如所有约束下界的 1.1 倍;如果数据跨度大,先归一化再求解。
5.2 解出负电价或极端尖峰:现货低价时模型让人"白嫖"
现象:现货某时段价格是 0.02 元/kWh,模型解出的最优零售电价只有 0.001 元,甚至直接触到 0。
原因:目标函数里购电成本是 (c_t D_t),现货越低,模型越希望用低价刺激负荷去消纳电力,这在数学上没错,但零售侧价格倒挂,用户会拼命加装充电桩套利,售电公司每度亏一毛。
解决:给零售电价加一个和现货联动的下限约束,至少覆盖现货价加合理附加。接线时改一行约束即可:
margin = cp.Parameter(T, nonneg=True) # 每度电的输配附加与利润留成 margin.value = np.full(T, 0.02) constraints.append(p >= c + margin)5.3 现货价时间戳错位:峰谷时段整体算反
现象:模型跑出来的峰段是凌晨,谷段是晚高峰,和实际用电曲线完全相反。
原因:spot.rar 里的现货价格是按"交易日期"还是"执行日期"存的,很多数据源两者差一天,直接用行号对齐就整体平移了 24 小时。还有时区问题,有些厂站存的是 UTC,没转北京时间。
解决:拿现货价和实际负荷做互相关,看滞后几小时相关性最高。正常情况下现货价和负荷同周期,峰值同步才对。对齐代码不复杂:
# 假设df已有spot和load两列 corr_shift = {} for lag in range(-6, 7): corr_shift[lag] = df["spot"].shift(lag).corr(df["load"]) best_lag = max(corr_shift, key=corr_shift.get) print(f"最佳滞后: {best_lag}个时点")正数表示现货价领先负荷,负数表示落后。发现错位后用shift(best_lag)重贴时间标签,而不是重新下载数据。
5.4 需求响应系数是"拍脑袋"设的:结果对弹性系数极其敏感
现象:把弹性系数从 0.2 改成 0.25,最优电价曲线完全变形,峰段时长从 4 小时变成 9 小时。
原因:需求响应模型里 (A_t) 和 (b_t) 是强耦合的,(b_t) 微小的变化会被削峰约束放大。尤其是把 (b_t) 当成常数填进去,而不是按各时段基线负荷归一化,结果就更不稳定。
解决:先归一化再进入模型,(b_t = \eta \cdot A_t / p_{\text{ref}}),让 (\eta) 成为一个无量纲的"平均弹性"。上线前对 (\eta) 做一遍 0.05~0.3 的灵敏度扫描,看峰谷价差和削峰率的变化曲线。如果某个 (\eta) 区间内结果剧烈跳变,说明模型在那个区域病态,要回头查约束是否合理,而不是硬找一个"最优弹性系数"。这个教训百试不爽——先跑灵敏度,再谈参数调优。
6. 验证与进阶:用历史现货数据回测你的实时最优电价
6.1 回测看三个数:账单、削峰率、求解耗时
模型做完不能直接上生产,拿过去一个月的现货价格跑一遍离线回测。我只看三个数:用户总账单变化、最大负荷削峰率、单次求解耗时。账单降太多说明电价可能压到亏损线了,削峰率超过 15% 基本不现实,求解耗时超过 5 秒就不适合做滚动实时计算。
6.2 灵敏度扫描:把"玄学参数"变成"验收依据"
rho、alpha、(\eta) 三个参数每个取 3~5 档,做网格扫描,把削峰率、账单、峰谷价差三张曲线画出来。运营团队要问"为什么这个方案可行",你直接甩灵敏度图,比解释一小时模型都管用。这也是把内点法从黑匣子变成可验收交付物的关键一步。
6.3 一个实用技巧:滚动窗口的"定时更新"策略
实时最优电价不等于每秒都算,常见做法是每天 16:00 用最新出清的现货价格重算一次次日 96 点电价,遇到极端天气或电网阻塞事件,临时追加一次重算。滚动窗口的好处是不依赖预测精度太高的光伏出力,现货价本身就是最新市场信息。我现在每次跑完,都会习惯性看一眼灵敏度曲线里有没有突变点——某个参数从 0.1 变到 0.15 时削峰率跳了 5 个百分点,这种位置一定要搞清楚原因再发版。这套流程走顺之后,实时最优电价就不再是实验室里的玩具,而是售电公司每天都能用的定价工具了。希望帮到你。
本文还有配套的精品资源,点击获取