Data Science II

Session 4: Spatial Joins, Measurement, and Buffers

Bogdan G. Popescu

Tec de Monterrey

What You’ll Learn Today

  1. What a spatial function hands back: predicates, measures, constructors
  2. st_join — combining layers by location instead of by a shared code
  3. Measures: st_area, st_centroid, st_distance, st_nearest_feature — and why the CRS decides the answer
  4. Constructors: buffers, unions, intersections, and geometry casting
  5. A complete access-to-services analysis, end to end

The question we answer today: how far is each municipio in Yucatán from its nearest hospital?

Before We Start

#Step1: Libraries for today
library(sf)
library(dplyr)
library(ggplot2)
library(patchwork)

sf_use_s2(FALSE)

Data, as before, in ../data/ — download first if you do not still have them:

Where We Left Off

In Session 3 we joined homicides to boundaries on CVEGEO — a shared code present in both tables.

That is an ordinary left_join. Nothing spatial happened.

But often there is no shared code. You have coordinates on one side and polygons on the other, and the only thing connecting them is where they are.

Part A: Joins — Predicates Plus Bookkeeping

Join by Location

The points. Three real hospitals from DENUE — a name and a place.

demo_hosp <- st_sf(
  hospital = c("Faro del Mayab",
               "Clínica Hospital Mérida",
               "Naval de Yukalpetén"),
  geometry = st_sfc(
    st_point(c(-89.60121, 21.04386)),
    st_point(c(-89.69165, 20.97476)),
    st_point(c(-89.66050, 21.28756))),
  crs = 4326)

demo_hosp
Simple feature collection with 3 features and 1 field
Geometry type: POINT
Dimension:     XY
Bounding box:  xmin: -89.69165 ymin: 20.97476 xmax: -89.60121 ymax: 21.28756
Geodetic CRS:  WGS 84
                 hospital                   geometry
1          Faro del Mayab POINT (-89.60121 21.04386)
2 Clínica Hospital Mérida POINT (-89.69165 20.97476)
3     Naval de Yukalpetén  POINT (-89.6605 21.28756)

1 field — just the hospital’s name. Nothing here says which municipio each one is in.

Join by Location

The polygons. Two municipios that share a border.

demo_mun <- st_read("../data/gadm41_MEX_shp/gadm41_MEX_2.shp",
                quiet = TRUE) %>%
  filter(NAME_1 == "Yucatán",
         NAME_2 %in% c("Mérida", "Progreso")) %>%
  select(NAME_2)

demo_mun
Simple feature collection with 2 features and 1 field
Geometry type: MULTIPOLYGON
Dimension:     XY
Bounding box:  xmin: -89.86554 ymin: 20.69711 xmax: -89.4513 ymax: 21.31777
Geodetic CRS:  WGS 84
    NAME_2                       geometry
1   Mérida MULTIPOLYGON (((-89.58713 2...
2 Progreso MULTIPOLYGON (((-89.51547 2...

1 field each — NAME_2. This is the attribute we want to hand to the points.

Join by Location

Put them on the same map.

ggplot() +
  geom_sf(data = demo_mun, aes(fill = NAME_2), alpha = 0.35) +
  geom_sf(data = demo_hosp, size = 3) +
  geom_sf_text(data = demo_mun, aes(label = NAME_2), size = 3,
               nudge_y = -0.12) +
  theme(legend.position = "none")

Two hospitals sit in Mérida, one in Progreso. Your eye does the join instantly; st_join is how you say it to R.

Join by Location

The join. NAME_2 crosses from the municipios into the hospitals.

demo_joined <- st_join(demo_hosp, demo_mun, join = st_within)

demo_joined
Simple feature collection with 3 features and 2 fields
Geometry type: POINT
Dimension:     XY
Bounding box:  xmin: -89.69165 ymin: 20.97476 xmax: -89.60121 ymax: 21.28756
Geodetic CRS:  WGS 84
                 hospital   NAME_2                   geometry
1          Faro del Mayab   Mérida POINT (-89.60121 21.04386)
2 Clínica Hospital Mérida   Mérida POINT (-89.69165 20.97476)
3     Naval de Yukalpetén Progreso  POINT (-89.6605 21.28756)

2 fields, and both Mérida hospitals got the same value. The polygon hands its attribute to every point inside it — still three rows, none added, none lost.

Join by Location

DENUE already carries a municipio column, so you can check this answer. Later today we do exactly that at scale — and find where the two disagree.

A spatial join matches rows from two layers by a geometric relationship. Everything today is a variation on this — the difference is that with 6,460 points you cannot check by eye.

The sf Operations Toolkit

Every panel above was drawn by the function it names. They all begin st_, they all take sf objects, and they all return sf objects.

What a Spatial Function Hands Back

It returns The family Examples What you do next
TRUE/FALSE, or a list of indices predicates st_intersects, st_within filter rows, or drive a join
a number measures st_area, st_distance put it in a column
a new geometry constructors st_buffer, st_centroid, st_union draw it, or feed it back in
a table joins and filters st_join, st_filter ordinary dplyr from here

Every panel on the last slide was a constructor — geometry in, new geometry out.

A join is a predicate plus bookkeeping: st_join(x, y, join = st_within) runs the predicate, then attaches columns. That is why the predicate is an argument.

This session walks the three families in order: A joins, B measures, C constructors.

The Setup

#Step1: The points -- health establishments with coordinates
denue   <- read.csv("../data/denue/denue_salud_yucatan.csv")
pts <- st_as_sf(denue, coords = c("longitud", "latitud"), crs = 4326)

#Step2: The polygons -- Yucatán municipios
mex <- st_read("../data/gadm41_MEX_shp/gadm41_MEX_2.shp", quiet = TRUE)
yuc <- mex %>%
  filter(NAME_1 == "Yucatán") %>%
  select(NAME_2)

nrow(pts)
[1] 6460
nrow(yuc)
[1] 106

6,460 points, 106 polygons, and no shared key — the DENUE file has a municipio name, but we are going to ignore it and use geometry instead.

The Setup

One more layer for later — just the hospitals:

#Step3: DENUE activity code 6221 is "hospital general"
hospitals <- denue %>%
  filter(substr(as.character(codigo_act), 1, 4) == "6221") %>%
  st_as_sf(coords = c("longitud", "latitud"), crs = 4326)

nrow(hospitals)
[1] 106

106 hospitals — one for every municipio in the state, if they were spread evenly. Part B asks whether they are.

st_join

#Step1: attach to each point the polygon it falls inside
joined <- st_join(pts, yuc, join = st_within)

#Step2: every point now carries a NAME_2
joined %>%
  st_drop_geometry() %>%
  filter(nom_estab != "") %>%
  select(nom_estab, NAME_2) %>%
  head(3)
                        nom_estab NAME_2
1 10 DE MAYO ALCOHOLICOS ANONIMOS  Tecoh
2                 211 GINECOLOGIA Mérida
3                            24/7   Kaua

st_join(x, y) keeps every row of x and adds columns from y. It is a left join, in spatial clothing.

st_join

#Step1: the six busiest municipios, by the count the join produced
top6 <- joined %>%
  st_drop_geometry() %>%
  filter(!is.na(NAME_2)) %>%
  count(NAME_2, sort = TRUE) %>%
  slice_head(n = 6) %>%
  pull(NAME_2)

#Step2: name those six, leave the rest grey
ggplot() +
  geom_sf(data = yuc, fill = "grey96",
          colour = "white", linewidth = 0.15) +
  geom_sf(data = filter(joined, !NAME_2 %in% top6),
          colour = "grey70", size = 0.35) +
  geom_sf(data = filter(joined, NAME_2 %in% top6),
          aes(colour = NAME_2), size = 0.5) +
  labs(colour = "municipio\nfrom the join")

Read the legend: every point now carries a municipio name it arrived without. That is what st_join bought you — you can now count, filter and group these 6,460 rows by municipio, using nothing but their coordinates.

Counting Points per Polygon

#Step1: Drop the geometry and count -- ordinary dplyr from here
spatial_count <- joined %>%
  st_drop_geometry() %>%
  filter(!is.na(NAME_2)) %>%
  count(NAME_2, name = "spatial")

head(spatial_count, 4)
   NAME_2 spatial
1   Abalá       5
2 Acanceh      19
3    Akil      32
4    Baca      22

Counting Points per Polygon

yuc %>%
  left_join(spatial_count, by = "NAME_2") %>%
  ggplot() +
  geom_sf(aes(fill = spatial), colour = "white", linewidth = 0.1) +
  scale_fill_viridis_c(direction = -1)

#The same map on a log scale
yuc %>%
  left_join(spatial_count, by = "NAME_2") %>%
  ggplot() +
  geom_sf(aes(fill = spatial), colour = "white", linewidth = 0.1) +
  scale_fill_viridis_c(direction = -1, trans = "log10")

Same data, same join. On the linear scale Mérida’s 4,218 swamps everything; on the log scale you can finally see the other 105 municipios differ from one another.

Counting Points per Polygon

Zoom in, and the colour is just a number you can read off the map.

The inset shows where we are — central Yucatán, east of Mérida. Seven municipios, the black dots are the establishments, and the number on each is exactly how many fell inside it. Tinum 22, Yaxcabá 12, Chankom 5, Kaua 3 — darker means more, and the label proves it.

Why They Disagree

Seven establishments DENUE labels Kanasín have coordinates inside Mérida; one labelled Mérida falls in Umán. Every one sits on the metropolitan boundary — where an address and a GPS reading are most likely to part company.

The last is a coastal clinic in San Felipe, sitting 34 m outside GADM’s simplified shoreline. The clinic is real; the coastline is approximate.

Which Answer Do You Trust?

DENUE carries a municipio label. The join just produced one from geometry. Do they agree?

#Step1: points whose label disagrees with where they fell
joined %>%
  st_drop_geometry() %>%
  filter(!is.na(NAME_2), municipio != NAME_2) %>%
  nrow()
[1] 8
#Step2: points that fell outside every polygon
sum(is.na(joined$NAME_2))
[1] 1

Eight disagree, one lands nowhere. Neither answer is simply “right”: the label is the administrative address a registry recorded; the geometry is where a GPS reading fell, judged against a boundary file that is itself simplified.

Important

Report the discrepancy rather than hiding it. Nine points out of 6,460 is 0.14% and changes nothing — if it had been 8%, your whole analysis would rest on which boundary file you happened to download.

Your Turn: Does the Predicate Matter?

You joined with st_within. Try st_intersects instead. Does the answer change? Should it?

joined_intersects <- st_join(pts, yuc, join = st_intersects)

#Compare: same number of rows? same number of NAs?
nrow(joined_intersects)
[1] 6460
sum(is.na(joined_intersects$NAME_2))
[1] 1

Identical — 6,460 rows, 1 unmatched, either way.

Tip

The two differ only for a point lying exactly on a boundary. None do here. The predicate is a real choice, but not every real choice changes the answer — check rather than assume.

Part B: Measures — Geometry Returns a Number

Three Kinds of Calculation

  • Numeric — returns a number: st_area, st_distance, st_length
  • Logical — returns TRUE/FALSE: st_intersects, st_within, st_disjoint
  • Spatial — returns new geometry: st_centroid, st_buffer, st_union

All three need a projected CRS if you want answers in metres.

st_area

#Step1: Project first -- EPSG:6372 has units of metres
yuc_p <- st_transform(yuc, 6372)

#Step2: Area comes back with units attached
head(st_area(yuc_p), 3)
Units: [m^2]
[1] 290237841 136063311  75871595
#Step3: Convert to km2 and strip the units for plotting
yuc_p$area_km2 <- as.numeric(units::set_units(st_area(yuc_p), km^2))
round(sum(yuc_p$area_km2))
[1] 39102

39,102 km² — the published area of Yucatán is about 39,600. The 1% gap is boundary simplification.

st_area

#The column is now ordinary -- map it like any other
ggplot(yuc_p) +
  geom_sf(aes(fill = area_km2),
          colour = "white", linewidth = 0.1) +
  scale_fill_viridis_c(direction = -1,
                       name = "km²")

Tizimín 3,847 km² against Sanahcat 26 km² — a 148-fold spread. Municipios are nothing like equal-sized, which is why a count per municipio told you so little.

st_area: Degrees or Metres?

#Step1: strip the CRS -- st_area just multiplies degrees together
yuc_deg <- st_set_crs(yuc, NA)
head(st_area(yuc_deg), 2)
[1] 0.02536948 0.01190959
#Step2: with a projected CRS, the units come attached
head(st_area(yuc_p), 2)
Units: [m^2]
[1] 290237841 136063311

0.025 square degrees against 292,689,496 square metres. The first is a bare number; the second knows what it is.

st_area: Degrees or Metres?

The same map twice. The two columns correlate at 1.00 — nothing about the picture tells you one of them is measured in a unit that means different things at different latitudes.

Warning

Only the legend gives it away. Project before you measure, and keep the units that sf hands you.

Your Turn: How Much Does the CRS Cost You?

Yucatán is 39,102 km² in EPSG:6372. Here are the same polygons measured two ways:

#The state outline, dissolved from the 106 municipios
state <- st_union(yuc_p)

area_lcc      <- st_area(st_transform(state, 6372))
area_mercator <- st_area(st_transform(state, 3857))

By what percentage is the area over- or under-calculated in Web Mercator?

st_centroid

st_centroid

st_centroid

#Step1: one point per polygon
centroids <- st_centroid(yuc_p)
nrow(centroids)
[1] 106
#Step2: polygons underneath, centroids on top
ggplot() +
  geom_sf(data = yuc_p, fill = "grey95",
          colour = "grey60", linewidth = 0.15) +
  geom_sf(data = centroids, shape = 3,
          size = 1.6, colour = "red") +
  coord_sf(crs = 4326)

The geometric centre of mass — the same operation as the top-left panel of the toolkit, on real municipios.

Centroids Can Lie

An L-shaped municipio.

Centroids Can Lie

st_centroid() — the centre of mass falls outside the polygon.

Centroids Can Lie

st_point_on_surface() — guaranteed to fall inside.

When That Matters

Metlatónoc, Guerrero — one polygon with a deep notch. Its centre of mass lands 5.6 km outside the municipio it belongs to.

Tecomán, Colima is worse: five separate parts, centroid 100.7 km from any of them.

Tip

Not compact, or made of several pieces? Use st_point_on_surface().

st_distance: Every Centroid to the Capital

#Step1: one target -- Mérida
cap <- centroids %>%
  filter(NAME_2 == "Mérida")

#Step2: many-to-one is a 106 x 1 matrix,
#       which is just a column
yuc_p$km_cap <- as.numeric(
  st_distance(centroids, cap)) / 1000

round(range(yuc_p$km_cap), 1)
[1]   0.0 189.6

Chemax at 190 km is the far end. The 0.0 is Mérida measuring itself — the nearest real neighbour is Kanasín, 13 km. Median across the state: 67 km.

st_nearest_feature

#Step1: The hospitals, projected to match the centroids
hosp <- hospitals %>%
  st_transform(6372)

#Step2: For each centroid, the index of the closest hospital
nearest <- st_nearest_feature(centroids, hosp)
head(nearest)
[1]  97  35  62  53  72 102
#Step3: by_element gives a vector, not a matrix -- one distance per pair
centroids$km_to_hospital <- as.numeric(
  st_distance(centroids, hosp[nearest, ], by_element = TRUE)) / 1000

Between 0.5 km and 44.2 km to the nearest hospital.

st_nearest_feature

Each line joins a municipio centroid (red +) to the hospital it matched (white circle).

Look at the fans: one hospital is nearest for five or six municipios at once. The match is many-to-one.

Only 57 of the 106 hospitals are nearest to anything. The other 49 never win.

The longest line is Chikindzonot, 44.2 km — and that is straight-line, not by road.

Who Is Furthest?

centroids %>%
  st_drop_geometry() %>%
  arrange(desc(km_to_hospital)) %>%
  transmute(NAME_2, km = round(km_to_hospital, 1)) %>%
  head(6)
        NAME_2   km
1 Chikindzonot 44.2
2 Río Lagartos 43.5
3   San Felipe 41.2
4      Tizimín 32.7
5       Chemax 32.5
6     Buctzotz 31.7

Chikindzonot, Río Lagartos, San Felipe — the eastern interior and the north coast.

Mapping Accessibility

#Step1: Put the distance back on the polygons
yuc_p$km_to_hospital <- centroids$km_to_hospital

#Step2: Map it, hospitals on top
ggplot() +
  geom_sf(data = yuc_p,
          aes(fill = km_to_hospital),
          colour = "white",
          linewidth = 0.1) +
  geom_sf(data = hosp, size = 0.5,
          colour = "white") +
  scale_fill_viridis_c(direction = -1,
                       name = "km") +
  coord_sf(crs = 4326) +
  labs(title = "Distance to the nearest hospital")

Your Turn: Centroid, or a Point Inside?

Part B measured accessibility from st_centroid. Redo it with st_point_on_surface — does the worst-served municipio change?

inside_points <- st_point_on_surface(yuc_p)

km_from_inside <- st_distance(inside_points, hosp[st_nearest_feature(inside_points, hosp), ],
                  by_element = TRUE)

yuc_p$NAME_2[which.max(km_from_inside)]

Still Chikindzonot — but 46.4 km instead of 44.2, and the top five change order.

Tip

The headline name survived; the ranking did not. If you were funding the three worst-served municipios, this choice would pick different ones.

Part C: Constructors — Geometry In, New Geometry Out

Buffers

Buffers

Buffers

st_buffer(x, dist) grows a geometry outward by dist — turning a point into a circle, a line into a corridor, a polygon into a larger polygon.

Buffers Need Metres

#Step1: The layer is already in EPSG:6372, so dist is in METRES
buf10 <- st_buffer(hosp, 10000)   # 10 km
nrow(buf10)
[1] 106

Warning

The number you pass to dist is in the units of the layer’s CRS. In EPSG:6372 that is metres, so 10000 is 10 km. In EPSG:4326 it would be 10,000 degrees — twenty-seven times around the planet.

Modern sf will quietly do the geodesically correct thing on a geographic CRS, which is worse in a way: you get a plausible answer without ever learning you were sloppy.

st_union: Dissolving

#Step1: 106 overlapping circles become one multipolygon
buffers_merged <- st_union(buf10)
length(buffers_merged)
[1] 1

Without the union, overlapping buffers would double-count the area they share.

st_intersection

#Step1: Clip the coverage to the state -- circles spill into the sea
state    <- st_union(yuc_p)
coverage <- st_intersection(state, buffers_merged)

st_difference

#Step1: The complement -- everywhere NOT within 10 km of a hospital
gap <- st_difference(state, buffers_merged)

intersection is “in both”, difference is “in the first but not the second”, union is “in either”. Those three cover most overlay work.

How Much of the State Is Covered?

Show code
#--- built on earlier slides ---
hospitals <- denue %>%
  filter(substr(as.character(codigo_act), 1, 4) == "6221") %>%
  st_as_sf(coords = c("longitud", "latitud"), crs = 4326)

hosp <- hospitals %>%
  st_transform(6372)

state <- st_union(yuc_p)
buf10 <- st_buffer(hosp, 10000)          # 10 km

bb <- st_bbox(st_transform(buf10, 4326))

ggplot() +
  geom_sf(data = state, fill = "#f2d7d5", colour = "grey60", linewidth = 0.3) +
  geom_sf(data = hosp, size = 0.7, colour = "black") +
  coord_sf(xlim = bb[c("xmin", "xmax")],
           ylim = bb[c("ymin", "ymax")], crs = 4326) +
  theme_void()

Start with the 106 hospitals.

How Much of the State Is Covered?

Show code
#--- built on earlier slides ---
hospitals <- denue %>%
  filter(substr(as.character(codigo_act), 1, 4) == "6221") %>%
  st_as_sf(coords = c("longitud", "latitud"), crs = 4326)

hosp <- hospitals %>%
  st_transform(6372)

state <- st_union(yuc_p)
buf10 <- st_buffer(hosp, 10000)          # 10 km

bb <- st_bbox(st_transform(buf10, 4326))

ggplot() +
  geom_sf(data = state, fill = "#f2d7d5", colour = "grey60", linewidth = 0.3) +
  geom_sf(data = buf10, fill = "#4c9f70", colour = NA, alpha = 0.25) +
  geom_sf(data = hosp, size = 0.7, colour = "black") +
  coord_sf(xlim = bb[c("xmin", "xmax")],
           ylim = bb[c("ymin", "ymax")], crs = 4326) +
  theme_void()

st_buffer(hosp, 10000) draws a 10 km circle around each one. They overlap each other, and some spill into the sea.

How Much of the State Is Covered?

Show code
#--- built on earlier slides ---
hospitals <- denue %>%
  filter(substr(as.character(codigo_act), 1, 4) == "6221") %>%
  st_as_sf(coords = c("longitud", "latitud"), crs = 4326)

hosp <- hospitals %>%
  st_transform(6372)

state <- st_union(yuc_p)
buf10 <- st_buffer(hosp, 10000)          # 10 km
buffers_merged <- st_union(buf10)

bb <- st_bbox(st_transform(buf10, 4326))

ggplot() +
  geom_sf(data = state, fill = "#f2d7d5", colour = "grey60", linewidth = 0.3) +
  geom_sf(data = buffers_merged, fill = "#4c9f70", colour = "#2e6b4a", alpha = 0.55) +
  geom_sf(data = hosp, size = 0.7, colour = "black") +
  coord_sf(xlim = bb[c("xmin", "xmax")],
           ylim = bb[c("ymin", "ymax")], crs = 4326) +
  theme_void()

st_union() melts the overlaps into one shape, so ground shared by two hospitals is counted once, not twice.

How Much of the State Is Covered?

Show code
#--- built on earlier slides ---
hospitals <- denue %>%
  filter(substr(as.character(codigo_act), 1, 4) == "6221") %>%
  st_as_sf(coords = c("longitud", "latitud"), crs = 4326)

hosp <- hospitals %>%
  st_transform(6372)

state <- st_union(yuc_p)
buf10 <- st_buffer(hosp, 10000)          # 10 km
buffers_merged <- st_union(buf10)
coverage <- st_intersection(state, buffers_merged)

bb <- st_bbox(st_transform(buf10, 4326))

ggplot() +
  geom_sf(data = state, fill = "#f2d7d5", colour = "grey60", linewidth = 0.3) +
  geom_sf(data = coverage, fill = "#4c9f70", colour = NA, alpha = 0.9) +
  geom_sf(data = hosp, size = 0.7, colour = "black") +
  coord_sf(xlim = bb[c("xmin", "xmax")],
           ylim = bb[c("ymin", "ymax")], crs = 4326) +
  theme_void()

st_intersection(state, covered) trims it to the land. Green is within 10 km of a hospital. Pink is not.

How Much of the State Is Covered?

Now it is just two areas divided:

#Step1: Areas in km2
area_state <- as.numeric(st_area(state))    / 1e6
area_covered   <- as.numeric(st_area(coverage)) / 1e6

#Step2: The green share of the whole
round(c(state_km2 = area_state, covered_km2 = area_covered,
        percent = 100 * area_covered / area_state), 1)
  state_km2 covered_km2     percent 
    39102.0     11685.9        29.9 

Only 29.9% of Yucatán is within 10 km of a hospital — and that is straight-line distance, so the real figure is worse.

Other Shapes You Can Derive

st_convex_hull

The tightest convex shape containing the points — a crude “service area”.

st_sym_difference

In one or the other, but not both.

Geometry Casting

#Step1: the state is ONE feature
st_geometry_type(state)
[1] MULTIPOLYGON
18 Levels: GEOMETRY POINT LINESTRING POLYGON MULTIPOINT ... TRIANGLE

One row, one geometry. But a MULTIPOLYGON can be made of many separate pieces.

Geometry Casting

#Step2: break it into its parts
pieces <- st_cast(state, "POLYGON")
length(pieces)
[1] 6
#Step3: how big is each one, in km2?
round(sort(as.numeric(st_area(pieces)) / 1e6,
           decreasing = TRUE), 1)
[1] 39094.4     4.6     2.3     0.5     0.2     0.0

Six pieces. The mainland is 39,094 km² — 99.98% of the state. The other five are islands, the largest 4.6 km².

All five sit on the north coast — barrier islands, circled.

Geometry Casting

Blue is the mainland, white is water, red are the five separate pieces. They are barrier islands — cut off from the coast by a lagoon, which is exactly why sf stores them as their own polygons.

Together they come to 7.6 km². Casting them off changes the state’s area by 0.02% — but it changes the number of features from one to six, and that is what breaks a loop.

Geometry Casting

#Step4: cast to lines instead -- the boundary on its own
edge <- st_cast(state, "MULTILINESTRING")
st_geometry_type(edge)
[1] MULTILINESTRING
18 Levels: GEOMETRY POINT LINESTRING POLYGON MULTIPOINT ... TRIANGLE

Same coordinates, different type. A polygon encloses an area; a line does not — so st_area(edge) is now zero.

That is what casting is for: keep the shape, change what R thinks it is.

Geometry Casting: Why Bother?

#How long is Yucatán's border?
st_length(state)
0 [m]
#The same question, after casting
st_length(st_cast(state, "MULTILINESTRING"))
1219611 [m]

Zero. A polygon encloses an area; it has no length. st_length answers 0 — no error, no warning, just a wrong number you might publish.

Cast it to lines and the border is 1,220 km.

Grey: the mainland edge. Red: the islands.

Those five islands are 0.02% of the area but 43.8 km of coastline — 3.6% of the total. Casting them off is an analytical decision, not housekeeping.

Part D: Putting It Together

The Whole Analysis

Six operations, in order:

  1. st_as_sf — coordinates become geometry (Session 2)
  2. st_join — each establishment picks up the municipio it falls in
  3. st_transform — into metres, EPSG:6372
  4. st_centroid — one representative point per municipio
  5. st_nearest_feature + st_distance — how far to the nearest hospital
  6. st_buffer + st_union + st_intersection — what share of the territory is served

That is a complete accessibility study, in about twenty lines.

What Have We Learned?

  • Three families: predicates return TRUE/FALSE, measures return numbers, constructors return geometry — and a join is a predicate plus bookkeeping
  • st_within and st_intersects differ only on the boundary; across 6,460 real points they agreed exactly
  • Joining by label and by location disagreed for 8 establishments, every one on the Mérida metropolitan border, plus one clinic 34 m outside the shoreline
  • Measurement needs a projected CRS: Web Mercator overstates Yucatán by 15.9%
  • st_nearest_feature + by_element = TRUE gives one distance per row instead of a matrix
  • union → intersection → area: only 29.9% of the state is within 10 km of a hospital
  • st_length on a polygon returns 0 — cast to lines first
  • Area covered is not population covered

Next session: rasters — continuous surfaces, and extracting them to your polygons.