Floating Catchment Accessibility with catchment

IN-CLASS EXERCISE 7

Author
Affiliation

Jedidiah Asaf Tallulembang

ISSS626 Geospatial Analytics and Applications

Published

October 17, 2026

R catchment Floating catchment Eldercare

remotes::install_github("uva-bi-sdad/catchment")
pacman::p_load(sf, tidyverse, tmap, catchment)

1. Importing the geospatial data

mpsz <- st_read(dsn = "data/geospatial", layer = "MP14_SUBZONE_NO_SEA_PL")
Reading layer `MP14_SUBZONE_NO_SEA_PL' from data source 
  `/Users/jedidiahasaf/Downloads/Projects/isss626-coursework/In-class_Ex/In-class_Ex07/data/geospatial' 
  using driver `ESRI Shapefile'
Simple feature collection with 323 features and 15 fields
Geometry type: MULTIPOLYGON
Dimension:     XY
Bounding box:  xmin: 2667.538 ymin: 15748.72 xmax: 56396.44 ymax: 50256.33
Projected CRS: SVY21
hexagons <- st_read(dsn = "data/geospatial", layer = "hexagons")
Reading layer `hexagons' from data source 
  `/Users/jedidiahasaf/Downloads/Projects/isss626-coursework/In-class_Ex/In-class_Ex07/data/geospatial' 
  using driver `ESRI Shapefile'
Simple feature collection with 3125 features and 6 fields
Geometry type: POLYGON
Dimension:     XY
Bounding box:  xmin: 2667.538 ymin: 21506.33 xmax: 50010.26 ymax: 50256.33
Projected CRS: SVY21 / Singapore TM
eldercare <- st_read(dsn = "data/geospatial", layer = "ELDERCARE")
Reading layer `ELDERCARE' from data source 
  `/Users/jedidiahasaf/Downloads/Projects/isss626-coursework/In-class_Ex/In-class_Ex07/data/geospatial' 
  using driver `ESRI Shapefile'
Simple feature collection with 120 features and 19 fields
Geometry type: POINT
Dimension:     XY
Bounding box:  xmin: 14481.92 ymin: 28218.43 xmax: 41665.14 ymax: 46804.9
Projected CRS: SVY21 / Singapore TM

2. Updating the CRS

mpsz <- st_transform(mpsz, 3414)
eldercare <- st_transform(eldercare, 3414)
hexagons <- st_transform(hexagons, 3414)

c(mpsz = st_crs(mpsz)$epsg,
  hexagons = st_crs(hexagons)$epsg,
  eldercare = st_crs(eldercare)$epsg)
     mpsz  hexagons eldercare 
     3414      3414      3414 

3. Cleaning the attribute fields

eldercare <- eldercare %>%
  select(fid, ADDRESSPOS) %>%
  mutate(capacity = 100)

hexagons <- hexagons %>%
  select(fid) %>%
  mutate(demand = 100)

4. Importing and checking the distance matrix

ODMatrix <- read_csv("data/aspatial/OD_Matrix.csv", skip = 0)
od_check <- ODMatrix %>%
  group_by(origin_id) %>%
  summarise(spread_m = max(total_cost) - min(total_cost),
            nearest_m = min(total_cost))

c(origins = nrow(od_check),
  same_distance_to_every_centre = sum(od_check$spread_m == 0))
                      origins same_distance_to_every_centre 
                         3125                           159 

5. Tidying the distance matrix

distmat <- ODMatrix %>%
  select(origin_id, destination_id, total_cost) %>%
  pivot_wider(names_from = destination_id,
              values_from = total_cost)

c(rows_match_hexagons = identical(distmat$origin_id,
                                  as.numeric(hexagons$fid)),
  cols_match_eldercare = identical(as.numeric(names(distmat)[-1]),
                                   as.numeric(eldercare$fid)))
 rows_match_hexagons cols_match_eldercare 
                TRUE                 TRUE 
distmat <- distmat %>%
  select(-origin_id)

distmat_km <- as.matrix(distmat / 1000)

6. Two-step Floating Catchment Area (2SFCA)

df_2sfca <- catchment_ratio(
  consumers = hexagons$demand,
  providers = eldercare$capacity,
  cost = distmat_km,
  max_cost = 5) %>%
  as.data.frame() %>%
  rename("2sfca" = ".")
summary(df_2sfca)
     2sfca        
 Min.   :0.00000  
 1st Qu.:0.00000  
 Median :0.02073  
 Mean   :0.03840  
 3rd Qu.:0.06007  
 Max.   :0.93136  
df_2sfca %>%
  mutate(fid = hexagons$fid,
         centres_within_5km = rowSums(distmat_km < 5)) %>%
  arrange(desc(`2sfca`)) %>%
  head(3)
      2sfca  fid centres_within_5km
1 0.9313638 1067                120
2 0.2254351 2348                 30
3 0.2251222 2282                 29

7. Mapping the 2SFCA Result

hexagon_2sfca <- bind_cols(hexagons, df_2sfca)

mapex <- st_bbox(hexagons)
nonzero <- hexagon_2sfca$`2sfca`[hexagon_2sfca$`2sfca` > 0]
q <- quantile(nonzero, probs = seq(0.2, 1, 0.2))

breaks_2sfca <- c(0, 1e-9, q)
labels_2sfca <- c("0, no centre within 5 km",
                  sprintf("above 0, under %.3f", q[1]),
                  sprintf("%.3f to %.3f", q[1:4], q[2:5]))

labels_2sfca
[1] "0, no centre within 5 km" "above 0, under 0.026"    
[3] "0.026 to 0.042"           "0.042 to 0.063"          
[5] "0.063 to 0.099"           "0.099 to 0.931"          
tm_shape(hexagon_2sfca,
         bbox = mapex) +
  tm_polygons(fill = "2sfca",
              fill.scale = tm_scale_intervals(
                breaks = breaks_2sfca,
                labels = labels_2sfca,
                values = c("grey85", "#deebf7", "#9ecae1",
                           "#6baed6", "#3182bd", "#08519c")),
              fill.legend = tm_legend(
                title = "Places per unit of demand")) +
tm_shape(eldercare) +
  tm_dots(size = 0.3,
          fill = "red",
          col = "black") +
  tm_title("Accessibility to eldercare: 2SFCA method") +
  tm_layout(frame = TRUE) +
  tm_compass(type = "8star", size = 2) +
  tm_scalebar() +
  tm_grid(alpha = 0.2)

8. Enhanced Two-step Floating Catchment Area (E2SFCA)

step_weights <- list(c(5, .01), c(4, .05), c(3, .1), c(2, .4), c(1, 1))
df_e2sfca <- catchment_ratio(
  consumers = hexagons$demand,
  providers = eldercare$capacity,
  cost = distmat_km,
  weight = step_weights) %>%
  as.data.frame() %>%
  rename("e2sfca" = ".")
summary(df_e2sfca)
     e2sfca        
 Min.   :0.000000  
 1st Qu.:0.000000  
 Median :0.003798  
 Mean   :0.038400  
 3rd Qu.:0.042698  
 Max.   :0.744475  
tibble(fid = hexagons$fid,
       `2sfca` = df_2sfca$`2sfca`,
       e2sfca = df_e2sfca$e2sfca,
       centres_within_1km = rowSums(distmat_km <= 1)) %>%
  mutate(rank_2sfca = min_rank(desc(`2sfca`)),
         rank_e2sfca = min_rank(desc(e2sfca))) %>%
  arrange(rank_e2sfca) %>%
  head(5)
# A tibble: 5 x 6
    fid `2sfca` e2sfca centres_within_1km rank_2sfca rank_e2sfca
  <dbl>   <dbl>  <dbl>              <dbl>      <int>       <int>
1  1816   0.198  0.744                  5         39           1
2  1769   0.193  0.681                  3         51           2
3  1862   0.207  0.573                  2         23           3
4  1864   0.190  0.571                  3         61           4
5  1722   0.200  0.560                  2         37           5

9. Mapping the E2SFCA Result

hexagon_e2sfca <- bind_cols(hexagons, df_e2sfca)

nonzero_e2 <- hexagon_e2sfca$e2sfca[hexagon_e2sfca$e2sfca > 0]
q_e2 <- quantile(nonzero_e2, probs = seq(0.2, 1, 0.2))

breaks_e2sfca <- c(0, 1e-9, q_e2)
labels_e2sfca <- c("0, no centre within 5 km",
                   sprintf("above 0, under %.3f", q_e2[1]),
                   sprintf("%.3f to %.3f", q_e2[1:4], q_e2[2:5]))
tm_shape(hexagon_e2sfca,
         bbox = mapex) +
  tm_polygons(fill = "e2sfca",
              fill.scale = tm_scale_intervals(
                breaks = breaks_e2sfca,
                labels = labels_e2sfca,
                values = c("grey85", "#deebf7", "#9ecae1",
                           "#6baed6", "#3182bd", "#08519c")),
              fill.legend = tm_legend(
                title = "Places per unit of demand")) +
tm_shape(eldercare) +
  tm_dots(size = 0.3,
          fill = "red",
          col = "black") +
  tm_title("Accessibility to eldercare: E2SFCA method") +
  tm_layout(frame = TRUE) +
  tm_compass(type = "8star", size = 2) +
  tm_scalebar() +
  tm_grid(alpha = 0.2)

10. Kernel Density Two-step Floating Catchment Area (KD2SFCA)

df_kd2sfca <- catchment_ratio(
  consumers = hexagons$demand,
  providers = eldercare$capacity,
  cost = distmat_km,
  weight = "gaussian",
  scale = 1 / sqrt(2),
  max_cost = 5) %>%
  as.data.frame() %>%
  rename("kd2sfca" = ".")
kd_spatialacc <- SpatialAcc::ac(hexagons$demand,
                                eldercare$capacity,
                                distmat_km,
                                d0 = 5,
                                power = 2,
                                family = "KD2SFCA")

c(max_abs_difference = max(abs(df_kd2sfca$kd2sfca - kd_spatialacc)))
max_abs_difference 
      4.440892e-16 
summary(df_kd2sfca)
    kd2sfca         
 Min.   :0.000e+00  
 1st Qu.:0.000e+00  
 Median :3.000e-08  
 Mean   :3.840e-02  
 3rd Qu.:4.616e-03  
 Max.   :1.476e+00  
d <- c(0.5, 1, 2, 3, 4, 5)
tibble(distance_km = d,
       `2SFCA` = 1,
       E2SFCA = c(1, 1, 0.4, 0.1, 0.05, 0.01),
       KD2SFCA = signif(exp(-d^2), 3))
# A tibble: 6 x 4
  distance_km `2SFCA` E2SFCA  KD2SFCA
        <dbl>   <dbl>  <dbl>    <dbl>
1         0.5       1   1    7.79e- 1
2         1         1   1    3.68e- 1
3         2         1   0.4  1.83e- 2
4         3         1   0.1  1.23e- 4
5         4         1   0.05 1.13e- 7
6         5         1   0.01 1.39e-11

11. Mapping the KD2SFCA Result

hexagon_kd2sfca <- bind_cols(hexagons, df_kd2sfca)

nonzero_kd <- hexagon_kd2sfca$kd2sfca[hexagon_kd2sfca$kd2sfca > 0]
q_kd <- quantile(nonzero_kd, probs = seq(0.2, 1, 0.2))

# the zero class ends below the smallest real score, however small it is
breaks_kd2sfca <- c(0, min(nonzero_kd) / 2, q_kd)
labels_kd2sfca <- c("0, no centre within 5 km",
                    sprintf("above 0, under %.2g", q_kd[1]),
                    sprintf("%.2g to %.2g", q_kd[1:4], q_kd[2:5]))
tm_shape(hexagon_kd2sfca,
         bbox = mapex) +
  tm_polygons(fill = "kd2sfca",
              fill.scale = tm_scale_intervals(
                breaks = breaks_kd2sfca,
                labels = labels_kd2sfca,
                values = c("grey85", "#deebf7", "#9ecae1",
                           "#6baed6", "#3182bd", "#08519c")),
              fill.legend = tm_legend(
                title = "Places per unit of demand")) +
tm_shape(eldercare) +
  tm_dots(size = 0.3,
          fill = "red",
          col = "black") +
  tm_title("Accessibility to eldercare: KD2SFCA method") +
  tm_layout(frame = TRUE) +
  tm_compass(type = "8star", size = 2) +
  tm_scalebar() +
  tm_grid(alpha = 0.2)

12. Three-step Floating Catchment Area (3SFCA)

df_3sfca <- catchment_ratio(
  consumers = hexagons$demand,
  providers = eldercare$capacity,
  cost = distmat_km,
  step_weights,
  normalize_weight = TRUE) %>%
  as.data.frame() %>%
  rename("3SFCA" = ".")
summary(df_3sfca)
     3SFCA         
 Min.   :0.000000  
 1st Qu.:0.000000  
 Median :0.005286  
 Mean   :0.038400  
 3rd Qu.:0.035281  
 Max.   :0.818821  

13. Mapping the 3SFCA Result

hexagon_3sfca <- bind_cols(hexagons, df_3sfca)

nonzero_3 <- hexagon_3sfca$`3SFCA`[hexagon_3sfca$`3SFCA` > 0]
q_3 <- quantile(nonzero_3, probs = seq(0.2, 1, 0.2))

breaks_3sfca <- c(0, min(nonzero_3) / 2, q_3)
labels_3sfca <- c("0, no centre within 5 km",
                  sprintf("above 0, under %.2g", q_3[1]),
                  sprintf("%.2g to %.2g", q_3[1:4], q_3[2:5]))
tm_shape(hexagon_3sfca,
         bbox = mapex) +
  tm_polygons(fill = "3SFCA",
              fill.scale = tm_scale_intervals(
                breaks = breaks_3sfca,
                labels = labels_3sfca,
                values = c("grey85", "#deebf7", "#9ecae1",
                           "#6baed6", "#3182bd", "#08519c")),
              fill.legend = tm_legend(
                title = "Places per unit of demand")) +
tm_shape(eldercare) +
  tm_dots(size = 0.3,
          fill = "red",
          col = "black") +
  tm_title("Accessibility to eldercare: 3SFCA method") +
  tm_layout(frame = TRUE) +
  tm_compass(type = "8star", size = 2) +
  tm_scalebar() +
  tm_grid(alpha = 0.2)