Skip to main content

31  Mapping Field-Level Data

In this chapter, we will create maps from the raw field-level data that we worked with in previous chapters. We will pick up with the file we wrote at the end of Chapter 30, which collapsed the data from a canola field at the University of Saskatchewan’s Kernen farm, east of Saskatoon to one row per second with the flags attached. You can directly download this file here: - kernen_b1_per_second.csv.

We will work with two additional files in this chapter: 1. kernen_b1_boundary.geojson is the field boundary as drawn on the machine’s display, 2. kernen_b1_wetlands.geojson holds the two outlines of two sloughs or wetlands in the field.

31.1 The machine path

The file we are working with is a csv file – not a spatial file. But we can easily convert it to a spatial file by telling R which columns hold the coordinates and what coordinate system they are in. The sf package does this with st_as_sf(), which takes a table and returns a table with a geometry column.

library(tidyverse)
library(sf)

##Read in the csv file
harvest_agg <- read_csv("data/kernen_b1_per_second.csv", show_col_types = FALSE)


# Convert to a spatial object
harvest_sf <- st_as_sf(harvest_agg, coords = c("longitude", "latitude"), crs = 4326)

## Read the first few rows of this object
harvest_sf
> library(tidyverse)
> library(sf)
> 
> ##Read in the csv file
> harvest_agg <- read_csv("data/kernen_b1_per_second.csv", show_col_types = FALSE)
> 
> 
> # Convert to a spatial object
> harvest_sf <- st_as_sf(harvest_agg, coords = c("longitude", "latitude"), crs = 4326)
> 
> ## Read the first few rows of this object
> harvest_sf
Simple feature collection with 28642 features and 11 fields
Geometry type: POINT
Dimension:     XY
Bounding box:  xmin: -106.5394 ymin: 52.14412 xmax: -106.5278 ymax: 52.15109
Geodetic CRS:  WGS 84
# A tibble: 28,642 × 12
   iso_time            yield elevation moisture speed_mph width_ft sections
 * <dttm>              <dbl>     <dbl>    <dbl>     <dbl>    <dbl>    <dbl>
 1 2026-09-29 16:14:28   0       1694.        0      2.36     2.99        1
 2 2026-09-29 16:14:29   0       1694.        0      2.36     7.98        1
 3 2026-09-29 16:14:30   0       1694.        0      2.18    23.0        46
 4 2026-09-29 16:14:31  17.4     1694.        0      2.30    25.0        50
 5 2026-09-29 16:14:32  48.5     1694.        0      2.29    26.5        53
 6 2026-09-29 16:14:33  49.7     1694.        0      2.25    27.5        55
 7 2026-09-29 16:14:34  50.0     1695.        0      2.19    28.5        57
 8 2026-09-29 16:14:35  48.8     1694.        0      2.25    29.0        58
 9 2026-09-29 16:14:36  47.7     1694.        0      2.21    31.0        62
10 2026-09-29 16:14:37  48.1     1694.        0      2.15    33.0        66
# ℹ 28,632 more rows
# ℹ 5 more variables: flag_partial <lgl>, flag_zero <lgl>, flag_extreme <lgl>,
#   flag_any <lgl>, geometry <POINT [°]>

Note that the data in harvest_sf is precisely the same as the data in harvest_agg, except that it has a geometry column. We can now plot the path of the combine through the field using our familiar friends from chapter 6: ggplot() and geom_sf().

ggplot(harvest_sf) +
  # 28,642 points, so they need to be small to see the pattern
  geom_sf(size = 0.1) +
  theme_minimal()
Map of the combine's path through field B-1, drawn as points. Straight parallel passes fill most of the field; tight concentric curves follow the edge of a slough up the middle, which contains no points at all.
Figure 31.1: The combine’s path through B-1. The hole up the middle is a slough, which the machine drove around.

The field is about 810 m across and 780 m top to bottom. Most of it is worked in straight parallel passes, which show as lines of points. However, we see that there are two large spots in the data that have no passes – we will later show that these are sloughs.

31.2 Yield maps

We can make a crude yield map by simply colouring the points according to their yield in the data. Let’s first recall that we still have our crazy yields in the data, so our colour scale is going to be dominated by a handful of these extreme readings.

ggplot(harvest_sf) +
  geom_sf(aes(colour = yield), size = 0.3) +
  scale_colour_viridis_c(name = "Yield (bu/ac)") +
  labs(x = NULL, y = NULL) +
  theme_minimal()
Map of field B-1 coloured by yield from the unfiltered file. Almost every point is the same dark colour because a handful of impossible readings stretch the colour scale.
Figure 31.2: Yield on the raw file. A handful of impossible readings own the colour scale and the field itself is one flat colour.

On this map, almost every point is the same dark colour because a handful of impossible readings stretch the colour scale, but there are so few of these observations that we cannot even see them on the map. To make these extreme values more visible, we can create a small data set that contains only the flagged readings. We can then plot these flagged readings on top of the full data set using a large size and a yellow colour.

# A second data set holding only the readings we could not see
flagged <- filter(harvest_sf, flag_extreme | flag_zero)

ggplot(harvest_sf) +
  geom_sf(aes(colour = yield), size = 0.3) +
  scale_colour_viridis_c(name = "Yield (bu/ac)") +
  labs(x = NULL, y = NULL) +
  theme_minimal() + 
  # drawn second, so they sit on top of the points below
  geom_sf(data = flagged, colour = "yellow", size = 1.5) 
Map of field B-1 coloured by yield, with 198 flagged readings marked as larger yellow points. The yellow points appear throughout the field but are most noticeable around the outside edge and along the margins of the two sloughs.
Figure 31.3: The same map with the zero and impossible readings picked out in yellow.

There are 198 of these flagged points out of 28,642 – 23 impossible yields and 175 zeros. As expected, most sit near the field boundary, though a handful are scattered through the middle of the field.

Let’s now remove these extreme value by filtering out the values above 250 bu/acre and the values that are 0 bu/acre.

# Drop the readings flagged in the previous chapter
clean_sf <- filter(harvest_sf, flag_extreme==FALSE & flag_zero==FALSE)

ggplot(clean_sf) +
  geom_sf(aes(colour = yield), size = 0.3) +
  # squish puts anything outside the limits at the nearest end colour
  scale_colour_viridis_c(name = "Yield (bu/ac)", limits = c(35, 50),
                         oob = scales::squish) +
  labs(x = NULL, y = NULL) +
  theme_minimal()
The same map after filtering. The colour now varies across the field, showing lower yields around the field edge and beside the slough.
Figure 31.4: Yield on the cleaned readings.

Now we get a map that shows a bit more variation within the field.

Two things are doing the work here. Dropping the impossible readings helps, but on its own it is not enough: a few readings still run to 231 bu/ac, and one reading that high flattens everything else into a single colour. So the colour scale is fixed to run from 35 to 50 bu/ac, which covers most of the readings, and oob = scales::squish paints anything outside that range in the end colour rather than leaving it blank. The high readings are still on the map, they just stop owning the whole scale. The cost is that the map no longer distinguishes a 50 bu/ac reading from a 231 bu/ac one – both are the brightest colour.

31.3 Merging with the wetland and boundary data

As mentioned, the field has some spots where the combine made no passes. Two visualize these we can use two additional files, one containing the wetlands that are on the field (kernen_b1_wetlands.geojson) and one containing the field boundary (kernen_b1_boundary.geojson).

We can read these files in using st_read() from the sf package, and then layer them on the map we just created.

## Read in boundary and wetlands files
boundary <- st_read("data/kernen_b1_boundary.geojson", quiet = TRUE)
wetlands <- st_read("data/kernen_b1_wetlands.geojson", quiet = TRUE)

##Add to the map
ggplot() +
  geom_sf(data = boundary, fill = NA, colour = "grey40") +
  geom_sf(data = clean_sf, aes(colour = yield), size = 0.25) +
  geom_sf(data = wetlands, fill = "steelblue", alpha = 0.6,
          colour = "steelblue4") +
  scale_colour_viridis_c(name = "Yield (bu/ac)", limits = c(35, 50),
                         oob = scales::squish) +
  labs(x = NULL, y = NULL) +
  theme_minimal()
Map of field B-1 showing harvested points coloured by yield, the field boundary as a grey outline, and the two sloughs shaded blue: a large one running up the middle of the field and a small round one.
Figure 31.5: The harvested points, the field boundary and the two sloughs.

As expected the boundary file outlines the acrea that was harvested, while the wetlands outline the areas within the fields that were not harvested.

31.4 Aggregating into cells

It is somewhat difficult to see exactly where yields are strongest on the field with the data plotted from the individual readings. This is because individual readings are noisy. A single second of grain flow depends on how full the machine was, whether the driver had just slowed, and where the previous few seconds of crop are in the threshing system. Averaging the readings that fall in the same small area removes most this noise.

This simply involves dividing up the field into cells, and averaging the readings that fall in each cell. The result is a grid of cells with their average yield, which can be plotted as a map.

A grid of cells with a value in each one is a raster, and the terra package handles rasters the way sf handles points and polygons. We do this in two steps:

  1. Build an empty grid of cells over the field with rast(). Cell sizes are given in the units of the coordinate system, so we first project the points to UTM zone 13 north (EPSG 32613), where the units are metres. vect() converts the points from an sf object to the form terra works with.
  2. Use rasterize() to put the readings onto that grid, with fun = mean saying what to do when several readings fall in the same cell.

To see how this works, you can work through the code below step by step to see what is generated on each line.

##Step 1a: change the coordinate system to be expressed in metres rather than degrees of lon and lat, and convert to a terra object
m_pts <- st_transform(clean_sf, 32613) |>
  vect()

##Step 1b: an empty grid of 20 m cells covering the readings
grid <- rast(m_pts, resolution = 20)

##Step 2: average the yield of the readings falling in each cell
grid_cells <- rasterize(m_pts, grid, field = "yield", fun = mean)

grid_cells
> ##Step 1a: change the coordinate system to be expressed in metres rather than degrees of lon and lat, and convert to a terra object
> m_pts <- st_transform(clean_sf, 32613) |>
+   vect()
> 
> ##Step 1b: an empty grid of 20 m cells covering the readings
> grid <- rast(m_pts, resolution = 20)
> 
> ##Step 2: average the yield of the readings falling in each cell
> grid_cells <- rasterize(m_pts, grid, field = "yield", fun = mean)
> 
> grid_cells
class       : SpatRaster
size        : 39, 40, 1  (nrow, ncol, nlyr)
resolution  : 20, 20  (x, y)
extent      : 394670.9, 395470.9, 5778183, 5778963  (xmin, xmax, ymin, ymax)
coord. ref. : WGS 84 / UTM zone 13N (EPSG:32613)
source(s)   : memory
name        :      mean
min value   : 15.118091
max value   :   147.178

We can now plot the data grid_cells.

ggplot() +
  geom_spatraster(data = grid_cells) +
  scale_fill_viridis_c(name = "Yield (bu/ac)", limits = c(35, 50),
                       oob = scales::squish, na.value = "white") +
  labs(x = NULL, y = NULL) +
  theme_minimal()
Map of mean yield in 20 metre cells across field B-1. A ring of darker, lower-yielding cells follows the field boundary and the slough margin, with lighter higher-yielding cells through the middle of each half.
Figure 31.6: Mean yield in 20 m cells. Cells the combine never drove through are empty and show as white.

The resulting map is much easier to read at a glance than the map of individual readings – we can make out areas with higher and lower yields distinctly.

The cells that we used are 20m by 20m, which is a reasonable size for this field. But we could have used any different size by replacing resolution = 20 in the rast() function with a different number. There is an obvious trade-off here: smaller cells keep more detail, but they also have fewer readings in them and are noisier. Larger cells average out the noise, but they also lose detail – it isn’t particularly helpful to just divide the field into two cells and determine whcih one has larger yields. The proper resolution likely depends on your particular application.

Below I illustrate this by creating four maps of the same field, but with different cell sizes. The four maps are all on the same colour scale, so you can see how the colour range changes as the cell size changes.

Four maps of field B-1 side by side, gridded at 5, 10, 20 and 50 metre cells, with the outlines of the two sloughs drawn in red on each. At 5 metres the map is mostly white with thin coloured stripes showing individual combine passes and 69 percent of the field blank. At 10 metres the stripes thicken and 37 percent is blank. At 20 metres the field is nearly solid with 19 percent blank and both sloughs sit in clear holes. At 50 metres only 4 percent is blank but the colours have flattened towards a single shade, the large slough is a ragged notch and the small one no longer leaves a hole of its own shape.
Figure 31.7: The same readings gridded at four cell sizes, on one colour scale. The slough outlines are drawn on every panel.