Density-dependent habitat selection

ecology tutorial
R
habitat selection
behaviour
population ecology
Density-dependent habitat selection and Morris’s isodar in R: how animals spill into poor habitat as density rises, and what the isodar’s slope reveals.
Author

Tidy Ecology

Published

2026-07-03

Modified

2026-10-15

Corrected 15 October 2026: the caption and the label of the isodar figure still called the intercept A’s quality advantage; they now call it the spillover density, as the text does.

Corrected 7 October 2026: the post said the isodar slope measures how fast B fills relative to A, and that the fitted intercept is the quality gap built into the fitness curves; it now says the slope measures how fast A fills relative to B once both are occupied, and that the intercept is the spillover density. The closing caution also said that once settlement stops being ideal and free, density no longer ranks habitat quality at all; it now says, as the source-sink post does, that density on its own can then mislead about habitat quality.

Setup: packages, colours and plot theme
knitr::opts_chunk$set(echo = TRUE, message = FALSE, warning = FALSE,
                      dev = "png", dpi = 120, fig.path = "figures/",
                      fig.width = 7, fig.height = 4.4)
library(ggplot2)
theme_te <- theme_minimal(base_size = 12) +
  theme(panel.background = element_rect(fill = "#f5f4ee", colour = NA),
        plot.background  = element_rect(fill = "#f5f4ee", colour = NA),
        panel.grid.minor = element_blank(),
        plot.title = element_text(face = "bold", size = 12))
theme_set(theme_te)
col_A <- "#3a6b52"; col_B <- "#b5651d"; col_ref <- "#555555"

aA <- 10; bA <- 0.05; aB <- 6; bB <- 0.03
WA <- function(n) aA - bA*n; WB <- function(n) aB - bB*n
Nstar <- (aA - aB)/bA                       # density where B starts to be used
iso_slope <- bB/bA; iso_int <- (aA - aB)/bA

## ideal-free split across a gradient of total density, then recover the isodar
ifd_split <- function(N) if (N <= Nstar) c(N, 0) else {
  nB <- (N - iso_int)/(1 + iso_slope); c(N - nB, nB) }
Ns <- seq(10, 320, 10)
sp <- t(sapply(Ns, ifd_split)); colnames(sp) <- c("nA", "nB")
occ <- sp[sp[, "nB"] > 0, ]
fit <- lm(occ[, "nA"] ~ occ[, "nB"])

The ideal free distribution equalises intake, moment to moment. But intake is a proxy; what natural selection cares about is fitness, and fitness in a habitat depends on how crowded it is. Turn the ideal free logic on fitness rather than on instantaneous intake and it becomes a tool for reading habitat quality straight off a map of densities, without ever measuring fitness. That tool is the isodar (Morris 1988 Evolutionary Ecology 2(3):253-269).

Spilling from good habitat into poor

Two habitats, A and B, where fitness falls as density rises. A is better: at the same density it delivers more. An ideal, free animal settles wherever its fitness would be highest, so at low total density everyone crowds into A. As A fills, its fitness drops, and at some point a newcomer would do just as well in empty B as in crowded A. From there on animals split between the two so that fitness stays equal in both.

aA <- 10; bA <- 0.05; aB <- 6; bB <- 0.03     # fitness = a - b * density, habitat A better
WA <- function(n) aA - bA*n; WB <- function(n) aB - bB*n
Nstar <- (aA - aB)/bA                          # total density at which B first gets used
c(spillover_density = Nstar)
spillover_density 
               80 

Below a total density of 80, habitat B stays empty: even packed into A, animals do better there than in an empty B. Above it, they spill into B, and both habitats are occupied at equal fitness thereafter. The spillover threshold is set by how much better A is and how fast it fills.

Wof <- function(N) if (N <= Nstar) WA(N) else WA(ifd_split(N)[1])
dn <- data.frame(N = seq(5, 320, 2)); dn$W <- sapply(dn$N, Wof)
ggplot(dn, aes(N, W)) +
  geom_vline(xintercept = Nstar, colour = col_ref, linetype = "dashed") +
  geom_line(colour = col_A, linewidth = 1) +
  annotate("text", x = Nstar + 4, y = WA(10), label = "B starts to be used", hjust = 0, colour = col_ref, size = 3.4) +
  labs(x = "Total density", y = "Fitness (equal across occupied habitats)",
       title = "The poor habitat opens once the good one is full enough")
A declining curve of equalised fitness against total density with a kink at the spillover threshold, where the decline becomes shallower as the second habitat opens.
Figure 1: Fitness against total density under ideal-free settlement. Below the spillover threshold (dashed) everyone is in A and fitness falls fast; above it, animals split into B and the shared fitness falls more slowly.

The isodar

Now the trick. Set the two fitnesses equal, a_A - b_A n_A = a_B - b_B n_B, and solve for the density in A as a function of the density in B. Because the fitness curves are straight lines, the relationship is a straight line too:

\[n_A = \frac{a_A - a_B}{b_A} + \frac{b_B}{b_A}\, n_B .\]

This is the isodar: every combination of densities that leaves an animal indifferent between the habitats. Its two numbers are the whole story. The intercept, 80, is how many animals A holds before B is touched, a measure of how much better A is. The slope, 0.60, is the ratio of the two habitats’ density dependence, how fast A fills relative to B once both are occupied. And the beautiful part is that you can recover both from censuses alone: gather paired densities across sites or years and regress one on the other.

ifd_split <- function(N) if (N <= Nstar) c(N, 0) else {   # ideal-free split of N animals
  nB <- (N - Nstar)/(1 + bB/bA); c(N - nB, nB) }
Ns <- seq(10, 320, 10); sp <- t(sapply(Ns, ifd_split))
occ <- data.frame(nA = sp[,1], nB = sp[,2]); occ <- occ[occ$nB > 0, ]
round(coef(lm(nA ~ nB, data = occ)), 2)
(Intercept)          nB 
       80.0         0.6 

Regressing the density in A on the density in B, over the range where both are occupied, returns an intercept of 80 and a slope of 0.60, exactly the spillover density and the density-dependence ratio built into the fitness curves. Two habitat parameters, read off nothing but head counts, because the animals’ own indifference did the measuring.

allpts <- data.frame(nA = sp[,1], nB = sp[,2])
ggplot(allpts, aes(nB, nA)) +
  geom_smooth(data = occ, method = "lm", se = FALSE, fullrange = TRUE, colour = col_A, linewidth = 0.9) +
  geom_point(size = 2.4, colour = col_ref) +
  annotate("text", x = 5, y = iso_int - 8, label = "intercept = spillover density",
           hjust = 0, colour = col_A, size = 3.4) +
  labs(x = "Density in habitat B", y = "Density in habitat A",
       title = "The isodar reads habitat quality from densities")
A scatter of density in habitat A against density in habitat B; points start on the vertical axis then follow a rising straight line, the fitted isodar, which meets the vertical axis at the top of that column and is labelled there as the intercept.
Figure 2: The isodar. Below the spillover density B is empty (points on the axis); above it, paired densities fall on a straight line whose intercept is the spillover density, a measure of how much better A is, and whose slope is the density-dependence ratio.

Reading it, and its one big assumption

The isodar is popular in field ecology because it asks so little: no fitness measurements, no marking, just counts in two habitats over a range of total densities. Its slope and intercept classify the habitats, a slope of one means the habitats share the same density dependence and differ only in quality, a steep or shallow slope flags one habitat filling faster than the other, and the intercept ranks them. But all of this rests on one assumption, that the animals are actually distributing themselves by equalising fitness, ideally and freely. If they are not, if movement is constrained, competitors unequal, or the good habitat defended, the line you fit is still a line, but it no longer means what the isodar says it means. Knowing when to trust it is the checking post, and the caution it shares with source-sink dynamics is that once settlement stops being ideal and free, density on its own can mislead about habitat quality.

References

Newsletter

Get updates by email

An occasional email when tutorials are added or substantially corrected. No spam; unsubscribe anytime.

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