SPATIAL INTERPOLATION - PARAGUAY SOIL MOISTURE DATA

0. LOAD PACKAGES

library(tidyverse)
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ dplyr     1.2.1     ✔ readr     2.2.0
## ✔ forcats   1.0.1     ✔ stringr   1.6.0
## ✔ ggplot2   4.0.2     ✔ tibble    3.3.1
## ✔ lubridate 1.9.5     ✔ tidyr     1.3.2
## ✔ purrr     1.2.2     
## ── 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(sp)
library(gstat)

1. READ DATA

coordinates_data <- read.csv(
  "~/Downloads/Manzano2022COMPAG_coordinates.csv"
)

measurements_data <- read.csv(
  "~/Downloads/Manzano2022COMPAG_data.csv"
)

# Check data structure
str(coordinates_data)
## 'data.frame':    12 obs. of  3 variables:
##  $ Node_ID: int  1 2 3 4 5 6 7 8 9 10 ...
##  $ x      : num  0 2.21 22.99 25.7 31.3 ...
##  $ y      : num  0 -9.16 3.9 -5.29 -11.43 ...
str(measurements_data)
## 'data.frame':    15777 obs. of  4 variables:
##  $ t      : chr  "09-Mar-2022 13:02:32" "09-Mar-2022 13:09:24" "09-Mar-2022 13:09:51" "09-Mar-2022 13:11:50" ...
##  $ Node_ID: int  9 3 3 10 1 9 4 4 3 5 ...
##  $ Temp   : num  27.1 27.4 27.4 27.4 27.1 ...
##  $ Moist  : num  41.2 38.4 38.4 42 56.7 ...
head(coordinates_data)
##   Node_ID      x       y
## 1       1  0.000   0.000
## 2       2  2.210  -9.159
## 3       3 22.992   3.905
## 4       4 25.696  -5.292
## 5       5 31.297 -11.428
## 6       6 47.449  -5.017
head(measurements_data)
##                      t Node_ID  Temp Moist
## 1 09-Mar-2022 13:02:32       9 27.06 41.22
## 2 09-Mar-2022 13:09:24       3 27.41 38.37
## 3 09-Mar-2022 13:09:51       3 27.41 38.37
## 4 09-Mar-2022 13:11:50      10 27.41 42.04
## 5 09-Mar-2022 13:19:28       1 27.06 56.73
## 6 09-Mar-2022 13:22:27       9 27.41 41.22
# Check number of observations and sensor locations
cat("Number of measurement rows:",
    nrow(measurements_data), "\n")
## Number of measurement rows: 15777
cat("Number of unique nodes:",
    length(unique(measurements_data$Node_ID)), "\n")
## Number of unique nodes: 12

2. DATA PREPROCESSING

# Aggregate repeated measurements into one mean per sensor node
# This creates one spatial observation per unique location.

mean_data <- measurements_data %>%
  group_by(Node_ID) %>%
  summarise(
    Moist = mean(Moist, na.rm = TRUE),
    Temp = mean(Temp, na.rm = TRUE),
    n_obs = n(),
    .groups = "drop"
  )

# Merge with spatial coordinates
data_spatial <- mean_data %>%
  left_join(coordinates_data, by = "Node_ID") %>%
  select(Node_ID, x, y, Moist, Temp, n_obs)

# Remove missing values
data_spatial <- data_spatial %>%
  filter(
    !is.na(x),
    !is.na(y),
    !is.na(Moist)
  )

# Check duplicated coordinates
duplicate_coordinates <- data_spatial %>%
  count(x, y) %>%
  filter(n > 1)

print(duplicate_coordinates)
## # A tibble: 0 × 3
## # ℹ 3 variables: x <dbl>, y <dbl>, n <int>
# Check final dataset
print(data_spatial)
## # A tibble: 12 × 6
##    Node_ID     x       y Moist  Temp n_obs
##      <int> <dbl>   <dbl> <dbl> <dbl> <int>
##  1       1  0      0      64.1  24.7  1319
##  2       2  2.21  -9.16   46.1  25.6  1737
##  3       3 23.0    3.90   47.3  25.2  1310
##  4       4 25.7   -5.29   64.3  25.3  1338
##  5       5 31.3  -11.4    45.7  24.5  1325
##  6       6 47.4   -5.02   43.9  24.8  1275
##  7       7 58.8   -5.86   44.1  24.9  1331
##  8       8 63.8    0.334  46.1  25.4  1203
##  9       9 74.0    0.019  48.5  25.3  1305
## 10      10 77.1  -10.9    52.6  25.7  1165
## 11      11 85.8    1.21   47.5  25.2  1184
## 12      12 90.3   -7.53   42.2  25.4  1285
cat("Final number of spatial observations:",
    nrow(data_spatial), "\n")
## Final number of spatial observations: 12

3. EXPLORATORY DATA ANALYSIS

# Summary statistics
summary(data_spatial$Moist)
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
##   42.19   45.28   46.71   49.38   49.56   64.33
# Spatial distribution of soil moisture
ggplot(
  data_spatial,
  aes(x = x, y = y, color = Moist)
) +
  geom_point(size = 4) +
  geom_text(
    aes(label = Node_ID),
    nudge_y = 0.5,
    size = 3
  ) +
  scale_color_viridis_c() +
  coord_equal() +
  labs(
    title = "Spatial Distribution of Soil Moisture",
    subtitle = "Paraguay Sensor Network",
    x = "X Coordinate (m)",
    y = "Y Coordinate (m)",
    color = "Mean Moisture"
  ) +
  theme_minimal()

4. DEFINE INTERPOLATION FUNCTIONS

# Function 1: Closest Observation
# Prediction = value of the nearest observed point

predict_closest <- function(train, test_point) {

  distances <- sqrt(
    (train$x - test_point$x)^2 +
    (train$y - test_point$y)^2
  )

  nearest_index <- which.min(distances)

  return(train$Moist[nearest_index])
}
# Function 2: Nearest Neighbors
# Prediction = average of k nearest observations
# Here k = 3

predict_nn <- function(train, test_point, k = 3) {

  distances <- sqrt(
    (train$x - test_point$x)^2 +
    (train$y - test_point$y)^2
  )

  k <- min(k, nrow(train))

  nearest_indices <- order(distances)[1:k]

  prediction <- mean(
    train$Moist[nearest_indices]
  )

  return(prediction)
}
# Function 3: Inverse Distance Weighting (IDW)
# Weight = 1 / distance^p
# Here p = 2

predict_idw <- function(train, test_point, p = 2) {

  distances <- sqrt(
    (train$x - test_point$x)^2 +
    (train$y - test_point$y)^2
  )

  # Handle exact coordinate match
  if (any(distances == 0)) {
    return(train$Moist[which.min(distances)])
  }

  weights <- 1 / (distances^p)

  prediction <- sum(
    weights * train$Moist
  ) / sum(weights)

  return(prediction)
}
# Function 4: Ordinary Kriging
# Variogram is estimated using training data only
# to avoid information leakage during cross-validation.

predict_kriging <- function(train, test_point) {

  # Convert training data to SpatialPointsDataFrame
  coordinates(train) <- ~x+y

  # Convert test point to SpatialPoints
  test_sp <- data.frame(
    x = test_point$x,
    y = test_point$y
  )

  coordinates(test_sp) <- ~x+y

  # Initial variogram model
  initial_variogram <- vgm(
    psill = var(train$Moist),
    model = "Exp",
    range = max(
      1,
      max(spDists(
        coordinates(train),
        longlat = FALSE
      )) / 3
    ),
    nugget = 0
  )

  # Calculate empirical variogram
  empirical_variogram <- variogram(
    Moist ~ 1,
    data = train
  )

  # Fit variogram model
  fitted_variogram <- tryCatch(
    fit.variogram(
      empirical_variogram,
      model = initial_variogram
    ),
    error = function(e) initial_variogram
  )

  # Ordinary Kriging prediction
  kriging_result <- tryCatch(
    krige(
      Moist ~ 1,
      locations = train,
      newdata = test_sp,
      model = fitted_variogram
    ),
    error = function(e) NULL
  )

  # Fallback if kriging fails
  if (is.null(kriging_result)) {
    return(
      predict_idw(
        as.data.frame(train),
        as.data.frame(test_point),
        p = 2
      )
    )
  }

  return(kriging_result$var1.pred[1])
}

5. LEAVE-ONE-OUT CROSS VALIDATION

n <- nrow(data_spatial)

# Store predictions
cv_results <- data.frame(
  Node_ID = data_spatial$Node_ID,
  Observed = data_spatial$Moist,
  Closest = NA_real_,
  NN = NA_real_,
  IDW = NA_real_,
  Kriging = NA_real_,
  Ensemble = NA_real_
)

for (i in 1:n) {

  cat("LOOCV iteration:", i, "of", n, "\n")

  # Training data: all points except point i
  train_data <- data_spatial[-i, ]

  # Test data: point i
  test_data <- data_spatial[i, ]

  # Closest Observation
  pred_closest <- predict_closest(
    train_data,
    test_data
  )

  # Nearest Neighbors
  pred_nn <- predict_nn(
    train_data,
    test_data,
    k = 3
  )

  # IDW
  pred_idw <- predict_idw(
    train_data,
    test_data,
    p = 2
  )

  # Ordinary Kriging
  pred_kriging <- predict_kriging(
    train_data,
    test_data
  )

  # Ensemble of three interpolation methods:
  # Closest Observation + IDW + Kriging
  pred_ensemble <- mean(
    c(
      pred_closest,
      pred_idw,
      pred_kriging
    ),
    na.rm = TRUE
  )

  # Store predictions
  cv_results$Closest[i] <- pred_closest
  cv_results$NN[i] <- pred_nn
  cv_results$IDW[i] <- pred_idw
  cv_results$Kriging[i] <- pred_kriging
  cv_results$Ensemble[i] <- pred_ensemble
}
## LOOCV iteration: 1 of 12
## Warning in fit.variogram(empirical_variogram, model = initial_variogram): No
## convergence after 200 iterations: try different initial values?
## [using ordinary kriging]
## LOOCV iteration: 2 of 12
## Warning in fit.variogram(empirical_variogram, model = initial_variogram): No
## convergence after 200 iterations: try different initial values?
## [using ordinary kriging]
## LOOCV iteration: 3 of 12
## Warning in fit.variogram(empirical_variogram, model = initial_variogram): No
## convergence after 200 iterations: try different initial values?
## [using ordinary kriging]
## LOOCV iteration: 4 of 12
## Warning in fit.variogram(empirical_variogram, model = initial_variogram): No
## convergence after 200 iterations: try different initial values?
## [using ordinary kriging]
## LOOCV iteration: 5 of 12
## Warning in fit.variogram(empirical_variogram, model = initial_variogram): No
## convergence after 200 iterations: try different initial values?
## [using ordinary kriging]
## LOOCV iteration: 6 of 12
## Warning in fit.variogram(empirical_variogram, model = initial_variogram): No
## convergence after 200 iterations: try different initial values?
## [using ordinary kriging]
## LOOCV iteration: 7 of 12
## Warning in fit.variogram(empirical_variogram, model = initial_variogram): No
## convergence after 200 iterations: try different initial values?
## [using ordinary kriging]
## LOOCV iteration: 8 of 12
## Warning in fit.variogram(empirical_variogram, model = initial_variogram): No
## convergence after 200 iterations: try different initial values?
## [using ordinary kriging]
## LOOCV iteration: 9 of 12
## Warning in fit.variogram(empirical_variogram, model = initial_variogram): No
## convergence after 200 iterations: try different initial values?
## [using ordinary kriging]
## LOOCV iteration: 10 of 12
## Warning in fit.variogram(empirical_variogram, model = initial_variogram): No
## convergence after 200 iterations: try different initial values?
## [using ordinary kriging]
## LOOCV iteration: 11 of 12
## Warning in fit.variogram(empirical_variogram, model = initial_variogram): No
## convergence after 200 iterations: try different initial values?
## [using ordinary kriging]
## LOOCV iteration: 12 of 12
## Warning in fit.variogram(empirical_variogram, model = initial_variogram): No
## convergence after 200 iterations: try different initial values?
## [using ordinary kriging]

6. CALCULATE RMSE

calculate_rmse <- function(observed, predicted) {

  sqrt(
    mean(
      (observed - predicted)^2,
      na.rm = TRUE
    )
  )
}

rmse_results <- data.frame(
  Method = c(
    "Closest Observation",
    "Nearest Neighbors",
    "IDW",
    "Kriging",
    "Ensemble"
  ),

  RMSE = c(
    calculate_rmse(
      cv_results$Observed,
      cv_results$Closest
    ),

    calculate_rmse(
      cv_results$Observed,
      cv_results$NN
    ),

    calculate_rmse(
      cv_results$Observed,
      cv_results$IDW
    ),

    calculate_rmse(
      cv_results$Observed,
      cv_results$Kriging
    ),

    calculate_rmse(
      cv_results$Observed,
      cv_results$Ensemble
    )
  )
)
# Sort from lowest to highest RMSE
rmse_results <- rmse_results %>%
  arrange(RMSE)

print(rmse_results)
##                Method      RMSE
## 1   Nearest Neighbors  8.640270
## 2             Kriging  9.273684
## 3                 IDW  9.407563
## 4            Ensemble 10.080028
## 5 Closest Observation 11.974150

7. VISUALIZE RMSE COMPARISON

ggplot(
  rmse_results,
  aes(
    x = reorder(Method, RMSE),
    y = RMSE
  )
) +
  geom_col() +
  coord_flip() +
  labs(
    title = "Comparison of Interpolation Methods",
    subtitle = "Leave-One-Out Cross Validation",
    x = "Interpolation Method",
    y = "RMSE"
  ) +
  theme_minimal()

8. OBSERVED VS PREDICTED PLOT

cv_long <- cv_results %>%
  pivot_longer(
    cols = c(
      Closest,
      NN,
      IDW,
      Kriging,
      Ensemble
    ),
    names_to = "Method",
    values_to = "Predicted"
  )

ggplot(
  cv_long,
  aes(
    x = Observed,
    y = Predicted
  )
) +
  geom_point(size = 3) +
  geom_abline(
    intercept = 0,
    slope = 1,
    linetype = "dashed"
  ) +
  facet_wrap(~Method) +
  labs(
    title = "Observed vs Predicted Soil Moisture",
    x = "Observed Moisture",
    y = "Predicted Moisture"
  ) +
  theme_minimal()

9. SAVE RESULTS

write.csv(
  data_spatial,
  "~/Downloads/Paraguay_Moisture_Preprocessed.csv",
  row.names = FALSE
)

write.csv(
  cv_results,
  "~/Downloads/Paraguay_LOOCV_Predictions.csv",
  row.names = FALSE
)

write.csv(
  rmse_results,
  "~/Downloads/Paraguay_RMSE_Comparison.csv",
  row.names = FALSE
)

cat("Analysis completed successfully!\n")
## Analysis completed successfully!