Water Flow Analysis from two dataset

Author

Guibril Ramde

Part C- Project 1

Exploratory Analysis and data loading Preparation

library(readxl)
Warning: package 'readxl' was built under R version 4.5.2
library(tidyverse)
Warning: package 'ggplot2' was built under R version 4.5.2
Warning: package 'tibble' was built under R version 4.5.2
Warning: package 'tidyr' was built under R version 4.5.2
Warning: package 'readr' was built under R version 4.5.2
Warning: package 'purrr' was built under R version 4.5.2
Warning: package 'dplyr' was built under R version 4.5.2
── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
✔ dplyr     1.2.0     ✔ readr     2.1.6
✔ forcats   1.0.1     ✔ stringr   1.6.0
✔ ggplot2   4.0.2     ✔ tibble    3.3.1
✔ lubridate 1.9.4     ✔ tidyr     1.3.2
✔ purrr     1.2.1     
── 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
library(fpp3)
Warning: package 'fpp3' was built under R version 4.5.2
── 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.5.2
Warning: package 'ggtime' was built under R version 4.5.2
Warning: package 'feasts' was built under R version 4.5.2
Warning: package 'fabletools' was built under R version 4.5.2
Warning: package 'fable' was built under R version 4.5.2
── 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()
library(lubridate)
library(janitor)

Attaching package: 'janitor'

The following objects are masked from 'package:stats':

    chisq.test, fisher.test
library(lubridate)
library(dplyr)
#library(fpp3)


# Load and clean original datasets
water_data1 <- read_excel("Waterflow_Pipe1.xlsx") |>
  clean_names() |>
  mutate(Pipe = "Pipe1")

water_data2 <- read_excel("Waterflow_Pipe2.xlsx") |>
  clean_names() |>
  mutate(Pipe = "Pipe2")

# Combine datasets
water_data <- bind_rows(
  water_data1,
  water_data2
)
class(water_data1$date_time)
[1] "numeric"
class(water_data2$date_time)
[1] "numeric"
range(water_data1$date_time, na.rm = TRUE)
[1] 42300.02 42309.98
range(water_data2$date_time, na.rm = TRUE)
[1] 42300.04 42341.67
class(water_data1$date_time)
[1] "numeric"
class(water_data2$date_time)
[1] "numeric"
head(water_data1$date_time)
[1] 42300.02 42300.03 42300.04 42300.04 42300.06 42300.06
head(water_data2$date_time)
[1] 42300.04 42300.08 42300.12 42300.17 42300.21 42300.25

Aggregate observation by hour

water_hourly <- water_data |>
  mutate(
    # Convert Excel numeric dates into POSIXct
    date_time = as.POSIXct(
      round(date_time * 86400),
      origin = "1899-12-30",
      tz = "UTC"
    ),
    
    # Aggregate timestamps to the hour
    date_time = floor_date(date_time, unit = "hour")
  ) |>
  group_by(Pipe, date_time) |>
  summarise(
    water_flow = if (all(is.na(water_flow))) {
      NA_real_
    } else {
      mean(water_flow, na.rm = TRUE)
    },
    .groups = "drop"
  )

water_hourly
# A tibble: 1,236 × 3
   Pipe  date_time           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
 7 Pipe1 2015-10-23 06:00:00       20.6
 8 Pipe1 2015-10-23 07:00:00       18.4
 9 Pipe1 2015-10-23 08:00:00       20.8
10 Pipe1 2015-10-23 09:00:00       21.2
# ℹ 1,226 more rows
glimpse(water_hourly)
Rows: 1,236
Columns: 3
$ Pipe       <chr> "Pipe1", "Pipe1", "Pipe1", "Pipe1", "Pipe1", "Pipe1", "Pipe…
$ date_time  <dttm> 2015-10-23 00:00:00, 2015-10-23 01:00:00, 2015-10-23 02:00…
$ water_flow <dbl> 26.10280, 18.85202, 15.15857, 23.07886, 15.48219, 22.72539,…
class(water_hourly$date_time)
[1] "POSIXct" "POSIXt" 
water_hourly |>
  group_by(Pipe) |>
  summarise(
    Start = min(date_time),
    End = max(date_time),
    Observations = n(),
    .groups = "drop"
  )
# A tibble: 2 × 4
  Pipe  Start               End                 Observations
  <chr> <dttm>              <dttm>                     <int>
1 Pipe1 2015-10-23 00:00:00 2015-11-01 23:00:00          236
2 Pipe2 2015-10-23 01:00:00 2015-12-03 16:00:00         1000

convert to tsibble

tsibble_water_data <- water_hourly |>
  as_tsibble(
    key = Pipe,
    index = date_time
  )

is_tsibble(tsibble_water_data)
[1] TRUE
has_gaps(tsibble_water_data)
# A tibble: 2 × 2
  Pipe  .gaps
  <chr> <lgl>
1 Pipe1 TRUE 
2 Pipe2 FALSE
count_gaps(tsibble_water_data)
# A tibble: 4 × 4
  Pipe  .from               .to                    .n
  <chr> <dttm>              <dttm>              <int>
1 Pipe1 2015-10-27 17:00:00 2015-10-27 17:00:00     1
2 Pipe1 2015-10-28 00:00:00 2015-10-28 00:00:00     1
3 Pipe1 2015-11-01 05:00:00 2015-11-01 05:00:00     1
4 Pipe1 2015-11-01 09:00:00 2015-11-01 09:00:00     1

Visualize the hourly data

tsibble_water_data |>
  autoplot(water_flow) +
  facet_wrap(~ Pipe, scales = "free_y") +
  labs(
    title = "Hourly Water Flow by Pipe",
    x = "Date and Time",
    y = "Average Water Flow"
  )

tsibble_water_data |>
  count_gaps() |>
  print()
# A tibble: 4 × 4
  Pipe  .from               .to                    .n
  <chr> <dttm>              <dttm>              <int>
1 Pipe1 2015-10-27 17:00:00 2015-10-27 17:00:00     1
2 Pipe1 2015-10-28 00:00:00 2015-10-28 00:00:00     1
3 Pipe1 2015-11-01 05:00:00 2015-11-01 05:00:00     1
4 Pipe1 2015-11-01 09:00:00 2015-11-01 09:00:00     1
tsibble_water_data |>
  group_by_key() |>
  summarise(
    Start_Date = min(date_time),
    End_Date = max(date_time),
    Observations = n()
  )
# A tsibble: 1,236 x 5 [1h] <UTC>
# Key:       Pipe [2]
   Pipe  date_time           Start_Date          End_Date           
   <chr> <dttm>              <dttm>              <dttm>             
 1 Pipe1 2015-10-23 00:00:00 2015-10-23 00:00:00 2015-10-23 00:00:00
 2 Pipe1 2015-10-23 01:00:00 2015-10-23 01:00:00 2015-10-23 01:00:00
 3 Pipe1 2015-10-23 02:00:00 2015-10-23 02:00:00 2015-10-23 02:00:00
 4 Pipe1 2015-10-23 03:00:00 2015-10-23 03:00:00 2015-10-23 03:00:00
 5 Pipe1 2015-10-23 04:00:00 2015-10-23 04:00:00 2015-10-23 04:00:00
 6 Pipe1 2015-10-23 05:00:00 2015-10-23 05:00:00 2015-10-23 05:00:00
 7 Pipe1 2015-10-23 06:00:00 2015-10-23 06:00:00 2015-10-23 06:00:00
 8 Pipe1 2015-10-23 07:00:00 2015-10-23 07:00:00 2015-10-23 07:00:00
 9 Pipe1 2015-10-23 08:00:00 2015-10-23 08:00:00 2015-10-23 08:00:00
10 Pipe1 2015-10-23 09:00:00 2015-10-23 09:00:00 2015-10-23 09:00:00
# ℹ 1,226 more rows
# ℹ 1 more variable: Observations <int>
water_complete <- tsibble_water_data |>
  fill_gaps(water_flow = NA_real_)

water_complete |>
  group_by_key() |>
  summarise(
    Total_Hours = n(),
    Missing_Hours = sum(is.na(water_flow)),
    Missing_Percent = mean(is.na(water_flow)) * 100
  )
# A tsibble: 1,240 x 5 [1h] <UTC>
# Key:       Pipe [2]
   Pipe  date_time           Total_Hours Missing_Hours Missing_Percent
   <chr> <dttm>                    <int>         <int>           <dbl>
 1 Pipe1 2015-10-23 00:00:00           1             0               0
 2 Pipe1 2015-10-23 01:00:00           1             0               0
 3 Pipe1 2015-10-23 02:00:00           1             0               0
 4 Pipe1 2015-10-23 03:00:00           1             0               0
 5 Pipe1 2015-10-23 04:00:00           1             0               0
 6 Pipe1 2015-10-23 05:00:00           1             0               0
 7 Pipe1 2015-10-23 06:00:00           1             0               0
 8 Pipe1 2015-10-23 07:00:00           1             0               0
 9 Pipe1 2015-10-23 08:00:00           1             0               0
10 Pipe1 2015-10-23 09:00:00           1             0               0
# ℹ 1,230 more rows
water_complete <- tsibble_water_data |>
  fill_gaps(water_flow = NA_real_) |>
  group_by_key() |>
  mutate(
    water_flow = zoo::na.approx(
      water_flow,
      na.rm = FALSE
    )
  ) |>
  ungroup()
# Verify missing values
water_complete |>
  group_by_key() |>
  summarise(
    Missing = sum(is.na(water_flow)),
    Observations = n()
  )
# A tsibble: 1,240 x 4 [1h] <UTC>
# Key:       Pipe [2]
   Pipe  date_time           Missing Observations
   <chr> <dttm>                <int>        <int>
 1 Pipe1 2015-10-23 00:00:00       0            1
 2 Pipe1 2015-10-23 01:00:00       0            1
 3 Pipe1 2015-10-23 02:00:00       0            1
 4 Pipe1 2015-10-23 03:00:00       0            1
 5 Pipe1 2015-10-23 04:00:00       0            1
 6 Pipe1 2015-10-23 05:00:00       0            1
 7 Pipe1 2015-10-23 06:00:00       0            1
 8 Pipe1 2015-10-23 07:00:00       0            1
 9 Pipe1 2015-10-23 08:00:00       0            1
10 Pipe1 2015-10-23 09:00:00       0            1
# ℹ 1,230 more rows

Interpretation: Data Preparation and Missing Values

The two water-flow datasets were imported from Excel and cleaned using the janitor package. Since the datasets contained numeric Excel date-time values, the timestamps were converted to POSIXct format. Small floating-point precision errors were corrected by rounding the timestamps to the nearest second.

The observations were then aggregated by hour, using the mean for hours containing multiple measurements. The datasets were combined into a tsibble, with each pipe identified by a separate key.

An examination of missing hourly observations revealed four missing hours for Pipe1 and no missing hours for Pipe2. Linear interpolation was selected to estimate the four missing observations in Pipe1 while preserving the hourly time structure for subsequent forecasting analysis.

water_complete |>
  ACF(water_flow, lag_max = 72) |>
  autoplot() +
  facet_wrap(~ Pipe, scales = "free_y") +
  labs(
    title = "ACF of Hourly Water Flow",
    x = "Lag (hours)",
    y = "Autocorrelation"
  )

stationarity_test <- water_complete |>
  features(
    water_flow,
    unitroot_kpss
  )

stationarity_test
# A tibble: 2 × 3
  Pipe  kpss_stat kpss_pvalue
  <chr>     <dbl>       <dbl>
1 Pipe1     0.193         0.1
2 Pipe2     0.105         0.1
water_complete |>
  features(
    water_flow,
    unitroot_ndiffs
  )
# A tibble: 2 × 2
  Pipe  ndiffs
  <chr>  <int>
1 Pipe1      0
2 Pipe2      0

Interpretation: Stationarity Analysis

The stationarity of the hourly water-flow data was evaluated using the Kwiatkowski–Phillips–Schmidt–Shin (KPSS) test and the recommended number of differences.

For Pipe1, the KPSS statistic was 0.1928, with a reported p-value of 0.10. For Pipe2, the KPSS statistic was 0.1054, also with a reported p-value of 0.10. Since both p-values exceed the 0.05 significance level, the null hypothesis of level stationarity cannot be rejected for either series.

Furthermore, the unitroot_ndiffs() analysis recommended zero nonseasonal differences for both pipes. These findings are consistent with the ACF plots, which did not display the slowly declining autocorrelation typically associated with nonstationary time series.

Therefore, both hourly water-flow series appear suitable for forecasting without nonseasonal differencing. However, stationarity alone does not guarantee accurate forecasts, so forecasting models must be evaluated using out-of-sample observations.

Explore daily seasonality

water_complete |>
  mutate(Hour = hour(date_time)) |>
  ggplot(aes(x = Hour, y = water_flow)) +
  geom_boxplot(aes(group = Hour)) +
  facet_wrap(~ Pipe, scales = "free_y") +
  labs(
    title = "Hourly Water Flow Distribution",
    x = "Hour of Day",
    y = "Water Flow"
  )

Interpretation: Autocorrelation and Daily Seasonality

The autocorrelation function (ACF) was examined for both water-flow series to investigate temporal dependence and possible seasonal patterns.

For Pipe1, most autocorrelation coefficients fall within the confidence bounds, indicating relatively weak serial dependence. The absence of a slowly declining ACF suggests that the series may be stationary.

For Pipe2, several autocorrelation coefficients exceed the confidence bounds, suggesting some serial dependence. However, the coefficients are generally small, and no strong repeating seasonal pattern is evident.

The hourly boxplots show that Pipe2 has a higher average water-flow level and greater variability than Pipe1. Although the distributions differ across hours, there is no clear or consistent daily seasonal pattern in either series.

Overall, the exploratory results suggest that both series may be suitable for forecasting using relatively simple time series models. Formal stationarity testing and model evaluation are required before selecting the final forecasting approach.

Fit forecasting model

water_models <- water_complete |>
  model(
    MEAN = MEAN(water_flow),
    ETS = ETS(water_flow),
    ARIMA = ARIMA(water_flow)
  )

water_models
# A mable: 2 x 4
# Key:     Pipe [2]
  Pipe     MEAN          ETS                             ARIMA
  <chr> <model>      <model>                           <model>
1 Pipe1  <MEAN> <ETS(M,N,N)>            <ARIMA(0,0,0) w/ mean>
2 Pipe2  <MEAN> <ETS(M,N,N)> <ARIMA(0,0,0)(0,0,1)[24] w/ mean>
water_models |>
  report()
Warning in report.mdl_df(water_models): 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: 6 × 12
  Pipe  .model   sigma2 log_lik    AIC   AICc    BIC   MSE  AMSE    MAE ar_roots
  <chr> <chr>     <dbl>   <dbl>  <dbl>  <dbl>  <dbl> <dbl> <dbl>  <dbl> <list>  
1 Pipe1 MEAN    17.8        NA     NA     NA     NA   NA    NA   NA     <NULL>  
2 Pipe1 ETS      0.0454  -1003.  2012.  2012.  2022.  17.8  17.7  0.167 <NULL>  
3 Pipe1 ARIMA   17.8      -686.  1376.  1376.  1383.  NA    NA   NA     <cpl>   
4 Pipe2 MEAN   258.         NA     NA     NA     NA   NA    NA   NA     <NULL>  
5 Pipe2 ETS      0.165   -6229. 12464. 12464. 12479. 257.  257.   0.331 <NULL>  
6 Pipe2 ARIMA  256.      -4191.  8387.  8387.  8402.  NA    NA   NA     <cpl>   
# ℹ 1 more variable: ma_roots <list>
water_models |>
  accuracy() |>
  select(Pipe, .model, RMSE, MAE, MAPE)
# A tibble: 6 × 5
  Pipe  .model  RMSE   MAE  MAPE
  <chr> <chr>  <dbl> <dbl> <dbl>
1 Pipe1 MEAN    4.21  3.32  18.2
2 Pipe1 ETS     4.21  3.32  18.2
3 Pipe1 ARIMA   4.21  3.32  18.2
4 Pipe2 MEAN   16.0  13.1   57.4
5 Pipe2 ETS    16.0  13.1   57.4
6 Pipe2 ARIMA  16.0  13.1   57.2
# Identify the final timestamp for each pipe
water_split <- water_complete |>
  group_by_key() |>
  mutate(
    Last_Time = max(date_time),
    Set = if_else(
      date_time > Last_Time - hours(48),
      "Test",
      "Train"
    )
  ) |>
  ungroup()

# Training data
water_train <- water_split |>
  filter(Set == "Train") |>
  select(Pipe, date_time, water_flow)

# Test data
water_test <- water_split |>
  filter(Set == "Test") |>
  select(Pipe, date_time, water_flow)

water_train_models <- water_train |>
  model(
    MEAN = MEAN(water_flow),
    ETS = ETS(water_flow),
    ARIMA = ARIMA(water_flow)
  )

# Forecast 48 hours
water_test_forecast <- water_train_models |>
  forecast(h = 48)

# Evaluate on unseen data
water_accuracy <- water_test_forecast |>
  accuracy(water_test)

water_accuracy |>
  select(Pipe, .model, RMSE, MAE)
# A tibble: 6 × 4
  Pipe  .model  RMSE   MAE
  <chr> <chr>  <dbl> <dbl>
1 Pipe1 ARIMA   3.94  3.02
2 Pipe2 ARIMA  17.3  13.8 
3 Pipe1 ETS     3.94  3.03
4 Pipe2 ETS    17.1  13.7 
5 Pipe1 MEAN    3.94  3.03
6 Pipe2 MEAN   17.1  13.7 
water_accuracy |>
  ggplot(aes(x = .model, y = RMSE, fill = Pipe)) +
  geom_col(position = "dodge") +
  labs(
    title = "Forecast Model Comparison",
    x = "Forecasting Model",
    y = "Test RMSE"
  )

Interpretation: Forecasting Model Comparison

Three forecasting models were evaluated for both water-flow series: MEAN, ETS, and ARIMA. The models were compared using Root Mean Squared Error (RMSE) and Mean Absolute Error (MAE), with the final 48 hours of each series reserved for testing.

For Pipe1, ARIMA achieved the lowest test RMSE of 3.9381 and MAE of 3.0208, slightly outperforming ETS and MEAN. However, the differences among the three models were very small, indicating that more complex models provided little improvement over the baseline.

For Pipe2, ETS achieved the lowest test RMSE of 17.0962 and MAE of 13.7128. Its performance was almost identical to the MEAN model, while ARIMA produced slightly larger forecast errors.

Based on the out-of-sample evaluation, ARIMA was selected for Pipe1 and ETS for Pipe2. These selections are provisional because the evaluation used a 48-hour forecast horizon, whereas the final assignment requires a seven-day forecast.

Overall, the results suggest that the water-flow series have relatively weak predictable temporal structure, making simple forecasting approaches competitive with more complex models.

The selected models using the complete datasets

# Fit selected models separately
final_models <- water_complete |>
  model(
    ARIMA = ARIMA(water_flow),
    ETS = ETS(water_flow)
  )
selected_models <- final_models |>
  select(Pipe, ARIMA, ETS)

selected_models |>
  augment() |>
  filter(
    (Pipe == "Pipe1" & .model == "ARIMA") |
      (Pipe == "Pipe2" & .model == "ETS")
  ) |>
  as_tsibble(
    key = c(Pipe, .model),
    index = date_time
  ) |>
  ACF(.innov, lag_max = 48) |>
  autoplot()

selected_models |>
  augment() |>
  filter(
    (Pipe == "Pipe1" & .model == "ARIMA") |
      (Pipe == "Pipe2" & .model == "ETS")
  ) |>
  group_by(Pipe, .model) |>
  features(
    .innov,
    ljung_box,
    lag = 24,
    dof = 0
  )
# A tibble: 2 × 4
  Pipe  .model lb_stat lb_pvalue
  <chr> <chr>    <dbl>     <dbl>
1 Pipe1 ARIMA     22.0     0.577
2 Pipe2 ETS       32.5     0.116
# Generate 168-hour forecasts
final_forecasts <- final_models |>
  forecast(h = 168) |>
  filter(
    (Pipe == "Pipe1" & .model == "ARIMA") |
      (Pipe == "Pipe2" & .model == "ETS")
  )

final_forecasts
# A fable: 336 x 5 [1h] <UTC>
# Key:     Pipe, .model [2]
   Pipe  .model date_time          
   <chr> <chr>  <dttm>             
 1 Pipe1 ARIMA  2015-11-02 00:00:00
 2 Pipe1 ARIMA  2015-11-02 01:00:00
 3 Pipe1 ARIMA  2015-11-02 02:00:00
 4 Pipe1 ARIMA  2015-11-02 03:00:00
 5 Pipe1 ARIMA  2015-11-02 04:00:00
 6 Pipe1 ARIMA  2015-11-02 05:00:00
 7 Pipe1 ARIMA  2015-11-02 06:00:00
 8 Pipe1 ARIMA  2015-11-02 07:00:00
 9 Pipe1 ARIMA  2015-11-02 08:00:00
10 Pipe1 ARIMA  2015-11-02 09:00:00
# ℹ 326 more rows
# ℹ 2 more variables: water_flow <dist>, .mean <dbl>

Interpretation: Residual Diagnostics

Residual diagnostics were performed to evaluate whether the selected forecasting models adequately captured the temporal dependence in the hourly water-flow data.

For Pipe1, the ARIMA model produced a Ljung–Box statistic of 22.0341 and a p-value of 0.5772. Since the p-value exceeds the 0.05 significance level, the null hypothesis of no residual autocorrelation up to lag 24 cannot be rejected.

For Pipe2, the ETS model produced a Ljung–Box statistic of 32.4628 and a p-value of 0.1159. This result also fails to reject the null hypothesis at the 5% significance level, although a few individual autocorrelations exceed the confidence bounds in the residual ACF plot.

Overall, the residual diagnostics provide no significant evidence of remaining autocorrelation through lag 24. These findings support the adequacy of the selected ARIMA and ETS models for capturing the main temporal structure of the data.

However, these results do not guarantee forecast accuracy, particularly over the longer seven-day forecasting horizon.

Visualize the seven day forecasts

final_forecasts |>
  autoplot(water_complete, level = c(80, 95)) +
  facet_wrap(~ Pipe, scales = "free_y") +
  labs(
    title = "Seven-Day Water Flow Forecast",
    x = "Date and Time",
    y = "Forecast Water Flow"
  )

Interpretation: Seven-Day Water Flow Forecast

A seven-day forecast was generated for both water-flow series, corresponding to 168 future hourly observations per pipe. The ARIMA model was selected for Pipe1, while the ETS model was selected for Pipe2 based on their performance during the 48-hour out-of-sample evaluation.

For Pipe1, the forecast remains approximately constant around 20 units of water flow. The relatively stable predictions suggest that the selected ARIMA model does not identify a strong trend or seasonal pattern that would substantially change expected water flow over the forecasting horizon.

For Pipe2, the ETS forecast remains approximately constant around 40 units. The prediction intervals are considerably wider than those of Pipe1, reflecting the greater variability in Pipe2’s historical measurements.

The 80% and 95% prediction intervals represent uncertainty around the forecast values. The wider 95% intervals indicate a greater range of plausible future observations.

Overall, both models predict relatively stable average water flow during the following seven days. However, the wide prediction intervals, particularly for Pipe2, indicate considerable uncertainty in individual hourly measurements. These forecasts may be useful for estimating typical water-flow levels, but they are less reliable for predicting sudden fluctuations or extreme observations.

Export the forecast to excel

library(writexl)
Warning: package 'writexl' was built under R version 4.5.2
# Hourly forecast
hourly_forecast <- final_forecasts |>
  as_tibble() |>
  transmute(
    Pipe,
    Date_Time = date_time,
    Model = .model,
    Forecast_Water_Flow = .mean
  )

# Daily summary
daily_forecast <- hourly_forecast |>
  mutate(Date = as.Date(Date_Time)) |>
  group_by(Pipe, Date) |>
  summarise(
    Average_Water_Flow = mean(Forecast_Water_Flow),
    .groups = "drop"
  )

write_xlsx(
  list(
    Hourly_Forecast = hourly_forecast,
    Daily_Summary = daily_forecast
  ),
  "Water_Flow_7_Day_Forecasts.xlsx"
)

Conclusion

The objective of this project was to analyze and forecast hourly water-flow measurements from two pipes using time series forecasting methods.

The datasets were cleaned, aligned to an hourly time base, and aggregated using the mean for multiple observations within each hour. Four missing hourly observations were identified for Pipe1 and estimated using linear interpolation, while Pipe2 contained no missing hours after correcting timestamp precision.

Stationarity was evaluated using the KPSS test and the recommended number of differences. Both series produced KPSS p-values of 0.10 and required zero nonseasonal differences, suggesting that both series were consistent with level stationarity.

Three forecasting methods—MEAN, ETS, and ARIMA—were evaluated using a 48-hour test period. ARIMA produced the lowest RMSE for Pipe1 (3.9381), while ETS performed best for Pipe2 (17.0962). However, the differences between competing models were small.

The selected models were refitted to the complete datasets to generate seven-day forecasts. Pipe1 was forecast to remain near 20 units of water flow, while Pipe2 was forecast to remain near 40 units. Pipe2 exhibited wider prediction intervals, indicating greater uncertainty.

Overall, both datasets appear suitable for forecasting typical water-flow levels. However, the relatively weak autocorrelation, substantial short-term variability, and limited validation horizon suggest that the forecasts should be interpreted cautiously, particularly when predicting individual hourly measurements.