data_624_project1

Author

Maxfield Raynolds

── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
✔ dplyr     1.1.4     ✔ readr     2.1.5
✔ forcats   1.0.0     ✔ stringr   1.5.1
✔ ggplot2   3.5.2     ✔ tibble    3.2.1
✔ lubridate 1.9.4     ✔ tidyr     1.3.1
✔ purrr     1.0.4     
── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
✖ dplyr::filter() masks stats::filter()
✖ dplyr::lag()    masks stats::lag()
ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
Warning: package 'fpp3' was built under R version 4.4.3
── Attaching packages ──────────────────────────────────────────── fpp3 1.0.3 ──
✔ tsibble     1.2.0     ✔ feasts      0.5.0
✔ tsibbledata 0.4.1     ✔ fable       0.5.0
✔ ggtime      1.0.0     
Warning: package 'tsibble' was built under R version 4.4.3
Warning: package 'feasts' was built under R version 4.4.3
Warning: package 'fable' was built under R version 4.4.3
── Conflicts ───────────────────────────────────────────────── fpp3_conflicts ──
✖ lubridate::date()    masks base::date()
✖ dplyr::filter()      masks stats::filter()
✖ tsibble::intersect() masks base::intersect()
✖ tsibble::interval()  masks lubridate::interval()
✖ dplyr::lag()         masks stats::lag()
✖ tsibble::setdiff()   masks base::setdiff()
✖ tsibble::union()     masks base::union()

Attaching package: 'hms'

The following object is masked from 'package:lubridate':

    hms

Project 1

Part A - ATM Forecast

Loading and looking at the data.

atm_raw <- read_xlsx("ATM624Data.xlsx") |> 
  rename(
    date = DATE,
    atm = ATM,
    cash_in_hundreds = Cash
  ) |> 
  mutate(
    date = as.Date(date, origin = "1899-12-30"),
  )

glimpse(atm_raw)
Rows: 1,474
Columns: 3
$ date             <date> 2009-05-01, 2009-05-01, 2009-05-02, 2009-05-02, 2009…
$ atm              <chr> "ATM1", "ATM2", "ATM1", "ATM2", "ATM1", "ATM2", "ATM1…
$ cash_in_hundreds <dbl> 96, 107, 82, 89, 85, 90, 90, 55, 99, 79, 88, 19, 8, 2…

Exploratory Data Analysis

An initial look at a summary of the data in the datas set shows that there are four separate ATMs.

It shows an equal number of entries per ATM but also indicates some rows with missing data. The first task is to decide how to handle this missing data.

atm_raw |> group_by(atm) |> 
  summarize(
    sum = sum(cash_in_hundreds),
    mean = mean(cash_in_hundreds),
    max = max(cash_in_hundreds),
    min = min(cash_in_hundreds),
    n = n()
  )
# A tibble: 5 × 6
  atm       sum    mean    max   min     n
  <chr>   <dbl>   <dbl>  <dbl> <dbl> <int>
1 ATM1      NA   NA        NA  NA      365
2 ATM2      NA   NA        NA  NA      365
3 ATM3     263    0.721    96   0      365
4 ATM4  173026. 474.    10920.  1.56   365
5 <NA>      NA   NA        NA  NA       14

Inspecting for NA values.

atm_raw |> 
  summarize_all(~ sum(is.na(.)))
# A tibble: 1 × 3
   date   atm cash_in_hundreds
  <int> <int>            <int>
1     0    14               19

Viewing the rows with NA values below.

atm_raw |> 
  filter(
    is.na(atm) | is.na(cash_in_hundreds)
  )
# A tibble: 19 × 3
   date       atm   cash_in_hundreds
   <date>     <chr>            <dbl>
 1 2009-06-13 ATM1                NA
 2 2009-06-16 ATM1                NA
 3 2009-06-18 ATM2                NA
 4 2009-06-22 ATM1                NA
 5 2009-06-24 ATM2                NA
 6 2010-05-01 <NA>                NA
 7 2010-05-02 <NA>                NA
 8 2010-05-03 <NA>                NA
 9 2010-05-04 <NA>                NA
10 2010-05-05 <NA>                NA
11 2010-05-06 <NA>                NA
12 2010-05-07 <NA>                NA
13 2010-05-08 <NA>                NA
14 2010-05-09 <NA>                NA
15 2010-05-10 <NA>                NA
16 2010-05-11 <NA>                NA
17 2010-05-12 <NA>                NA
18 2010-05-13 <NA>                NA
19 2010-05-14 <NA>                NA

There are 19 rows that contain NA values of 1,474 rows. This is 1.28% of the total data set.

There are two types of missing data.
1. There are 14 rows with a date but no atm and no cash value. As the goal is to forecast May of 2010, these are likely forecasting placeholders, for the time being they will be dropped from the dataset during analysis.

  1. There are 5 values that have an ATM but no cash value. These can be imputed based on the other values in the data set for the specific ATMs’ missing data. For this small number of missing data points, a potential approach is to use the median value from the same ATM and the same day of the week.
atm <- atm_raw |> 
  drop_na(atm) |> 
  mutate(
    day = wday(date)
  )

atm_medians <- atm |> 
  drop_na(cash_in_hundreds) |> 
  group_by(atm, day) |> 
  filter(date < as.Date("2010-04-01")) |> 
  summarise(
    median = median(cash_in_hundreds),
    .groups = "drop"
  )

atm <- atm |> 
  left_join(
    atm_medians,
    by = c("atm", "day")
  )

atm <- atm |> 
  mutate(
    cash_imputed = if_else(
      is.na(cash_in_hundreds),
      median,
      cash_in_hundreds
    )
  ) |> 
  select(
    date, day, atm, cash_in_hundreds, cash_imputed
  )

It is now possible to see the values that were imputed.

atm |> 
  filter(
    is.na(atm) | is.na(cash_in_hundreds)
  )
# A tibble: 5 × 5
  date         day atm   cash_in_hundreds cash_imputed
  <date>     <dbl> <chr>            <dbl>        <dbl>
1 2009-06-13     7 ATM1                NA         96  
2 2009-06-16     3 ATM1                NA        105  
3 2009-06-18     5 ATM2                NA          7.5
4 2009-06-22     2 ATM1                NA         86  
5 2009-06-24     4 ATM2                NA         27  

Now the summary data should be clear:

atm |> 
  group_by(atm) |> 
  summarise(
    n = n(),
    sum = sum(cash_imputed),
    mean = mean(cash_imputed),
    max = max(cash_imputed),
    min = min(cash_imputed),
    median = median(cash_imputed)
    )
# A tibble: 4 × 7
  atm       n     sum    mean    max   min median
  <chr> <int>   <dbl>   <dbl>  <dbl> <dbl>  <dbl>
1 ATM1    365  30654   84.0     180   1       91 
2 ATM2    365  22750.  62.3     147   0       66 
3 ATM3    365    263    0.721    96   0        0 
4 ATM4    365 173026. 474.    10920.  1.56   404.

The summary data provides an interesting observation. The max value of ATM4 is over $1 million dollars. When all the data is plotted there are two immediate takeaways: - ATM4 has the highest daily total cash being withdrawn - The withdrawal day of $1,091,976 dollars is an outlier.

atm |> ggplot() +
  geom_point(aes(x = date, y = cash_imputed, color = atm))

That single value from ATM4 is so extreme that it has the potential to skew summarized data and forecasts. To proceed with the analysis, removing this outlier from the data set should be considered.

With the goal of forecasting how much will be taken out of each ATM each day for May 2010, this single outlier will be treated as an isolated anomaly and therefore will be removed from the dataset, prior to analysis. It will be replaced in the same manner as the missing data was earlier, with the median for the day of the week for that specific ATM.

atm_medians <- atm |> 
  drop_na(cash_in_hundreds) |>
  filter(
    !(date == as.Date("2010-02-09") & atm == "ATM4")
  ) |> 
  group_by(atm, day) |> 
  summarise(
    median = median(cash_in_hundreds),
    .groups = "drop"
  )

atm <- atm |> 
  left_join(
    atm_medians,
    by = c("atm", "day")
  ) |> 
  mutate(
    cash_imputed = if_else(
      date == as.Date("2010-02-09") & atm == "ATM4",
      median,
      cash_imputed
    )
  ) |> 
  select(
    date, day, atm, cash_in_hundreds, cash_imputed
  )
atm |> 
  group_by(atm) |> 
  summarise(
    n = n(),
    sum = sum(cash_imputed),
    mean = mean(cash_imputed),
    max = max(cash_imputed),
    min = min(cash_imputed),
    median = median(cash_imputed)
    )
# A tibble: 4 × 7
  atm       n     sum    mean   max   min median
  <chr> <int>   <dbl>   <dbl> <dbl> <dbl>  <dbl>
1 ATM1    365  30654   84.0    180   1       91 
2 ATM2    365  22750.  62.3    147   0       66 
3 ATM3    365    263    0.721   96   0        0 
4 ATM4    365 162407. 445.    1712.  1.56   403.

With the outlier removed, the highest value for ATM 4 is now $171207.50.

atm |> ggplot() +
  geom_boxplot(aes(x = atm, y = cash_imputed, color = atm))

The box plot shows the notable difference in the level of the withdrawals by ATM.

atm |> ggplot() +
  geom_line(aes(x = date, y = cash_imputed, color = atm))

The line plot shows more of the distribution, however they are difficult to separate out as they each exist at a different level. Below the line plots are separated by ATM.

atm |> ggplot() +
  geom_line(aes(x = date, y = cash_imputed, color = atm)) +
  facet_grid(atm ~ ., scales = "free_y")

The line plots provide a new piece of insight. Almost all of ATM3’s values are $0. This indicates several possible scenarios for ATM3:

  • It is a new ATM and has just started being used.
  • It has been broken, empty or non-operational until recently.

The shape of the data for ATM3 will be relevant to the approach for forecasting.

Looking at the shape of the other three plots and they all have similarities:

  • There does not seem to be a trend for any of the ATMs.
  • There withdrawals all seem to be around the same level per ATM.
  • Each atm has a consistent level of variation in itself.
  • It is possible that there is a seasonal variation based on day of the week.

Forecast Experimentation

Converting the data to a tsibble and creating separate tsibbles for each atm.

atm_ts <- as_tsibble(atm, index = date, key = atm)

atm1 <- atm_ts |> 
  filter(atm == "ATM1")

atm2 <- atm_ts |> 
  filter(atm == "ATM2")

atm3 <- atm_ts |> 
  filter(atm == "ATM3")

atm4 <- atm_ts |> 
  filter(atm == "ATM4")

Considering a Box-Cox Transformation

Considering a Box-Cox transformation: Since the variation does not appear to change based on level of the data, a Box-Cox transformation is not necessary for this data.

Time Series Decomposition

atm_decomp <- atm_ts |> 
  model(stl = STL(cash_imputed ~ season(period = 7)))

atm_components <- atm_decomp |> 
  components()

for (atms in unique(atm_components$atm)) {
  p <- atm_components |> 
    filter(atm == atms) |> 
    autoplot() +
    labs(title = paste("STL decomposition:", atms))
  
  print(p)
}

Decomposition notes for ATM1, ATM2, ATM4:

  • Decomposition supports that there is no sustained upward or downward trend but the underlying level does change over time.

  • The weekly seasonality appears to have a low withdrawal day one day per week.

Decomposition notes for ATM3:

  • Consistent with the earlier assessment that ATM3 was not being withdrawn from until late in April of 2010.

Forecasting

The following code fits ATM1, ATM2, and ATM4 with several forecasting models through March 31st 2010 as the training data. The models are then tested on the withheld month of April in order to discover which model has the lowest root mean square error for each model.

The models being trained include:

  • seasonal naive

  • exponential smoothing models with seasonality in additive, multiplicative, and an auto calculated model.

  • ARIMA models with seasonality of a 7 day period in 001113, 102111, and an auto calculated model.

atm_ts <- atm |> 
  filter(atm != "ATM3") |> 
  as_tibble() |> 
  as_tsibble(index = date, key = atm)

seasonal_atms <- atm_ts #|> 
  #filter(atm != "ATM3")

train <- seasonal_atms |> 
  filter(date < as.Date("2010-04-01"))

test <- seasonal_atms |> 
  filter(date >= as.Date("2010-04-01"))

atm_fits <-  train |> 
  model(
    snaive = SNAIVE(cash_imputed ~ lag("week")),
    smooth_additive = ETS(cash_imputed ~ error("A") + trend("N") + season("A", period = 7 )),
    smooth_multiplicative = ETS(cash_imputed ~ error("M") + trend("N") + season("M", period = 7)),
    smooth_auto = ETS(cash_imputed ~ season(period = 7)),
    arima_auto = ARIMA(cash_imputed ~ pdq() + PDQ(period = 7),
                       stepwise = FALSE,
                       approximation = FALSE),
    arima001113 = ARIMA(cash_imputed ~ 1 + pdq(0, 0, 1) + PDQ(1, 1, 3, period = 7)),
    arima102111 = ARIMA(cash_imputed ~ 1 + pdq(1, 0, 2) + PDQ(1, 1, 1, period = 7)) 
  )

test_forecasts <- atm_fits |> 
  forecast(new_data = test)

atm_accuracy <- test_forecasts |> 
  accuracy(test) |> 
  select(atm, .model, RMSE, MAE) |> 
  arrange(atm, RMSE)

ATM1

atm_accuracy |> 
  filter(atm == "ATM1") |> print()
# A tibble: 7 × 4
  atm   .model                 RMSE   MAE
  <chr> <chr>                 <dbl> <dbl>
1 ATM1  smooth_additive        11.6  9.13
2 ATM1  smooth_auto            11.6  9.13
3 ATM1  arima102111            13.0  9.84
4 ATM1  arima001113            13.3  9.89
5 ATM1  arima_auto             13.9 10.5 
6 ATM1  smooth_multiplicative  15.5 13.2 
7 ATM1  snaive                 16.0 13.5 

The root mean square error (RMSE) data for ATM1 indicate that the exponential smoothing additive model with seasonality has the lowest RMSE (the auto-calculated model has identical settings).

atm_fits |> 
  select(smooth_additive) |> 
  filter(atm == "ATM1") |> 
  gg_tsresiduals(lag = 31)

The residual plot indicates the errors are centered around zero and look largely like noise. The acf has no determinate shape, although a couple positions exceed the boundary limits. The histogram of residuals appears normal.

With a model selected, the full data, not just the training set is fit below, and the month of MAY 2010 is forecast.

The 95% prediction interval is extrapolated. Then two columns are created:

  • “atm_total_cash_to_stock” is the upper limit of the 95% prediction interval, or the 97.5th percentile rounded up and is a forecast of what each ATM should be stocked with, in hundreds of dollars, for the day, to attempt to ensure there is adequate cash to cover the days withdrawals.

  • “atm_forecasted_cash_withdrawal_per_day” is the forecast of what will be taken out each day in hundreds of dollars. It is the rounded up mean forecast by the model.

atm1_final_fit <-  atm1 |> 
  model(
    smooth_additive = ETS(cash_imputed ~ error("A") + trend("N") + season("A", period = 7 ))
  )

atm1_final_forecast <- atm1_final_fit |> 
  forecast(h = 31)

atm1_intervals <- atm1_final_forecast |> 
  hilo(level = 95) |> 
  as_tibble() |> 
  mutate(
    lower95 = `95%`$lower,
    upper95 = `95%`$upper,
    atm_total_cash_to_stock = ceiling(upper95),
    atm_forecasted_cash_withdrawal_per_day = ceiling(.mean)
  ) |> 
  select(atm, .model, date, atm_total_cash_to_stock, atm_forecasted_cash_withdrawal_per_day)

atm1_intervals
# A tibble: 31 × 5
   atm   .model         date       atm_total_cash_to_st…¹ atm_forecasted_cash_…²
   <chr> <chr>          <date>                      <dbl>                  <dbl>
 1 ATM1  smooth_additi… 2010-05-01                    135                     88
 2 ATM1  smooth_additi… 2010-05-02                    148                    101
 3 ATM1  smooth_additi… 2010-05-03                    121                     74
 4 ATM1  smooth_additi… 2010-05-04                     53                      6
 5 ATM1  smooth_additi… 2010-05-05                    148                    101
 6 ATM1  smooth_additi… 2010-05-06                    127                     80
 7 ATM1  smooth_additi… 2010-05-07                    136                     86
 8 ATM1  smooth_additi… 2010-05-08                    137                     88
 9 ATM1  smooth_additi… 2010-05-09                    151                    101
10 ATM1  smooth_additi… 2010-05-10                    124                     74
# ℹ 21 more rows
# ℹ abbreviated names: ¹​atm_total_cash_to_stock,
#   ²​atm_forecasted_cash_withdrawal_per_day
atm1_final_forecast |> 
  autoplot(
    atm1 |> filter(date >= as.Date("2010-03-01")),
    level = 95
  ) + labs(
    title = "ATM1: May 2010 withdrawal forecast",
    x = "Date",
    y = "Daily withdrawals (hundreds of dollars)"
  ) +
  theme_minimal()

ATM2

atm_accuracy |> 
  filter(atm == "ATM2") |> print()
# A tibble: 7 × 4
  atm   .model                 RMSE   MAE
  <chr> <chr>                 <dbl> <dbl>
1 ATM2  smooth_additive        19.4  14.3
2 ATM2  smooth_auto            19.4  14.3
3 ATM2  arima001113            20.2  15.0
4 ATM2  arima102111            20.5  15.1
5 ATM2  arima_auto             23.2  17.8
6 ATM2  snaive                 26.2  17.8
7 ATM2  smooth_multiplicative  52.6  45.3

The root mean square error (RMSE) data for ATM2 indicate that the exponential smoothing additive model with seasonality has the lowest RMSE (the auto-calculated model has identical settings).

atm_fits |> 
  select(smooth_additive) |> 
  filter(atm == "ATM2") |> 
  gg_tsresiduals(lag = 31)

The residual plot indicates the errors are centered around zero and look largely like noise. The acf has no determinate shape, although a couple positions exceed the boundary limits. The histogram of residuals appears normal.

With a model selected, the full data, not just the training set is fit below, and the month of MAY 2010 is forecasted.

The 95% prediction interval is extrapolated. Then two columns are created:

  • “atm_total_cash_to_stock” is the upper limit of the 95% prediction interval, or the 97.5th percentile rounded up and is a forecast of what each ATM should be stocked with, in hundreds of dollars, for the day, to attempt to ensure there is adequate cash to cover the days withdrawals.

  • “atm_forecasted_cash_withdrawal_per_day” is the forecast of what will be taken out each day in hundreds of dollars. It is the rounded up mean forecast by the model.

atm2_final_fit <-  atm2 |> 
  model(
    smooth_additive = ETS(cash_imputed ~ error("A") + trend("N") + season("A", period = 7 ))
  )

atm2_final_forecast <- atm2_final_fit |> 
  forecast(h = 31)

atm2_intervals <- atm2_final_forecast |> 
  hilo(level = 95) |> 
  as_tibble() |> 
  mutate(
    lower95 = `95%`$lower,
    upper95 = `95%`$upper,
    atm_total_cash_to_stock = ceiling(upper95),
    atm_forecasted_cash_withdrawal_per_day = ceiling(.mean)
  ) |> 
  select(atm, .model, date, atm_total_cash_to_stock, atm_forecasted_cash_withdrawal_per_day)

atm2_intervals
# A tibble: 31 × 5
   atm   .model         date       atm_total_cash_to_st…¹ atm_forecasted_cash_…²
   <chr> <chr>          <date>                      <dbl>                  <dbl>
 1 ATM2  smooth_additi… 2010-05-01                    118                     69
 2 ATM2  smooth_additi… 2010-05-02                    124                     75
 3 ATM2  smooth_additi… 2010-05-03                     61                     12
 4 ATM2  smooth_additi… 2010-05-04                     52                      3
 5 ATM2  smooth_additi… 2010-05-05                    151                    102
 6 ATM2  smooth_additi… 2010-05-06                    142                     93
 7 ATM2  smooth_additi… 2010-05-07                    122                     70
 8 ATM2  smooth_additi… 2010-05-08                    121                     69
 9 ATM2  smooth_additi… 2010-05-09                    127                     75
10 ATM2  smooth_additi… 2010-05-10                     64                     12
# ℹ 21 more rows
# ℹ abbreviated names: ¹​atm_total_cash_to_stock,
#   ²​atm_forecasted_cash_withdrawal_per_day
atm2_final_forecast |> 
  autoplot(
    atm2 |> filter(date >= as.Date("2010-03-01")),
    level = 95
  ) + labs(
    title = "ATM2: May 2010 withdrawal forecast",
    x = "Date",
    y = "Daily withdrawals (hundreds of dollars)"
  ) +
  theme_minimal()

ATM3

Generally the same workflow has been used to analyze ATM1, ATM2, and ATM4, but ATM3 must be considered separately. As discussed earlier, it mostly contains no transactions until recently the last three days of April.

autoplot(atm3, cash_imputed)

With only three points with data above zero, and those being the three most recent, it is harder to determine a forecast. As a result of this small number of non-zero points, a naive forecast approach is the most reasoned choice. This method assumes that the non-zero values represent a new operational state and that going forward, the atm will be actively aused.

atm3_final_fit <- atm3 |> 
  model(naive = NAIVE(cash_imputed)
        )

atm3_final_forecast <- atm3_final_fit |> 
  forecast(h = 31)

atm3_intervals <- atm3_final_forecast |> 
  hilo(level = 95) |> 
  as_tibble() |> 
  mutate(
    lower95 = `95%`$lower,
    upper95 = `95%`$upper,
    atm_total_cash_to_stock = ceiling(upper95),
    atm_forecasted_cash_withdrawal_per_day = ceiling(.mean)
  ) |> 
  select(atm, .model, date, atm_total_cash_to_stock, atm_forecasted_cash_withdrawal_per_day)

atm3_intervals
# A tibble: 31 × 5
   atm   .model date       atm_total_cash_to_stock atm_forecasted_cash_withdra…¹
   <chr> <chr>  <date>                       <dbl>                         <dbl>
 1 ATM3  naive  2010-05-01                      95                            85
 2 ATM3  naive  2010-05-02                     100                            85
 3 ATM3  naive  2010-05-03                     103                            85
 4 ATM3  naive  2010-05-04                     105                            85
 5 ATM3  naive  2010-05-05                     108                            85
 6 ATM3  naive  2010-05-06                     110                            85
 7 ATM3  naive  2010-05-07                     112                            85
 8 ATM3  naive  2010-05-08                     114                            85
 9 ATM3  naive  2010-05-09                     115                            85
10 ATM3  naive  2010-05-10                     117                            85
# ℹ 21 more rows
# ℹ abbreviated name: ¹​atm_forecasted_cash_withdrawal_per_day
atm3_final_forecast |> 
  autoplot(
    atm3 |> filter(date >= as.Date("2010-03-01")),
    level = 95
  ) + labs(
    title = "ATM3: May 2010 withdrawal forecast",
    x = "Date",
    y = "Daily withdrawals (hundreds of dollars)"
  ) +
  theme_minimal()

The prediction interval is based primarily on a mostly zero history and may not represent uncertainty.

ATM4

atm_accuracy |> 
  filter(atm == "ATM4") |> print()
# A tibble: 7 × 4
  atm   .model                 RMSE   MAE
  <chr> <chr>                 <dbl> <dbl>
1 ATM4  smooth_multiplicative  243.  207.
2 ATM4  arima001113            269.  228.
3 ATM4  arima102111            272.  231.
4 ATM4  arima_auto             273.  234.
5 ATM4  snaive                 301.  227.
6 ATM4  smooth_additive        311.  263.
7 ATM4  smooth_auto            311.  263.

The root mean square error (RMSE) data for ATM4 indicate that the exponential smoothing multiplicative model with seasonality has the lowest RMSE.

atm_fits |> 
  select(smooth_multiplicative) |> 
  filter(atm == "ATM4") |> 
  gg_tsresiduals(lag = 31)

The residual plot indicates the errors are centered around zero and look largely like noise. The acf has no determinate shape and appears to be noise, and all points are within the boundary lines. The histogram of residuals approaches is right skewed.

With a model selected, the full data, not just the training set is fit below, and the month of MAY 2010 is forecast.

The 95% prediction interval is extrapolated. Then two columns are created:

  • “atm_total_cash_to_stock” is the upper limit of the 95% prediction interval, or the 97.5th percentile rounded up and is a forecast of what each ATM should be stocked with, in hundreds of dollars, for the day, to attempt to ensure there is adequate cash to cover the days withdrawals.

  • “atm_forecasted_cash_withdrawal_per_day” is the forecast of what will be taken out each day in hundr3eds of dollars. It is the rounded up mean forecast by the model.

atm4_final_fit <-  atm4 |> 
  model(
    smooth_multiplicative = ETS(cash_imputed ~ error("M") + trend("N") + season("M", period = 7 ))
  )

atm4_final_forecast <- atm4_final_fit |> 
  forecast(h = 31)

atm4_intervals <- atm4_final_forecast |> 
  hilo(level = 95) |> 
  as_tibble() |> 
  mutate(
    lower95 = `95%`$lower,
    upper95 = `95%`$upper,
    atm_total_cash_to_stock = ceiling(upper95),
    atm_forecasted_cash_withdrawal_per_day = ceiling(.mean)
  ) |> 
  select(atm, .model, date, atm_total_cash_to_stock, atm_forecasted_cash_withdrawal_per_day)

atm4_intervals
# A tibble: 31 × 5
   atm   .model         date       atm_total_cash_to_st…¹ atm_forecasted_cash_…²
   <chr> <chr>          <date>                      <dbl>                  <dbl>
 1 ATM4  smooth_multip… 2010-05-01                    770                    288
 2 ATM4  smooth_multip… 2010-05-02                    978                    366
 3 ATM4  smooth_multip… 2010-05-03                    953                    356
 4 ATM4  smooth_multip… 2010-05-04                    144                     54
 5 ATM4  smooth_multip… 2010-05-05                   1181                    440
 6 ATM4  smooth_multip… 2010-05-06                    828                    308
 7 ATM4  smooth_multip… 2010-05-07                   1262                    468
 8 ATM4  smooth_multip… 2010-05-08                    817                    291
 9 ATM4  smooth_multip… 2010-05-09                   1039                    369
10 ATM4  smooth_multip… 2010-05-10                   1011                    359
# ℹ 21 more rows
# ℹ abbreviated names: ¹​atm_total_cash_to_stock,
#   ²​atm_forecasted_cash_withdrawal_per_day
atm4_final_forecast |> 
  autoplot(
    atm4 |> filter(date >= as.Date("2010-03-01")),
    level = 95
  ) + labs(
    title = "ATM4: May 2010 withdrawal forecast",
    x = "Date",
    y = "Daily withdrawals (hundreds of dollars)"
  ) +
  theme_minimal()

Combined Final Forecasts

The following code combines the forecasts into a single dataframe and exports them to excel.

atm_final_forecasts <- bind_rows(
  atm1_intervals, 
  atm2_intervals, 
  atm3_intervals, 
  atm4_intervals) |> 
  arrange(date) |> 
  rename(
    atm_total_cash_to_stock_in_hundreds_of_dollars = atm_total_cash_to_stock,
    atm_forecasted_cash_withdrawal_per_day_in_undreds_of_dollars = atm_forecasted_cash_withdrawal_per_day
  )

write_xlsx(atm_final_forecasts, "atm_final_forecasts.xlsx")

Part B - Forecasting Power

The data is loaded below and converted to a tsibble.

power_raw <- read_xlsx("ResidentialCustomerForecastLoad-624.xlsx") |> 
  rename(
    case_sequence = CaseSequence,
    date = "YYYY-MMM",
    kwh = KWH
  ) |> 
  mutate(
    date = yearmonth(date),
  ) |> as_tsibble(index = "date") |> 
  arrange(date)

glimpse(power_raw)
Rows: 192
Columns: 3
$ case_sequence <dbl> 733, 734, 735, 736, 737, 738, 739, 740, 741, 742, 743, 7…
$ date          <mth> 1998 Jan, 1998 Feb, 1998 Mar, 1998 Apr, 1998 May, 1998 J…
$ kwh           <dbl> 6862583, 5838198, 5420658, 5010364, 4665377, 6467147, 89…

The data is plotted below.

power_raw |> 
  autoplot(kwh) +
  labs(title = "Power usage in Kilowatt-Hours",
       x = "Year, Month",
       y = "Kilowatt-Hours")

The plot of power usage shows several things: - There is the possibility of a slight upward trend, particularly starting around 2009. - The data has fluctuations which appear to be seasonality. - There is missing data around 2008. - There is one very low value in 2010 which appears to be an outlier.

Handling the anomalies

To start, we will examine the outlier.

power_raw |>
  filter(date >= yearmonth("2010 Jan") & date <= yearmonth("2010 Dec"))
# A tsibble: 12 x 3 [1M]
   case_sequence     date     kwh
           <dbl>    <mth>   <dbl>
 1           877 2010 Jan 9397357
 2           878 2010 Feb 8390677
 3           879 2010 Mar 7347915
 4           880 2010 Apr 5776131
 5           881 2010 May 4919289
 6           882 2010 Jun 6696292
 7           883 2010 Jul  770523
 8           884 2010 Aug 7922701
 9           885 2010 Sep 7819472
10           886 2010 Oct 5875917
11           887 2010 Nov 4800733
12           888 2010 Dec 6152583

The outlier value is 2010 July at 770523. This is a tenth of the other values nearby in the timeline. This strongly indicates that this is an anomalous value. Without more information, it is difficult to determine why this one value is so much lower than all other values. It is potentially an error in either collection of data or recording of data. While it is theoretically possible that an unseasonably cool July in 2010 meant less power usage, considering how much lower it is than all other data in the data set, the likelihood that the value is accurate is low. Therefore, it is assumed that 2010 July is an isolated anomaly.

In this instance the median value of prior July energy usage will be used to impute the 2010 July value.

This code calculates the median of July energy usage prior to 2010 July. The dataframe will be updated later on.

july_median <- power_raw |> 
  as_tibble() |> 
  filter(
    date < yearmonth("2010 Jul"),
    month(date) == 7) |> 
  summarise(
   median_kwh = median(kwh, na.rm = TRUE)
  ) |> pull(median_kwh)

Now the data will be checked for missing values. Below we can see a missing value for 2008 Sep. 

power_raw |> 
  as_tibble() |> 
  filter(if_any(everything(), is.na))
# A tibble: 1 × 3
  case_sequence     date   kwh
          <dbl>    <mth> <dbl>
1           861 2008 Sep    NA

This missing value will be imputed using the same method as the extremely low value, suing the median for the previous same months.

sep_median <- power_raw |> 
  as_tibble() |> 
  filter(
    date < yearmonth("2008 Sep"),
    month(date) == 9) |> 
  summarise(
   median_kwh = median(kwh, na.rm = TRUE)
  ) |> pull(median_kwh)

The missing value and the very low value are replaced below.

power_imputed <- power_raw |> 
  mutate(
    kwh = if_else(date == yearmonth("2008 Sep"), sep_median, kwh),
    kwh = if_else(date == yearmonth("2010 Jul"), july_median, kwh)
  )

Exploratory Data Analysis

Now, with the missing value and the outlier replaced, the data is plotted below.

power_imputed |> autoplot(kwh) +
  labs(title = "Power usage in Kilowatt-Hours",
       x = "Year, Month",
       y = "Kilowatt-Hours")

dcmp <- power_imputed |> 
  model(stl = STL(kwh))

components(dcmp) |> 
  as_tsibble() |> 
  autoplot(kwh, colour = "gray") +
  geom_line(aes(y = trend), colour = "orange") +
  labs(
    y = "Kilowatt-Hours",
    title = "Power usage"
  )

The plotted decomposition supports the earlier impression that the later part of the data has an upward trend.

components(dcmp) |> autoplot()

The full decomposition plot supports the trend assertion, indicates a consistent seasonality and the remainder appears to be noise.

There does appear to be slight increase in the variation of the seasonal fluctuation, which means a Box-Cox transformation may be useful.

power_lambda_train <- power_imputed |> 
  features(kwh, features = guerrero) |> 
  pull(lambda_guerrero)

power_imputed |> 
  autoplot(box_cox(kwh, power_lambda_train)) +
  labs(
    y = "",
    title = paste0(
      "Transformed kwh consumption with lambda = ",
      round(power_lambda_train, 2)
    ))

Forecasting

Models are fit to the un-transformed data from 1998 to 2012 and one Box-Cox ARIMA model, and then forecast for 2013.

power_ts <- power_imputed 

power_train <- power_ts |> 
  filter(date < yearmonth("2013 Jan"))

power_test <- power_ts |> 
  filter(date >= yearmonth("2013 Jan"))

power_fits <-  power_train |> 
  model(
    snaive = SNAIVE(kwh ~ lag("year")),
    smooth_additive = ETS(kwh ~ error("A") + trend("N") + season("A", period = 12)),
    smooth_multiplicative = ETS(kwh ~ error("M") + trend("N") + season("M", period = 12)),
    smooth_auto = ETS(kwh ~ season(period = 12)),
    arima_auto = ARIMA(kwh ~ pdq() + PDQ(period = 12),
                       stepwise = FALSE,
                       approximation = FALSE),
    arima_auto_box_cox = ARIMA(box_cox(kwh, power_lambda_train) ~ pdq() + PDQ(period = 12),
                       stepwise = FALSE,
                       approximation = FALSE),
    arima301113 = ARIMA(kwh ~ 1 + pdq(3, 0, 1) + PDQ(1, 1, 3, period = 12)),
    arima102111 = ARIMA(kwh ~ 1 + pdq(1, 0, 2) + PDQ(1, 1, 1, period = 12)) 
  )

power_test_forecasts <- power_fits |> 
  forecast(new_data = power_test)

power_accuracy <- power_test_forecasts |> 
  accuracy(power_test) |> 
  select(.model, RMSE, MAE) |> 
  arrange(RMSE)

Below are the fitted models.

power_fits |> 
  pivot_longer(
    cols = everything(),
    names_to = "model",
    values_to = "parameters"
  )
# A mable: 8 x 2
# Key:     model [8]
  model                                         parameters
  <chr>                                            <model>
1 snaive                                          <SNAIVE>
2 smooth_additive                             <ETS(A,N,A)>
3 smooth_multiplicative                       <ETS(M,N,M)>
4 smooth_auto                                 <ETS(M,N,A)>
5 arima_auto            <ARIMA(3,0,1)(2,1,0)[12] w/ drift>
6 arima_auto_box_cox    <ARIMA(4,0,0)(2,1,0)[12] w/ drift>
7 arima301113           <ARIMA(3,0,1)(1,1,3)[12] w/ drift>
8 arima102111           <ARIMA(1,0,2)(1,1,1)[12] w/ drift>
report(power_fits)
Warning in report.mdl_df(power_fits): Model reporting is only supported for
individual models, so a glance will be shown. To see the report for a specific
model, use `select()` and `filter()` to identify a single model.
# A tibble: 8 × 11
  .model          sigma2 log_lik    AIC   AICc    BIC      MSE     AMSE      MAE
  <chr>            <dbl>   <dbl>  <dbl>  <dbl>  <dbl>    <dbl>    <dbl>    <dbl>
1 snaive        6.31e+11     NA     NA     NA     NA  NA       NA       NA      
2 smooth_addit… 3.73e+11  -2858.  5746.  5749.  5794.  3.44e11  3.76e11  4.54e+5
3 smooth_multi… 8.79e- 3  -2853.  5735.  5738.  5783.  3.55e11  3.84e11  7.21e-2
4 smooth_auto   8.68e- 3  -2851.  5732.  5735.  5780.  3.48e11  3.68e11  6.97e-2
5 arima_auto    3.28e+11  -2467.  4950.  4951.  4975. NA       NA       NA      
6 arima_auto_b… 1.43e- 5    708. -1400. -1399. -1375. NA       NA       NA      
7 arima301113   3.36e+11  -2468.  4955.  4956.  4986. NA       NA       NA      
8 arima102111   3.51e+11  -2473.  4959.  4960.  4981. NA       NA       NA      
# ℹ 2 more variables: ar_roots <list>, ma_roots <list>

The table of the RMSE is below. The lowest RMSE value is for the arima_auto model: ARIM(3,0,1)(2,1,0)[12] with drift.

power_accuracy
# A tibble: 8 × 3
  .model                    RMSE     MAE
  <chr>                    <dbl>   <dbl>
1 arima_auto             946077. 633448.
2 arima301113            952387. 596311.
3 arima102111            952723. 611241.
4 arima_auto_box_cox     965465. 682745.
5 smooth_multiplicative  975514. 618313.
6 snaive                1035538. 618606.
7 smooth_auto           1071745. 639669.
8 smooth_additive       1083001. 649364.
power_test_forecasts |>
  filter(.model == "arima_auto") |> 
  autoplot(power_ts, level = NULL) +
  labs(
    title = "Power usage: actual and forecast ARIMA(3,0,1)(2,1,0) with drift",
    x = "Year Month",       
    y = "kWh",
    colour = "Model"
  ) +
  theme_minimal()

A look at the plots of residuals looks like the selected model is performing well. The residuals look like noise adn are centered around zero. The ACF has no specific patter and all values are within the boundary range. The histogram of residuals is approximately normal.

power_fits |> 
  select(arima_auto) |> 
  gg_tsresiduals()

As arima_auto was the best fit, below it is refit to the full dataset and forecasted for the year of 2014.

power_final_fit <- power_imputed |> 
  model(
  arima_auto = ARIMA(kwh ~ pdq() + PDQ(period = 12),
                       stepwise = FALSE,
                       approximation = FALSE)
  )

power_final_forecast <- power_final_fit |> 
  forecast(h = 12)
  
report(power_final_fit)
Series: kwh 
Model: ARIMA(0,0,3)(2,1,0)[12] w/ drift 

Coefficients:
         ma1     ma2     ma3     sar1     sar2   constant
      0.3481  0.0337  0.2298  -0.7083  -0.4232  230388.99
s.e.  0.0786  0.0895  0.0739   0.0775   0.0783   76271.72

sigma^2 estimated as 3.788e+11:  log likelihood=-2656.03
AIC=5326.07   AICc=5326.72   BIC=5348.42

The resulting ARIMA is different than the original with the additional data.

Then prediction intervals are given their own columns.

power_intervals <- power_final_forecast |> 
  hilo(level = 95) |> 
  as_tibble() |> 
  mutate(
    lower95 = `95%`$lower,
    upper95 = `95%`$upper
  )

power_intervals
# A tibble: 12 × 7
   .model         date
   <chr>         <mth>
 1 arima_auto 2014 Jan
 2 arima_auto 2014 Feb
 3 arima_auto 2014 Mar
 4 arima_auto 2014 Apr
 5 arima_auto 2014 May
 6 arima_auto 2014 Jun
 7 arima_auto 2014 Jul
 8 arima_auto 2014 Aug
 9 arima_auto 2014 Sep
10 arima_auto 2014 Oct
11 arima_auto 2014 Nov
12 arima_auto 2014 Dec
# ℹ 5 more variables: kwh <dist>, .mean <dbl>, `95%` <hilo>, lower95 <dbl>,
#   upper95 <dbl>
power_final_forecast |> 
  autoplot(
    power_ts,
    level = 95
  ) + labs(
    title = "Power usage and forecast for 2014",
    x = "Year Month",
    y = "kWh"
  ) +
  theme_minimal()

The plot of the forecast and full data.

power_final_forecast |> 
  autoplot(
    power_ts |> 
      filter(date >= yearmonth("2009 Jan")),
    level = 95
  ) + labs(
    title = "Power usage and forecast for 2014",
    x = "Year Month",
    y = "kWh"
  ) +
  theme_minimal()

A closer look at the forecast.

Below the forecast is exported to an excel file with the .mean as the forecast value along with the predficiton interval for forecasting reference.

power_intervals |> 
  transmute(
    month = format(date, "%Y-%m"),
    kwh_forecast = .mean,
    lower95_kwh = lower95,
    upper95_kwh = upper95
  ) |> 
  write_xlsx("power_final_forecast.xlsx")

Part C - BONUS

The intended approach to this task is slightly unclear. I see two possible approaches combining and averaging the two sets of pipe data together, or analyzing them separately. Both are attmepted here.

Combine both pipe data sets and forecast

The following code imports the data, converts the date time column to a date time data type and combines the two dataframes.

pipe1 <- read_xlsx("Waterflow_Pipe1.xlsx", col_types = c("date", "numeric")) |>
  rename(
  date_time = `Date Time`,
  water_flow = WaterFlow
  )

pipe2 <- read_xlsx("Waterflow_Pipe2.xlsx", col_types = c("date", "numeric")) |>
  rename(
  date_time = `Date Time`,
  water_flow = WaterFlow
  )

pipe <- bind_rows(pipe1, pipe2) |> 
  arrange(date_time)

A look at the head of the data.

head(pipe)
# A tibble: 6 × 2
  date_time           water_flow
  <dttm>                   <dbl>
1 2015-10-23 00:24:06      23.4 
2 2015-10-23 00:40:02      28.0 
3 2015-10-23 00:53:51      23.1 
4 2015-10-23 00:55:40      30.0 
5 2015-10-23 01:00:00      18.8 
6 2015-10-23 01:19:17       6.00

The following code averages the data by hour and converts it to a tsibble.

avg_pipe <- pipe |> 
  group_by(hour = floor_date(date_time, "1 hour")) |> 
  summarize(avg_hourly_water_flow = mean(water_flow, na.rm = TRUE)) |> 
  arrange(hour) |> 
  as_tsibble(index = hour)

head(avg_pipe)
# A tsibble: 6 x 2 [1h] <UTC>
  hour                avg_hourly_water_flow
  <dttm>                              <dbl>
1 2015-10-23 00:00:00                  26.1
2 2015-10-23 01:00:00                  18.8
3 2015-10-23 02:00:00                  24.5
4 2015-10-23 03:00:00                  25.6
5 2015-10-23 04:00:00                  22.4
6 2015-10-23 05:00:00                  23.6

A plot of the data.

avg_pipe |> 
  autoplot(avg_hourly_water_flow)

The data does not appear to be stationary. While it does not appear to have a trend or seasonal variation, it has significant non-seasonal variation and a notable change in variation. Around November 1, the level and the variation change significantly.

avg_pipe |> 
  ACF(avg_hourly_water_flow) |> 
  autoplot()

The data has strong autocorrelation but it does not appear to follow a specific seasonal fluctuation.

avg_pipe |> 
  features(avg_hourly_water_flow, unitroot_kpss)
# A tibble: 1 × 2
  kpss_stat kpss_pvalue
      <dbl>       <dbl>
1      4.06        0.01

As indicated by the p-value of the unit root test the null hypothesis is rejected inidicating that ht edata are not stationary.

avg_pipe |> 
  mutate(diff = difference(avg_hourly_water_flow)) |> 
  features(diff, unitroot_kpss)
# A tibble: 1 × 2
  kpss_stat kpss_pvalue
      <dbl>       <dbl>
1   0.00752         0.1

Differencing changes the p-value to 0.1 or above so the data may in fact be stationary now.

avg_pipe |> 
  features(avg_hourly_water_flow, unitroot_ndiffs)
# A tibble: 1 × 1
  ndiffs
   <int>
1      1

Unitroot ndiffs indicate that 1 differencing is recommended.

avg_pipe |> 
  autoplot(difference(avg_hourly_water_flow))
Warning: Removed 1 row containing missing values or values outside the scale range
(`geom_line()`).

Differencing centers the data around zero but does little to stabilize the variation.

water_lambda <- avg_pipe |> 
  features(avg_hourly_water_flow, features = guerrero) |> 
  pull(lambda_guerrero)

avg_pipe |> 
  autoplot(box_cox(avg_hourly_water_flow, water_lambda))

Box-Cox transformation is also not beneficial in stabilizing the variation.

Ultimately it may not be beneficial to transform this data to make it stationary.

A better approach may be to consider only data since November 1st as that data, when considered without the earlier data is stationary. From that we can will create a forecast.

post_nov_pipe <- avg_pipe |> 
  filter(hour >= as.Date("2015-11-01 00:00:00"))
post_nov_pipe |>
  autoplot(avg_hourly_water_flow)

Now the data appears stationary.

pipe_fits <- post_nov_pipe |> 
  model(
    arima_auto = ARIMA(avg_hourly_water_flow,
                       stepwise = FALSE,
                       approximation = FALSE)
  )
pipe_fits
# A mable: 1 x 1
                         arima_auto
                            <model>
1 <ARIMA(0,0,0)(0,0,1)[24] w/ mean>

Utilizing the auto ARIMA function, a lag of 24 is selected as a seasonal term. Interestingly the auto arima does not select a differencing coefficient.

Below a forecast is made.

pipe_forecast <- pipe_fits |> 
  forecast(h = (168))
pipe_forecast |> 
  autoplot(post_nov_pipe, level = 95)

The ARIMA(0, 0, 0)(0, 0, 1)[24] model is plotted above. Forecasts vary during the first 24 hours and then settles on the estimated mean. It is the influence of Pipe 1’s shorter data set that creates the earlier variation change in the combined data set. Ultimately, an analysis of the two data sets separately seems the preferred method. This is performed below.

pipe_forecast |> 
  hilo(level = 95) |>
  as_tibble() |>
  transmute(
    hour = format(hour, "%Y-%m-%d %H:%M:%S"),
    forecast_flow = .mean,
    lower95 = `95%`$lower,
    upper95 = `95%`$upper
  ) |>
  write_xlsx("pipe_forecast.xlsx")

Analyze both pipes separately

The following code imports the data, converts the date time column to a date time data type and combines the two dataframes while retaining an id column.

pipe1 <- read_xlsx("Waterflow_Pipe1.xlsx", col_types = c("date", "numeric")) |>
  rename(
  date_time = `Date Time`,
  water_flow = WaterFlow
  )

pipe2 <- read_xlsx("Waterflow_Pipe2.xlsx", col_types = c("date", "numeric")) |>
  rename(
  date_time = `Date Time`,
  water_flow = WaterFlow
  )

pipe <- bind_rows(pipe1 = pipe1, pipe2 = pipe2, .id = "pipe") |> 
  arrange(pipe, date_time)

head(pipe)
# A tibble: 6 × 3
  pipe  date_time           water_flow
  <chr> <dttm>                   <dbl>
1 pipe1 2015-10-23 00:24:06      23.4 
2 pipe1 2015-10-23 00:40:02      28.0 
3 pipe1 2015-10-23 00:53:51      23.1 
4 pipe1 2015-10-23 00:55:40      30.0 
5 pipe1 2015-10-23 01:19:17       6.00
6 pipe1 2015-10-23 01:23:58      15.9 

The following code averages the data by hour and converts it to a tsibble.

avg_pipe <- pipe |> 
  group_by(pipe, hour = floor_date(date_time, "1 hour")) |> 
  summarize(avg_hourly_water_flow = mean(water_flow, na.rm = TRUE), .groups = "drop") |> 
  arrange(hour) |> 
  as_tsibble(key = pipe, index = hour)

head(avg_pipe)
# A tsibble: 6 x 3 [1h] <UTC>
# Key:       pipe [1]
  pipe  hour                avg_hourly_water_flow
  <chr> <dttm>                              <dbl>
1 pipe1 2015-10-23 00:00:00                  26.1
2 pipe1 2015-10-23 01:00:00                  18.9
3 pipe1 2015-10-23 02:00:00                  15.2
4 pipe1 2015-10-23 03:00:00                  23.1
5 pipe1 2015-10-23 04:00:00                  15.5
6 pipe1 2015-10-23 05:00:00                  22.7

A plot of the data.

avg_pipe |> 
  autoplot(avg_hourly_water_flow) +
  facet_grid(pipe ~ .) +
  labs(
    title = "Hourly average water flow by pipe",
    x = "Date",
    y = "Average water flow"
  ) +
  guides(colour = "none") +
  theme_minimal()

The data is possibly stationary. The two sets of data have very different time spans and size of variation, which explains the variation difference in the original analysis of this data. However, as was learned in the earlier analysis, it is possible that there is a 24 hour seasonality.

Gaps

scan_gaps(avg_pipe)
# A tsibble: 4 x 2 [1h] <UTC>
# Key:       pipe [1]
  pipe  hour               
  <chr> <dttm>             
1 pipe1 2015-10-27 17:00:00
2 pipe1 2015-10-28 00:00:00
3 pipe1 2015-11-01 05:00:00
4 pipe1 2015-11-01 09:00:00

Pipe 1 has four times with missing values. These will be imputed by using the median value for the same hour.

avg_pipe_imputed <- avg_pipe |> 
  fill_gaps()

median17 <- avg_pipe_imputed |> 
  as_tibble() |> 
  filter(pipe == "pipe1" & as_hms(hour) == as_hms("17:00:00")) |> 
  pull(avg_hourly_water_flow) |> 
  median(na.rm = TRUE)

median00 <- avg_pipe_imputed |> 
  as_tibble() |> 
  filter(pipe == "pipe1" & as_hms(hour) == as_hms("00:00:00")) |> 
  pull(avg_hourly_water_flow) |> 
  median(na.rm = TRUE)

median05 <- avg_pipe_imputed |> 
  as_tibble() |> 
  filter(pipe == "pipe1" & as_hms(hour) == as_hms("05:00:00")) |> 
  pull(avg_hourly_water_flow) |> 
  median(na.rm = TRUE)

median09 <- avg_pipe_imputed |> 
  as_tibble() |> 
  filter(pipe == "pipe1" & as_hms(hour) == as_hms("09:00:00")) |> 
  pull(avg_hourly_water_flow) |> 
  median(na.rm = TRUE)

avg_pipe_imputed <- avg_pipe_imputed |>
  as_tibble() |>
  mutate(
    avg_hourly_water_flow = case_when(
      pipe == "pipe1" & is.na(avg_hourly_water_flow) &
        hour(hour) == 17 ~ median17,

      pipe == "pipe1" & is.na(avg_hourly_water_flow) &
        hour(hour) == 0 ~ median00,

      pipe == "pipe1" & is.na(avg_hourly_water_flow) &
        hour(hour) == 5 ~ median05,

      pipe == "pipe1" & is.na(avg_hourly_water_flow) &
        hour(hour) == 9 ~ median09,

      TRUE ~ avg_hourly_water_flow
    )
  ) |>
  as_tsibble(key = pipe, index = hour)
avg_pipe_imputed |> 
  filter(pipe == "pipe1") |> 
  ACF(avg_hourly_water_flow, lag_max = 72) |> 
  autoplot()

For Pipe 1 there is no indication of autocorrelation.

avg_pipe_imputed |> 
  filter(pipe == "pipe1") |> 
  features(avg_hourly_water_flow, unitroot_kpss)
# A tibble: 1 × 3
  pipe  kpss_stat kpss_pvalue
  <chr>     <dbl>       <dbl>
1 pipe1     0.237         0.1

As indicated by the p-value of the unit root test the null hypothesis is not rejected indicating that stationarity is not rejected for Pipe 1.

avg_pipe_imputed |> 
  filter(pipe == "pipe2") |> 
  ACF(avg_hourly_water_flow, lag_max = 72) |> 
  autoplot()

The autocorrelation for Pipe 2 possibly provides weak indications of autocorrelation at best.

avg_pipe_imputed |> 
  filter(pipe == "pipe2") |> 
  features(avg_hourly_water_flow, unitroot_kpss)
# A tibble: 1 × 3
  pipe  kpss_stat kpss_pvalue
  <chr>     <dbl>       <dbl>
1 pipe2     0.105         0.1

The p-value indicates that the null hypothesis should fail to be rejected, failing to reject stationarity.

avg_pipe_imputed |> 
  filter(pipe == "pipe2") |> 
  features(avg_hourly_water_flow, unitroot_ndiffs)
# A tibble: 1 × 2
  pipe  ndiffs
  <chr>  <int>
1 pipe2      0

Both individual data sets of pipes appear to be stationary.

pipe_fits <- avg_pipe_imputed |> 
  model(
    mean = MEAN(avg_hourly_water_flow),
    smooth_auto = ETS(avg_hourly_water_flow),
    arima_auto = ARIMA(avg_hourly_water_flow,
                       stepwise = FALSE,
                       approximation = FALSE)
  )
pipe_fits |> pivot_longer(
  cols = -pipe,
  names_to = "model",
  values_to = "parameters"
)
# A mable: 6 x 3
# Key:     pipe, model [6]
  pipe  model                              parameters
  <chr> <chr>                                 <model>
1 pipe1 mean                                   <MEAN>
2 pipe1 smooth_auto                      <ETS(M,N,N)>
3 pipe1 arima_auto             <ARIMA(0,0,0) w/ mean>
4 pipe2 mean                                   <MEAN>
5 pipe2 smooth_auto                      <ETS(M,N,N)>
6 pipe2 arima_auto  <ARIMA(0,0,0)(1,0,0)[24] w/ mean>
pipe_split <- avg_pipe_imputed |> 
  group_by(pipe) |> 
  mutate(cutoff = max(hour) - hours(48)) |> 
  ungroup()

pipe_train <- pipe_split |> 
  filter(hour <= cutoff) |> 
  select(-cutoff)

pipe_test <- pipe_split |> 
  filter(hour > cutoff) |> 
  select(-cutoff)

pipe_test_fits <- pipe_train |> 
  model(
    mean = MEAN(avg_hourly_water_flow),
    smooth_auto = ETS(avg_hourly_water_flow),
    arima_auto = ARIMA(avg_hourly_water_flow,
                       stepwise = FALSE,
                       approximation = FALSE)
  )
pipe_test_fits |> pivot_longer(
  cols = -pipe,
  names_to = "model",
  values_to = "parameters"
)
# A mable: 6 x 3
# Key:     pipe, model [6]
  pipe  model                              parameters
  <chr> <chr>                                 <model>
1 pipe1 mean                                   <MEAN>
2 pipe1 smooth_auto                      <ETS(M,N,N)>
3 pipe1 arima_auto             <ARIMA(1,0,1) w/ mean>
4 pipe2 mean                                   <MEAN>
5 pipe2 smooth_auto                      <ETS(A,N,N)>
6 pipe2 arima_auto  <ARIMA(0,0,0)(1,0,0)[24] w/ mean>
pipe_accuracy <- pipe_test_fits |>
  forecast(new_data = pipe_test) |>
  accuracy(pipe_test) |>
  select(pipe, .model, RMSE, MAE) |>
  arrange(pipe, RMSE)

pipe_accuracy
# A tibble: 6 × 4
  pipe  .model       RMSE   MAE
  <chr> <chr>       <dbl> <dbl>
1 pipe1 mean         3.87  2.88
2 pipe1 smooth_auto  3.87  2.88
3 pipe1 arima_auto   3.94  2.91
4 pipe2 smooth_auto 17.1  13.7 
5 pipe2 mean        17.1  13.7 
6 pipe2 arima_auto  17.3  13.9 

The results of the the comparison of the RMSE indicates that for Pipe 1 a simple mean model have the lowest RMSE, while for Pipe 2 the exponential smoothing model (A, N, N) minimizes the RMSE. Although, in both instances, these are marginally improved over the next closest model. Additionally, the smoothing model for Pipe 2 is not notably different than the mean model used for Pipe 1.

best_models <- pipe_accuracy |> 
  group_by(pipe) |> 
  slice_min(RMSE, n = 1, with_ties = FALSE) |> 
  ungroup() |> 
  select(pipe, .model)

pipe_final_fits <- avg_pipe_imputed |> 
  model(
     mean = MEAN(avg_hourly_water_flow),
    smooth_auto = ETS(avg_hourly_water_flow),
    arima_auto = ARIMA(
      avg_hourly_water_flow,
      stepwise = FALSE,
      approximation = FALSE
  )
  )

pipe_final_forecast <- pipe_final_fits |> 
  forecast(h = 168) |> 
  semi_join(best_models, by = c("pipe", ".model"))
pipe_final_forecast |> 
  autoplot(avg_pipe_imputed, level = 95) +
  facet_wrap(~ pipe, ncol = 1) +
  labs(
    title = "Forecast for one week water flow",
    x = "Date",
    y = "Hourly average water flow"
  ) +
  theme_minimal()

The plot of the final forecasts show that both are essentially a mean forecast moving forward for the week, while the prediction intervals cover much of the variation within each model.

pipe_final_forecast |> 
  hilo(level = 95) |> 
  as_tibble() |> 
  transmute(
    pipe,
    model = .model,
    hour = format(hour, "%Y-%m-%d %H:%M:%S"),
    forecast_flow = .mean,
    lower95 = `95%`$lower,
    upper95 = `95%`$lower
  ) |> 
  write_xlsx("separate_pipe_forecasts.xlsx")