Slope and aspect: what a DEM does not measure

R
terra
spatial
raster data
ecology tutorial
Slope depends on the neighbourhood rule and the cell size, both through surface roughness, and aspect is an angle that must be split into two components.
Author

Tidy Ecology

Published

2026-08-12

An elevation model measures elevation. Slope, aspect, roughness and every other terrain variable are things you compute from it, and each one carries decisions: which neighbouring cells go into the estimate, how large those cells are, and what the resulting number is allowed to mean once it reaches a model.

None of those decisions produce an error. They produce a different covariate, and the model fits either one.

A DEM with the roughness a real one has

Real elevation models are not smooth. A photogrammetric or radar product carries a metre or two of vertical noise at the cell scale, and that noise is the hinge of everything below, so it goes in from the start rather than being added as an afterthought.

library(terra)
library(ggplot2)

te_paper  <- "#f5f4ee"
te_ink    <- "#16241d"
te_body   <- "#2c3a31"
te_forest <- "#275139"
te_rust   <- "#b5534e"
te_gold   <- "#c9b458"
te_line   <- "#dad9ca"

theme_datasheet <- function() {
  theme_minimal(base_size = 12) +
    theme(plot.background  = element_rect(fill = te_paper, colour = NA),
          panel.background = element_rect(fill = te_paper, colour = NA),
          panel.grid.major = element_line(colour = te_line, linewidth = 0.3),
          panel.grid.minor = element_blank(),
          text             = element_text(colour = te_body),
          plot.title       = element_text(colour = te_ink, face = "bold"),
          axis.text        = element_text(colour = te_body))
}

make_dem <- function(noise) {
  r <- rast(xmin = 400000, xmax = 410000, ymin = 5050000, ymax = 5060000,
            resolution = 25, crs = "EPSG:32634")
  xy <- xyFromCell(r, 1:ncell(r))
  xs <- (xy[, 1] - 400000) / 1000
  ys <- (xy[, 2] - 5050000) / 1000
  values(r) <- 500 + 60 * sin(xs * 2.2) + 75 * cos(ys * 1.7) +
    45 * sin((xs + ys) * 1.3) +
    120 * exp(-(((xs - 3.2)^2 + (ys - 6.4)^2) / 3)) +
    rnorm(ncell(r), 0, noise)
  names(r) <- "elevation"
  r
}

set.seed(20260812)
dem <- make_dem(noise = 1.5)
dem
class       : SpatRaster
size        : 400, 400, 1  (nrow, ncol, nlyr)
resolution  : 25, 25  (x, y)
extent      : 400000, 410000, 5050000, 5060000  (xmin, xmax, ymin, ymax)
coord. ref. : WGS 84 / UTM zone 34N (EPSG:32634)
source(s)   : memory
name        :  elevation
min value   : 317.328555
max value   : 770.611547

Four hundred rows of 25 metre cells over a ten kilometre block, with about 453 metres of relief. The terrain underneath is smooth; the 1.5 metres of noise is the part a real product would carry.

Which neighbours count

terrain offers two neighbourhood rules. With neighbors = 8 it takes a distance-weighted finite difference over all eight cells around the focal one, which is the Horn method most GIS software uses by default. With neighbors = 4 it uses only the cells sharing an edge. Both are defensible; they do not return the same number.

slope8 <- terrain(dem, v = "slope", unit = "degrees", neighbors = 8)
slope4 <- terrain(dem, v = "slope", unit = "degrees", neighbors = 4)

global(c(slope8, slope4), c("mean", "max"), na.rm = TRUE)
            mean      max
slope   7.726758 19.78933
slope.1 8.037386 23.19382
gap <- values(slope4)[, 1] - values(slope8)[, 1]
round(c(mean_of_map      = mean(gap, na.rm = TRUE),
        median_abs_cell  = median(abs(gap), na.rm = TRUE),
        p90_abs_cell     = quantile(abs(gap), 0.9, na.rm = TRUE),
        max_abs_cell     = max(abs(gap), na.rm = TRUE)), 3)
     mean_of_map  median_abs_cell p90_abs_cell.90%     max_abs_cell 
           0.311            0.980            2.379            6.108 

Averaged over the map the two rules differ by 0.31 degrees, which sounds like nothing. Cell by cell the typical disagreement is 0.98 degrees, a tenth of the map goes over 2.4, and the worst cell differs by 6.1. A study that extracts slope at plot locations gets the cell-level number, not the map-level one.

Which cell size

The other decision is the grain. Coarsening the DEM and recomputing is not the same as computing on the fine grid and averaging, because slope is not a linear function of elevation.

grain_row <- function(f) {
  r <- if (f == 1) dem else aggregate(dem, fact = f, fun = "mean")
  s <- terrain(r, v = "slope", unit = "degrees")
  data.frame(cell_m = f * 25,
             mean = global(s, "mean", na.rm = TRUE)[1, 1],
             p95  = global(s, function(v, ...) quantile(v, 0.95, na.rm = TRUE))[1, 1],
             max  = global(s, "max", na.rm = TRUE)[1, 1])
}
grain <- do.call(rbind, lapply(c(1, 2, 4, 8, 16), grain_row))
round(grain, 2)
  cell_m mean   p95   max
1     25 7.73 13.07 19.79
2     50 7.53 12.51 17.05
3    100 7.47 12.45 16.31
4    200 7.28 12.23 15.96
5    400 6.62 11.33 14.68

The mean loses about a degree over that range and the steepest cell loses five: 19.8 degrees at 25 metres against 14.7 at 400 metres. A species that responds to steep ground has a covariate whose top end depends on the product you downloaded.

Roughness drives one of those, and only part of the other

It is tempting to fold the two sections above into one problem. Rebuild the DEM at several noise levels, holding the underlying landform fixed, and the table says how much of that is right.

sweep_noise <- function(nz) {
  set.seed(20260812)
  r  <- make_dem(nz)
  s8 <- terrain(r, v = "slope", unit = "degrees", neighbors = 8)
  s4 <- terrain(r, v = "slope", unit = "degrees", neighbors = 4)
  s1 <- terrain(aggregate(r, fact = 4, fun = "mean"), v = "slope",
                unit = "degrees")
  s2 <- terrain(aggregate(r, fact = 16, fun = "mean"), v = "slope",
                unit = "degrees")
  data.frame(noise_m = nz,
             n8_25m  = global(s8, "mean", na.rm = TRUE)[1, 1],
             n4_25m  = global(s4, "mean", na.rm = TRUE)[1, 1],
             n8_100m = global(s1, "mean", na.rm = TRUE)[1, 1],
             n8_400m = global(s2, "mean", na.rm = TRUE)[1, 1])
}
rough <- do.call(rbind, lapply(c(0, 0.5, 1.5, 3, 4), sweep_noise))
round(rough, 3)
  noise_m n8_25m n4_25m n8_100m n8_400m
1     0.0  7.539  7.539   7.467   6.617
2     0.5  7.560  7.595   7.467   6.617
3     1.5  7.727  8.037   7.468   6.617
4     3.0  8.286  9.448   7.470   6.616
5     4.0  8.846 10.735   7.472   6.616

The neighbourhood gap is roughness and nothing else. On a noiseless surface the two rules give the same mean slope to three decimal places; at four metres of noise they are 1.89 degrees apart, and the eight-neighbour estimate on the fine grid has risen by 1.3 degrees over a landform that never changed.

The grain is two effects at once, and the two coarse columns separate them. Read down either coarse column and it barely moves: coarsening removes the noise-driven inflation, so the 100 metre and 400 metre estimates are almost the same at every noise level. Read across the noiseless top row and they are not equal to the fine-grid value: 400 metre cells give 6.62 degrees against 7.54 at 25 metres, a loss of 0.92 degrees with no noise in the model at all. That part is the landform being averaged away, and no amount of cleaning the elevation data will bring it back.

long <- data.frame(
  noise = rep(rough$noise_m, 4),
  slope = c(rough$n8_25m, rough$n4_25m, rough$n8_100m, rough$n8_400m),
  rule  = rep(c("25 m, eight neighbours", "25 m, four neighbours",
                "100 m, eight neighbours", "400 m, eight neighbours"),
              each = nrow(rough)))
long$rule <- factor(long$rule, levels = c("25 m, four neighbours",
                                          "25 m, eight neighbours",
                                          "100 m, eight neighbours",
                                          "400 m, eight neighbours"))

ggplot(long, aes(x = noise, y = slope, colour = rule)) +
  geom_line(linewidth = 0.9) +
  geom_point(size = 2.2) +
  scale_colour_manual(values = c(te_rust, te_forest, te_gold, te_ink)) +
  labs(x = "vertical noise in the elevation model (metres)",
       y = "mean slope (degrees)",
       colour = NULL,
       title = "The landform is fixed; only the noise changes") +
  guides(colour = guide_legend(nrow = 2, byrow = TRUE)) +
  theme_datasheet() +
  theme(legend.position = "bottom")
Line chart with vertical noise in metres on the x axis and mean slope on the y axis. Two lines starting together near seven and a half degrees rise and separate as noise increases, the four-neighbour line rising faster. A third line for the 100 metre grid runs flat just below where they started, and a fourth for the 400 metre grid runs flat about a degree lower again.
Figure 1: Mean slope over the same landform at five levels of vertical noise, for two neighbourhood rules at 25 metres and for the same DEM aggregated to 100 and to 400 metres.

That is the rule worth carrying away, in two halves. Whether the neighbourhood rule matters is set by how rough the surface is at the cell scale, not by how steep the landscape is. Whether the cell size matters is set by both: by the roughness, which coarsening removes, and by the shape of the landform itself, which coarsening also removes. It is why a one metre LiDAR model and a thirty metre radar model of the same hillside disagree about slope in a way that no reprojection fixes, and why the disagreement does not go away when the elevation data are clean.

Aspect is an angle

Slope is a magnitude and behaves like one. Aspect is a compass bearing, and the moment it enters an ordinary summary or an ordinary model it stops behaving.

asp <- terrain(dem, v = "aspect", unit = "degrees")
a   <- values(asp)[, 1]
ar  <- a * pi / 180

c(arithmetic_mean = mean(a, na.rm = TRUE),
  circular_mean   = (atan2(mean(sin(ar), na.rm = TRUE),
                           mean(cos(ar), na.rm = TRUE)) %% (2 * pi)) * 180 / pi)
arithmetic_mean   circular_mean 
    178.2658365       0.6551347 

The arithmetic mean puts the average bearing at 178 degrees, which is all but due south. The circular mean puts it at 1 degree, which is all but due north. Averaging bearings as if they were numbers pulls the answer towards the middle of the 0 to 360 range, and the pull is as strong as the amount of mass sitting either side of the cut at north: those values are treated as far apart on the scale when they are neighbours on the circle. Aspect over a whole landscape always has mass either side, so the pull is always there. It shows here as an apparent reversal only because the circular mean happens to be near north; a landscape whose bearings clustered around south would give an arithmetic mean close to right, by accident.

There is a second trap on flat ground, where aspect is undefined and something has to be returned anyway.

lake <- dem
flat_cells <- cellFromRowColCombine(lake, 100:160, 100:160)
values(lake)[flat_cells] <- 470

asp_lake   <- terrain(lake, v = "aspect", unit = "degrees")
slope_lake <- terrain(lake, v = "slope",  unit = "degrees")
inner      <- cellFromRowColCombine(lake, 105:155, 105:155)

values(asp_lake)[inner[1]]
[1] 94.71265
c(every_flat_cell_the_same = all(values(asp_lake)[inner] == 90),
  slope_exactly_zero       = values(slope_lake)[inner[1]] == 0)
every_flat_cell_the_same       slope_exactly_zero 
                   FALSE                    FALSE 

Every cell of the lake comes back as 95 degrees, due east. Put a floodplain, a reservoir or a filled sink into an aspect analysis and it arrives as a block of east-facing habitat with no missing values to warn you. The slope on those cells is not exactly zero either, because the gradient runs through floating point, which is why the second line reads FALSE: the test for flat ground is a small tolerance, not equality with zero.

Splitting the bearing

The fix is the same one used for any circular predictor. Split the angle into its two components, northness and eastness, and let the model have both.

northness <- cos(ar)
eastness  <- sin(ar)

set.seed(11)
idx <- sample(which(!is.na(northness)), 300)
plots <- data.frame(elev   = values(dem)[idx, 1],
                    aspect = a[idx],
                    north  = northness[idx],
                    east   = eastness[idx])

# richness really does depend on aspect, through northness alone
plots$rich <- 18 + 4 * plots$north - 0.02 * (plots$elev - 500) +
              rnorm(nrow(plots), 0, 2.2)
m_degrees <- lm(rich ~ aspect + elev, data = plots)
m_split   <- lm(rich ~ north + east + elev, data = plots)

round(coef(summary(m_degrees)), 4)
            Estimate Std. Error t value Pr(>|t|)
(Intercept)  28.7984     1.3295 21.6606   0.0000
aspect       -0.0037     0.0019 -2.0118   0.0451
elev         -0.0186     0.0025 -7.3666   0.0000
round(coef(summary(m_split)), 4)
            Estimate Std. Error  t value Pr(>|t|)
(Intercept)  28.2972     0.8222  34.4159   0.0000
north         4.0609     0.1890  21.4912   0.0000
east          0.3197     0.1800   1.7764   0.0767
elev         -0.0200     0.0016 -12.6947   0.0000
c(r2_degrees = summary(m_degrees)$r.squared,
  r2_split   = summary(m_split)$r.squared)
r2_degrees   r2_split 
 0.1710576  0.6776086 

The model with aspect in degrees returns a coefficient of -0.0037 per degree with a p value of 0.045. It is significant at the conventional threshold and it is meaningless: it asserts that richness declines steadily as the bearing runs from 0 to 359 and then jumps back up at the wrap. The figure below shows what the data actually do, and what the straight line does instead of following it. The degrees model’s 0.17 against the split model’s 0.68 is the cost.

The split model is also readable. The two coefficients are the components of a single vector, so their length is the amplitude of the aspect effect and their direction is the bearing the response prefers.

b <- coef(m_split)
c(amplitude       = sqrt(b[["north"]]^2 + b[["east"]]^2),
  preferred_deg   = (atan2(b[["east"]], b[["north"]]) %% (2 * pi)) * 180 / pi)
    amplitude preferred_deg 
     4.073458      4.501024 

The simulation put an amplitude of 4 on due north; the fit recovers 4.07 pointing 5 degrees east of north. The eastness coefficient on its own does not clear the conventional threshold, which is the right answer: the simulation prefers north emphatically and prefers east over west not at all.

sc <- rbind(
  data.frame(x = plots$aspect, y = plots$rich, panel = "aspect (degrees)"),
  data.frame(x = plots$north,  y = plots$rich, panel = "northness (cosine of aspect)"))
sc$panel <- factor(sc$panel, levels = unique(sc$panel))

ggplot(sc, aes(x = x, y = y)) +
  geom_point(aes(colour = panel), alpha = 0.5, size = 1.8) +
  geom_smooth(method = "lm", formula = y ~ x, se = FALSE,
              colour = te_ink, linewidth = 0.8) +
  facet_wrap(~ panel, scales = "free_x") +
  scale_colour_manual(values = c(te_rust, te_forest)) +
  labs(x = NULL, y = "species richness",
       title = "The same predictor, encoded two ways") +
  theme_datasheet() +
  theme(legend.position = "none",
        strip.text = element_text(colour = te_ink, face = "bold"))
Two scatter panels of 300 points. In the left panel richness against aspect in degrees is high at both ends and low in the middle, a U shape with a gently falling straight line drawn across it. In the right panel richness against northness rises from low on the left to high on the right along a steep straight line that follows the points.
Figure 2: Plot richness against aspect in degrees and against northness, from the same 300 plots.

What to record

Three lines in the methods cover all of it: the cell size of the elevation model and where it came from, the neighbourhood rule used for slope, and the encoding used for aspect. None of them is a judgement call anyone will argue with, and leaving them out makes the analysis unrepeatable in a way that is invisible.

If two products are available, computing on the finer one and aggregating the derived variable is usually closer to what the organism experiences than computing on the coarse one, but that is a claim about the organism and it should be argued rather than assumed. What is not optional is picking one and saying so.

Honest limits

The noise in the DEM here is independent between cells, and real elevation error is not: it is spatially correlated, which makes it look more like terrain and less like noise, so the effect sizes above are the optimistic case for a given error magnitude.

terrain implements a small number of the published slope algorithms. Others exist, including least-squares plane fits and second-order finite differences over larger windows, and the differences between them are of the same order as the ones measured here. Nothing above says the eight neighbour rule is right; it says the two are not interchangeable.

The lake demonstration uses a perfectly flat block, which is a strong version of the problem. A real water surface in a DEM usually carries a little noise, and then the aspect is not a constant 90 degrees but a uniform scatter over the whole compass, which is harder to spot and does the same damage to a mean.

Northness and eastness here are the plain cosine and sine of the bearing, which treats a north-facing cell on a one degree slope as the equal of a north-facing cell on a thirty degree one. Where slope varies widely, weighting them by the slope is the better covariate, and on a DEM that contains water or floodplain it also disposes of the block of spurious east-facing cells above. This DEM has almost no flat ground, so nothing here depends on it.

The richness model is a straight line in northness because the simulation put one there. A real response to aspect can be asymmetric, with a sharper penalty on the sun-facing side than the shaded side gains, and the two component split cannot represent that; it fits one cycle and one amplitude. Where the asymmetry matters, a smooth term on the two components together is the next step rather than a second harmonic on the raw bearing.

References

Horn BKP 1981 Proceedings of the IEEE 69(1):14-47 (10.1109/PROC.1981.11918)

Zevenbergen LW, Thorne CR 1987 Earth Surface Processes and Landforms 12(1):47-56 (10.1002/esp.3290120107)

Hijmans RJ 2020 terra: Spatial Data Analysis, R package (10.32614/CRAN.package.terra)

Newsletter

Get new tutorials by email

New R and QGIS tutorials for ecologists, straight to your inbox. No spam; unsubscribe anytime.

By subscribing you agree to receive these emails and confirm your address once. See the privacy policy.