Short-term probabilistic forecasting of GB national net demand

Gordon McFadzean 23-08-2021

2 Data preparation

2.1 Dependencies

Dependencies are sourced, including the installation of the ProbCast package if necessary.

list.of.packages <- c("data.table", "tidyr", "tidyverse", "purrr", "readr",
                      "lubridate", "ProbCast", "mgcViz", "gifski", "dplyr",
                      "ggpubr", "ROOPSD", "zoo", "plotly", "scales", "gganimate")
new.packages <- list.of.packages[!(list.of.packages %in% installed.packages()[,"Package"])]

if(length(new.packages)){
  for(new_package in new.packages){
    if(new_package == "ProbCast"){
      library(devtools)
      Sys.setenv("R_REMOTES_NO_ERRORS_FROM_WARNINGS" = "true") 
      install_github("jbrowell/ProbCast")
    }
    else{
      install.packages(new_package)
      }
  
  }
  
  }
for(package in list.of.packages){
  library(package, character.only = T)}

Some custom functions are also sourced. This includes:

  • qreg_gam_ma.R which provides a bespoke model, based on the GAMs plus quantile regressions in ProbCast, but which allows for a “moving average” type approach.
  • features.R which contains some functions for processing training features.
  • worm.R which can be used to generate “worm plots”.
  • ggplotmqr which enables visualisation of multiple quantile regression objects as fan-plots within the ggplot2 package.
  • layout_ggplotly which helps keep label positions tidy on ggplotly plots.
source("~/RProjects/reactforecasting/R/features.R")
source("~/RProjects/reactforecasting/R/worm.R")
source("~/RProjects/reactforecasting/R/qreg_gam_ma.R")
source("~/RProjects/reactforecasting/R/ggplotmqr.R")
source("~/RProjects/reactforecasting/R/layout_ggplotly.R")

2.2 Input datasets

The preparation of input data is largely the same as that for the day-ahead forecasts. The only exception is that we initially keep a wider-range of lead-times of meteorological forecasts.

2.2.1 Meteorological forecasts

Load the NWP data from ECMWF as RDA objects, then merge these together.

load("~/RProjects/reactforecasting/data/raw/ecmwf/extr_nwp_pvlive_dno.rda")
load("~/RProjects/reactforecasting/data/raw/ecmwf/extr_nwp_windemb_dno.rda")
met_solar_forecasts <- setDT(solar_dno_nwp)
met_wind_forecasts <- setDT(wind_emb_nwp)
names(met_solar_forecasts)[names(met_solar_forecasts) == 'pes_name'] <- 'gsp_group'
met_forecasts <- merge(met_solar_forecasts, met_wind_forecasts, 
                       all.x = TRUE, all.y = TRUE,
                       by=c('targetTime', 'issueTime', 'gsp_group'))

Target and issue times are converted to UTC date-times.

met_forecasts <- timestamps(met_forecasts, "issueTime", "targetTime")

We will be producing forecasts at lead times from 30 minutes ahead to 12 hours ahead.

min_lead_time <- 0.5
max_lead_time <- 12

The data from all GSP groups is pivoted from long to wide format. We combine meteorological forecasts across all GSP group by taking, for example, the average across all fourteen GSP groups. This includes a simple estimate of the national standard deviation, as the average standard deviation across all fourteen GSP groups, plus the standard deviation across all fourteen groups.

met_forecast <- pivot_wider(met_forecasts,
                            id_cols=c('targetTime', 'leadTime', 'issueTime'),
                            names_from = 'gsp_group',
                            values_from = seq(from = 4, 
                                              length.out = 36))
met_forecast <- setDT(met_forecast)
features = c('2T', 'TP', 'SSRD', 'WindSpd10', 'WindSpd100')
features_out = c('targetTime', 'leadTime', 'issueTime')
for(feature in features){
  feature_mean <- paste0('mean_', feature)
  feature_mean_pattern <- paste0(feature, '_mean_cell_')
  feature_min <- paste0('min_', feature)
  feature_min_pattern <- paste0(feature, '_min_cell_')
  feature_max <- paste0('max_', feature)
  feature_max_pattern <- paste0(feature, '_max_cell_')
  feature_sd <- paste0('sd_', feature)
  feature_sd_pattern <- paste0(feature, '_sd_cell_')
  met_forecast[, (feature_mean) := rowMeans(.SD),
               .SDcols=patterns(feature_mean_pattern)]
  met_forecast[, (feature_min) := apply(.SD, 1, min, 
                               na.rm = TRUE),
               .SDcols=patterns(feature_min_pattern)]
  met_forecast[, (feature_max) := apply(.SD, 1, 
                               max, na.rm = TRUE),
               .SDcols=patterns(feature_max_pattern)]
  met_forecast[, (feature_sd) := (rowMeans(.SD) 
                   + apply(.SD, 1, sd, na.rm = TRUE)),
               .SDcols=patterns(feature_sd_pattern)]
  features_out <- c(features_out, feature_mean, feature_min,
                    feature_max, feature_sd)
}
met_forecast <- setDT(met_forecast)
met_forecast <- met_forecast[, ..features_out]

2.2.2 Demand

We start with nationally aggregated half-hourly demand, sourced from NGESO’s website (https://demandforecast.nationalgrid.com/efs_demand_forecast/faces/DataExplorer).

This demand is defined as the sum of transmission metered generation, and therefore does not include embedded generation BMUs (including embedded wind BMUs). It includes actual demand within GSPs and from directly connected consumers, with non-transmission-metered generation netted off.

These files are read-in and combined, have UTC time-stamps created. We keep the demand observations from 2014 onward.

demand <- 
  list.files(path = "~/RProjects/reactforecasting/data/raw/demand",  
             pattern = "*.csv", full.names = T) %>% 
  map_df(~fread(.))

demand$targetTime <- as.POSIXct(seq.POSIXt(strptime("2011-01-01 00:00:00", 
                                                 "%Y-%m-%d %H:%M:%S", tz="UTC"), 
                                        by = "30 min",
                                along.with = demand$ND), tz='UTC')
demand$net_demand <-demand$ND
demand <- demand[, c("targetTime", "net_demand", 
                     'EMBEDDED_WIND_CAPACITY', 'EMBEDDED_SOLAR_CAPACITY'), 
                 with = FALSE]
demand <- demand[demand$targetTime>="2014-01-01"]

2.2.3 Autoregressive demand terms

We calculate smooth autoregressive terms for demand, in this case, the average demand by half-hour for the last several days, in the days prior to the forecast being issued.

max_lead_time_days <- ceiling(max_lead_time / 24)
demand[,clock_hour_factor:=as.factor(hour(targetTime)
                                     +minute(targetTime)/60)]
demand[, 
       net_demand_smooth_week:=frollmean(shift(net_demand, max_lead_time_days), 
                                         7,
                                         fill = NA, align = "right", 
                                         na.rm = T),
       by=.(clock_hour_factor)]
demand[, 
       net_demand_smooth_2week:=frollmean(shift(net_demand, max_lead_time_days), 
                                          14,
                                          fill = NA, align = "right", 
                                          na.rm = T),
       by=.(clock_hour_factor)]
demand[, 
       net_demand_smooth_day:=frollmean(shift(net_demand, max_lead_time_days), 
                                         1,
                                         fill = NA, align = "right", 
                                         na.rm = T),
       by=.(clock_hour_factor)]

demand[, 
       net_demand_sd_week:=rollapply(shift(net_demand, max_lead_time_days), 
                                     7, sd,
                                     fill = NA, align = "right", 
                                     na.rm = T),
       by=.(clock_hour_factor)]
demand[,clock_hour_factor:=NULL]

2.2.4 Combining datasets

The demand data is merged with the weather forecast data.

data <- merge(demand, met_forecast, all.x = TRUE, all.y = FALSE,
              by='targetTime' )
data <- data[order(targetTime)]

The other data objects are removed, to save memory.

rm(solar_dno_nwp)
rm(wind_emb_nwp)
rm(met_solar_forecasts)
rm(met_wind_forecasts)
rm(met_forecasts)
rm(demand)
rm(met_forecast)

2.3 Feature engineering

The initial steps of feature engineering are again similar to the day-ahead forecasts.

2.3.1 Calendar variables

Calendar variables are added using the functionality within ProbCast which provides hour of the year, type of day, day of the year, and a linear trend term “t” which runs from 0 to 1. We also add the calendar year and a factor variable for the month.

data[, localtargetTime:=with_tz(data$targetTime, "Europe/London")]
data <- add_calendar_variables(data, 'localtargetTime')
data$month <- as.factor(month(data$localtargetTime, label = TRUE, abbr = FALSE))
data$year <- year(data$localtargetTime)

Smooth second order fourier terms for annual position are defined.

data <- annual_position(data)

2.3.2 Daylight savings

We provided an adjusted clock-hour to account for day light savings, and define factor versions too.

data[,clock_hour_local:=clock_hour]
data[,clock_hour:=hour(targetTime) +minute(localtargetTime)/60]
data[,clock_hour_factor:=as.factor(clock_hour)]
data[,clock_hour_local_factor:=as.factor(clock_hour_local)]

We label days according to whether or not daylight savings time is in effect. We specifically label the days on which daylight savings starts and ends.

unique_years<- unique(data$year)
dst_start = as.Date(x = integer(0), origin = "1970-01-01")
dst_end = as.Date(x = integer(0), origin = "1970-01-01")
for(year in unique_years){
  days_in_year <- data.table(days=as.POSIXct(seq(as.Date(paste0(year,"-01-01"), 
                                                         origin = "1970-01-01"),
                                                 as.Date(paste0(year,"-12-31"), 
                                                         origin = "1970-01-01"),
                                      by="+1 day"), tz='Europe/London'))
  dst_days_in_year <- days_in_year[dst(days)==TRUE]
  dst_start <- c(dst_start, as.Date(min(dst_days_in_year$days),
                                    origin = "1970-01-01"))
  dst_end <-c(dst_end, as.Date(max(dst_days_in_year$days), 
                               origin = "1970-01-01")+1)
}
data[, dst:='N']
data[dst(localtargetTime)==TRUE, dst:='Y']
data[date(targetTime)%in%dst_start, dst:='Start']
data[date(targetTime)%in%dst_end, dst:='End']

data[, dst:=ordered(relevel(droplevels(as.factor(dst)), ref='N'))]
rm(days_in_year, dst_days_in_year)

2.3.3 Bank holidays and types of day

We read in a csv file of bank holidays, which is merged with the main datatable.

all_holidays <- read_csv("~/RProjects/reactforecasting/data/raw/holidays/all_holidays.csv",
                         col_types = cols(
                           holiday = col_character(),
                           date = col_date("%d/%m/%Y")))
data[, localDate:=date(localtargetTime)]
data <- merge(data, all_holidays, all.x = TRUE, 
              by.x = "localDate", by.y = "date")
data[is.na(holiday), holiday:='N']
data[,noyeardates := format(localDate, format = "%d/%m")]
rm(all_holidays)

We carry out some manual manipulation of holidays. This is to account for cases where, for example, Christmas Day occurs at the weekend such that the Christmas Day bank holiday occurs on the 27th of December. This also allows us to group together some holiday types which we expect to have very similar effects on net demand, or to eliminate some Scottish bank holidays which are not expected to have much impact.

data[, raw_holiday:=holiday]
data[holiday=="St Andrew's Day", holiday:='N']
data[holiday=="Christmas Day" & noyeardates!="25/12", 
     holiday:='Christmas Substitute']
data[holiday=="Boxing Day" & noyeardates!="26/12", 
     holiday:='Christmas Substitute']
data[holiday=="New Year's Day" & noyeardates!="01/01", 
     holiday:='Christmas Substitute']

#Group some holidays together

data[noyeardates=="24/12", holiday:='Christmas Eve']
data[noyeardates=="25/12", holiday:='Christmas Day']
data[noyeardates=="26/12", holiday:='Boxing Day']
data[noyeardates=="31/12", holiday:='New Years Eve']
data[noyeardates=="01/01", holiday:="New Year's Day"]
data[holiday%in%c("Early May bank holiday", "Spring bank holiday",
                  "Summer bank holiday"),
     holiday:='Spring/Summer Holiday']
data[holiday%in%c("Summer bank holiday (Scotland)"),
     holiday:='N']
data[,holiday:=relevel(droplevels(as.factor(holiday)), ref='N')]
data[,holiday:=ordered(holiday)]

Factor variables for day types and holidays are processed. This results in four types of categorical variables:

  1. Distinguishes between all day types, and specific types of holiday.
  2. Distinguishes between all day types, and holidays in general.
  3. As per 1 but with Tuesday through Thursday grouped together.
  4. As per 2 but with Tuesday through Thursday grouped.
data <- holiday_day_types(data)
dow_Rph_levels <- c("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", "Hol")
dow_RphG_levels <- c("Mon", "TueThu", "Fri", "Sat", "Sun", "Hol")
dow_RpH_levels <- c("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun",
                    "Christmas Eve", "Christmas Day", "Boxing Day", 
                    "New Years Eve", "New Year's Day", "Christmas Substitute", 
                    "Good Friday", "Easter Monday",  "Spring/Summer Holiday")
dow_RpHG_levels <- c("Mon", "TueThu", "Fri", "Sat", "Sun",
                     "Christmas Eve", "Christmas Day", "Boxing Day",
                     "New Years Eve", "New Year's Day", "Christmas Substitute",
                     "Good Friday", "Easter Monday","Spring/Summer Holiday")
data[, dow_Rph:=ordered(dow_Rph, levels=dow_Rph_levels)]
data[, dow_RphG:=ordered(dow_RphG, levels=dow_RphG_levels)]
data[, dow_RpH:=ordered(dow_RpH, levels=dow_RpH_levels)]
data[, dow_RpHG:=ordered(dow_RpHG, levels=dow_RpHG_levels)]

We also explicitly label weekdays and weekends, and include a second variable which distinguishes between weekdays, weekends and holidays.

data[, weekend := 'Weekday']
data[dow %in% c('Sat', 'Sun'), weekend := 'Weekend']

data[, hol_weekend:= weekend]
data[holiday != "N", hol_weekend := 'Holiday']
data[, weekend:= droplevels(as.factor(weekend))]
data[, hol_weekend:= ordered(relevel(droplevels(as.factor(hol_weekend)), 
                                     ref='Holiday'))]
data[,holiday_binary:=F]
data[holiday!="N",holiday_binary:=T]

2.3.4 School holidays

We label the points over the Christmas holidays, which we define as the period from 20th December to 3rd January, and assign an integer to each one.

data[, christmas:=F]
data[as.Date(format(data$targetTime, format="%d/%m"),
             "%d/%m")>as.Date("19/12", "%d/%m"), christmas:=T]
data[as.Date(format(data$targetTime, format="%d/%m"),
             "%d/%m")<as.Date("08/01", "%d/%m"), christmas:=T]

# Create a unique table of dates over Christmas (ignoring the years)
# Shuffle these so that December 20th is first.
christmasdates <- data.table()
christmasdates$noyeardates <- data[christmas==T,
                                   unique(format(localDate, format = "%d/%m"))]
christmasdates <- rbind(christmasdates[8:19], christmasdates[1:7])
christmasdates[,christmasdoy:=seq(length.out = .N)]

# Merge these back in to the main data table.
data <- merge(data, christmasdates, all.x = TRUE, all.y = FALSE, 
              by = "noyeardates")
data <- data[order(targetTime)]
data[is.na(christmasdoy)==TRUE,christmasdoy:=0]
rm(christmasdates)

A similar approach is taken for other school holidays, based on a publicly sourced set of historic school holidays (available from https://data.gov.uk/dataset/aad853ad-b0d8-47a9-8491-a630b703b8a9/school-term-times).

school_dates <- read_csv("~/RProjects/reactforecasting/data/raw/holidays/school_hols.csv",
                         col_types = cols(
                           holiday = col_character(),
                           year = col_double(),
                           start = col_date("%d/%m/%Y"),
                           end = col_date("%d/%m/%Y")))
school_dates <- setDT(school_dates)
school_dates$start <- as.Date(school_dates$start)
school_dates$end <- as.Date(school_dates$end)
holidaydates <- list()
for(holiday_loop in unique(school_dates$holiday)){
  holidaydates[[holiday_loop]] <- list()
  column <- paste0(holiday_loop, "doy")
  for(year_loop in as.character(seq(from=2014, to=2019))){
    start = school_dates[(year == year_loop) & (holiday == holiday_loop), 
                         .(start)][[1,1]]
    end = school_dates[(year == year_loop) & (holiday == holiday_loop), 
                       "end"][[1,1]]
    holidaydates[[holiday_loop]][[year_loop]] <- data.table()
    holidaydates[[holiday_loop]][[year_loop]]$localDate <- seq(start-1,
                                                               end, by="days")
    holidaydates[[holiday_loop]][[year_loop]][,
                                              (column):=as.numeric(
                                                seq(from=0, to=1,
                                                    length.out = .N))]
  }
  holidaydates[[holiday_loop]] <- rbindlist(holidaydates[[holiday_loop]])
  holidaydates[[holiday_loop]] <- holidaydates[[holiday_loop]][(column)>0]
  data <- merge(data, holidaydates[[holiday_loop]], all.x = TRUE, all.y = FALSE,
              by = "localDate")
  data[,(paste0(column, "bin")):=F]
}
data[is.na(easterdoy)==TRUE,easterdoy:=0]
data[is.na(springdoy)==TRUE,springdoy:=0]
data[is.na(maydoy)==TRUE,maydoy:=0]
data[is.na(summerdoy)==TRUE,summerdoy:=0]
data[is.na(autumndoy)==TRUE,autumndoy:=0]
data[,school_hols:="N"]
data[christmasdoy>0,school_hols:="Christmas"]
data[easterdoy>0 & hol_weekend == "Weekday",school_hols:="Easter"]
data[springdoy>0 & hol_weekend == "Weekday",school_hols:="Spring"]
data[maydoy>0 & hol_weekend == "Weekday",school_hols:="May"]
data[summerdoy>0 & hol_weekend == "Weekday",school_hols:="Summer"]
data[autumndoy>0 & hol_weekend == "Weekday",school_hols:="Autumn"]
data[, school_hols:=relevel(droplevels(as.factor(school_hols)), ref='N')]
data[, school_hols_ord:=ordered(school_hols)]
rm(school_dates, holidaydates)

2.4 Moving-average set-up

This is the first significant difference from the day-ahead forecasts. In this workbook, we use the same model to produce predictions for the entire range of lead-times. For each lead-time, we will use different lagged values within the model. For example, in each of these-short term predictions, we can use the most recently available values of temperature, or demand, or even the “residual” of the deterministic forecast.

data[, leadTime:=as.numeric(leadTime)]
data[,metLeadTime:=leadTime]
fit_data = c()
for(i in seq(0.5, 12, by=0.5)){
  data_lag = copy(data)
  data_lag[, leadTime:=i]
  data_lag[, 
           net_demand_lag := shift(.SD, n=i*2, type='lag'),
           .SDcols=c("net_demand")]
  data_lag <- data_lag[leadTime <= metLeadTime-6]
  data_lag <- data_lag[, .SD[metLeadTime==min(metLeadTime)], 
                       by = .(targetTime, leadTime)]
  nrow(data_lag)
  data_lag[, mean_2T_24 := shift(.SD, n=max_lead_time_days,
                                 type='lag',),
           .SDcols="mean_2T", by=clock_hour_factor]
  data_lag$mean_2T_lag <- shift(data_lag$mean_2T, 
                                n=i*2,
                                type='lag',)
  fit_data = rbind(fit_data, data_lag)
}
fit_data[is.na(net_demand_lag),missing_data:=TRUE]
fit_data[, metIssueTime:=issueTime]
fit_data[, issueTime:=targetTime - minutes(60*leadTime)]
fit_data[, leadTime_factor:=as.factor(leadTime)]
fit_data[, 
         net_demand_lag := shift(.SD, n=leadTime*2, type='lag'),
         .SDcols=c("net_demand"), by=leadTime]
rm(data, data_lag)

2.5 Weights

We include an option for exponentially declining weights. By default, we set the “forget factor” such that the weights for all data points are 100%.

forget_factor = 1
fit_data[, weights:=forget_factor^(1-t)]
rm(forget_factor)

2.6 Cross-fold validation

We set up k-fold Cross-Validation labels. We create three training cross-folds, repeating with a period of one week. These span the data from 2014 to 2018. The test-data cross-fold is all of the data from 2019. Note that all of the predictions from each lead time must all belong to the same cross-fold. The three training cross-folds each contain about 28% of the data, and the test cross-fold contains about 17% of the data.

fit_data <- fit_data[order(targetTime)]
dt_issues <- data.table(issueTime = unique(fit_data$issueTime))
dt_issues <- na.omit(dt_issues)
dt_issues[,kfold:=as.character(rep(1:3, each=48*7, length.out=.N))]
dt_issues[issueTime>="2018-12-31",kfold:="Test"]

fit_data <- merge(fit_data, dt_issues, 
                  all.x = TRUE, all.y = TRUE, 
                  by = "issueTime")

xtabs(~kfold, data=fit_data) %>% prop.table() %>% round(2)
## kfold
##    1    2    3 Test 
## 0.28 0.28 0.28 0.17
rm(dt_issues)

We can also check that each fold contains a good representation of data through different months…

xtabs(~kfold+month, data=fit_data[leadTime==0.5])
##       month
## kfold  January February March April  May June July August September October November December
##   1       2435     2269  2527  2352 2511 2352 2529   2463      2400    2485     2400     2509
##   2       2544     2208  2492  2415 2433 2448 2448   2496      2352    2548     2365     2483
##   3       2448     2291  2411  2433 2496 2400 2463   2481      2448    2417     2435     2401
##   Test    1488     1344  1486  1440 1488 1440 1488   1488      1440    1490     1440     1535

… and by type of day.

xtabs(~kfold+dow_Rph, data=fit_data[leadTime==0.5])
##       dow_Rph
## kfold   Mon  Tue  Wed  Thu  Fri  Sat  Sun  Hol
##   1    3744 4080 4093 4080 4032 4128 4080  995
##   2    3840 4032 4128 4080 4080 4128 4128  816
##   3    3648 4080 4115 4128 3984 4128 4128  913
##   Test 2304 2400 2400 2448 2448 2496 2496  575

This only considers the number of observations of demand (i.e. for a single lead time). Clearly, there is less data available in general for specific types of bank holiday – this is part of the rationale for grouping these together where appropriate.

2.7 Missing data

We exclude data that has missing values or clearly erroneous temperatures, as in the day-ahead forecasts. We also consider whether the lagged values will have missing data.

fit_data[,missing_data:=FALSE]
fit_data[mean_2T<260,missing_data:=TRUE]
fit_data[is.na(net_demand),missing_data:=TRUE] 
fit_data[is.na(mean_2T),missing_data:=TRUE]
fit_data[is.na(net_demand_smooth_week),missing_data:=TRUE]
fit_data <- fit_data[order(targetTime)]
fit_data[mean_2T_24<260,missing_data:=TRUE]
fit_data[mean_2T_lag<260,missing_data:=TRUE]
fit_data <- fit_data[order(targetTime, -leadTime)]
fit_data[,missing_lag:=F]
fit_data[, missing_lag:= shift(.SD, n=leadTime*2, type='lag'),
         .SDcols="missing_data", by=leadTime]
fit_data[missing_lag==T, missing_data:=T]

After setting up the lagged values, the data-set is much bigger. There are ~100,000 different net demand observations, and for each of those we are now creating forecasts at 24 lead times, for a total of 2.4 million rows. For memory and computational purposes, we set the model to consider only a subset of these lead-times when training. We create new objects fit_data_all_leads and pred_all_leads for the full set of lead times, and then save these as .rds objects (to save memory).

fit_data_trainleads <- c(0.5, 1, 2, 3, 4, 6, 9, 12)
fit_data_all_leads <- copy(fit_data)
fit_data <- fit_data[leadTime %in% fit_data_trainleads]
missing_index <- which(fit_data$missing_data == F)
missing_index_all_leads <- which(fit_data_all_leads$missing_data == F)
saveRDS(fit_data_all_leads, 
        "~/RProjects/reactforecasting/data/output/R_objects/ShortTerm_NetDemand/fit_data_all_leads.rds")
if(!file.exists(
    paste0("~/RProjects/reactforecasting/data/output/R_objects/",
           "ShortTerm_NetDemand/qgam_update-model.rds"))){
  pred_all_leads <- list()
  pred_all_leads$mqr_pred <- data.table(matrix(as.numeric(NA),
                                               ncol = length(c(0.025,
                                                               seq(0.05, 0.95, 0.05),
                                                               0.975)), 
                                               nrow = nrow(fit_data_all_leads)))
  colnames(pred_all_leads$mqr_pred) <- paste0("q",100*c(0.025,
                                                        seq(0.05, 0.95, 0.05),
                                                        0.975))
  class(pred_all_leads$mqr_pred) <- c("MultiQR",class(pred_all_leads$mqr_pred))
  
  pred_all_leads$gam_pred <- numeric(length=nrow(fit_data_all_leads))
  pred_all_leads$gam_sd <- numeric(length=nrow(fit_data_all_leads))
  
  saveRDS(pred_all_leads, 
          "~/RProjects/reactforecasting/data/output/R_objects/ShortTerm_NetDemand/pred_all_leads.rds")
  rm(pred_all_leads)
}
rm(fit_data_all_leads)

3 Model fitting

We use multiple quantile regression with GAMs to produce probabilistic forecasts. The approach is explained in the covering report. We use the functionality within the ProbCast package, but define some custom functions which fit models in stages. For these short-term forecasts, we use a custom “moving-average” approach, which adjusts the expected value of the forecast based on the extent to which recent observations differed from their expected value.

3.1 Model formulas

We define forms for only one model, which is very similar to the most successful of the day-ahead forecast models. The conditional expectation of the model is fitted in four stages – this is similar to removing seasonal patterns from time-series data and aims to account for multi-collinearity within the data. The main difference in comparison to the day-ahead model is that more recent observations are available at the short timescales (e.g. we can use the demand from the same time on the previous day). We also add an extra stage to consider the lead-time lags and moving average elements.

form <- ~ 
  s(clock_hour_local, k=30,bs="cr") +
  s(mean_2T,k=3,bs="cr")
          
form_res <- ~ 
  doy_s + doy_c + doy_s2 + doy_c2 +  # Fourier annual seasonality
  dow_RpH +
  school_hols +
  s(christmasdoy,k=5,bs="cr") +
  s(summerdoy,k=5,bs="cr") +
  s(clock_hour_local, by=dow_RpH, k=15,bs="cr") + 
  ti(clock_hour_local, doy, by=hol_weekend, bs=c('cr', 'cr'), k=c(15, 20)) +
  s(t, by=weekend, k=3, bs='cr') +
  s(net_demand_smooth_week, by=weekend, k=3, bs='cr') 
  
form_res2 <- ~
  doy_s + doy_c + doy_s2 + doy_c2 +
  dow_Rph +
  school_hols +
  ti(clock_hour_local, doy, by=hol_weekend, bs=c('cr', 'cr'),
     k=c(10, 15)) +
  s(clock_hour_local, by=weekend, k=30,bs="cr") +
  s(clock_hour, by=dst, k=7,bs="cr") +
  ti(clock_hour_local, t, by=weekend, bs=c('cr', 'cr'), k=c(10,3)) +
  s(net_demand_smooth_week, by=weekend, k=3, bs='cr')+
  s(net_demand_smooth_day, by=weekend, k=3, bs='cr') +
  s(mean_TP,k=10, bs="cr") +
  s(mean_2T,k=3,bs="cr") +
  ti(mean_2T, mean_WindSpd10, bs=c('cr', 'cr'),
     k=c(3, 3)) +
  s(mean_SSRD, by=EMBEDDED_SOLAR_CAPACITY, k=3,bs="cr") +
  s(mean_WindSpd100, by=EMBEDDED_WIND_CAPACITY, k=3,bs="cr") +
  ti(mean_SSRD, clock_hour_local, by=EMBEDDED_SOLAR_CAPACITY, 
     k=c(3, 5), bs="cr") +
  ti(mean_SSRD, doy, by=EMBEDDED_SOLAR_CAPACITY, k=c(3, 5), bs="cr") +
  ti(mean_WindSpd100, clock_hour_local, by=EMBEDDED_WIND_CAPACITY, 
     k=c(3, 15), bs="cr") + 
  ti(mean_2T, clock_hour_local, k=c(3, 10), bs="cr") +
  s(mean_2T_24, k=3)

form_res3 <- ~ 
  te(net_demand_r3_lag, leadTime, k=c(3, 3), 
                  bs=c('cr', 'cr')) + 
  ti(net_demand_r3_lag, clock_hour, k=c(3, 8), 
                   bs=c('cr', 'cr')) +
  s(mean_2T_lag, k=3)

form_list = c(form, form_res, form_res2, form_res3)

Uncertainty in the forecasts is modeled by fitting a GAM to the absolute value of the residuals. This GAM includes terms that we expect may be associated with increased uncertainty in the forecasts. For example, we include terms for the standard deviations across the country of some of the meteorological forecasts. The quantile regression is then a linear combination of the forecasted absolute value of the residual (labeled gam_sd) with some other variables such as the time-of-day and the month. In addition, we include the lead time of the forecast as a feature in both the absolute residual GAM, and the quantile regression. Within the quantile regression, we include this as a non-linear spline term.

form_res_sq <- ~ 
  s(gam_pred, k=4, bs='cr')  +
  doy_s + doy_c +
  dow_RphG +
  school_hols + 
  s(clock_hour_local, by=weekend, k=20,bs="cr") +
  ti(clock_hour_local, doy, by=hol_weekend, bs=c('cr', 'cr'), k=c(7, 7)) +
  ti(clock_hour_local, t, bs=c('cr', 'cr'), k=c(10, 3)) +
  s(t, k=3, bs='cr')  +
  s(net_demand_sd_week, by=weekend, k=4, bs='cr') +
  s(sd_2T,k=4,bs="cr") +
  I(sd_SSRD*EMBEDDED_SOLAR_CAPACITY) +
  I(sd_WindSpd100*EMBEDDED_WIND_CAPACITY) +
  s(leadTime, k=3, bs='cs')

form_qr <- ~clock_hour_local_factor + month + gam_sd  + 
    I(sd_SSRD*EMBEDDED_SOLAR_CAPACITY) +
  I(sd_WindSpd100*EMBEDDED_WIND_CAPACITY) +
  bs(leadTime, df=5)

Note that, to an extent, this approach is a compromise, and it might actually be better to directly model GAMs for each quantile, for example using the qgam package. However, this is currently much more computationally expensive.

Finally, we also explore a simple benchmark model (Tao’s Vanilla Benchmark). We include terms to account for embedded generation within this benchmark model.

benchmark <-  ~
  t  +
  month + 
  dow_RpH  +
  clock_hour_local_factor + 
  clock_hour_local_factor:dow_RpH  +
  mean_2T + 
  I(mean_2T^2)+
  I(mean_2T^3)+
  mean_2T:month + 
  I(mean_2T^2):month +
  I(mean_2T^3):month +
  mean_2T:clock_hour_local_factor + 
  I(mean_2T^2):clock_hour_local_factor +
  I(mean_2T^3):clock_hour_local_factor +
  mean_WindSpd100 +
  I(mean_SSRD * EMBEDDED_SOLAR_CAPACITY / 1e9)

bench_res_sq <- ~
  gam_pred + 
  I(gam_pred^2) +
  clock_hour_local_factor +
  dow_RphG +
  mean_2T +
  mean_WindSpd100 +
  I(mean_SSRD * EMBEDDED_SOLAR_CAPACITY / 1e9)

bench_qr <- ~clock_hour_local_factor + month+ gam_sd +
    I(sd_SSRD*EMBEDDED_SOLAR_CAPACITY) +
  I(sd_WindSpd100*EMBEDDED_WIND_CAPACITY) +
  leadTime

We assemble all these models forms within a list.

list_of_forms <- list(model = form_list,
                      bench = c(benchmark))
list_of_res_sq_forms <- list(bench = bench_res_sq,
                             model = form_res_sq)
list_of_qr_forms <- list(bench = bench_qr,
                             model = form_qr)

3.2 Model fitting

The quantile regression GAMs are fitted to the data using the custom function, for evenly spaced quantiles between the 5th and 95th, as well as the 2.5th and 97.5th.

The models are initially fitted with all of the 2019 data withheld as test data. After this initial fitting, the models are retrained and forecasts are reissued on a rolling monthly basis. We also generate predictions on the extended lead-time data. Models are saved as .rds objects to save memory.

Model fitting is reasonably slow, and can take several hours.

for (name in names(list_of_forms)){
  if(file.exists(
    paste0("~/RProjects/reactforecasting/data/output/R_objects/",
           "ShortTerm_NetDemand/qgam_update-",name,".rds"))){
    print(paste0("Model '", name,"' already fitted."))
    
  }else{
    start_time <- Sys.time()
    print(paste0("Model: ", name))
    selected_list_of_forms <- list_of_forms[[name]]
    selected_res_sq_form <- list_of_res_sq_forms[[name]]
    selected_qr_form <- list_of_qr_forms[[name]]
    qgam <- qreg_gam_ma(data = fit_data,
                        formula_list = selected_list_of_forms,
                        response = "net_demand",
                        model_r_sq = T,
                        formula_r_sq = selected_res_sq_form,
                        kfold = "kfold",
                        exclude_train = 'missing_data',
                        formula_qr = selected_qr_form,
                        weights = weights,
                        quantiles = c(0.025, seq(0.05, 0.95, 0.05),
                                      0.975)
                        )
  
    qgam_update <- copy(qgam)
  
    saveRDS(qgam, 
            paste0("~/RProjects/reactforecasting/data/output/R_objects/",
            "ShortTerm_NetDemand/qgam-",name,".rds"))
    
    rm(qgam)
    test_issues <- fit_data[kfold=="Test",(unique(issueTime))]
    weekly_test_issues <- (split(split(test_issues, test_issues),
                                 ceiling(seq_along(test_issues)/(14*48))))
    for(week in 1:(length(weekly_test_issues)-1)){
      week_index = which(fit_data$issueTime%in%weekly_test_issues[[week]]
                         &fit_data$kfold=="Test" )
      next_week_index = which(fit_data$issueTime%in%weekly_test_issues[[week+1]]
                              &fit_data$kfold=="Test" )
      
      previous_day <- fit_data[week_index,min(issueTime)] - days(1)
      
      last_day <- fit_data[next_week_index,max(issueTime)]
      
      fit_data_week <- fit_data[issueTime>=previous_day &
                                  issueTime <= last_day]
      
      week_index_sub = which(fit_data_week$issueTime%in%weekly_test_issues[[week]]
                         &fit_data_week$kfold=="Test" )
      next_week_index_sub = which(fit_data_week$issueTime%in%weekly_test_issues[[week+1]]
                              &fit_data_week$kfold=="Test" )
      
      print(paste0("Updating forecast for 4-week block ",
                   week," of ",length(weekly_test_issues)-1))
    
      ## Update "Test" model with previous block's data
      qgam_update <- qreg_gam_ma.update(qgam_update, data=fit_data_week, 
                                        update_index = week_index_sub)
    
      ## Make new predictions for current block
      new_preds <- predict(qgam_update,data = fit_data_week, 
                           predict_index = next_week_index_sub, predict_quantiles = T)
    
      ## Update test predictions for current block
      qgam_update$mqr_pred[next_week_index,] <- new_preds$mqr_pred
      qgam_update$models$gam_pred$gam_pred[next_week_index] <- new_preds$gam_pred
      qgam_update$models$gam_pred$gam_sd[next_week_index] <- new_preds$gam_sd
    
      rm(new_preds)
      if(name=="model"){
        print("All lead times.")
        fit_data_all_leads <- readRDS("~/RProjects/reactforecasting/data/output/R_objects/ShortTerm_NetDemand/fit_data_all_leads.rds")
        fit_data_all_week <- fit_data_all_leads[issueTime>=previous_day &
                                                  issueTime <= last_day]
        next_week_index_all_sub = which(fit_data_all_week$issueTime 
                                        %in% weekly_test_issues[[week+1]]
                                        & fit_data_all_week$kfold=="Test" )
        next_week_index_all = which(fit_data_all_leads$issueTime 
                                    %in% weekly_test_issues[[week+1]]
                                    & fit_data_all_leads$kfold=="Test" )
        all_new_preds <- predict(qgam_update,
                                 data = fit_data_all_week, 
                                 predict_index = next_week_index_all_sub, 
                                 predict_quantiles = T)
        rm(fit_data_all_leads)
        
        pred_all_leads <- readRDS("~/RProjects/reactforecasting/data/output/R_objects/ShortTerm_NetDemand/pred_all_leads.rds")
        pred_all_leads$mqr_pred[next_week_index_all] <- all_new_preds$mqr_pred
        pred_all_leads$gam_pred[next_week_index_all] <- all_new_preds$gam_pred
        pred_all_leads$gam_sd[next_week_index_all] <- all_new_preds$gam_sd
        rm(all_new_preds)
        saveRDS(pred_all_leads, 
                "~/RProjects/reactforecasting/data/output/R_objects/ShortTerm_NetDemand/pred_all_leads.rds")
        rm(pred_all_leads)
      }
    }
    
    saveRDS(qgam_update, 
            paste0("~/RProjects/reactforecasting/data/output/R_objects/",
            "ShortTerm_NetDemand/qgam_update-",name,".rds"))
    
    rm(qgam_update)
    rm(test_issues)
    rm(weekly_test_issues)
    rm(week_data)
    rm(next_week_data)
    rm(selected_list_of_forms)
    rm(selected_res_sq_form)
    print(paste0("Model fitting time for ", name,": ", format(Sys.time() - start_time)))
  }
}

4 Evaluation

We then evaluate the model and compare it to the benchmark. Note that as in other work packages and workbooks, we are evaluating model performance on a hold-out testing data set.

4.1 Benchmark comparison

The comparison with the benchmark model is based on the extent to which the model maximises sharpness subject to calibration.

list_of_forms <- list(bench = c(benchmark), 
                      model_a = form_list_a, 
                      model_b = form_list_b, 
                      model_c = form_list_c,
                      model_d = form_list_d)
list_of_res_sq_forms <- list(bench = bench_res_sq,
                             model_a = form_res_sq, 
                             model_b = form_res_sq,
                             model_c = form_res_sq,
                             model_d = form_res_sq)
rm(form_list_a, form_list_b, form_list_c, form_list_d)

4.1.1 Mean Absolute Error

We initially examine the mean absolute error (MAE) of the expected value forecast.

mae_data <- data.table(model = character(), 
                       mean = numeric(),
                       median = numeric())
for(name in names(list_of_forms)){
  qgam_update_name<-readRDS(paste0("~/RProjects/reactforecasting/data/output",
                                   "/R_objects/ShortTerm_NetDemand/qgam_update-",
                                   name,".rds"))
  gam_pred <- qgam_update_name$models$gam_pred$gam_pred[missing_index]
  gam_resid <- fit_data$net_demand[missing_index] - gam_pred
  mean_absolute_error <- mean(abs(gam_resid), na.rm=T)
  median_absolute_error <- median(abs(gam_resid), na.rm=T)
  mae_data <- add_row(mae_data, model = name, 
                      mean = mean_absolute_error,
                      median = median_absolute_error)
  rm(qgam_update_name)
}
mae_data  %>% mutate_at(vars(mean, median), funs(round(., 3)))
mae_data[, model:=as.factor(model)]

We can see that the performance of the model is much better on this metric than the benchmark model.

mae_data %>% pivot_longer(cols = c(mean, median),
                          names_to = "metric", values_to = "absolute_error")%>%
  ggplot(aes(x=model, y=absolute_error, color=model, fill=model)) +
  geom_col() + 
  coord_flip(expand = T) + 
  scale_x_discrete("Model", limits = rev) + facet_wrap(~str_to_title(metric)) +
  scale_y_continuous("Absolute error (MW)") +
  geom_text(aes(label = round(absolute_error,2)), hjust=1, colour = "black")
rm(mae_data)

The MAE of the expected value/deterministic parts of the model is comparable with the performance of NGESO’s existing demand forecasts, although our understanding is that NGESO’s current deterministic forecasts achieve slightly lower MAE. As with the day ahead forecasts, we have not accounted for the alteration to forecasts due to national events, such as sports and TV events and eclipses. NGESO manually adjusts its forecasts based one experience to deal with these events, but we have not made any sort of similar adjustment within our model.

We have also calculated the median absolute error of each model. These values are lower than the MAE for every model, which suggests that the distribution of errors is heavily skewed. Large errors due to “special” days and national events could be a possible cause of such a skew in the distribution of errors.

In the rest of our evaluation, we will consider the full probabilistic forecast. This means we no longer think about forecast error but instead about the uncertainty within the forecast.

4.1.2 Calibration

We calculate calibration (and sharpness, and pinball loss) metrics for the model and the benchmark.

seasons_list <- list("Spring" = c("March", "April", "May"),
                     "Summer" = c("June", "July", "August"), 
                     "Autumn" = c("September", "October", "November"),
                     "Winter" = c("December", "January", "February"))
reliability_combined <- c()
pinball_combined <- c()
sharpness_combined <- c()
reliability_combined_subsets <- c()
pinball_combined_subset <- c()
sharpness_combined_subset <- c()
for(name in names(list_of_forms)){
  qgam_update_name<-readRDS(paste0("~/RProjects/reactforecasting/data/output",
                                   "/R_objects/ShortTerm_NetDemand/qgam_update-",
                                   name,".rds"))
  
  # Reliability
  new_reliability <- reliability(qgam_update_name$mqr_pred[missing_index], 
                                 realisations = fit_data$net_demand[missing_index],
                                 plot.it = F)
  new_reliability$model <- name
  reliability_combined <- rbind(reliability_combined, new_reliability)
  
  # Pinball
  new_pinball <- pinball(qgam_update_name$mqr_pred[missing_index], 
                         realisations = fit_data$net_demand[missing_index], 
                         plot.it = F)
  new_pinball$model <- name
  pinball_combined <- rbind(pinball_combined, new_pinball)
  
  # Sharpness
  new_sharpness <- sharpness(qgam_update_name$mqr_pred[missing_index])
  new_sharpness$model <- name
  sharpness_combined <- rbind(sharpness_combined, new_sharpness)
  
  hour_blocks <- c()
  for(hour in seq(0, 20, 4)){
    hour_blocks <- c(hour_blocks, paste0(hour,"-",hour+4))
    for(season in names(seasons_list)){
      months <- seasons_list[[season]]
      level_index <- which(between(fit_data$clock_hour_local, hour, hour+3.99) 
                           & fit_data$month%in%months
                           & fit_data$missing_data == F)
      
      # Reliability
      new_reliability_subsets <- reliability(qgam_update_name$mqr_pred[level_index],
                                             realisations = fit_data$net_demand[level_index],
                                             plot.it = F)
      new_reliability_subsets$model <- name
      new_reliability_subsets$hours <- paste0(hour,"-",hour+4)
      new_reliability_subsets$months <- season
      reliability_combined_subsets <- rbind(reliability_combined_subsets, new_reliability_subsets)
      
      # Pinball
      new_pinball_subsets <- pinball(qgam_update_name$mqr_pred[level_index],
                                     realisations = fit_data$net_demand[level_index], 
                                     plot.it = F)
      new_pinball_subsets$model <- name
      new_pinball_subsets$hours <- paste0(hour,"-",hour+4)
      new_pinball_subsets$months <- season
      pinball_combined_subset <- rbind(pinball_combined_subset, new_pinball_subsets)
      
      # Sharpness
      new_sharpness_subsets <- sharpness(qgam_update_name$mqr_pred[level_index])
      new_sharpness_subsets$model <- name
      new_sharpness_subsets$hours <- paste0(hour,"-",hour+4)
      new_sharpness_subsets$months <- season
      sharpness_combined_subset <- rbind(sharpness_combined_subset, new_sharpness_subsets)
    }
  }
}
rm(qgam_update_name)
reliability_combined <- setDT(reliability_combined)
pinball_combined <- setDT(pinball_combined)
sharpness_combined <- setDT(sharpness_combined)
reliability_combined_subsets <- setDT(reliability_combined_subsets)
reliability_combined_subsets[, hours:=ordered(hours, hour_blocks)]
reliability_combined_subsets[, months:=ordered(months, names(seasons_list))]
pinball_combined_subset <- setDT(pinball_combined_subset)
pinball_combined_subset[, hours:=ordered(hours, hour_blocks)]
pinball_combined_subset[, months:=ordered(months, names(seasons_list))]
sharpness_combined_subset <- setDT(sharpness_combined_subset)
sharpness_combined_subset[, hours:=ordered(hours, hour_blocks)]
sharpness_combined_subset[, months:=ordered(months, names(seasons_list))]

Calibration of all predictions

A QQ plot is used to examine the calibration of the models. The model is well calibrated when looking at all of the predictions, more so than the benchmark model.

ggplotly(ggplot(setDT(reliability_combined),
       aes(x=Nominal, y=Empirical, group=model,
           text=paste0('Model: ', model,
                       '<br>Nominal: ', paste0(round(Nominal*100,2),"%"),
           '<br>Empirical: ', paste0(round(Empirical*100,2),"%")
           ))) + 
  geom_line(aes(color=model)) +
  geom_point(aes(color=model), size=1) +
  geom_abline(intercept = 0, slope = 1, color="black", linetype="dashed") +
  scale_x_continuous(labels = percent) + scale_y_continuous(labels = percent),
  tooltip=c("text"))

Calibration by season and time-of-day

However, partitioning the forecasts into groups of months and groups of hours (4-hour blocks) shows that the Benchmark model is very poorly calibrated for some combinations of time-of-day and time-of-year (e.g. 8am-noon in Spring).

p <- ggplot(reliability_combined_subsets,
            aes(x=Nominal, y=Empirical, group=model,
                text=paste0('Model: ', model, 
                            '<br>Season: ',months,
                            '<br>Hour-block: ',hours,
                            '<br>Nominal: ', paste0(round(Nominal*100,2),"%"),
                            '<br>Empirical: ', paste0(round(Empirical*100,2),"%")))) +
  geom_line(aes(color=model)) +
  geom_point(aes(color=model), size=2/3) +
  geom_abline(intercept = 0, slope = 1, color="black", linetype="dashed") +
  scale_x_continuous(labels = percent) + scale_y_continuous(labels = percent) +
  facet_grid(vars(hours), vars(months))
ggplotly(p, tooltip=c("text")) %>% layout_ggplotly(y=-0.07)

The calibration of the model will be examined in more detail later.

4.1.3 Sharpness

Sharpness of all predictions

As expected (based on the MAE), the model has narrower interval widths than the benchmark.

ggplot(setDT(sharpness_combined), 
       aes(x=Interval, y=Width, group=model)) +
  geom_line(aes(color=model)) +
  geom_point(aes(color=model), size=1) +
  scale_x_continuous(labels = percent) + 
  scale_y_continuous("Width (MW)", labels=comma)

Sharpness by season and time-of-day

This impact is particularly pronounced for some combinations of season and time-of-day.

ggplot(sharpness_combined_subset,
       aes(x=Interval, y=Width, group=model)) +
  geom_line(aes(color=model)) +
  geom_point(aes(color=model), size=1/2) +
  facet_grid(vars(hours), vars(months))+
  scale_x_continuous(labels = percent) + 
  scale_y_continuous("Width (MW)", labels=comma)

4.1.4 Pinball losses

Pinball losses for all predictions

These observations about calibration and sharpness are confirmed by the pinball losses, where the model has much lower pinball losses than the benchmark.

ggplot(setDT(pinball_combined),
       aes(x=Quantile, y=Loss, group=model)) +
  geom_line(aes(color=model)) +
  geom_point(aes(color=model), size=1) +
  scale_x_continuous(labels = percent)

Pinball losses by season and time-of-day

And again, this impact is particularly pronounced for some combinations of season and time-of-day.

ggplot(pinball_combined_subset,
       aes(x=Quantile, y=Loss, group=model)) +
  geom_line(aes(color=model)) +
  geom_point(aes(color=model), size=1/2) +
  facet_grid(vars(hours), vars(months))+
  scale_x_continuous(labels = percent)

4.1.5 Model selection

Based on these metrics, we conclude that model’s sharpness is justified by it being well calibrated. Therefore, we select this model for production of forecasts.

name <- "model"
qgam_update <- readRDS(paste0("~/RProjects/reactforecasting/data/output",
                              "/R_objects/ShortTerm_NetDemand/qgam_update-",
                              name,".rds"))
fit_data$gam_pred <- qgam_update$models$gam_pred$gam_pred
fit_data$gam_resid <- fit_data$net_demand - fit_data$gam_pred
fit_data$gam_abs_resid <- abs(fit_data$gam_resid)
fit_data$gam_sd <- qgam_update$models$gam_pred$gam_sd
mqr_pred <- qgam_update$mqr_pred
realisations <- fit_data$net_demand

We also complete the predictions for the full lead-time data-set, which we will use when visualising the forecasts.

nvisible(gc())
for(fold in unique(fit_data$kfold)){
  invisible(gc())
  if(fold!="Test"){
    fit_data_all_leads <- readRDS("~/RProjects/reactforecasting/data/output/R_objects/ShortTerm_NetDemand/fit_data_all_leads.rds")
    fold_index <- which(fit_data_all_leads$kfold==fold)
    all_new_preds <- predict(qgam_update,
                             data = fit_data_all_leads, 
                             predict_index = which(fit_data_all_leads$kfold==fold), 
                             predict_quantiles = T,
                             model_name = fold)
    cols <- colnames(all_new_preds$mqr_pred)
    rm(fit_data_all_leads)
    pred_all_leads <- readRDS("~/RProjects/reactforecasting/data/output/R_objects/ShortTerm_NetDemand/pred_all_leads.rds")
    pred_all_leads$mqr_pred[fold_index, cols] <- all_new_preds$mqr_pred
    pred_all_leads$gam_pred[fold_index] <- all_new_preds$gam_pred
    pred_all_leads$gam_sd[fold_index] <- all_new_preds$gam_sd
    saveRDS(pred_all_leads, 
        "~/RProjects/reactforecasting/data/output/R_objects/ShortTerm_NetDemand/pred_all_leads.rds")
    rm(pred_all_leads)
  }
}
pred_all_leads <- readRDS("~/RProjects/reactforecasting/data/output/R_objects/ShortTerm_NetDemand/pred_all_leads.rds")
fit_data_all_leads <- readRDS("~/RProjects/reactforecasting/data/output/R_objects/ShortTerm_NetDemand/fit_data_all_leads.rds")
fit_data_all_leads$gam_pred <- pred_all_leads$gam_pred
fit_data_all_leads$gam_resid <- fit_data_all_leads$net_demand - fit_data_all_leads$gam_pred
fit_data_all_leads$gam_abs_resid <- abs(fit_data_all_leads$gam_resid)
fit_data_all_leads$gam_sd <- pred_all_leads$gam_sd
saveRDS(fit_data_all_leads, 
        "~/RProjects/reactforecasting/data/output/R_objects/ShortTerm_NetDemand/fit_data_all_leads.rds")
rm(fit_data_all_leads,pred_all_leads)

4.2 Evaluation of the model

4.2.1 Calibration

The calibration the model is explored in more detail.

Calibration of all predictions

We start with a QQ plot looking at all predictions.

ggplotly(ggplot(setDT(reliability(qgam_update$mqr_pred[missing_index], 
                         realisations = realisations[missing_index],
                         plot.it = F)),
       aes(x=Nominal, y=Empirical,
           text=paste0('Nominal: ', paste0(round(Nominal*100,2),"%"),
                       '<br>Empirical: ', paste0(round(Empirical*100,2),"%")
                       )
           )) + 
  geom_line(color="blue", group=1) +
  geom_point(color="blue", size=1) +
  geom_abline(intercept = 0, slope = 1, color="black", linetype="dashed") +
  scale_x_continuous(labels = percent) + scale_y_continuous(labels = percent),
  tooltip=c("text"))

Calibration by cross-fold

We can examine the calibration of each of the cross-folds. There is some deviation for individual cross-folds, but this is to be expected since each fold contains a smaller sample than the whole set of predictions.

ggplotly(ggplot(setDT(reliability(mqr_pred[missing_index], 
                         realisations = realisations[missing_index], 
                         kfold = fit_data$kfold[missing_index], plot.it=F)),
         aes(x=Nominal, y=Empirical, group=kfold,
             text=paste0("Cross-fold: ",kfold,
                         '<br>Nominal: ', paste0(round(Nominal*100,2),"%"),
                       '<br>Empirical: ', paste0(round(Empirical*100,2),"%")
                       ))) +
  geom_line(aes(color=kfold)) + 
  geom_point(aes(color=kfold)) + 
  geom_abline(intercept = 0, slope = 1, color="black", linetype="dashed") +
    scale_x_continuous(labels = percent) + scale_y_continuous(labels = percent),
  tooltip=c("text"))

Calibration by lead-time

We can check that the model is well-calibrated for all of the lead times that have been used in training.

reliability_combined_leadtimes <- c()
for(leadTime in unique(fit_data$leadTime)){
  level_index <- which((fit_data$missing_data == F)
                       & (fit_data$leadTime == leadTime))
  reliability_leadtime <- reliability(qgam_update$mqr_pred[level_index],
                                     realisations = fit_data$net_demand[level_index], 
                                     plot.it = F)
  reliability_leadtime$leadTime <- as.factor(leadTime)
  reliability_combined_leadtimes <- rbind(reliability_combined_leadtimes, reliability_leadtime)
}
reliability_combined_leadtimes <- setDT(reliability_combined_leadtimes)

ggplotly(ggplot(reliability_combined_leadtimes,
       aes(x=Nominal, y=Empirical, group=leadTime, 
           text=paste0("Lead time: ",leadTime,
                       '<br>Nominal: ', paste0(round(Nominal*100,2),"%"),
                       '<br>Empirical: ', paste0(round(Empirical*100,2),"%")
                       ))) +
  geom_line(aes(color=leadTime)) +
  geom_point(aes(color=leadTime), size=1) +
  geom_abline(intercept = 0, slope = 1, color="black", linetype="dashed") +
  scale_color_discrete("Lead time <br>(hours)")+
    scale_x_continuous(labels = percent) + scale_y_continuous(labels = percent),
  tooltip=c("text"))

Calibration by season and time-of-day

The partitioned QQ plots of just the selected model are presented again.

p <- ggplot(setDT(reliability_combined_subsets)[model=='model'],
         aes(x=Nominal, y=Empirical, group=months,
             text=paste0('Season: ',months,
                         '<br>Hour-block: ',hours,
                         '<br>Nominal: ', paste0(round(Nominal*100,2),"%"),
                         '<br>Empirical: ', paste0(round(Empirical*100,2),"%")))) +
  geom_line(aes(color=months)) + 
  geom_point(aes(color=months)) + 
  geom_abline(intercept = 0, slope = 1, color="black", linetype="dashed") +
  facet_wrap(vars(hours), ncol=2) +
  scale_x_continuous(labels = percent) + scale_y_continuous(labels = percent) +
  scale_color_discrete("Season")
ggplotly(p, tooltip=c("text")) %>% layout_ggplotly(y=-0.07)

Calibration by meteorological features

We can also examine the calibration within subsets of various meteorological features. We do this by binning the data into five equally sized bins, for four of the meteorological forecast variables: mean_2Tmean_WindSpd10mean_SSRD, and mean_TP.

temp_data <- rbind(cbind(reliability(mqr_pred[missing_index], 
                                     realisations = realisations[missing_index], 
                                     subsets = fit_data$mean_2T[missing_index], 
                                     plot.it = F),
                         feature = "2m temperature"),
                   cbind(reliability(mqr_pred[missing_index], 
                                     realisations = realisations[missing_index], 
                                     subsets = fit_data$mean_WindSpd10[missing_index], 
                                     plot.it = F),
                         feature = "10m wind speed"),
                   cbind(reliability(mqr_pred[missing_index], 
                                     realisations = realisations[missing_index], 
                                     subsets = fit_data$mean_SSRD[missing_index], 
                                     plot.it = F),
                         feature = "SSRD"),
                   cbind(reliability(mqr_pred[missing_index], 
                                     realisations = realisations[missing_index], 
                                     subsets = fit_data$mean_TP[missing_index], 
                                     plot.it = F),
                         feature = "Precipitation"))
                         
p <- ggplot(setDT(temp_data)[is.na(subset)==F],
            aes(x=Nominal, y=Empirical, group=as.factor(paste("Bin",subset)),
                text=paste0("Feature: ", feature,
                            "Bin: ", subset,
                            '<br>Nominal: ', paste0(round(Nominal*100,2),"%"),
                            '<br>Empirical: ', paste0(round(Empirical*100,2),"%"))
                )) +
  scale_color_discrete(name ="Bin") + theme(legend.position="top")+
  scale_x_continuous(labels = percent) + scale_y_continuous(labels = percent) +
  facet_wrap(~feature, ncol=2)
ggplotly(p, tooltip=c("text")) %>% layout_ggplotly(y=-0.07)
rm(temp_data)

We conclude that the model is sufficiently well calibrated to be used for production of forecasts, although it is possible that calibration could be improved for subsets of wind speed and irradiance.

4.2.2 Sharpness

We evaluate the sharpness of the model in terms of interval width.

Sharpness by lead time

We first look at this by lead time. As expected, the predictions get sharper with shorter lead times.

ggplotly(ggplot(setDT(reliability(qgam_update$mqr_pred[missing_index], 
                         realisations = realisations[missing_index],
                         plot.it = F)),
       aes(x=Nominal, y=Empirical,
           text=paste0('Nominal: ', paste0(round(Nominal*100,2),"%"),
                       '<br>Empirical: ', paste0(round(Empirical*100,2),"%")
                       )
           )) + 
  geom_line(color="blue", group=1) +
  geom_point(color="blue", size=1) +
  geom_abline(intercept = 0, slope = 1, color="black", linetype="dashed") +
  scale_x_continuous(labels = percent) + scale_y_continuous(labels = percent),
  tooltip=c("text"))

Sharpness by type of day

We can also look at sharpness by type of day, including the seven days of the week and holidays in general. Unsurprisingly, we find that the model predictions are not as skillful for holidays as they are for non-holidays (although holidays only make up 3% of the data). We expect that, in practice, expected value forecasts for holidays would be subject to some manual adjustment, which could help to improve this. It is also noteworthy that the model is less skillful on Mondays than other days of the week.

sharpness_combined_daytypes <- c()
for(dow_Rph in unique(fit_data$dow_Rph)){
  level_index <- which((fit_data$missing_data == F)
                       & (fit_data$dow_Rph == dow_Rph))
  sharpness_daytype <- sharpness(mqr_pred[level_index])
  sharpness_daytype$dow_Rph <- dow_Rph
  sharpness_combined_daytypes <- rbind(sharpness_combined_daytypes, sharpness_daytype)
}
sharpness_combined_daytypes <- setDT(sharpness_combined_daytypes)
sharpness_combined_daytypes[, dow_Rph:=ordered(dow_Rph, levels=dow_Rph_levels)]

ggplot(setDT(sharpness_combined_daytypes),
       aes(x=Interval, y=Width, group=dow_Rph)) +
  geom_line(aes(color=dow_Rph)) +
  geom_point(aes(color=dow_Rph), size=1) +
  scale_color_discrete("Type of day") + scale_x_continuous(labels = percent)

Distribution of interval width

Sharpness diagrams only show average interval widths. We can also look at the distribution of specific interval widths – i.e. the distribution of the width between quantile 2.5 and quantile 97.5. The figures below shows the empirical distribution of this range for the entire data (excluding bad data), and then separated out again by day type. Again, we can see that the predictions are less sharp for holidays.

fit_data$range95 <- mqr_pred$q97.5 - mqr_pred$q2.5
width_plot <- ggplot(fit_data[missing_index], aes(x=range95)) +
  geom_density(color="blue") +
  xlab("Central 95% interquantile range (MW)") +
  scale_color_discrete() +
  scale_x_continuous(labels=comma)
day_width_plot <- ggplot(fit_data[missing_index], aes(x=range95, group=dow_Rph)) +
  geom_density(aes(color=dow_Rph)) +
  xlab("Central 95%  interquantile range (MW)") +
  scale_color_discrete("Type of day") +
  scale_x_continuous(labels=comma)
ggarrange(width_plot, day_width_plot)
rm(width_plot, day_width_plot)

We can similarly look at the distributions by lead-time, with longer lead-times having wider intervals.

ggplot(fit_data[missing_index], aes(x=range95, group=as.factor(leadTime))) +
  geom_density(aes(color=as.factor(leadTime))) +
  xlab("Central 95%  interquantile range (MW)") +
  scale_color_discrete("Lead time \n (hours)") +
  scale_x_continuous(labels=comma)

4.2.3 Pinball Losses

We can confirm our conclusions about calibration and sharpness by examining pinball losses.

Pinball losses by lead-time

Firstly, by lead time.

pinball_combined_leadtimes <- c()
for(leadTime in unique(fit_data$leadTime)){
  level_index <- which((fit_data$missing_data == F)
                       & (fit_data$leadTime == leadTime))
  pinball_leadtime <- pinball(mqr_pred[level_index],
                             realisations = fit_data$net_demand[level_index], 
                             plot.it = F)
  pinball_leadtime$leadTime <- as.factor(leadTime)
  pinball_combined_leadtimes <- rbind(pinball_combined_leadtimes, pinball_leadtime)
}
pinball_combined_leadtimes <- setDT(pinball_combined_leadtimes)

ggplot(pinball_combined_leadtimes,
       aes(x=Quantile, y=Loss, group=leadTime)) +
  geom_line(aes(color=leadTime)) +
  geom_point(aes(color=leadTime), size=1) +
  scale_color_discrete("Lead time \n (hours)")  +
  scale_x_continuous(labels = percent)

We can also consider this by looking at the relationship between the mean and median absolute errors and forecast lead time.

p <- fit_data %>%
  group_by(leadTime) %>%
  summarise(
    Mean = mean(gam_abs_resid, na.rm = T),
    Median = median(gam_abs_resid, na.rm = T)
    ) %>% pivot_longer(cols=c(Mean, Median), names_to = "metric", values_to = "absolute_error") %>%
  ggplot(aes(x=leadTime, y=absolute_error, color=metric)) +
  geom_line() +
  geom_point(aes(text=paste0("Lead time (hours): ", leadTime,
                             "<br>", metric," absolute error (MW): ", round(absolute_error,2)))) +
  xlab("Lead time (hours)") + ylab("Absolute Error (MW)") + scale_color_discrete("Metric")
ggplotly(p, tooltip=c("text"))

Pinball losses by type of day

Then, pinball losses by type of day.

pinball_combined_daytypes <- c()
for(dow_Rph in unique(fit_data$dow_Rph)){
  level_index <- which((fit_data$missing_data == F)
                       & (fit_data$dow_Rph == dow_Rph))
  pinball_daytype <- pinball(mqr_pred[level_index],
                             realisations = fit_data$net_demand[level_index], 
                             plot.it = F)
  pinball_daytype$dow_Rph <- dow_Rph
  pinball_combined_daytypes <- rbind(pinball_combined_daytypes, pinball_daytype)
}
pinball_combined_daytypes <- setDT(pinball_combined_daytypes)
pinball_combined_daytypes[, dow_Rph:=ordered(dow_Rph, levels=dow_Rph_levels)]

ggplot(setDT(pinball_combined_daytypes),
       aes(x=Quantile, y=Loss, group=dow_Rph)) +
  geom_line(aes(color=dow_Rph)) +
  geom_point(aes(color=dow_Rph), size=1) +
  scale_color_discrete("Type of day") + scale_x_continuous(labels = percent)

4.3 Interpreting the model

One very helpful feature of GAMs is that the individual smooth and parametric terms within the model can be easily visualised. We processes the plots using the mcgViz package.

model_r0 <- getViz(qgam_update$models$gams$Test$expectation$r0)
model_r1 <- getViz(qgam_update$models$gams$Test$expectation$r1)
model_r2 <- getViz(qgam_update$models$gams$Test$expectation$r2)
model_r3 <- getViz(qgam_update$models$gams$Test$expectation$r3)
model_r4_sq <- getViz(qgam_update$models$gams$Test$stddev$r4_sq)

For brevity, we only present a selection of the interpretation plots within this workbook.

4.3.1 Conditional expected values

Effect of time-of-day

For example, the plot shows the relative effect of the local time-of-day on net demand. Demand is typically 10GW lower at 4:30am than it is 7:30am or 10pm, but at 5:30pm it is typically 7.5GW higher.

plot(sm(model_r0, 1)) +
  geom_hline(yintercept = 0, linetype = 'dashed') + 
  l_fitLine(color="blue") +
  l_ciPoly(fill = "lightblue", alpha=0.5) +
  theme_grey() + xlab("Local time (BST)") + 
  scale_y_continuous("Relative demand (MW)", labels=comma)

Effect of time-of-day on a Friday

The model specifies additional effects for different types of day and holiday. For example, the plot below shows the relative effect of different times of day on typical Friday demand.

plot(sm(model_r1, 6)) +
  geom_hline(yintercept = 0, linetype = 'dashed') +
  l_fitLine(color="blue") +
  l_ciPoly(fill = "lightblue", alpha=0.5) +
  theme_grey()+ xlab("Local time (BST) on Friday") + 
  scale_y_continuous("Relative demand (MW)", labels=comma)

Effect of time-of-day during Christmas Day

The plot below shows the impact of time-of-day on the demand during Christmas day, with lower demand in the morning and evening.

plot(sm(model_r1, 10)) +
  geom_hline(yintercept = 0, linetype = 'dashed') +
  l_fitLine(color="blue") +
  l_ciPoly(fill = "lightblue", alpha=0.5) +
  theme_grey() + xlab("Local time (BST) on Christmas day") +
  scale_y_continuous("Relative demand (MW)", labels=comma)

Impact of temperature

The influence of forecast temperature on demand is shown in the plot below. (This requires some manipulation of the mgcViz plots, since there is a term for temperature in both the 1st and 3rd stage of the model.) This may not tell us the whole story, as there are also model terms that depend on lagged values of temperature.

temp_plot <- plot(sm(model_r0, 2))
temp_plot$data$fit$y <- plot(sm(model_r0, 2))$data$fit$y + 
  plot(sm(model_r2, 13))$data$fit$y
temp_plot$data$fit$se <- plot(sm(model_r0, 2))$data$fit$se + 
  plot(sm(model_r2, 13))$data$fit$se
temp_plot$data$fit$ty <- plot(sm(model_r0, 2))$data$fit$ty + 
  plot(sm(model_r2, 13))$data$fit$ty
temp_plot + 
  geom_hline(yintercept = 0, linetype = 'dashed') +
  l_fitLine(color="blue") +
  l_ciPoly(fill = "lightblue", alpha=0.5) +
  theme_grey() + xlab("Average temperature (K)") + 
  scale_y_continuous("Relative demand (MW)", labels=comma)

Daily and annual interactions

We can similarly examine a 2D plot of the relative influence of different combinations of local time and day-of-the-year. This shows the propensity, for example, for demand to be higher around midnight in the summer.

hour_doy_plot <- plot(sm(model_r1, 18))
hour_doy_plot$data$fit$z <- plot(sm(model_r1, 18))$data$fit$z +
  plot(sm(model_r2, 1))$data$fit$z
hour_doy_plot$data$fit$se <- plot(sm(model_r1, 18))$data$fit$se + 
  plot(sm(model_r2, 1))$data$fit$se
hour_doy_plot$data$fit$tz <- plot(sm(model_r1, 18))$data$fit$tz + 
  plot(sm(model_r2, 1))$data$fit$tz
hour_doy_plot + theme_gray() + 
  l_fitRaster() + l_fitContour() + 
  xlab("Local time (BST)") +
  ylab("Day of the year") + labs(title = NULL) +
  scale_fill_gradient2("Relative demand (MW)", labels=comma)

Moving-average effect

We can also visualise the “moving-average” element within the model. This updates the prediction of the expected value of the forecast based on the extent to which previous observed values of net demand were higher or lower than their expected value. (We interpolate between the gaps between the 6hr to 9hr to 12hr lead times).

leadtime_plot <- plot(sm(model_r3, 1))
leadtime_plot$data$fit <- setDT(leadtime_plot$data$fit)
leadtime_plot$data$fit[,tz:=na.spline(tz),by=x]
leadtime_plot$data$fit[,z:=na.spline(z),by=x]
leadtime_plot + theme_gray() + 
  l_fitRaster() + l_fitContour() +
  scale_fill_gradient2("Relative demand (MW)", labels=comma)+ 
  xlab('"Error" in expected value forecast (MW)') +
  ylab("Lead time (hours)") + labs(title = NULL)

4.3.2 Probability density

We can also visualise the GAM terms used to model the absolute value of the residuals, which is ultimately used to drive the modeling of the uncertainty around the expected value.

Time of day on weekdays

For example, the plot below shows the expected value of the absolute value of the residual by the time-of-day on Weekdays, showing that there is greater uncertainty during the morning demand ramp, and also during the evening peak period.

plot(sm(model_r4_sq,2)) +
  geom_hline(yintercept = 0, linetype = 'dashed') +
  l_fitLine(color="blue") +
  l_ciPoly(fill = "lightblue", alpha=0.5) +
  theme_grey()+ xlab("Local time (BST)") +
  ylab("Relative absolute residual (MW)")

Lead time

And we can see how the lead-time affects the GAM of the absolute value of the residual. (Although the spline terms within the quantile regression will complicate this further).

plot(sm(model_r4_sq,11)) +
  geom_hline(yintercept = 0, linetype = 'dashed') +
  l_fitLine(color="blue") +
  l_ciPoly(fill = "lightblue", alpha=0.5) +
  theme_grey()+ xlab("Lead time (hours)") +
  ylab("Relative absolute residual (MW)") +
  xlim(0,12) + ylim(-250, 200) 

5 Extreme quantiles

Quantile regression is used to model quantiles between the 2.5th and 97.5th. For quantiles below the 2.5th and above the 97.5th, we fit models based on Extreme Value Theory using a Generalised Pareto Distribution (GPD).

5.1 Models for extreme quantiles

5.1.1 Model fitting

For both tails, we assume the scale of the GPD is a function of the expected value of the forecast, and terms related to wind and solar output as well as the lead time. We assume the shape of the right tail of the GPD changes with the forecast lead time. We allow the scale of the tails to be different for holidays by introducing a categorical variable holiday_binary.

ev_form_right <- list(net_demand ~ gam_pred + s(gam_pred, k=4) +
                        I(mean_SSRD*EMBEDDED_SOLAR_CAPACITY) + 
                        I(mean_WindSpd100*EMBEDDED_WIND_CAPACITY) +
                        holiday_binary +
                        s(leadTime, k=4),
                      ~ s(leadTime, k=4))

ev_form_left <- list(net_demand ~ gam_pred + s(gam_pred, k=4) +
                        I(mean_SSRD*EMBEDDED_SOLAR_CAPACITY) + 
                        I(mean_WindSpd100*EMBEDDED_WIND_CAPACITY) + 
                       holiday_binary+
                       s(leadTime, k=4),
                     ~ 1)

We fit the tail models, adding columns to the fit_data object columns for the GPD parameters and the “tail residual” (i.e. the gap between the realised demand and either the 2.5th or 97.5th quantile).

tail_from <- 2.5

ev_tails <- tails_ev(fit_data, 
                     mqr_data = qgam_update$mqr_pred,
                     tail_starts = c(tail_from, 100-tail_from),
                     formula = ev_form_left,
                     formula_r = ev_form_right,
                     BadData_col = "missing_data",
                     evgam_family  = 'gpd',
                     print_summary = F,
                     return_models=T
                    )
fit_data <- ev_tails$data
ev_tail_models <- ev_tails$models
rm(ev_tails)

5.1.2 Tail residuals

The plots below show the “tail residuals” of the data set, i.e. the extent to which values of demand exceed the 97.5th quantile prediction, or are lower than the 2.5th quantile. The shape of the data is fairly well suited to modeling with extreme value theory, although it is clear that the poorer performance of the model for holiday days is having an impact and leading to some outliers in both tails.

tail_left <- ggplot(fit_data[missing_data==F], aes(x=tail_l_resid, group=dow_Rph)) +
  geom_density(aes(color=dow_Rph), alpha=0) +
  coord_cartesian(xlim=c(0, 4000), ylim=c(0, 1e-04), expand = FALSE) +
  scale_color_discrete("Day type") + xlab("Residual below 2.5th quantile (MW)")

tail_right <- ggplot(fit_data[missing_data==F], aes(x=tail_r_resid, group=dow_Rph)) +
  geom_density(aes(color=dow_Rph), alpha=0) +
  coord_cartesian(xlim=c(0, 4000), ylim=c(0, 1e-04), expand = FALSE) +
  scale_color_discrete("Day type") + xlab("Residual beyond 97.5th quantile (MW)")

ggarrange(tail_left, tail_right, ncol=2)
rm(tail_left, tail_right)

5.1.3 Tail model parameters

We can visualise the parameters of the extreme value theory distributions. The scale parameters vary conditionally, whereas there is only a single left-tail shape parameter for each cross-fold.

scale_l_plt <- ggplot(fit_data[missing_index], aes(x=gpd_scale_l,
                                                      fill=kfold)) +
  geom_density(aes(color=kfold), alpha=0) +
  xlab("Left tail scale parameter") + coord_cartesian(xlim=c(0, 750))
scale_r_plt <- ggplot(fit_data[missing_index], aes(x=gpd_scale_r,
                                                      fill=kfold)) +
  geom_density(aes(color=kfold), alpha=0) +
  xlab("Right tail scale parameter") + coord_cartesian(xlim=c(0, 750))

shape_l_plt <- ggplot(fit_data[missing_index], aes(x=gpd_shape_l, 
                                                      fill=kfold)) +
  geom_histogram(aes(color=kfold)) +
  xlab("Left tail shape parameter") 
shape_r_plt <- ggplot(fit_data[missing_index], aes(x=gpd_shape_r, 
                                                      fill=kfold)) +
  geom_histogram(aes(color=kfold)) +
  xlab("Right tail shape  parameter")

ggarrange(scale_l_plt, scale_r_plt, shape_l_plt, shape_r_plt, nrow=2, ncol=2)
rm(scale_l_plt, scale_r_plt, shape_l_plt, shape_r_plt)

Some of the fitted parameters are significantly different between cross-folds. This could be due to the finite size of the samples and the somewhat random-allocation of samples to cross-folds.

The tables below present the number of data points used to fit these tail models for first the left then right tail, by cross-fold.

xtabs(~kfold+leadTime, fit_data[tail_l_resid>0&missing_data==F])
##       leadTime
## kfold  0.5   1   2   3   4   6   9  12
##   1    726 841 912 939 913 832 748 725
##   2    822 870 915 929 924 905 916 883
##   3    730 756 703 763 859 939 971 944
##   Test 277 350 515 512 525 500 473 486
xtabs(~kfold+leadTime, fit_data[tail_r_resid>0&missing_data==F])
##       leadTime
## kfold   0.5    1    2    3    4    6    9   12
##   1     817  914  978  948  966  966  943  942
##   2     734  795  848  956  966  954 1079 1075
##   3     727  671  693  728  763  717  718  786
##   Test  264  345  487  515  500  461  501  505

In model fitting, each tail model has at most a few thousand data points per lead time, and it possible that this is leading to an undesirable amount of variation in the parameters of the models between folds. For example, cross-fold 3 has a “light-tailed” model for its left tail (with a negative shape), but the other folds all have somewhat “heavy-tailed” models.

5.1.4 Impact of lead time on Tail model parameters

We can also see the scales of the GPD distributions changing with lead-time.

scale_l_plt <- ggplot(fit_data[missing_index], aes(x=gpd_scale_l,
                                                      group=as.factor(leadTime))) +
  geom_density(aes(group=as.factor(leadTime),
                   color=as.factor(leadTime)), alpha=0) + scale_color_discrete("Lead time") +
  xlab("Left tail scale parameter") + coord_cartesian(xlim=c(0, 750)) 
scale_r_plt <- ggplot(fit_data[missing_index], aes(x=gpd_scale_r,
                                                      group=as.factor(leadTime))) +
  geom_density(aes(group=as.factor(leadTime),
                   color=as.factor(leadTime)), alpha=0) +scale_color_discrete("Lead time") +
  xlab("Right tail scale parameter") + coord_cartesian(xlim=c(0, 750))

ggarrange(scale_l_plt, scale_r_plt, nrow=1, ncol=2)
rm(scale_l_plt, scale_r_plt)

5.1.5 Impact of holidays on Tail model parameters

And we can see the impact that holidays have on the scale of the left tail.

ggplot(fit_data[missing_index], aes(x=gpd_scale_l,
                                    fill=holiday_binary)) +
  geom_density(aes(color=holiday_binary), alpha=0) +
  xlab("Left tail scale parameter")

5.2 Evaluating tail quantile predictions

We evaluate these tail distributions for a selection of quantiles in the left and right tails, from the 99th to the 99.99th in the right tail and equivalent in the left tail. These are added to the mqr_pred object with the other quantiles.

mqr_pred <- qgam_update$mqr_pred
for(prob in c(0.99,0.995,0.9975,
              0.999,0.9995, 0.99975, 
              0.9999)){
  mqr_pred[[paste0("q",round(prob*100, 6))]] <-  mqr_pred[[paste0("q",100-tail_from)]] +
    qgpd(p=rep((prob-1+tail_from/100)/(tail_from/100), nrow(fit_data)),
         shape=na.aggregate(fit_data$gpd_shape_r),
         scale=fit_data$gpd_scale_r)
  mqr_pred[[paste0("q",round(1-prob, 6)*100)]] <-  mqr_pred[[paste0("q",tail_from)]] -
    qgpd(p=rep((prob-1+tail_from/100)/(tail_from/100), nrow(fit_data)),
         shape=na.aggregate(fit_data$gpd_shape_l),
         scale=fit_data$gpd_scale_l)
}
mqr_pred <- mqr_pred[, 
                     order(as.numeric(gsub("q","",colnames(mqr_pred)))),with=F]

5.3 Calibration

We re-evaluate the calibration of the model with the addition of the extreme quantiles.

ev_reliability <- reliability(mqr_pred[missing_index], 
                              realisations[missing_index], 
                              plot.it = F)
pr <- c(0.01, 0.1,1,10,50,90,99, 99.9, 99.99)

ggplotly(ggplot(data=ev_reliability, 
       aes(x=Nominal, y=Empirical,
           text=paste('Nominal: ', paste0(round(Nominal*100,2),"%"),
                      '<br>Empirical: ', paste0(round(Empirical*100,2),"%")))) +
  geom_line(color="blue", aes(group=1)) +
  geom_point(color="blue")+ 
  geom_abline(slope = 1, intercept = 0, linetype = 'dashed') +
  scale_x_continuous("Nominal",labels = percent) +
  scale_y_continuous("Empirical",labels = percent) +
  coord_cartesian(xlim = c(0, 1),
                  ylim = c(0, 1)),
  tooltip=c("text"))

It is difficult to see exactly what is happening in the tails. We therefore take an inverse normal transformation of the plotted data, which essentially zooms in on both the left and right tail of the QQ plot.

ev_reliability$qNominal <- qnorm(ev_reliability$Nominal)
ev_reliability$qEmpirical <- qnorm(ev_reliability$Empirical)
unQ <- qnorm(pr/100)

ggplotly(ggplot(data=ev_reliability, 
       aes(x=qNominal, 
           y=qEmpirical,
           text=paste('Nominal: ', paste0(round(Nominal*100,2),"%"),
                      '<br>Empirical: ', paste0(round(Empirical*100,2),"%")
                      )
           )
       ) +
  geom_line(color="blue", aes(group=1)) +
  geom_point(color="blue") + 
  geom_abline(slope = 1, intercept = 0, linetype = 'dashed') +
  scale_x_continuous("Nominal", breaks = unQ, 
                     labels = paste0(pr,"%")) +
  scale_y_continuous("Empirical", breaks = unQ, 
                     labels = paste0(pr,"%")) +
  coord_cartesian(xlim = c(min(unQ), max(unQ)),
                  ylim = c(min(unQ), max(unQ))),
  tooltip=c("text"))

The calibration of the models may not be as good in the extremes of the tails, beyond the 0.1st quantile and the 99.9th, However, it is important to bear in mind that there are only a very small number of data points for checking this calibration (e.g. there are only around 100 data points to the left of the 0.1th quantile and to the right of the 99.9th quantile). One possible factor is that the GPD has infinite support, meaning that the model could allow any positive (or negative) values of demand, from \(-\infty\) to \(\infty\).

We can also examine how this varies by lead-time, which suggests there may be room to improve the calibration for very extreme quantiles at 6-12 hour lead times.

reliability_all_lead_times <- c()
for(selected_leadTime in unique(fit_data$leadTime)){
  leadtime_index <- which((fit_data$missing_data == F) 
                          &(fit_data$leadTime == selected_leadTime))
  ev_reliability <- reliability(mqr_pred[leadtime_index], 
                                realisations[leadtime_index], 
                                plot.it = F)
  ev_reliability$leadTime <- selected_leadTime
  reliability_all_lead_times <- rbind(reliability_all_lead_times, 
                                      ev_reliability)
}
reliability_all_lead_times <- setDT(reliability_all_lead_times)
reliability_all_lead_times[,leadTime:=as.factor(leadTime)]

reliability_all_lead_times$qNominal <- qnorm(reliability_all_lead_times$Nominal)
reliability_all_lead_times$qEmpirical <- qnorm(reliability_all_lead_times$Empirical)
unQ <- qnorm(pr/100)

ggplotly(ggplot(data=reliability_all_lead_times, 
                aes(x=qNominal, y=qEmpirical, group=leadTime, 
                    text=paste('Lead time:', leadTime, "hours",
                      '<br>Nominal: ', paste0(round(Nominal*100,2),"%"),
                      '<br>Empirical: ', paste0(round(Empirical*100,2),"%")))) +
         geom_line(aes(color=leadTime)) +
         geom_point(aes(color=leadTime))+ 
  geom_abline(slope = 1, intercept = 0, linetype = 'dashed') +
  scale_x_continuous("Nominal", breaks = unQ, 
                     labels = paste0(pr,"%")) +
  scale_y_continuous("Empirical", breaks = unQ, 
                     labels = paste0(pr,"%")) +
  coord_cartesian(xlim = c(min(unQ), max(unQ)),
                  ylim = c(min(unQ), max(unQ))) +
    scale_color_discrete("Lead time"),
  tooltip = c("text"))

Nevertheless, we adopt these tail models within our forecasts, noting that they need to be used cautiously given the difficulty in verifying their calibration.

5.4 Sharpness

5.4.1 Sharpness of all predictions

As shown previously, we can understand the sharpness of the extreme predictions by looking at the width of interquantile ranges, this time looking at the interval widths 99.99th central range.

ggplot(setDT(sharpness(mqr_pred[missing_index]))[Interval<0.9999],
       aes(x=Interval, y=Width)) +
  geom_line(color="blue") +
  geom_point(color="blue", size=1) +
  scale_color_discrete("Type of day") + scale_x_continuous(labels=percent) +
  scale_y_continuous("Width (MW)", labels=comma)

5.4.2 Sharpness by type of day

As before, we can partition by type of day…

sharpness_combined_daytypes <- c()
for(dow_Rph in unique(fit_data$dow_Rph)){
  level_index <- which((fit_data$missing_data == F)
                       & (fit_data$dow_Rph == dow_Rph))
  sharpness_daytype <- sharpness(mqr_pred[level_index],
                                 realisations = fit_data$net_demand[level_index], 
                                 plot.it = F)
  sharpness_daytype$dow_Rph <- dow_Rph
  sharpness_combined_daytypes <- rbind(sharpness_combined_daytypes, sharpness_daytype)
  }

sharpness_combined_daytypes <- setDT(sharpness_combined_daytypes)
sharpness_combined_daytypes[, dow_Rph:=ordered(dow_Rph, levels=dow_Rph_levels)]

ggplot(setDT(sharpness_combined_daytypes)[Interval<0.9999],
       aes(x=Interval, y=Width, group=dow_Rph)) +
  geom_line(aes(color=dow_Rph)) +
  geom_point(aes(color=dow_Rph), size=1) +
  scale_color_discrete("Type of day") + scale_x_continuous(labels=percent) +
  scale_y_continuous("Width (MW)", labels=comma)

5.4.3 Sharpness by lead time

… and by lead-time.

sharpness_combined_leadtimes <- c()
for(selected_leadTime in unique(fit_data$leadTime)){
  level_index <- which((fit_data$missing_data == F)
                       & (fit_data$leadTime == selected_leadTime))
  sharpness_leadtime <- sharpness(mqr_pred[level_index],
                                 realisations = fit_data$net_demand[level_index], 
                                 plot.it = F)
  sharpness_leadtime$leadTime <- selected_leadTime
  sharpness_combined_leadtimes <- rbind(sharpness_combined_leadtimes, sharpness_leadtime)
  }

sharpness_combined_leadtimes <- setDT(sharpness_combined_leadtimes)
sharpness_combined_leadtimes[, leadTime:=as.factor(leadTime)]

ggplot(setDT(sharpness_combined_leadtimes)[Interval<0.9999],
       aes(x=Interval, y=Width, group=leadTime)) +
  geom_line(aes(color=leadTime)) +
  geom_point(aes(color=leadTime), size=1) +
  scale_color_discrete("Lead time (hours)") + scale_x_continuous(labels=percent) +
  scale_y_continuous("Width (MW)", labels=comma)

5.5 Other lead times

We evaluate the GPD tail parameters for the extended lead-time forecasts.

invisible(gc())
fit_data_all_leads <- readRDS("~/RProjects/reactforecasting/data/output/R_objects/ShortTerm_NetDemand/fit_data_all_leads.rds")
pred_all_leads <- readRDS("~/RProjects/reactforecasting/data/output/R_objects/ShortTerm_NetDemand/pred_all_leads.rds")

for(fold in unique(fit_data_all_leads$kfold)){
  params_l <- predict(ev_tail_models[[fold]][["fit_l"]],
                      newdata = fit_data_all_leads[kfold==fold],
                      type="response")
  params_r <- predict(ev_tail_models[[fold]][["fit_r"]],
                      newdata = fit_data_all_leads[kfold==fold],
                      type = "response")
  fit_data_all_leads[kfold==fold,c(paste0("gpd_",names(params_l),"_l"),
                                   paste0("gpd_",names(params_r),"_r")):=cbind(params_l,params_r)]
}

Then we make predictions in the tails.

mqr_pred_all <- pred_all_leads$mqr_pred
for(prob in c(0.99,0.995,0.9975,
              0.999,0.9995, 0.99975, 
              0.9999)){
  mqr_pred_all[[paste0("q",round(prob*100, 6))]] <-  mqr_pred_all[[paste0("q",100-tail_from)]] +
    qgpd(p=rep((prob-1+tail_from/100)/(tail_from/100), nrow(fit_data_all_leads)),
         shape=na.aggregate(fit_data_all_leads$gpd_shape_r),
         scale=fit_data_all_leads$gpd_scale_r)
  mqr_pred_all[[paste0("q",round(1-prob, 6)*100)]] <-  mqr_pred_all[[paste0("q",tail_from)]] -
    qgpd(p=rep((prob-1+tail_from/100)/(tail_from/100), nrow(fit_data_all_leads)),
         shape=na.aggregate(fit_data_all_leads$gpd_shape_l),
         scale=fit_data_all_leads$gpd_scale_l)
}
mqr_pred_all <- mqr_pred_all[, 
                     order(as.numeric(gsub("q","",colnames(mqr_pred_all)))),with=F]
pred_all_leads$mqr_pred <- mqr_pred_all
rm(mqr_pred_all)
saveRDS(fit_data_all_leads, 
        "~/RProjects/reactforecasting/data/output/R_objects/ShortTerm_NetDemand/fit_data_all_leads.rds")
saveRDS(pred_all_leads, 
              "~/RProjects/reactforecasting/data/output/R_objects/ShortTerm_NetDemand/pred_all_leads.rds")

6 Probability integral transform

We take the Probability Integral Transform (PIT) of each realisation of net demand. This is the probability of that value of demand having been realised, based on the fitted model of the probability density.

all_quantiles <- as.numeric(gsub("q","",colnames(mqr_pred)))/100
orig_quantiles <- all_quantiles[all_quantiles %between% c(0.025, 0.975)]
orig_cols <- paste0("q",orig_quantiles*100)
orig_mqr <- pred_all_leads$mqr_pred[,..orig_cols]
fit_data_all_leads[,
                   pit:=PIT(orig_mqr, 
                            net_demand, 
                            tails=list(method="gpd",
                                       scale_r=gpd_scale_r,
                                       shape_r=gpd_shape_r,
                                       scale_l=gpd_scale_l,
                                       shape_l=gpd_shape_l))]
rm(orig_mqr)

6.1 Auto-correlation and partial auto-correlation

We can examine the auto-correlation and partial auto-correlation of the PIT of the forecast. We look at sequences of forecasts issued at each lead time. There is more persistent auto-correlation for longer lead time forecasts, which makes sense intuitively; over or under-estimation of expected values will persist in longer lead-time forecasts, but the moving average treatment minimises this for shorter lead-times.

acf_lead <- function(lead){
  pit_acf <- acf(fit_data_all_leads[leadTime==lead]$pit, na.action = na.pass, lag.max = 48*7, plot = F)
  pit_acf <- data.table(lag = pit_acf$lag, acf = pit_acf$acf)
  pit_acf$leadTime <- lead
  return(pit_acf)
}
pit_acf <- setDT(rbindlist(lapply(c(0.5, 1, 3, 4, 6, 12), acf_lead)))
pit_acf$max_lag <- 48*7
pit_acf_short <- pit_acf[lag<=48]
pit_acf_short$max_lag <- 48
pit_acf <- rbind(pit_acf, pit_acf_short)
p <- ggplot(pit_acf, aes(x=lag, y=0, xend=lag, yend=acf,
                         text=paste('Lag: ', lag,
                                    '<br>Autocorrelation: ', paste0(round(acf*100,2),"%")))) + 
  geom_segment(color="blue") + 
  geom_point(aes(x=lag, y=acf), color="blue") +
  geom_hline(yintercept = 0, color='darkgrey') +
  geom_hline(yintercept = 0.05, linetype='dashed', color='red') +
  geom_hline(yintercept = -0.05, linetype='dashed', color='red')+
  scale_x_continuous("Lag") + 
  scale_y_continuous("Autocorrelation", labels=percent)  +
  facet_grid(leadTime~paste("Maximum lag: ",max_lag), scales="free_x")
ggplotly(p, tooltip=c("text"))

6.2 Calibration of the PIT

We can also visualise the calibration of the model by examining the extent which the predictions deviate from the quantiles implied by the model after taking the PIT. We include consistency bands, where these are adjusted to reflect the strong correlation at the half-hour lag. These also suggest some calibration issues for the longer lead-time forecasts at the most extreme quantiles, although it does suggest that the right-tail could be improved for the 30 minute lead-time.

(These plots are based on those used by Browell and Fasiolo in this pre-print: https://arxiv.org/abs/2103.10335 and use some of the code that accompanied that paper.)

worm_lead <- function(lead){
  worm <- worm_data(fit_data_all_leads[missing_data==F & leadTime==lead, pit], cov_max_lag = 1)
  worm$leadTime <- lead
  return(worm)
}
pr_worm <- c(0.01, 0.1, 1,10,50,90,99, 99.9, 99.99)
unQ_worm <- qnorm(pr_worm/100)
worm_plot_data <- setDT(rbindlist(lapply(c(0.5, 1, 3, 4, 6, 12), worm_lead)))
p <- ggplot(worm_plot_data, aes(x=theoretical,y=sample,group=type,
                           text=paste('Lead time:', leadTime, "hours",
                      '<br>Theoretical: ', paste0(round(pnorm(theoretical)*100,2),"%"),
                      '<br>Deviation: ', paste0(round(sample,2)))
                      )
            ) +
  geom_line(aes(color=type, lty=type))+
  scale_x_continuous("Nominal", breaks = unQ_worm[1:length(unQ_worm)-1], 
                     labels = paste0(pr_worm[1:length(pr_worm)-1],"%")) +
  scale_y_continuous("Deviation") +
  coord_cartesian(xlim = c(min(unQ_worm), max(unQ_worm)),
                  ylim = c(-1,1), expand=F) +
  facet_wrap(~ordered(paste0("Lead time: ", leadTime, " hours"),
                        levels=paste0("Lead time: ", c(0.5, 1, 3, 4, 6, 12), " hours")),
                        ncol=2) +
  scale_color_manual(NULL,
                     breaks = c("Lower", "PIT", "Upper"),
                     values = c("Lower" = "black",
                                "PIT" = "red",
                                "Upper" = "black")) +
  scale_linetype_manual(NULL,
                     breaks = c("Lower", "PIT", "Upper"),
                     values = c("Lower" = "dotted",
                                "PIT" = "solid",
                                "Upper" = "dotted"))

ggplotly(p, tooltip=c("text")) %>% layout_ggplotly(y=-0.04)

The PIT of the forecast will be used further in Work Package 4.

7 Visualising the forecasts

We can visualise the probabilistic forecasts using a fan chart.

7.1 Rolling forecasts over four weeks

In the animation below, we show four weeks of forecasts, starting from 20th January 2019. The fan chart covers the 0.5th to 99.5th quantiles, such that 99% of realised demands within the area of the fan-plot. We also include dashed lines representing the range between the 0.25% and 99.75% quantile (the 99.5% interquantile range). Each frame of the animation corresponds to a different forecast issue time, with the issued forecasts spanning the period from 30 minutes to 12 hours ahead. We overlay the actual realised value of demand in black.

start_date <- ymd_hm("2019/01/20 00:00")
end_date <- start_date + days(7*4)
indexes <- which(fit_data_all_leads$issueTime>=start_date &
                   fit_data_all_leads$issueTime<end_date)
p <- ggplotmqr(pred_all_leads$mqr_pred[indexes], 
               targetTimes = fit_data_all_leads[indexes, localtargetTime],
               issueTime = fit_data_all_leads[indexes, issueTime],
               quantiles=paste0("q",100*c(0.005, 0.01, 0.025, 
                                          seq(0.05, 0.95, by=0.05),
                                          0.975, 0.99, 0.995)), 
               cols = list(type="colorRampPalette", 
                           func=colorRampPalette(c("lightgoldenrod1","firebrick1"))))
p_anim <- p + 
  geom_line(data=fit_data_all_leads[indexes], 
            aes(x=localtargetTime, y=net_demand, 
                color="Actual net demand", linetype="Actual net demand",
                group=issueTime), size=1) +
  geom_line(data=data.table(localtargetTime  = fit_data_all_leads[indexes, localtargetTime],
                            q0.25  = pred_all_leads$mqr_pred[indexes, q0.25],
                            issueTime  = fit_data_all_leads[indexes, issueTime]),
            aes(x=localtargetTime, y=q0.25, color="99.5% interval",linetype="99.5% interval",
                group=issueTime)) +
  geom_line(data=data.table(localtargetTime  = fit_data_all_leads[indexes, localtargetTime],
                            q99.75  = pred_all_leads$mqr_pred[indexes, q99.75],
                            issueTime  = fit_data_all_leads[indexes, issueTime]),
            aes(x=localtargetTime, y=q99.75, color="99.5% interval",linetype="99.5% interval",
                group=issueTime)) +
  scale_x_datetime(name="Time (BST)", 
                   sec.axis=sec_axis(~difftime(., min(.)-30*60, units="hours"),
                                     name="Lead time (hours)"),
                   expand = expansion(mult = c(0,0))) + 
  scale_color_manual(name=NULL, 
                     breaks = c("Actual net demand", "99.5% interval"),
                     values = c("Actual net demand" = "black", "99.5% interval" = "firebrick1")) +
  scale_linetype_manual(name=NULL, 
                     breaks = c("Actual net demand", "99.5% interval"),
                     values = c("Actual net demand" = "solid", "99.5% interval" = "dotdash")) +
  theme_bw() + 
  transition_states(as.factor(issueTime)) + 
  view_follow(fixed_y = T) +
  scale_y_continuous(name="Net demand (MW)", labels = comma, expand = expansion(mult = c(.1, .1))) +
  ggtitle("Issue: {closest_state} (BST)") + 
  theme(panel.grid.major.x = element_blank(), panel.grid.minor.x = element_blank()) 
animation <- animate(p_anim, device = "png", fps = 20, height = 450, width =600, 
                     nframes = length(unique(fit_data_all_leads[indexes, issueTime])),
                     detail=2)

invisible(anim_save("qgam_pred_shorttermnetdemand.gif", animation = animation))
knitr::include_graphics("qgam_pred_shorttermnetdemand.gif")

7.2 Specific example

As a specific example we examine four forecasts that would have been issued on the 21st January 2019, showing lead-times up to six hours ahead.

issues <- c(ymd_hm("2019/01/21 00:00"),
            ymd_hm("2019/01/21 06:00"),
            ymd_hm("2019/01/21 12:00"),
            ymd_hm("2019/01/21 18:00")) -
  minutes(30)
indexes <- which(fit_data_all_leads$issueTime%in%issues
                 &fit_data_all_leads$leadTime<=6)
p <- ggplotmqr(pred_all_leads$mqr_pred[indexes], 
               targetTimes = fit_data_all_leads[indexes, localtargetTime],
               issueTime = fit_data_all_leads[indexes, issueTime],
               quantiles=paste0("q",100*c(0.005, 0.01, 0.025, 
                                          seq(0.05, 0.95, by=0.05),
                                          0.975, 0.99, 0.995)), 
               cols = list(type="colorRampPalette", 
                           func=colorRampPalette(c("lightgoldenrod1","firebrick1"))))
p <- p + 
  geom_line(data=fit_data_all_leads[indexes], 
            aes(x=localtargetTime, y=net_demand, 
                color="Actual net demand",
                group=issueTime, linetype="Actual net demand"), size=1) +
  geom_line(data=data.table(localtargetTime  = fit_data_all_leads[indexes, localtargetTime],
                            q0.25  = pred_all_leads$mqr_pred[indexes, q0.25],
                            issueTime  = fit_data_all_leads[indexes, issueTime]),
            aes(x=localtargetTime, y=q0.25, color="99.5% interval",
                group=issueTime,  linetype="99.5% interval")) +
  geom_line(data=data.table(localtargetTime  = fit_data_all_leads[indexes, localtargetTime],
                            q99.75  = pred_all_leads$mqr_pred[indexes, q99.75],
                            issueTime  = fit_data_all_leads[indexes, issueTime]),
            aes(x=localtargetTime, y=q99.75, color="99.5% interval",
                group=issueTime, linetype="99.5% interval")) +
  scale_x_datetime(name="Time (BST)",
                   sec.axis=sec_axis(~difftime(., min(.)-30*60, units="hours"),
                                     name="Lead time (hours)"),
                   expand = expansion(mult = c(0, 0))) + 
  scale_color_manual(name=NULL, 
                     breaks = c("Actual net demand", "99.5% interval"),
                     values = c("Actual net demand" = "black", "99.5% interval" = "firebrick1")) +
  scale_linetype_manual(name=NULL,  
                        breaks = c("Actual net demand", "99.5% interval"),
                        values = c("Actual net demand" = "solid", "99.5% interval" = "dotdash")) +
  theme_bw() + 
  scale_y_continuous(name="Net demand (MW)", labels = comma, expand = expansion(mult = c(.1, .1))) +
  facet_wrap(~as.factor(with_tz(issueTime,tz="Europe/London")), ncol = 4, dir = 'h', scales = "free_x")
p

These show how the uncertainty tends to reduce for shorter lead-times, with the forecasts also shifting closer to the actual value (depending on the extent to which previous values have under or over-estimated).

We can “zoom in” even more on the evening peak of the day, with the forecasts issued at 2:30pm, 3:30pm, 4:30pm and 5:30pm. By the time we get to 5:30pm, the forecast has been “corrected” such that the distribution is fairly close to the actual value.

issues <- c(ymd_hm("2019/01/21 15:00"),
            ymd_hm("2019/01/21 16:00"),
            ymd_hm("2019/01/21 17:00"),
            ymd_hm("2019/01/21 18:00")) -
  minutes(30)
indexes <- which(fit_data_all_leads$issueTime%in%issues
                 &fit_data_all_leads$leadTime<=12)
p <- ggplotmqr(pred_all_leads$mqr_pred[indexes], 
               targetTimes = fit_data_all_leads[indexes, localtargetTime],
               issueTime = fit_data_all_leads[indexes, issueTime],
               quantiles=paste0("q",100*c(0.005, 0.01, 0.025, 
                                          seq(0.05, 0.95, by=0.05),
                                          0.975, 0.99, 0.995)), 
               cols = list(type="colorRampPalette", 
                           func=colorRampPalette(c("lightgoldenrod1","firebrick1"))))
p <- p + 
  geom_line(data=fit_data_all_leads[indexes], 
            aes(x=localtargetTime, y=net_demand, 
                color="Actual net demand",
                group=issueTime, linetype="Actual net demand"), size=1) +
  geom_line(data=data.table(localtargetTime  = fit_data_all_leads[indexes, localtargetTime],
                            q0.25  = pred_all_leads$mqr_pred[indexes, q0.25],
                            issueTime  = fit_data_all_leads[indexes, issueTime]),
            aes(x=localtargetTime, y=q0.25, color="99.5% interval",
                group=issueTime,  linetype="99.5% interval")) +
  geom_line(data=data.table(localtargetTime  = fit_data_all_leads[indexes, localtargetTime],
                            q99.75  = pred_all_leads$mqr_pred[indexes, q99.75],
                            issueTime  = fit_data_all_leads[indexes, issueTime]),
            aes(x=localtargetTime, y=q99.75, color="99.5% interval",
                group=issueTime, linetype="99.5% interval")) +
  scale_x_datetime(name="Time (BST)", expand = expansion(mult = c(0, 0))) + 
  scale_color_manual(name=NULL, 
                     breaks = c("Actual net demand", "99.5% interval"),
                     values = c("Actual net demand" = "black", "99.5% interval" = "firebrick1")) +
  scale_linetype_manual(name=NULL,  
                        breaks = c("Actual net demand", "99.5% interval"),
                        values = c("Actual net demand" = "solid", "99.5% interval" = "dotdash")) +
  theme_bw() + 
  scale_y_continuous(name="Net demand (MW)", labels = comma, expand = expansion(mult = c(.1, .1))) +
  coord_cartesian(xlim=c(ymd_hm("2019/01/21 18:00"), ymd_hm("2019/01/21 21:00"))) +
  facet_wrap(~as.factor(with_tz(issueTime,tz="Europe/London")), 
             ncol = 4, dir = 'h')
p

8 Summary

In this workbook we demonstrated a methodology for generating probabilistic forecasts of national net-demand at short time scales, using Generalised Additive Models and Quantile Regression. We demonstrated that the forecasts were well calibrated, even for some extreme quantiles of the predictive distribution, and that they are skillful compared to a standard benchmark model.

Some possible areas for further research could include:

  • Further exploration and research into the modeling of extreme quantiles of the tails of the predictive distributions.
  • Methods to incorporate the ways in which NGESO adjusts forecasts manually to account for holidays and special events.
  • Further refinement of the way in which very short-term forecasts are updated based on observations.

In WP4, these forecasts will be applied to a variety of decision-making use cases.

Finally, we save the forecast data for later sections of the project.

forecast_output <- cbind(fit_data_all_leads[,.(targetTime,issueTime,leadTime,
                                               metIssueTime, metLeadTime,
                                               kfold,missing_data,
                                               net_demand, gam_pred, pit,
                                               gpd_scale_r, gpd_shape_r, 
                                               gpd_scale_l, gpd_shape_l)],
                         pred_all_leads$mqr_pred)
forecast_output <- forecast_output[order(targetTime, -leadTime)]    
fwrite(forecast_output,
       file = paste0("~/RProjects/reactforecasting/data/output/",
                     "shortterm_netdemand.csv"))

And we save the data as an R object.

saveRDS(fit_data_all_leads, 
        "~/RProjects/reactforecasting/data/output/R_objects/ShortTerm_NetDemand/fit_data_all_leads.rds")

9 Session Info

sessionInfo()
## R version 4.0.4 (2021-02-15)
## Platform: x86_64-w64-mingw32/x64 (64-bit)
## Running under: Windows 10 x64 (build 19042)
## 
## Matrix products: default
## 
## locale:
## [1] LC_COLLATE=English_United Kingdom.1252  LC_CTYPE=English_United Kingdom.1252    LC_MONETARY=English_United Kingdom.1252 LC_NUMERIC=C                            LC_TIME=English_United Kingdom.1252    
## 
## attached base packages:
## [1] parallel  splines   stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
##  [1] gganimate_1.0.7     scales_1.1.1        plotly_4.9.4.1      zoo_1.8-9           ROOPSD_0.2.5        ggpubr_0.4.0        gifski_1.4.3        mgcViz_0.1.6        rgl_0.105.22        qgam_1.3.2          ProbCast_0.0.0.9000 quantreg_5.86       SparseM_1.81        mgcv_1.8-34         evgam_0.1.4         fst_0.9.4           mvnfast_0.2.7       gamboostLSS_2.0-5   mboost_2.9-5        stabs_0.6-4         doSNOW_1.0.19       snow_0.4-3          iterators_1.0.13    foreach_1.5.1       gamlss_5.3-4        nlme_3.1-152        gamlss.dist_5.3-2   MASS_7.3-53         gamlss.data_6.0-1   lubridate_1.7.10    forcats_0.5.1       stringr_1.4.0       dplyr_1.0.5         purrr_0.3.4         readr_1.4.0         tibble_3.1.0        ggplot2_3.3.3       tidyverse_1.3.0     tidyr_1.1.3        
## [40] data.table_1.14.0  
## 
## loaded via a namespace (and not attached):
##   [1] readxl_1.3.1            backports_1.2.1         plyr_1.8.6              lazyeval_0.2.2          crosstalk_1.1.1         digest_0.6.27           htmltools_0.5.1.1       viridis_0.5.1           fansi_0.4.2             magrittr_2.0.1          doParallel_1.0.16       openxlsx_4.2.4          modelr_0.1.8            matrixStats_0.59.0      lpSolve_5.6.15          prettyunits_1.1.1       colorspace_2.0-0        rvest_1.0.0             haven_2.3.1             xfun_0.22               crayon_1.4.1            jsonlite_1.7.2          libcoin_1.0-8           lme4_1.1-27.1           survival_3.2-10         glue_1.4.2              gtable_0.3.0            nnls_1.4                webshot_0.5.2           MatrixModels_0.5-0      car_3.0-11              abind_1.4-5             mvtnorm_1.1-2          
##  [34] DBI_1.1.1               GGally_2.1.1            rstatix_0.7.0           miniUI_0.1.1.1          Rcpp_1.0.7              isoband_0.2.4           progress_1.2.2          viridisLite_0.3.0       xtable_1.8-4            units_0.7-2             proxy_0.4-26            foreign_0.8-81          Formula_1.2-4           htmlwidgets_1.5.3       httr_1.4.2              RColorBrewer_1.1-2      ellipsis_0.3.1          farver_2.1.0            pkgconfig_2.0.3         reshape_0.8.8           transformr_0.1.3        sass_0.3.1              dbplyr_2.1.0            utf8_1.2.1              labeling_0.4.2          tidyselect_1.1.0        rlang_0.4.10            manipulateWidget_0.10.1 later_1.1.0.1           munsell_0.5.0           cellranger_1.1.0        tools_4.0.4             cli_2.3.1              
##  [67] generics_0.1.0          broom_0.7.5             evaluate_0.14           fastmap_1.1.0           yaml_2.2.1              knitr_1.31              fs_1.5.0                zip_2.1.1               mime_0.10               xml2_1.3.2              compiler_4.0.4          rstudioapi_0.13         gamm4_0.2-6             curl_4.3                e1071_1.7-8             ggsignif_0.6.2          reprex_1.0.0            tweenr_1.0.2            bslib_0.2.4             stringi_1.5.3           highr_0.8               ps_1.6.0                lattice_0.20-41         Matrix_1.3-2            classInt_0.4-3          nloptr_1.2.2.2          vctrs_0.3.6             pillar_1.5.1            lifecycle_1.0.0         jquerylib_0.1.3         cowplot_1.1.1           conquer_1.0.2           httpuv_1.5.5           
## [100] R6_2.5.0                promises_1.2.0.1        KernSmooth_2.23-18      gridExtra_2.3           rio_0.5.27              Lmoments_1.3-1          codetools_0.2-18        boot_1.3-26             assertthat_0.2.1        withr_2.4.1             hms_1.0.0               quadprog_1.5-8          grid_4.0.4              rpart_4.1-15            class_7.3-18            minqa_1.2.4             rmarkdown_2.7           inum_1.0-4              carData_3.0-4           sf_1.0-2                partykit_1.2-13         shiny_1.6.0

To download the report, please enter your email address:

To view the videos please enter a valid email address: