Session 4: Spatial Joins, Measurement, and Buffers
st_join — combining layers by location instead of by a shared codest_area, st_centroid, st_distance, st_nearest_feature — and why the CRS decides the answerThe question we answer today: how far is each municipio in Yucatán from its nearest hospital?
Data, as before, in ../data/ — download first if you do not still have them:
data/denue/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.
The points. Three real hospitals from DENUE — a name and a place.
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.
The polygons. Two municipios that share a border.
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.
Put them on the same map.
Two hospitals sit in Mérida, one in Progreso. Your eye does the join instantly; st_join is how you say it to R.
The join. NAME_2 crosses from the municipios into the hospitals.
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.
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.
sf Operations ToolkitEvery panel above was drawn by the function it names. They all begin st_, they all take sf objects, and they all return sf objects.
| 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.
#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
[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.
One more layer for later — just the 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 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.
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.
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.
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.
DENUE carries a municipio label. The join just produced one from geometry. Do they agree?
[1] 8
[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.
You joined with st_within. Try st_intersects instead. Does the answer change? Should it?
[1] 6460
[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.
st_area, st_distance, st_lengthst_intersects, st_within, st_disjointst_centroid, st_buffer, st_unionAll three need a projected CRS if you want answers in metres.
st_areaUnits: [m^2]
[1] 290237841 136063311 75871595
39,102 km² — the published area of Yucatán is about 39,600. The 1% gap is boundary simplification.
st_areaTizimí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?[1] 0.02536948 0.01190959
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.
Yucatán is 39,102 km² in EPSG:6372. Here are the same polygons measured two ways:
By what percentage is the area over- or under-calculated in Web Mercator?
st_centroidst_centroidst_centroidThe geometric centre of mass — the same operation as the top-left panel of the toolkit, on real municipios.
An L-shaped municipio.
st_centroid() — the centre of mass falls outside the polygon.
st_point_on_surface() — guaranteed to fall inside.
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[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[1] 97 35 62 53 72 102
Between 0.5 km and 44.2 km to the nearest hospital.
st_nearest_featureEach 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.
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.
#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")Part B measured accessibility from st_centroid. Redo it with st_point_on_surface — does the worst-served municipio change?
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.
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.
[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[1] 1
Without the union, overlapping buffers would double-count the area they share.
st_intersectionst_differenceintersection is “in both”, difference is “in the first but not the second”, union is “in either”. Those three cover most overlay work.
#--- 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.
#--- 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.
#--- 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.
#--- 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.
Now it is just two areas divided:
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.
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.
[1] 6
[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.
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.
[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.
0 [m]
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.
Six operations, in order:
st_as_sf — coordinates become geometry (Session 2)st_join — each establishment picks up the municipio it falls inst_transform — into metres, EPSG:6372st_centroid — one representative point per municipiost_nearest_feature + st_distance — how far to the nearest hospitalst_buffer + st_union + st_intersection — what share of the territory is servedThat is a complete accessibility study, in about twenty lines.
TRUE/FALSE, measures return numbers, constructors return geometry — and a join is a predicate plus bookkeepingst_within and st_intersects differ only on the boundary; across 6,460 real points they agreed exactlyst_nearest_feature + by_element = TRUE gives one distance per row instead of a matrixst_length on a polygon returns 0 — cast to lines firstNext session: rasters — continuous surfaces, and extracting them to your polygons.
Popescu Data Science II — Session 4: Spatial Joins, Measurement, and Buffers