Solar Forecasting

Analyzing and forecasting daily solar power generation in Germany using time series models.

Motivation

Solar energy is one of the fundaments of our transition toward green energy. With all its advantages over other power sources, solar energy has one drawback that complicates its use: It only gets generated when it’s sunny. To utilize an efficient mix of energy sources, it is important to forecast their availability in the future.

Data

The data used is the daily solar power generation in Germany by its four network operators (50Hertz, Amprion, Tennet and Transnet BW). The data is provided by Fraunhofer Institute for Solar Energy Systems ISE and is publicly available on Energy-Charts.

Approach

To model the process, the following steps were undertaken:

  1. Transformation of the distribution of the data
  2. Modeling of the seasonality
  3. Modeling of the short-term effects using an ARIMA model
Notebook Analysis
15 min read Click to collapse

Modelling Power Load of Solar Energy

David Schulte

Course work: Statistical Tools in Finance and Insurance

Prof. Dr. López Cabrera

Imports and helper functions

import oss
import pandas as pd
from matplotlib import pyplot as plt
import numpy as np
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
import statsmodels.api as sm

from statsmodels.tsa.arima.model import ARIMA
from statsmodels.tsa.stattools import acf, pacf
from statsmodels.api import OLS, add_constant
from sklearn.linear_model import LinearRegression
from sklearn.preprocessing import MinMaxScaler
from sklearn.neighbors import KernelDensity
import seaborn as sns

Loading the data

First we load the data from all four network operators from 2010 until 2020.
The data can be accessed on https://energy-charts.info/.

header_list = ['Date', '50Hertz', 'Amprion', 'Tennet', 'Transnet BW']
for year in range(2010, 2021):
    filename = f"energy-charts_Electricity_production_in_Germany_in_{year}.csv"
    filepath = os.path.join('data', filename)
    df_current = pd.read_csv(filepath, sep=',', names=header_list, skiprows=1)
    df_current.fillna(0, inplace=True)

#     df_current["all"] = df_current["50Hertz"] + df_current["Amprion"] + df_current["Tennet"] + df_current["Transnet BW"]
        
    if year == 2010:
        df = df_current
    else:
        df = pd.concat([df, df_current])
df.head()

Date50HertzAmprionTennetTransnet BW
02009-12-31T23:00:00.000Z0.00.00.00.0
12010-01-01T00:00:00.000Z0.00.00.00.0
22010-01-01T01:00:00.000Z0.00.00.00.0
32010-01-01T02:00:00.000Z0.00.00.00.0
42010-01-01T03:00:00.000Z0.00.00.00.0

We sum up the energy generation by the network operators to get the total energy generation. Since the data contains values for different time intervals, we aggregate them to get daily power generation.

df["all"] = df["50Hertz"] + df["Amprion"] + df["Tennet"] + df["Transnet BW"]
df['Date'] = pd.to_datetime(df['Date'])
daily_df = df.groupby([df['Date'].dt.date]).mean()[1:]
daily_df.head()

50HertzAmprionTennetTransnet BWall
Date
2010-01-010.0044580.00.0483330.0232080.076000
2010-01-020.0043330.00.0432500.0208330.068417
2010-01-030.0021670.00.0316250.0151670.048958
2010-01-040.0014170.00.0887500.0426670.132833
2010-01-050.0022500.00.0422500.0203330.064833

This is how our data looks like.

plt.figure(figsize=(30,10))
plt.plot(daily_df['all'])
plt.xlabel('Year', fontsize=30)
plt.ylabel('Daily Solar Power Generation in GW', fontsize=30)
plt.title('Daily Solar Power Generation in Germany', fontsize=40)
plt.savefig('data.png')

png

Distribution of the data

Let us take a look at the distribution of daily power generation.

sns.displot(daily_df['all'], kde=True)
plt.xlabel('U')
plt.savefig('datadistr.png')

png

We can see that the distribution is skewed to the left. Since we will later apply a linear regression, we would prefer data that is approximately normally distributed. To shift our distribution, we apply the following transformation.

First, we apply Min-Max scaling.

$\tilde{U_t}=\frac{U_t-U_{min}}{U_{max}-U_{min}}$

scaler = MinMaxScaler()
scaled = scaler.fit_transform(daily_df['all'].to_numpy().reshape(-1, 1))
epsilon = 0.1
scaled[scaled==0]=epsilon
scaled[scaled==1]=1-epsilon

Then, we apply a logit normal transformation to the scaled values.

$U^*=\log{\left(\frac{\tilde{U}_t}{1-\tilde{U}_t}\right)}$

transformed = np.log(scaled/(1-scaled))
daily_df['transformed'] = transformed
sns.displot(transformed, kde=True, legend=None)
plt.xlabel('$U^*$')
plt.savefig('transformeddistr.png')

png

Let us take a look at our transformed time series.

plt.plot(daily_df['transformed'])
[<matplotlib.lines.Line2D at 0x22e1b2926d0>]

png

Seasonality

We can see a strong seasonal component in the data. To get rid of it, we apply a linear regression and continue working with its residuals.
After thinking about the underlying process behind the data and experimenting with it, we model our data as following:

$U^*_t = \beta_0 + \beta_1 \cdot t + \beta_2 \cdot \sqrt[4]t \cos \left(2\pi \frac{t-11}{365}\right)+X_t$

  • $\beta_0$ is the intercept.
  • $\beta_1$ is a linear time coefficient. The behind it is that the amount of solar panels increases steadily over time.
  • $\beta_2$ models seasonality. The cosine term describes the yearly seasonality, as power generation heavily depends on natural seasons. The wave is shifted by 11 days. That is because winter solstice is exactly 11 days before New Year. Furthermore, we scale seasonal component by the fourth square-root of time, as a non-linear development of solar energy plants comes into play.

We will use the scikit-learn library for implementation. To get more more information about the regression, we will also conduct it using the statsmodels library and print the model summary.

def get_seasonal_component(timeseries):
    time = np.arange(len(timeseries))
    x_vals = np.array((time, (time**0.25*np.cos(2*np.pi*(time+11)/365)))).T
    lm = LinearRegression().fit(x_vals, timeseries)
    print(f'Intercept: {lm.intercept_}')
    print(f'Coefficients: {lm.coef_}')
    print(f'R-squared: {lm.score(x_vals, timeseries)}')
    return lm.predict(x_vals)
time = np.arange(len(transformed))
x_vals = np.array((time, (time**0.25*np.cos(2*np.pi*(time+11)/365)))).T

model = sm.OLS(transformed, sm.add_constant(x_vals))

results = model.fit()

results.summary()
OLS Regression Results
Dep. Variable:yR-squared:0.724
Model:OLSAdj. R-squared:0.724
Method:Least SquaresF-statistic:5272.
Date:Sun, 07 Aug 2022Prob (F-statistic):0.00
Time:12:57:19Log-Likelihood:-4394.5
No. Observations:4018AIC:8795.
Df Residuals:4015BIC:8814.
Df Model:2
Covariance Type:nonrobust
coefstd errtP>|t|[0.0250.975]
const-2.08410.023-91.4130.000-2.129-2.039
x10.00059.83e-0646.9230.0000.0000.000
x2-0.22820.002-92.1810.000-0.233-0.223
Omnibus:507.081Durbin-Watson:0.620
Prob(Omnibus):0.000Jarque-Bera (JB):1086.897
Skew:-0.766Prob(JB):9.62e-237
Kurtosis:5.036Cond. No.4.64e+03


Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.
[2] The condition number is large, 4.64e+03. This might indicate that there are
strong multicollinearity or other numerical problems.
f = open('ols.tex', 'w')
f.write(results.summary(xname=['beta0', 'beta1', 'beta2']).as_latex())
f.close()
daily_df['seasonality'] = get_seasonal_component(transformed)
daily_df['cleaned'] = daily_df['transformed'] - daily_df['seasonality']
Intercept: [-2.08409933]
Coefficients: [[ 0.00046127 -0.22820751]]
R-squared: 0.7242120048393265
plt.figure(figsize=(15,5))
plt.plot(daily_df['transformed'])
plt.plot(daily_df['seasonality'], c='r', linewidth=3)
plt.legend(['Observed data', 'Regression fit'])
plt.xlabel('Date', fontsize=12)
plt.savefig('seasonality.png')

png

Residuals after regression

plt.plot(daily_df['cleaned'])
plt.title('Residual $X_t$', fontsize=15)
plt.xlabel('Date', fontsize=12)
plt.savefig('afterlm.png')

png

The residuals look good, except in the very beginning. That is no problem, since we can just drop the first year of our data and work with the remaining 10 years.

daily_df = daily_df[365:]
daily_df.head()

50HertzAmprionTennetTransnet BWalltransformedseasonalitycleaned
Date
2011-01-010.0110420.0220420.0582500.0099170.101250-4.775061-2.895385-1.879675
2011-01-020.0245420.0729580.0902080.0356670.223375-3.973572-2.892215-1.081357
2011-01-030.0225830.0808750.0660420.0788750.248375-3.865377-2.888749-0.976628
2011-01-040.0191250.0895000.0671670.0679580.243750-3.884564-2.884988-0.999576
2011-01-050.0510420.1910000.2057500.1065000.554292-3.036477-2.880932-0.155545

We will print the RMSE of our residuals.

rmse = np.linalg.norm(daily_df['cleaned'])/np.sqrt(len(daily_df))
print(f'RMSE of residuals: {rmse.round(4)}')
RMSE of residuals: 0.6584
sns.displot(daily_df['cleaned'], kde=True)
<seaborn.axisgrid.FacetGrid at 0x22e1b7145b0>

png

We can see that our residuals are approximately normally distributed.

Time series modelling

Now it is time to work on the time series. First, we inspect its partial autocorrelation.

fig = plot_pacf(daily_df['cleaned'])
plt.xlabel('Lags', fontsize=12)
plt.title('Partial Autocorrelation', fontsize=15)
plt.savefig('pacf.png')

png

Based on this result, we apply a ARIMA(1,0,1) model.

model = ARIMA(daily_df['cleaned'], order=(1,0,1)).fit()
C:\ProgramData\Anaconda3\lib\site-packages\statsmodels\tsa\base\tsa_model.py:524: ValueWarning: No frequency information was provided, so inferred frequency D will be used.
  warnings.warn('No frequency information was'
C:\ProgramData\Anaconda3\lib\site-packages\statsmodels\tsa\base\tsa_model.py:524: ValueWarning: No frequency information was provided, so inferred frequency D will be used.
  warnings.warn('No frequency information was'
C:\ProgramData\Anaconda3\lib\site-packages\statsmodels\tsa\base\tsa_model.py:524: ValueWarning: No frequency information was provided, so inferred frequency D will be used.
  warnings.warn('No frequency information was'

Again, we give out the residuals after applying the ARIMA model.

rmse = np.linalg.norm(model.resid)/np.sqrt(len(model.resid))
print(f'RMSE of residuals after ARIMA: {rmse.round(4)}')
RMSE of residuals after ARIMA: 0.5124
model.summary()
SARIMAX Results
Dep. Variable:cleanedNo. Observations:3653
Model:ARIMA(1, 0, 1)Log Likelihood-2738.148
Date:Sun, 07 Aug 2022AIC5484.296
Time:12:57:23BIC5509.109
Sample:01-01-2011HQIC5493.132
- 12-31-2020
Covariance Type:opg
coefstd errzP>|z|[0.0250.975]
const0.05930.0252.3380.0190.0100.109
ar.L10.69610.01838.0430.0000.6600.732
ma.L1-0.12080.024-5.0310.000-0.168-0.074
sigma20.26210.00643.6120.0000.2500.274
Ljung-Box (L1) (Q):0.14Jarque-Bera (JB):95.96
Prob(Q):0.71Prob(JB):0.00
Heteroskedasticity (H):1.20Skew:-0.37
Prob(H) (two-sided):0.00Kurtosis:3.30


Warnings:
[1] Covariance matrix calculated using the outer product of gradients (complex-step).
f = open('arma.tex', 'w')
f.write(model.summary().as_latex())
f.close()
plt.figure(figsize=(30,10))

# plt.plot(predictions, c='g')
plt.plot(daily_df['cleaned'], c='b')
plt.plot(model.fittedvalues, c='r')
plt.title('Model fit over the whole timespan', fontsize=25)
plt.xlabel('Date', fontsize=18)
plt.legend(['Observed data', 'Model fit'], fontsize=15)
plt.savefig('armafitall.png')

png

plt.figure(figsize=(30,10))

daily_df['arma_fit'] = model.fittedvalues
# plt.plot(predictions, c='g')
plt.plot(daily_df['cleaned'].iloc[365:365*2], c='b')
plt.plot(daily_df['arma_fit'].iloc[365:365*2], c='r')
plt.title('Model fit in 2012', fontsize=25)
plt.xlabel('Date', fontsize=18)
plt.legend(['Observed data', 'Model fit'], fontsize=15)
plt.savefig('armafit1year.png')

png

The statsmodels library returns four plots that describe the model fit.

fig = plt.figure(figsize=(20,20))
a = model.plot_diagnostics(fig=fig)
plt.savefig('residplot.png')
C:\ProgramData\Anaconda3\lib\site-packages\statsmodels\graphics\gofplots.py:993: UserWarning: marker is redundantly defined by the 'marker' keyword argument and the fmt string "bo" (-> marker='o'). The keyword argument will take precedence.
  ax.plot(x, y, fmt, **plot_style)

png

Learnings and outlook

Even with limited knowledge about the underlying process, it is possible to model seasonality fairly easily. We do not need deep learning but can resort to a statistical time series model.
The scope of the project was fairly narrow and given more time, the following steps should be performed:

  • Retransformation of the data to its original distribution
  • Out-of-sample predictions
  • Thorough evaluation of the performance and generality of the model

Additionally, it would be interesting to also forecast power generation in the short term. To do this, one could incorporate different sources, like weather forecasts.