风电和光伏出力的随机性,一直是电力系统规划、调度和储能配置里最让人头疼的部分。每次有刚入行的朋友问我“风光不确定性到底怎么建模”,我第一反应都是:别再用一个典型日代表一整年了,“看天吃饭”的东西用一个点根本扛不住后续分析。做新能源消纳、微电网调度、储能容量优化这类课题,几乎都绕不开“场景生成与削减”。这篇文章把我实际项目里用的框架整理出来——用 Copula 理论刻画风光出力之间的相关性,再通过 K-means 聚类把成千上万个场景压缩成几个典型场景,兼顾精度和计算效率。原理、代码骨架、参数设置、踩过的坑都会提到,给正在做相关方向的同行一个可以直接参考的完整思路。
1. 场景方法的基本逻辑
1.1 场景方法解决的是什么问题
电力系统里的风光出力,本质上是一个随机过程。今天测到风速 5m/s,明天可能是 2m/s 也可能是 9m/s;辐照度更是受云层、季节、昼夜影响,波动幅度比风速还大。这种不确定性让规划人员和调度人员很尴尬:不能把 8760 个小时的出力数据全部塞进优化模型里,那样问题规模会爆炸;也不能拍脑袋取一个“平均出力”,那会严重低估极端情况的风险。
场景方法就是把“不确定性”转成“一组离散的可能性”。假设明天有 1000 种可能的风光出力组合,每种都带一个出现概率,那后续的优化模型就可以把这些场景作为输入,去计算期望成本、风险指标或者最恶劣情况下的应对策略。这个过程分两步:先生成大量原始场景,充分覆盖可能的出力区间;再削减成少量代表性场景,让计算规模可控。
这里有个常见误区:很多人以为场景削减就是“取平均值”。其实不是。削减的目的是在保留原始场景集统计特征(均值、方差、相关性、尾部行为)的前提下,用尽量少的场景去近似它。K-means 聚类在这里扮演的角色,就是把相似的场景归为一类,用每个类的中心点替代这一类里的所有成员。
1.2 生成和削减为什么要配合使用
只生成不削减,数学上没问题,工程上不可行。1000 个场景意味着后续的随机优化模型变量数扩大 1000 倍,很多实际规模的问题根本解不动,尤其混合整数规划。只削减不生成也不行,因为历史数据数量有限,而且只代表过去已经发生的情况,可能漏掉很多“虽然没有发生过、但完全有可能出现”的场景。
Copula 生成和 K-means 削減是流水线关系:Copula 负责“扩样本”,把历史数据里隐含的相关结构提取出来,再生成大量新样本;K-means 负责“压规模”,把生成的大样本集压缩回可计算的规模。这两步各管一段,配合好了,得到的典型场景既保留了相关性特征,又能塞进优化模型里解。
在项目里我通常生成 1000~2000 个原始场景,再削减到 5~10 个典型场景。这个规模对随机规划、机会约束规划都算友好,而且典型场景带上概率后,目标函数的期望计算变得很直接。
1.3 典型应用方向
场景生成与削减的应用方向很广,常见的有:
- 风电场、光伏电站的出力场景预测,作为随机调度的输入。
- 储能容量配置中,用典型场景计算系统全年运行成本和弃风弃光率。
- 微电网日前调度,用场景集描述次日风光出力不确定性,再配合预测控制做滚动优化。
- 输电网概率潮流分析,用典型场景代替蒙特卡洛抽样的海量样本。
- 电力市场下新能源报价策略的风险评估。
这套方法在学术界和工程界都已经很成熟了,核心差别就在于“生成质量”和“削减效率”的平衡,后面的 Copula 和 K-means 正是解决这两个问题的关键。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Copula 理论的正确打开方式
2.1 Sklar 定理与“边缘-相关”解耦
Copula 的核心思想可以用 Sklar 定理一句话概括:任意一个多维联合分布,都可以分解成两部分——每个变量自己的边缘分布,以及一个描述变量之间关联结构的 Copula 函数。数学上写作:
F(x, y) = C(F_X(x), F_Y(y))
其中 F_X、F_Y 分别是风速和辐照度的边缘累积分布函数,C 是 Copula 函数。这个分解的精妙之处在于:把“单个变量的分布形态”和“变量之间的相关关系”完全解耦了。
换句话说,我们可以先分别给风速和辐照度建一个分布模型,比如风速用威布尔分布、辐照度用 Beta 分布,然后再单独回答一个问题:“风速高的时候,辐照度是偏高还是偏低?极端情况下呢?”这两个问题分开处理,比直接拟合一个二维联合分布简单得多。直接拟合二维分布的问题在于,现实中很难找到一种已知分布能同时拟合出“风速偏态 + 辐照度双峰 + 特殊尾部相关”这些特征,但 Copula 可以自由组合边缘分布和连接结构。
用一个生活类比:Copula 就像给两个独立零件配的“连接件”。你手里有一根按照满足特定分布的轴和一根满足另一个分布的孔,用什么形状的连接件去组合它们,决定了轴和孔在运动中是同步起伏还是反向错动。边缘分布是零件的形状,Copula 是连接件的行为模式。
2.2 常用 Copula 族怎么选
Copula 有好几个家族,选错了结果会差很多。我在项目里常用的有这么几类:
| Copula 类型 | 参数 | 尾部相关特征 | 适用场景 |
|---|---|---|---|
| Gaussian Copula | 相关矩阵 Σ | 无尾部相关 | 常规建模,默认选择 |
| t-Copula | 相关矩阵 Σ + 自由度 ν | 上下尾对称相关 | 需要捕捉极端同步波动 |
| Clayton Copula | 参数 θ > 0 | 下尾相关强 | 低出力区同步性好 |
| Gumbel Copula | 参数 θ ≥ 1 | 上尾相关强 | 高出力区同步性好 |
| Frank Copula | 参数 θ ≠ 0 | 尾部相关弱 | 相关性较均匀的场景 |
风光的场景里有个典型现象:无风或者低辐照度的情况下,出力区域容易“扎堆”。比如一片区域内多个风电场同时接近零出力,因为大风天气往往是天气系统整体扫过,要么都有风,要么都静风;光伏也类似,阴天的时候一片区域都低辐照。这种“低端同步”特征,用 Clayton Copula 来建模通常比 Gaussian 更贴切。
但具体的选型不能拍脑袋。我的做法是把几类 Copula 都拟合一遍,计算 AIC/BIC 指标,选最优的。BIC 更严格,对复杂模型有惩罚,在小样本下更稳妥。有时候数据量不够,AIC 和 BIC 会给出不同的排序,这时再看尾部相关是否符合物理直觉。
2.3 参数估计与采样流程
实际工程中,Copula 的参数估计用两步法(IFM,Inference Functions for Margins)就够了。第一步先估计每个变量的边缘分布参数;第二步把原始数据代入边缘累积分布函数,得到均匀分布空间里的序列 U,再基于 U 估计 Copula 的参数。
第二步里有个非常实用的技巧:对 Gaussian Copula,不需要做数值极大似然估计,直接用数据间的 Kendall 秩相关系数 τ 反算相关系数 ρ,公式是 ρ = sin(πτ/2)。Kendall τ 比皮尔逊相关系数稳健得多,不受边缘分布形态影响,也不要求线性相关,对 Copula 建模是更合适的相关性度量。
采样流程正好是拟合的逆过程:先从 Copula 中采样出 [0,1] 区间上的均匀分布样本 U1、U2,然后分别套上边缘分布的反函数,得到风速和辐照度的采样值。做法是:
- 生成服从对应 Copula 的均匀分布样本 U = (U1, U2)。
- 用逆变换法得到 X = F_X^{-1}(U1),Y = F_Y^{-1}(U2)。
在 Python 里,Gaussian Copula 的采样很简单,先生成多元正态样本,再通过标准正态分布的累积分布函数 Φ 转换成均匀分布样本。后面代码部分会直接给出完整实现。
3. K-means 削减场景的做法与细节
3.1 削减的本质是聚类
场景削减从数学上看就是聚类。每个场景可以看成高维空间里的一个点,比如一个日场景由 24 个风电出力值和 24 个光伏出力值组成,那就是一个 48 维的点。K-means 的任务就是把这 1000 个点划分成 K 个簇,让同一个簇里的场景相似度高,不同簇之间的差异大。完成后,每个簇的中心(质心)就是典型场景。
K-means 做场景削减有这么几个优势:算法简单、收敛快、对大规模样本友好,而且聚类结果容易解释。相比于层次聚类,K-means 的内存和计算量都小很多,生成 2000 个场景、削减到 10 个,在普通电脑上几秒就能跑完。
但这里有个容易被忽略的细节:场景削减不只是要得到质心,还要给每个质心分配概率。概率的定义很直接:某个簇包含的场景数除以总场景数。这一步做完,典型场景集就变成了一个离散概率分布,可以直接接入随机规划的期望值目标函数。
3.2 K 值选择和聚类前处理
K 值怎么选是场景削减里最主观的一步。K 太小,典型场景太少,容易丢失原始场景集的多样性,极端情况(比如同时大风大光、双低出力)可能直接被平均掉;K 太大,削减失去意义,后续优化模型的计算负担又回来了。
工程上常用的方法有两个。第一个是肘部法则:画 SSE(簇内平方和)随 K 变化的曲线,找“肘部”位置,也就是增加 K 带来的 SSE 下降幅度变小的点。第二个是轮廓系数,数值越大代表聚类效果越好。我通常结合两个指标,再考虑后续优化模型的承受能力,在 K=5~10 之间选一个。
聚类之前还有一个必须做的操作:标准化。风速的量纲是 m/s,辐照度的量纲是 W/m²,如果直接放在一起算欧氏距离,辐照度会把风速的特征完全淹没。很多初学者在这里踩坑,我见过有人不做标准化就跑聚类,结果削减出来的典型场景里风速特征基本被忽略了。用 sklearn 的 StandardScaler 对每个维度做 z-score 标准化即可,聚类完成后把质心反变换回物理量纲。
3.3 代表场景概率怎么算
每个典型场景的权重计算看起来简单,但有一个隐藏问题:K-means 默认每个样本是等权的。如果原始场景是用拉丁超立方采样或重要性采样生成的,每个场景本身可能带有不同的权重,这时候直接数簇内样本数量就不对了。需要做加权聚类,或者把每个场景的权重累加到所属簇里,再归一化。
另一个实操细节是:要不要保留极端场景。K-means 的质心本质上是均值,天然会把极端场景“拉平”。我在实际项目中常用的做法是:先把极端场景单独挑出来(比如按全天发电量排序,前 5% 和后 5%),它们不参与聚类;对剩余场景做 K-means;最后把保留的极端场景作为一个或多个典型场景加入集合,并相应调整概率。这样既保证了计算效率,也没有丢掉尾部风险。
4. 实操流程全拆解(附 Python 骨架)
4.1 数据预处理与边缘分布拟合
整套流程从数据开始。假设我们有某个站点一整年的逐小时风速和辐照度数据。首先做数据清洗,剔除风速小于 0 或超过物理上限(比如 40m/s)的异常值,辐照度超出太阳常数(1361 W/m²)的数据也要检查。
python复制import numpy as np
import pandas as pd
from scipy import stats
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
df = pd.read_csv("site_data.csv")
df = df[(df["wind_speed"] >= 0) & (df["wind_speed"] < 40)]
df = df[(df["irradiance"] >= 0) & (df["irradiance"] <= 1361)]
接下来拟合边缘分布。风速一般用两参数威布尔分布拟合,辐照度由于有大量夜间 0 值,直接拟 Beta 分布会把分布形状带偏。一个比较实用的做法是:把辐照度大于 0 的样本单独拿出来拟 Beta 分布,同时单独统计“辐照度等于 0”的概率。这样就是零膨胀模型,比直接拟合更贴近真实分布。
python复制# 风速用 Weibull 分布拟合
shape_w, loc_w, scale_w = stats.weibull_min.fit(df["wind_speed"])
# 辐照度:分零值概率 + 正值的 Beta 分布
irr_pos = df.loc[df["irradiance"] > 0, "irradiance"]
p_zero = (df["irradiance"] == 0).mean()
irr_norm = irr_pos / 1361.0
a_irr, b_irr, loc_i, scale_i = stats.beta.fit(irr_norm, floc=0, fscale=1)
注意这里用 floc=0, fscale=1 固定 Beta 分布的位置和尺度参数,只让形状参数自由,这样能避免拟合过程中的数值不稳定。辐照度经过除以 1361 归一化到 [0,1],后续逆变换时再乘回来。
4.2 Copula 拟合与场景采样
边缘分布拟合完成后,把原始数据代入各自的 CDF,得到 [0,1] 区间上的均匀分布序列:
python复制u_w = stats.weibull_min.cdf(df["wind_speed"], shape_w, loc_w, scale_w)
u_i = stats.beta.cdf(irr_norm, a_irr, b_irr, loc_i, scale_i)
# 防止数值边缘的 0 或 1 导致后续计算异常
u_w = np.clip(u_w, 1e-6, 1 - 1e-6)
u_i = np.clip(u_i, 1e-6, 1 - 1e-6)
用 Kendall 秩相关系数反算 Gaussian Copula 的相关矩阵:
python复制from scipy.stats import kendalltau
tau, _ = kendalltau(u_w, u_i)
rho = np.sin(np.pi * tau / 2.0)
然后从 Gaussian Copula 中采样:
python复制n_samples = 2000
rng = np.random.default_rng(42)
z = rng.multivariate_normal(
mean=[0, 0],
cov=[[1.0, rho], [rho, 1.0]],
size=n_samples,
)
u_syn = stats.norm.cdf(z)
# 逆变换回原始物理量
w_syn = stats.weibull_min.ppf(u_syn[:, 0], shape_w, loc_w, scale_w)
# 辐照度:先合并零值概率,再逆变换正值部分
zero_flag = rng.random(n_samples) < p_zero
i_syn_pos = stats.beta.ppf(u_syn[:, 1], a_irr, b_irr, loc_i, scale_i) * 1361.0
i_syn = np.where(zero_flag, 0.0, i_syn_pos)
这段代码里采样过程分两步:多元正态采样 → 标准正态 CDF 变换,正是 Gaussian Copula 采样的标准流程。零值处理则对应了前面提到的零膨胀模型。这样采样出来的风速和辐照度,既保持了各自的分布形态,同时保留了数据中两者的关联关系。
4.3 功率转换与场景削减
有了风速和辐照度的样本,下一步是把它们转成风电功率和光伏功率。风电功率需要一条风机功率曲线,简化模型用切入风速、额定风速、切出风速三段式:
python复制v_in, v_r, v_out = 3.0, 12.0, 25.0
p_rated = 2.0 # MW
def wind_power(v):
if v < v_in or v >= v_out:
return 0.0
if v >= v_r:
return p_rated
return p_rated * (v - v_in) / (v_r - v_in)
p_wind = np.array([wind_power(v) for v in w_syn])
光伏功率用简化的线性模型,考虑组件效率和面积:
python复制eta_pv = 0.18
area_pv = 10000 # m^2
p_solar = eta_pv * area_pv * i_syn / 1000.0 # 单位 kW
如果我们要生成一天 24 小时的场景,可以对每个小时分别执行上面的拟合和采样,然后把同一序号下 24 个风电值拼成一条风电日场景、24 个光伏值拼成一条光伏日场景。这样做的好处是保留了每个时刻的风光相关性,代价是忽略了不同小时之间的时间相关性。在工程上,这种“逐时解耦再拼接”的做法很常用,因为绝大多数规划问题对时间相关性不敏感,真正敏感的是同一时刻的风光互补性。
场景削减的代码:
python复制scenarios = np.column_stack([p_wind, p_solar])
scaler = StandardScaler()
X = scaler.fit_transform(scenarios)
k = 6
km = KMeans(n_clusters=k, n_init=10, random_state=42)
labels = km.fit_predict(X)
centers = scaler.inverse_transform(km.cluster_centers_)
probs = np.bincount(labels, minlength=k) / len(labels)
这里 n_init=10 是告诉 K-means 用 10 组不同的初始质心去跑,取最优结果,避免随机初始化带来的不稳定。minlength=k 是为了防止某种概率为 0 时 bincount 返回的数组长度不对。centers 是反标准化后的质心,也就是我们最终要用的典型风光出力场景。
4.4 削减效果评估
削减完不能直接拿去用,先要验证一下削减前后的统计特征是否一致。最简单的评估方式是对比原始场景集和削减后场景集的均值、标准差和分位数。计算削减后统计量时要记得用概率加权:
python复制orig_mean = scenarios.mean(axis=0)
reduced_mean = (centers * probs.reshape(-1, 1)).sum(axis=0)
rmse = np.sqrt(((orig_mean - reduced_mean) ** 2).mean())
print("均值 RMSE:", rmse)
orig_std = scenarios.std(axis=0)
reduced_std = (probs * (centers - reduced_mean) ** 2).sum(axis=0) ** 0.5
print("标准差偏差:", np.abs(orig_std - reduced_std).mean())
画图对比也很重要:把原始 2000 个场景的风电、光伏功率直方图叠加上削减后典型场景的概率棒状图,一眼就能看出削减是否保留了分布形状。如果只看误差数字,有时会被均值匹配良好的假象欺骗,因为均值相同而分布形态完全不同是可能的。有条件的话,可以在同一张图上画削减前后的经验 CDF 曲线,观察两者的拟合程度。
5. 常见问题排查列表与我的个人经验
5.1 高频问题复盘
整理一个排查列表,都是我实际跑项目时遇到过的:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 采样出的风速出现负值 | Weibull 分布位置参数设置不合理,或逆变换时的数值问题 | 检查 loc 参数,采样后手动 clip 到 0 以上 |
| 辐照度大量为零导致 Beta 拟合变形 | 夜间零值污染了分布拟合 | 用零膨胀模型,先统计零概率再对正值拟合 |
| K-means 质心出现负功率 | 标准化后聚类再反标准化,可能产生越界值 | 对最终质心做物理约束 clip,限制在 [0, P_max] |
| 削减前后相关性消失 | K 太小或标准化方式破坏了联合结构 | 增大 K,并在评估阶段检查削减后场景的风光相关系数 |
| Copula 采样后极端场景丢失 | Gaussian Copula 尾部相关不足 | 改用 t-Copula 或 Clayton Copula |
| 每次运行结果不一样 | K-means 初始质心随机 | 固定 random_state,或增大 n_init |
5.2 一些在项目里验证过的小技巧
边缘分布的选择,我尝试过很多种组合,最后觉得核密度估计(KDE)在很多场景下比参数分布更稳。特别是辐照度分布,受天气类型影响常常呈现多峰形态,用 Beta 分布硬拟合多峰数据效果不好。如果项目里对可解释性要求不高,可以直接用 KDE 替代参数分布,代码上就是把 scipy.stats.weibull_min.fit 换成 scipy.stats.gaussian_kde,逆变换用数值积分加插值实现。计算量稍大,但对样本量在几千级别的场景生成来说可以接受。
还有一个工程技巧:如果数据量小,生成的场景质量会很不稳定。遇到这种情况,我会对原始数据做 Bootstrap 重采样,产生多个训练集,分别拟合 Copula 参数,再取平均。这样得到的 Copula 参数更稳健,生成的场景也不会因为某年的特殊气候而产生明显偏差。
5.3 后续可以怎么扩展
这套“Copula + K-means”的组合,只是场景生成的起点。如果项目时间序列特征很强,可以考虑用 D-vine Copula 把小时之间的时间相关性也建模进去;如果随机变量不止风、光两个,还有负荷、电价等,多维 Copula 的拟合会变得复杂,这时候 R-vine Copula 是更灵活的选择,它把高维相关结构分解成多个二元 Copula 的层级组合,可解释性和灵活性都很好。
场景削减方面,K-means 简单有效,但如果你想追求更精确的概率分布保持能力,可以试试同步回代削减法。它每次迭代都找到“被删除后概率损失最小”的场景,直到削减到目标数量。这种方法在概率距离意义上比 K-means 更精细,但计算复杂度高不少。K-means 适用于大规模初步削减,SBR 适用于最后几轮精细削减,两者结合是一种很实用的组合。
做场景生成这件事,我最大的体会是:方法都是工具,核心是理解数据背后的物理特性。风速和辐照度的边缘分布长什么样、相关结构在什么区间最明显、削減后的场景会不会漏掉极端风险,这些判断比套用哪个模型更重要。每次拿到新项目的数据,我都会先画一堆分布图和散点图,把数据的“脾气”摸透了,再决定用哪种 Copula、选什么 K 值。这套流程跑顺了,后续的优化模型、风险评估才有可靠输入。
