光伏发电量短期预测,我前前后后做了小半年。刚开始的想法很简单:找个模型把历史发电量喂进去,直接跑出未来几天的曲线就行。等真拿到电站数据才发现,网上教程讲的是模型,现实考验的是数据。晴天的时候功率曲线确实光滑得像教科书,但一旦碰上多云天,云层一挡,出力在几分钟内能掉一半再拉回来,这种数据用单一模型硬扛,基本就是翻车的命。
后来我改用SARIMA(季节性自回归移动平均模型)和Prophet的组合方案,才算走通了一条比较稳的路。SARIMA擅长捕捉日周期和小时级的季节性规律,Prophet对趋势变化和天气扰动的适应能力更强,两个模型一配合,能覆盖对方顾不上的误差区间。这篇文就把这套方案的完整流程、Python代码和我在实际项目里踩过的坑全部写出来,适合正在做光伏功率预测、电力交易策略,或者研究时间序列模型组合的朋友参考,也适合刚接触这两个模型的初学者拿去做一个能跑通的完整项目。
1. 光伏功率预测的难点与"组合拳"思路
1.1 数据里那种"规律与随机并存"的拧巴感
光伏发电功率主要由三个因素决定:辐照度、温度和组件自身状态。三者里辐照度的波动最要命。晴空条件下,辐照度变化非常有规律,基本跟着太阳高度角走,所以功率曲线呈现典型的"早晚低、中午高"钟形,而且日与日之间高度相似。这种强烈的日重复性,正好是SARIMA这类强调季节性的模型的拿手好戏——它本质上就是通过季节差分把这种周期规律提炼出来。
但多云天气下,辐照度受云层移动影响会剧烈波动。云遮住太阳的那几分钟,功率能骤降七成,云移走后又会快速恢复。这种突变不是周期性规律,而是气象过程驱动的。SARIMA很难提前捕捉到,因为它是历史数据的线性组合外推,遇到非周期性突变就力不从心。Prophet相对好一点:它把趋势变化建模成一系列可学习的"突变点",拟合时会把极端点当成趋势切换信号来处理,不至于被瞬时毛刺带得全线失真。
所以为什么一定要做组合?因为短期预测(未来24到72小时)的功率曲线,本质上就是"稳定周期加天气扰动"的叠加。SARIMA管周期,Prophet管扰动,两者误差模式不同,融合之后明显比单模型稳。
1.2 两个模型各自的脾气与适用边界
SARIMA,全称季节性自回归移动平均模型,是ARIMA的扩展版,专门处理带季节性的数据。光伏功率数据天然带多尺度季节性:日季节性最明显(每天同一时刻的功率模式高度相似),如果数据跨度够长,还会有天气尺度上的周季节性波动。SARIMA通过差分、季节差分、自回归项和移动平均项的组合,把规律压缩成一组参数,然后顺着历史轨道往外推。
Prophet是Meta开源的时间序列预测工具,思路完全不同。它把时间序列拆成趋势项、季节项、节假日项和误差项,用加法模型拟合,训练速度快,对非统计专业背景的人很友好。最关键的差异是,Prophet允许你塞进额外回归变量,比如辐照度、温度、云量。这些外生变量正好补上SARIMA"只看历史功率、不看天气原因"的短板。
我之前见过不少项目为省事只跑一个模型。说实话,电站地理位置好、天气常年稳定的话,单用Prophet够用;但你要是做电力交易策略或者微网调度,预测误差直接跟真金白银挂钩,组合方案的价值就不是"提升两三个点的精度"那么简单了,而是它能显著降低大误差出现的概率。哪怕只是让最坏情况从"偏差30%"降到"偏差18%",业务上就能接受很多。
2. 数据预处理:先把电站日志变成一条干净的时间序列
2.1 拿到原始SCADA数据,先别急着建模
电站的SCADA系统导出的数据,通常包含时间戳、总有功功率、气象站辐照度、环境温度、组件温度等字段。但原始数据远没想象中干净。我第一次拿到的数据就撞上几个典型问题:时间戳不连续,传输中断导致某些小时段整段缺失;功率值偶尔出现负的,是夜间传感器零漂造成的;辐照度偶尔超过物理上限,明显是仪器异常或积灰干扰。
处理原则可以固定下来,我一般按小时粒度做,始终不建议拿到数据直接丢进模型:
- 按固定频率重采样,15分钟粒度更精细但模型计算量成倍上涨,1小时粒度对72小时尺度的短期预测足够。
- 时间戳缺失用线性插值补。功率值不要用均值填充——夜间功率就是0,补一个非0值等于凭空造噪声。
- 辐照度超过理论上限(不同地区有差异,多数电站设计在1000 W/m²左右)的,按上限截断或者标记异常后插值替换。
- 夜间辐照度接近0但功率不为0的小幅波动不用特意剔除。这本身就是电站自耗电的真实特性,让模型学进去无妨。
下面这段是我常用的预处理代码,可以直接套:
import pandas as pd import numpy as np df = pd.read_csv("pv_power.csv", parse_dates=["time"]) df = df.set_index("time").sort_index() # 固定为1小时粒度重采样 df = df.resample("1H").mean() # 功率下限约束+线性插值 df["power"] = df["power"].clip(lower=0) df["power"] = df["power"].interpolate(method="linear", limit=48) # 辐照度、温度处理 df["irradiance"] = df["irradiance"].clip(upper=1000) df["temperature"] = df["temperature"].interpolate(method="linear", limit=48) # 辐照度几乎为0时,认定无光照,功率直接归零 df.loc[df["irradiance"] < 5, "power"] = 0 df = df.dropna(subset=["power"])注意interpolate里的limit参数。限制最大插值距离是为了防止大段缺失被硬生生补出一条虚假曲线。超过48个点连续缺失,说明那段数据不值得救,直接剔除更省心。
2.2 天气数据到底要不要进模型
SARIMA的标准形式不直接支持外生变量,但它的扩展版SARIMAX可以。不过我自己的经验是:做短期预测时,SARIMA其实只用历史功率序列就够了,加入天气变量反而容易引入未来不可得的麻烦。真正需要外生变量的是Prophet,它本来就有专门的外生回归机制,把辐照度和温度放进去,预测效果提升非常明显。
这里有个特别容易踩的坑:训练阶段你可以用历史实测天气数据,但预测阶段手上只有天气预报数据(有些场景甚至连天气预报都没有)。所以做验证的时候,必须用"预测时刻实际能拿到的天气数据"来测,不能用实测天气数据事后替代,否则验证指标会虚高一大截。如果项目确实拿不到未来天气预报,那就做个简化版:Prophet只加温度回归变量,因为温度预报相对靠谱,辐照度的预报误差太大反而会把模型拖垮。
2.3 训练集和验证集必须按时间切,不能随机打乱
时间序列模型的验证方式跟普通机器学习完全不同,绝对禁止随机shuffle。光伏数据尤其敏感的是季节差异:夏天光伏出力峰值远高于冬天,如果随机抽样让训练集里混着夏天的数据去预测冬天,模型会在季节边界上暴露严重的滞后偏差,你还以为是自己模型没调好。
正确做法是按时间顺序切分。比如有两年数据,用前20个月训练,后4个月验证。更严格一点的做法是滚动预测验证(也叫时间序列交叉验证):每预测完一段,就把真实值并入训练集,再预测下一段。我在第6章评估部分会专门说这个。先把切分的思路定下来,后面所有模型都遵循同一套切分,结果才公平。
3. SARIMA建模:从定阶到滚动预测的完整流程
3.1 先看自相关图,再决定参数
SARIMA有一堆参数要定:p(自回归阶数)、d(差分阶数)、q(移动平均阶数),以及季节部分的P、D、Q和季节周期m。很多人一上来就丢给auto_arima跑,跑完拿到一组参数也不知道为什么。我的建议是:先用眼睛看,再让工具帮你确认。
光伏功率序列最明显的特征是日季节性。如果按小时采样,季节周期m就是24。快速验证的办法:把连续七天每天同时刻的功率拉出来画在一起,如果曲线高度重合,说明日季节性极强,季节部分必须有。进一步看周季节性:如果工作日和周末的功率形态有明显差异,可以考虑m=168(一周的小时数)的周期项。不过对短期光伏预测,我一般只保留m=24,周季节性放到Prophet里用weekly_seasonality去兜。
接下来对序列做一阶差分去掉整体趋势,再做一次间隔24小时(季节周期)的季节差分,然后去看ACF和PACF图。光伏序列经过这两步差分后,ACF往往在滞后24处有明显的拖尾,PACF在滞后24附近有截断,这说明季节自回归项P=1是合适的。非季节部分p和q一般0到2之间就足够。这里的原则是宁可参数少,也不要为了完美拟合历史而把模型搞得很复杂——参数越多,对未来预测的稳定性越差,尤其是超过一个季节周期之后,复杂模型特别容易崩。
3.2 statsmodels实现与参数含义
statsmodels里的SARIMAX类可以直接建模,支持SARIMA加外生变量。下面这个例子是hourly数据、季节周期24的标准配置:
from statsmodels.tsa.statespace.sarimax import SARIMAX model = SARIMAX( train["power"], order=(2, 1, 2), seasonal_order=(1, 1, 1, 24), enforce_stationarity=False, enforce_invertibility=False, ) result = model.fit(disp=False, maxiter=200) print(result.summary())参数的具体含义:
order=(2,1,2):非季节部分,AR阶数2,一阶差分,MA阶数2。seasonal_order=(1,1,1,24):季节部分,季节AR阶数1,季节差分1次,季节MA阶数1,季节周期24小时。enforce_stationarity=False和enforce_invertibility=False:让优化器放宽平稳性与可逆性限制,避免因边界条件拒收收敛。但注意,这两个开关不能当成万能药。如果数据本身有明显趋势或者异常段,宁可先回头清洗数据,也不要靠放宽限制硬凑。
fit的时候建议固定maxiter,默认值在某些异常数据上会过早停止迭代,我习惯给到200到500。训练完先看summary里的系数p值,明显不显著(比如p>0.1)的项可以考虑删掉,把模型压瘦。
3.3 预测阶段:直接预测还是滚动预测
SARIMA做多步预测时,有两种思路。一种是直接调用get_forecast(steps=48)一步输出未来48小时所有预测值;另一种是滚动预测:预测下1小时,拿到真实值后更新模型,再预测下1小时。直接预测适合做计划排产,滚动预测适合在线实时更新。短期光伏预测我更推荐直接预测,因为实际场景里你不可能为了预测未来一小时而在线重训模型,也没法等到真实值出来再推下一步。
forecast = result.get_forecast(steps=48) forecast_mean = forecast.predicted_mean forecast_ci = forecast.conf_int()直接预测的局限也很明显:超过24小时之后,预测曲线会逐渐趋向一个周期性的"平均形状",对天气突变没有任何反应。SARIMA本质上假设"未来是历史的某种重复",这个假设在晴天成立,在多云转晴或者突降暴雨的场景就彻底失效。这正好是Prophet登场的理由。
4. Prophet建模:把天气因素塞进去,效果直接上一个台阶
4.1 输入输出格式与训练过程
Prophet要求的输入格式很简单:一个DataFrame,两列,ds必须是datetime类型,y是目标值。对光伏数据,y就是功率。预测前要构造一个包含未来时间点的futureDataFrame,最好从训练集的结束时刻无缝衔接,不要随便从中间截断。
训练代码基础版长这样:
from prophet import Prophet df_prophet = df[["power"]].reset_index() df_prophet.columns = ["ds", "y"] df_prophet["irradiance"] = df["irradiance"].values df_prophet["temperature"] = df["temperature"].values model = Prophet( yearly_seasonality=False, weekly_seasonality=True, daily_seasonality=True, changepoint_prior_scale=0.05, seasonality_prior_scale=10.0, ) model.add_regressor("irradiance", standardize=False) model.add_regressor("temperature", standardize=False) model.add_country_holidays(country_name="CN") model.fit(df_prophet)关于yearly_seasonality=False,我的理由是光伏功率虽然有季节性,但那是太阳高度角的季节性,数据长度不够两年的话,逐年季节性项很容易和日季节性混淆,拟合过度。如果你手里有三年以上数据,再考虑打开也不迟。
4.2 预测阶段必须处理的外生变量问题
Prophet预测时必须为未来时间段的每个回归变量提供值。这里就是前面2.2节说的坑:你要用预测时刻实际能拿到的数据,而不是事后拿实测值填进去。实际项目里我会用数值天气预报的辐照度和温度,没有预报源时退而求其次用气候平均值补,但一定要在代码里留下备注,说明假设是什么。
future = model.make_future_dataframe(periods=48, freq="H") # 从数值天气预报读取未来辐照度与温度 future["irradiance"] = weather_forecast["irradiance"].values future["temperature"] = weather_forecast["temperature"].values forecast = model.predict(future)4.3 参数调整的几个实战经验
changepoint_prior_scale是Prophet里最需要小心调的参数。光伏序列里的天气突变会导致出力趋势出现阶段性变化,设置太小,模型把突变当噪声平滑掉;设置太大,模型过度拟合历史里的随机波动,预测曲线毛刺很多,观感差而且误差大。我从0.05起步,观察预测曲线如果出现锯齿状抖动就往下调,如果晴天预测峰值系统性偏低就往上调一点,范围控制在0.02到0.08之间。
seasonality_prior_scale默认是10,控制季节项的灵活度。光伏的日季节性强且形状稳定,默认值基本够用。如果你发现预测曲线在晴天中午的峰值明显偏低,可以把季节项尺度调大到15左右,让它更贴合历史峰值形态。
还有一个容易被忽略的默认行为:Prophet会自动搜寻突变点(changepoint),主动把历史切分成多段趋势。这对光伏数据是双刃剑。它能识别出一段连续的阴雨天气对应的低出力趋势,但如果识别得太碎,会把正常天气波动也当成趋势切换信号,导致预测后续出力一路走低。我一般会显式传入n_changepoints,控制在5到10之间,同时结合前面的changepoint_prior_scale一起做调参。
5. 模型融合:不是简单加权平均,是取长补短
5.1 固定权重的问题在哪
SARIMA和Prophet各自预测做完,最朴素的做法是加权平均:final = 0.5 * sarima + 0.5 * prophet。实操下来你会发现,两个模型的误差分布在不同天气场景下差别很大。晴天SARIMA往往更好,因为周期性强、噪声小;突变天气下Prophet误差更小,因为它的趋势突变机制更扛造。固定权重让你在两个场景间都不出错也不出彩,但没办法在正确的时间把权重倾斜到更准的那个模型上。
所以我想做的不是固定权重,而是动态权重:用一个滑动窗口统计最近一段时间两个模型各自的预测误差,根据误差大小动态调整下一个时段的权重。误差小的权重自然变大,误差大的权重变小。这个思路实现起来非常简单,效果却非常直观。
5.2 动态权重的实现
下面这个函数就是核心逻辑。window表示滑动窗口大小,我一般取24小时,这样权重能跟上一天内的天气变化节奏。
import pandas as pd import numpy as np def dynamic_weights(sarima_pred, prophet_pred, actual, window=24): # 逐点绝对误差 errors = pd.DataFrame({ "sarima": np.abs(sarima_pred - actual), "prophet": np.abs(prophet_pred - actual), }) # 滚动平均绝对误差 rolling_mae = errors.rolling(window).mean() # 权重与误差成反比 weights = 1.0 / rolling_mae weights = weights.div(weights.sum(axis=1), axis=0) return weights # 假设在验证集上不停滚动预测,得到两个模型的预测序列 weights = dynamic_weights(sarima_history, prophet_history, actual_history) final_pred = (weights["sarima"] * sarima_forecast + weights["prophet"] * prophet_forecast)注意一个细节:动态权重最好在验证集上校准,不要直接用训练集数据算权重。否则模型在训练集上的误差分布会过于乐观,到了验证集上实际误差放大后,权重切换会滞后。我一开始就在训练集上算权重,结果验证集效果比预想差,后来改成验证集内滚动计算才正常。
5.3 残差修正:一个进阶方向
除了加权,另一种主流思路是残差修正。先拿SARIMA或Prophet做一个基准预测,然后用机器学习模型去学习"预测误差与天气变量的关系",最后把预测的误差加回基准预测上。比如拿随机森林去拟合误差项,特征是辐照度变化率、云量、温度变化、基准预测值等。
这个方向潜力很大,但对特征工程的要求也高,而且上线后的稳定性风险更大。我自己的项目里,动态权重相对单模型大概能把RMSE降3%到5%,残差修正再多降1到2个百分点,但代码复杂度、线上数据依赖、特征漂移问题都明显上升。如果你刚开始做,我建议先把动态权重跑稳,再考虑残差修正,不要一上来就两头抓。
6. 评估指标与实测:分天气看误差才是真实水平
6.1 评估指标怎么选
光伏功率预测常用的指标有MAE、RMSE、MAPE、R²。我的实际建议是:
- MAE:直观,反映平均偏差水平,方便向业务解释。
- RMSE:对大误差惩罚更重。如果业务关注极端偏差,用RMSE当核心指标。
- MAPE:对非技术背景的人友好,但功率在夜间趋近于0,直接算全天MAPE会被极小值放大到离谱。我一般只统计白天时段(辐照度大于50 W/m²)的MAPE。
- R²和相关系数:时间序列预测里参考价值有限,不建议做主要指标。
如果是电力交易场景,我还会单独看高峰时段的RMSE。高峰时段出力大、误差绝对值也大,直接影响交易策略的盈亏,跟全天平均误差是两码事。
6.2 按天气类型拆分评估
光伏预测有个铁律:不同天气类型下误差差异极大。晴天的RMSE可能只有装机容量的5%,阴雨天能到20%以上。只报一个总的RMSE,什么都看不出来。我习惯把验证集按目标天气拆成晴天、多云、雨天三组,分别评估。分组方式可以用气象站的云量数据,也可以用辐照度曲线的波动率简单聚类,实在不行人工逐日标注。
下面是我一次验证结果的示意(数值仅做演示,别当真):
| 天气类型 | SARIMA RMSE (kW) | Prophet RMSE (kW) | 融合模型 RMSE (kW) |
|---|---|---|---|
| 晴天 | 36.2 | 41.5 | 33.8 |
| 多云 | 58.7 | 52.1 | 47.3 |
| 雨天 | 79.4 | 71.8 | 65.2 |
表格里的规律很典型:晴天SARIMA强,多雨天气Prophet强,融合模型在各类天气下都能保持居中偏上的表现。这也是我不建议只看单一模型总指标的原因——总指标好可能只是晴天占比高,把多雨天的拉胯藏掉了。
6.3 一次完整预测流程的代码骨架
把上述步骤串起来,主流程大概是这样:
from statsmodels.tsa.statespace.sarimax import SARIMAX from prophet import Prophet # 1. 数据准备 train_df = ... # 含 power, irradiance, temperature test_df = ... # 2. SARIMA预测 sarima_model = SARIMAX(train_df["power"], order=(2, 1, 2), seasonal_order=(1, 1, 1, 24)) sarima_result = sarima_model.fit(disp=False, maxiter=200) sarima_forecast = sarima_result.get_forecast(steps=48).predicted_mean # 3. Prophet预测 prophet_model = Prophet(daily_seasonality=True, weekly_seasonality=True) prophet_model.add_regressor("irradiance") prophet_model.add_regressor("temperature") prophet_train = train_df.rename(columns={"power": "y"})[["ds", "y", "irradiance", "temperature"]] prophet_model.fit(prophet_train) future = prophet_model.make_future_dataframe(periods=48, freq="H") future["irradiance"] = test_df["irradiance"].values future["temperature"] = test_df["temperature"].values prophet_forecast = prophet_model.predict(future)["yhat"].values # 4. 动态权重融合 final_pred = (weights["sarima"] * sarima_forecast + weights["prophet"] * prophet_forecast) # 5. 结果约束 final_pred = np.clip(final_pred, 0, plant_capacity)最后一步np.clip别嫌多余。预测值出现负的或者超过装机容量,在业务汇报时会被立刻质疑模型可靠性。加上上下限约束,虽然只是后处理,但对外展示专业度高出一截。
7. 踩坑记录:版本、时区、预测步长的三个大坑
7.1 Prophet 的安装与版本演进
Prophet这个库改名过多次,早期叫fbprophet,后来改成prophet。直接pip install的时候经常跟numpy、cmdstanpy的版本打架,在Windows上尤其明显。我自己用过最省事的方案是conda建独立环境,然后conda install -c conda-forge prophet,一套下来基本不会撞依赖。如果你项目里还要跑statsmodels,建议两个库在同一个环境里装好之后固定版本,不要频繁升级,时间序列库的版本变更经常悄悄改默认行为,线上复现对不上版本会非常痛苦。
新版Prophet还换了后端的编译方式,安装时间变长,首次训练还会触发Stan编译。第一次跑通别着急,等它编译完,后续训练速度就正常了。
7.2 时区问题导致整个预测曲线偏移
光伏电站数据一般记录的是北京时间。Prophet内部处理时间戳时如果不带时区信息或者混入了UTC,预测出的时间点就会整体偏移8小时。白天峰值的预测曲线会跟真实曲线错开一大截,看起来就像模型完全没学会规律,其实是时区没统一。
建议在数据加载阶段就强制统一:
df.index = df.index.tz_localize("UTC").tz_convert("Asia/Shanghai")注意如果原始时间戳已经带时区,就不要再用tz_localize直接加,先检查再处理。我吃过一次亏,连续排查了两天才发现是时区混用,白白浪费了很多时间。
7.3 预测步长越长,SARIMA越容易"均值回归"
SARIMA做多步预测时,超过一个季节周期后,预测值会慢慢收向历史同期的平均值,方差越来越小。这意味着你预测未来72小时,前24小时还行,后48小时基本就是一条"平均曲线",在天气突变的日子里误差会迅速放大。
应对方法有两个:一是把融合模型里的Prophet权重在长步长时段提高,因为Prophet至少还带着天气回归变量,能对突变做出部分反应;二是做分段预测,比如先预测24小时,拿到真实数据更新后再预测下一个24小时,效果比一次性预测72小时好很多。这个方法会根据预测时间尺度的不同,采取不同的融合策略:
- 未来24小时内:SARIMA表现好,权重高一些。
- 24到72小时:Prophet权重逐步提高,因为它保留了天气信息。
这些权重调节规则,可以通过之前提到的动态权重函数实现,也可以在后期固定为经验值。如果你做的是每天滚动更新一次的系统,我建议直接把"近24小时偏SARIMA、远时段偏Prophet"这个规则固化成代码,运维起来省心不少。
我自己的感受是,光伏预测这个项目最后拼的不是模型有多先进,而是对数据特性有多少敬畏。把数据洗干净,把两个模型的适用边界摸透,再用一个简单的动态权重去平衡它们,已经能跑赢绝大多数直接用单一模型的项目了。如果后续还想进一步提升,可以从光伏电站的实况辐照度监测入手,把分钟级辐照度变化率做成高频特征,做成一个独立的辐照度突变预警模块,让光伏发电量预测从"统计模型"往"物理与统计混合"的方向再走一步。