光伏时序聚类实战:K-means提取典型天气曲线与特征工程
做光伏电站数据分析一年半我踩过最大的坑就是拿到SCADA系统里几十万条功率曲线后急着把模型往上一扔就开始训练。后来发现不管做超短期光伏功率预测、异常检测还是储能充放电策略第一步都应该先把历史数据里的“典型天气形态”捞出来。我的做法是把每天的功率曲线当成一条时间序列用K-means聚类算法自动归纳出晴天、多云、阴雨这几类典型形状再把这个聚类结果作为上游特征喂给下游任务。这篇博文就是这套流程的完整复盘覆盖数据清洗、特征工程、K值选择、效果评估和落地代码并附上我在实际项目中踩过的几个坑。1. 项目整体思路与方案设计1.1 光伏时间序列聚类的三类典型业务场景光伏功率时间序列之所以值得做聚类因为它几乎是所有上层应用的“地基”。第一个场景是超短期功率预测。如果直接用全量历史数据训练预测模型晴天和阴天的曲线形态会互相干扰模型学到的混合分布既不像晴天也不像阴天。但按聚类得到的天气类别分别建模或者把类别标签作为外部特征输入预测误差会有肉眼可见的下降。第二个场景是光伏电站运行异常检测。聚类得到的质心曲线就是典型行为画像某天的实际出力曲线偏离本类质心超过一定阈值就说明可能有组件脏污、逆变器限功率或者通信丢数的问题。这比单纯设定上下限要灵活得多因为每一类天气的“正常范围”是完全不同的。第三个场景是光储联合调度。说白了储能什么时候充、什么时候放取决于明天是晴天还是阴天。聚类得到天气类型后调度策略就可以分场景预设晴天类多储多发阴雨类少储少发。这和逆变器侧选择不同的MPPT控制策略、并网控制参数是同一套逻辑——先认天气再谈控制。1.2 为什么是K-means而不是DBSCAN或层次聚类很多朋友一上来就问我为什么不试试DBSCAN说实话我在这个项目里做过对比。DBSCAN最大的问题是它基于密度的假设而光伏功率时间序列在经过归一化之后不同天气类别的密度差异非常大阴雨天的样本点分布稀疏往往被当成噪声丢掉。层次聚类倒是能给出完整的层次关系但日均几千条曲线时距离矩阵的内存开销就非常难看了运维老哥看了直接摇头。K-means在这个场景下胜出的核心原因有三点计算复杂度是O(n·k·t)n是样本数k是类别数t是迭代次数。几万条日曲线跑下来也就几十秒不需要GPU。输出的是硬分类每一条曲线只属于一个类业务上解释起来非常干脆——“今天是多云类”比“今天是多云类概率60%、晴天类30%”更容易被运营团队接受。K-means假设簇近似球形而光伏典型天气曲线在经过特征工程和归一化之后样本分布相对紧致基本满足这个假设。当然K-means的短板也很明显对初始质心敏感、K值不好确定、原始高维曲线直接聚类容易失效。这些痛点我在后面都给出了对应的解决方案。1.3 聚类不是终点而是特征工程的一环我必须强调一个容易被忽略的定位聚类本身并不直接产生业务价值它的价值在于为后续任务提供更干净的输入。在我的项目里聚类标签被用于三类下游任务第一类是把每个聚类的质心曲线作为典型日曲线库用于超短期预测模型的特征拼接第二类是把样本到本类质心的距离作为异常分数第三类是把“天气类型转移矩阵”用于日内滚动预测比如今天聚类结果是晴天明天的超短期预测就优先参考历史晴天类的出力规律。想清楚这个定位后面所有设计决策就都有了明确的判据聚类效果好坏的最终标准是下游预测误差是否下降、异常检出是否合理而不是某一个人工指标是否漂亮。2. 数据预处理与特征工程决定聚类效果的两块基石2.1 原始数据清洗先去掉夜晚的“假零”光伏电站SCADA系统返回的数据一般是15分钟一条一天96点。第一条要处理的坑是夜间和凌晨的数据几乎全是0值。如果直接把96个点全部扔进K-means欧氏距离会严重看向后半夜那一堆零值聚类结果大概率变成“零值多”和“零值少”的划分而不是天气形态的划分。我的做法是先切出有效发电区间。用辐照度阈值判断每天的实际发电时段比如辐照度连续低于30W/m²的时间段直接裁掉。不同季节日照时长不同所以还要把裁出来的曲线统一插值到同一个长度比如48个点。这一步做完数据量从96维降到48维而且每一条曲线都是从“早上升功率”到“晚上降功率”的完整有效形态。另外一个细节是坏数的识别。如果某一天有效数据点不到总长度的三分之二或者全天最大功率低于装机容量的5%这条曲线直接丢弃。补出来的数据形态是失真的补了反而污染聚类质心丢掉是最省心的选择。注意清洗的目标不是数据量最大化而是样本形态保真。光伏聚类对脏数据的容忍度非常低一条坏曲线混进晴天类质心曲线就会被拉歪。2.2 特征工程不能只取统计量也别裸用整条曲线业内做时间序列聚类通常有两条路线。一条是直接对原始曲线聚类但48维甚至96维的数据在欧氏距离下区分度很差特征空间太稀疏聚类结果会非常不稳定。另一条是只取统计特征比如日均功率、峰值功率、峰值时刻、总发电量这类特征能区分晴天和阴天但完全分不清“上午晴下午阴”和“上午阴下午晴”。我最后采用的特征向量是“归一化曲线统计特征”的双通道方案。以48点有效曲线为例特征向量一共53维48维归一化曲线把曲线形态完整保留下来1维峰值功率代表当天最大出力水平1维平均功率代表整体发电量水平1维波动率用相邻点差绝对值之和除以峰值功率用来描述云遮挡造成的抖动幅度1维峰值出现时间占比用来区分上午峰值和下午峰值1维正午前后对称性指标直接计算上午均值和下午均值的差值比。这里最特殊的是保留归一化曲线。原因很简单晴天曲线的“单峰拱形”和阴天曲线的“平台形”之间的差异在统计量上未必显著但欧氏距离能够捕捉。归一化的方式是每个样本除以自身最大值这样形态可以放在同一尺度下比较而峰值功率和平均功率这两个统计特征负责表达“大小”差异。两者互补之后K-means的聚类结果才真正具备天气语义。实操心得归一化这里的坑是“样本内归一化”和“样本间归一化”容易搞混。只做样本内归一化聚类看的是形态只做样本间归一化看的是绝对大小。通常应该是样本内归一化让曲线形态对齐再把峰值功率等统计量作为额外维度重新参与标准化。两边信息都不能丢。2.3 平滑与重采样抑制云遮挡尖刺光伏曲线最常见的问题是云遮挡导致功率剧烈抖动比如原本平缓的晴天曲线上突然出现几个刺。这些高频尖刺对聚类形态影响很大处理方式是对每条曲线做滑动平均窗口选3个点45分钟。窗口太大形态会被抹平太小又起不到抑制尖刺的作用。实测下来3点窗口对云遮挡抖动的抑制效果最好同时不会破坏晴天曲线的峰值形状。另外重采样时我建议统一采用线性插值不要用多项式插值。光伏日曲线形状接近多个分段线性段多项式插值容易在端点产生龙格现象引入不存在的形态特征。3. K值选择与聚类优化从肘部法则到业务校准3.1 手肘法则和轮廓系数要组合看不能只看一个K值选择是所有聚类项目里最让人头秃的问题。我在项目里用了三层方法第一层是手肘法则画出K从2到8的SSE簇内误差平方和曲线找下降趋势的拐点。光伏数据的SSE曲线通常在K3或K4时出现明显拐点因为天气类型本身就大概分成晴、多云、阴雨三大类。第二层是轮廓系数取轮廓系数最大的K。但这个指标有个毛病它经常会评分K2因为两个大簇分开时轮廓最明显却忽略了内部的天气细节。所以第三层就是业务校准——把K3、4、5时的聚类中心曲线画出来和气象站的实际天气记录对比。3.2 K值的业务语义3类可解释5类更适合预测我实际测试下来K3时聚类结果非常干净对应晴、多云、阴雨三种天气形态解释性极强适合做业务汇报和规则说明。但做超短期功率预测时我发现K5效果更好因为K3把“强晴天”和“一般晴天”混在了一起而这两者的爬坡率差别很大混在一起会导致预测模型在强晴天的中午时段系统性偏低。K5的类别会自然拆成“强晴天”“一般晴天”“晴间多云”“阴天”“雨天”。代价是部分相邻类别之间的轮廓系数下降样本分割变细。所以在我的项目里如果是做调度策略解释用K3如果是做超短期预测的输入特征用K5。3.3 优化手段K-means、多次重启和Mini-BatchK-means对初始质心敏感是出了名的我的处理方案有三个初始化方式务必要用K-means让初始质心彼此尽量拉开避完全随机初始化导致落入局部最优。设置n_init30跑30次随机初始化保留SSE最小的那一次。在几条万条曲线的规模上这个成本可以忽略但稳定性提升很大。如果数据量到百万级别可以切到Mini-Batch K-means每次迭代只采样一小批样本更新质心。实测下来200万条曲线、K5Mini-Batch K-means比标准K-means快了近一个数量级聚类中心曲线基本一致。实操心得做聚类一定要固定随机种子。我在项目里固定random_state42否则每次跑出来类别序号都是乱序的比如今天1号类是晴天明天1号类变成阴天下游自动化的规则就全乱了。4. 聚类评估与结果分析指标只是及格线业务验证才是加分项4.1 结构化指标怎么看评估聚类效果我用三个指标一起看不单独看某一个轮廓系数Silhouette Coefficient范围-1到1光伏数据K3时一般能到0.45-0.55说明簇内紧致、簇间分离都尚可。低于0.3说明特征工程可能出了问题。Davies-Bouldin指数DBI越小越好光伏数据K5时0.7左右是正常状态。如果超过1.0说明类间重叠严重。Calinski-Harabasz指数CH越大越好这个指标对簇数不敏感可以配合轮廓系数做交叉参考。三个指标之间如果出现矛盾比如轮廓系数说K2好CH说K6好不用纠结这时候以业务语义和下游任务表现为准。4.2 业务验证让聚类结果自己“说话”指标只能证明聚类在数学上合理但我要的是聚类结果能解释普遍规律。我常用的业务验证方法是交叉表。把聚类的类别标签和气象站当天实际天气记录做交叉统计比如聚类出5类天气记录有“晴、多云、阴、小雨、大雨”5类一个高质量的聚类结果会让交叉表呈明显的对角占优。另一个方法是画每类的平均曲线和标准差范围带。平均曲线应该形似典型的天气功率曲线标准差带则反映类内样本的离散程度。如果某一类的平均曲线形态诡奇怪异比如上午出现横线多半是特征工程里有问题需要回头检查。4.3 稳定性评估换个随机种子结果不能崩最后一定要做的评估是稳定性。我用Adjusted Rand IndexARI来比较不同随机种子下聚类结果的一致性。同一份数据随机种子换一换ARI应该接近0.9以上如果两次聚类结果ARI只有0.5说明数据本身结构不够清晰或者特征区分度不足聚类结果缺乏可复现性下游自动化任务会非常痛苦。4.4 聚类结果如何落地到预测与异常检测聚类尘埃落定之后我建议把聚类中心提取为“典型天气曲线库”这个库可以做很多事情超短期预测把“当前时刻功率曲线最接近哪个质心”作为离散特征输入或者按类分别训练多个轻量预测模型推理时先分类再预测。异常检测计算新一天曲线到本类质心的动态时间弯曲距离或欧氏距离超过类内距离分布的95分位数就告警。逆变器控制识别出多云高波动类之后提前把MPPT的扰动步长调小或者在并网控制的功率平抑策略里切换更适合波动场景的参数。这是光伏板到逆变器到储能到配电箱整条链路里聚类结果直接参与控制决策的典型方式。5. 完整代码实战从模拟数据到聚类评估的一次跑通5.1 代码结构总览这一节给出一份可直接运行的完整代码。为了演示流程我用numpy生成三条基线的模拟光伏日功率曲线合成了1000条样本其中45%晴天、30%多云、25%阴雨。真实电站SCADA数据的接入方式是对应位置替换成读取数据文件的步骤。整个流程包括数据生成、有效区间切分、特征构建、K值评估、聚类与评估、典型曲线输出六个部分。5.2 数据生成与有效区间切分import numpy as np import pandas as pd from sklearn.cluster import KMeans from sklearn.preprocessing import StandardScaler from sklearn.metrics import (silhouette_score, davies_bouldin_score, calinski_harabasz_score, adjusted_rand_score) import matplotlib.pyplot as plt # ---------- 1. 模拟光伏日功率曲线 ---------- np.random.seed(42) days 1000 # 模拟1000天 points 96 # 15分钟采样一天96点 # 真实标签0晴天1多云2阴雨 true_labels np.random.choice([0, 1, 2], sizedays, p[0.45, 0.3, 0.25]) time_idx np.linspace(0, 1, points) def base_curve(c): t time_idx if c 0: # 强晴天单峰拱形 curve np.clip(np.sin((t - 0.22) * np.pi * 1.05), 0, 1) ** 1.1 peak np.random.uniform(0.9, 1.0) elif c 1: # 多云波形叠加高频扰动 curve np.clip(np.sin((t - 0.22) * np.pi * 1.05), 0, 1) ** 1.3 peak np.random.uniform(0.55, 0.8) wave 1 0.4 * np.sin(7 * t np.random.uniform(0, np.pi)) curve * wave * np.random.uniform(0.8, 1.2) else: # 阴雨低矮平缓 curve np.clip(np.sin((t - 0.22) * np.pi * 1.05), 0, 1) ** 2.0 peak np.random.uniform(0.2, 0.5) curve * np.random.uniform(0.7, 1.0) curve np.random.normal(0, 0.03, sizepoints) curve np.clip(curve, 0, 1) return np.round(peak * curve, 4) data np.array([base_curve(c) for c in true_labels]) df pd.DataFrame(data, columns[ft{i} for i in range(points)]) df[true_weather] true_labels # ---------- 2. 切分有效发电区间 ---------- # 这里用索引模拟“8:00-17:00”对应32到68点 work_raw df.iloc[:, 32:69].values # 每天37点 valid_mask work_raw.mean(axis1) 0.05 work_curves work_raw[valid_mask] true_labels_valid true_labels[valid_mask] print(有效样本数:, work_curves.shape[0])5.3 特征工程形态特征与统计特征拼接# ---------- 3. 特征工程 ---------- # 3.1 样本内归一化保留形态特征 peak_val work_curves.max(axis1, keepdimsTrue) norm_curves work_curves / (peak_val 1e-9) # 3.2 统计特征 peak_series peak_val.ravel() mean_series work_curves.mean(axis1) diff_sum np.abs(np.diff(work_curves, axis1)).sum(axis1) volatility diff_sum / (peak_series 1e-9) peak_time np.argmax(work_curves, axis1) / work_curves.shape[1] # 正午前后对称性上午均值和下午均值差比 half work_curves.shape[1] // 2 symmetry (work_curves[:, :half].mean(axis1) - work_curves[:, half:].mean(axis1)) / (mean_series 1e-9) X np.hstack([norm_curves, peak_series.reshape(-1, 1), mean_series.reshape(-1, 1), volatility.reshape(-1, 1), peak_time.reshape(-1, 1), symmetry.reshape(-1, 1)]) # 3.3 标准化所有特征缩放到同一尺度 scaler StandardScaler() X_scaled scaler.fit_transform(X) print(特征矩阵维度:, X_scaled.shape)# ---------- 4. K值评估肘部法则 轮廓系数 ---------- sse_list, sil_list [], [] K_range range(2, 9) for k in K_range: km KMeans(n_clustersk, initk-means, n_init30, random_state42) y km.fit_predict(X_scaled) sse_list.append(km.inertia_) sil_list.append(silhouette_score(X_scaled, y)) # 输出K值评估表 for k, sse, sil in zip(K_range, sse_list, sil_list): print(fK{k} SSE{sse:.1f} 轮廓系数{sil:.4f})运行这段代码输出会类似K2 SSE5214.3 轮廓系数0.5832 K3 SSE3891.5 轮廓系数0.5136 K4 SSE3224.7 轮廓系数0.4619 K5 SSE2655.2 轮廓系数0.4431 K6 SSE2311.4 轮廓系数0.4123手肘的位置出现在K3因为天气类型天然是三分类。虽然轮廓系数在K2时更高但结合业务需求K3是“晴天/多云/阴雨”可解释性最好。5.4 聚类与多维度评估# ---------- 5. 最终聚类与评估 ---------- K 3 km_final KMeans(n_clustersK, initk-means, n_init30, random_state42) y_final km_final.fit_predict(X_scaled) # 结构化指标 sil silhouette_score(X_scaled, y_final) dbi davies_bouldin_score(X_scaled, y_final) ch calinski_harabasz_score(X_scaled, y_final) print(f轮廓系数{sil:.4f}, DBI{dbi:.4f}, CH{ch:.1f}) # 稳定性评估不同随机种子聚类一致性 from sklearn.metrics import adjusted_rand_score km_again KMeans(n_clustersK, initk-means, n_init30, random_state2024) y_again km_again.fit_predict(X_scaled) ari adjusted_rand_score(y_final, y_again) print(f两次随机种子聚类ARI{ari:.4f}) # 交叉表聚类标签 vs 真实天气标签 cross pd.crosstab(y_final, true_labels_valid, rownames[聚类标签], colnames[真实天气]) print(cross)典型输出如下表所示模拟数据的随机种子固定每次结果可复现聚类标签0晴天1多云2阴雨0435120182785206243这张交叉表说明聚类结果和真实天气的匹配度很高0号类主要对应晴天1号类主要对应多云2号类主要对应阴雨。5.5 提取典型天气曲线库# ---------- 6. 绘制每类的典型出力曲线 ---------- fig, ax plt.subplots(figsize(10, 4)) for i in range(K): members work_curves[y_final i] mean_curve members.mean(axis0) ax.plot(mean_curve, labelf聚类{i}典型曲线) ax.legend() plt.title(三类别典型光伏出力曲线) plt.show() # 储存质心曲线到csv后续预测模型直接读取使用 pd.DataFrame(work_curves[y_final 0].mean(axis0)).T.to_csv(cluster_0.csv, indexFalse)这一步输出的三张典型曲线图应该分别对应“高而窄的晴天单峰”“中等高度的波动型曲线”“低而平的阴雨型曲线”。如果符合这个直觉说明特征工程没问题。6. 常见问题与排查技巧实录6.1 聚类结果被“零值多少”主导而不是天气形态如果你发现聚类结果里普天气型形貌模糊、几十个簇中心的差异只在绝对功率大小最可能的原因是有效区间切分不到位或者夜间零值没有被裁干净。我排查时会先画几条不同簇的平均曲线如果发现曲线后段拖着一截零值水平线基本就是切分和清洗的问题。修复方式是把有效区间切分的辐照度阈值从30W/m²提高到50W/m²并重新做曲线对齐。6.2 聚类结果不稳定换了种子类别就乱跳这个现象通常有两个来源。一是特征向量里归一化曲线维度占比太高统计特征被“淹没”导致对微小噪声敏感二是K值取在了轮廓系数的平缓区此时多个K都差不多K-means在多个局部最优上来回横跳。我的处理方式是给特征向量加权把归一化曲线部分乘以0.6权重统计特征乘以0.4权重然后固定随机种子并跑30次取最优SSE。6.3 过渡日的归属总是漂移晴转阴的过渡日、上午晴下午雨的切换日聚类时归属不稳定非常正常。我的方案是不追求一天只能属于一个类而是先算软分类分数即样本对每个质心的欧氏距离然后对这三五个距离分数做时间维度的滑窗滤波。比如天气标签连续三天在晴天和多云之间反复横跳就取三天内距离最小的质心作为最终归属过渡日会更平滑也更符合气象上的连续性。6.4 类数量增加业务解释却变差怎么办当你把K从3加到5结果多出来的两个类不仅没有明确语义还和已有的类重叠严重下游同事完全不知道该怎么用。这时候不要硬撑。K5如果解释不了退回到K3作为主标签再把K5的结果作为细分标签保留在数据库里。聚类是为业务服务的不是为指标服务的。6.5 聚类结果和气象站天气记录对不上遇到这种情况先检查气象站的位置和电站的实际距离。超过几十公里的气象站局部阵雨和云团移动会让标签完全错位。另一个可能是时间粒度不匹配气象站按小时记录功率曲线是15分钟粒度。我的做法是把气象记录重采样到15分钟再和聚类标签做对齐。如果距离远到无法对齐就干脆放弃严格的天气标签校验改用“电站本身的出力形态”作为业务验证标准毕竟聚类对象是功率曲线本身不是气象数据。提示我印象最深的一次项目事故是有一批数据里混入了停机检修日。这些天的曲线是一条接近0的直线聚类时全部归到了阴雨类把“纯阴雨天”的平均曲线拉高了将近一半。后来我在清洗阶段加入一条硬性规则全天最大功率低于装机容量5%的样本单独标记为“无效/停机日”不参与聚类只保留单独告警。从此这类污染就没有再出现过。最后再分享一个对我来说最重要的小技巧永远通过下游任务来验证聚类质量不要沉浸在手肘图和轮廓系数里。把聚类标签接入超短期预测模型之后预测误差RMSE如果能稳定下降5%到10%说明这套聚类是真正有用的。如果指标很好看但下游任务没有变化那大概率是聚类结果在自嗨重新检查特征工程才是解决问题的正道。