Week Seven Shrodinger

Author

MD

In this week’s practical we will return to a graph theoretic framework for modelling habitat connectivity but will focus more on the development of a function approach that builds on the species-oriented aspects of the previous analysis. In Week Five, this consisted of assigning cost values to the landscape based on some species specific assumptions. In this practical we will go a little deeper and consider the quantification of habitat itself from a species-oriented perspective (as opposed to the more common parameterisation of habitat as one or other land cover type).

First sort out the packages needed for this analysis. Note that there are no new packages this week so you may just choose to load all the necessary libraries instead.

#install 

#install.packages(c("terra","dplyr","raster","gdistance","sf"))


# load
library(terra)
terra 1.9.1
library(dplyr)

Attaching package: 'dplyr'
The following objects are masked from 'package:terra':

    intersect, union
The following objects are masked from 'package:stats':

    filter, lag
The following objects are masked from 'package:base':

    intersect, setdiff, setequal, union
library(raster)
Loading required package: sp

Attaching package: 'raster'
The following object is masked from 'package:dplyr':

    select
library(gdistance)
Loading required package: igraph

Attaching package: 'igraph'
The following object is masked from 'package:raster':

    union
The following objects are masked from 'package:dplyr':

    as_data_frame, groups, union
The following objects are masked from 'package:terra':

    blocks, compare, union
The following objects are masked from 'package:stats':

    decompose, spectrum
The following object is masked from 'package:base':

    union
Loading required package: Matrix

Attaching package: 'gdistance'
The following object is masked from 'package:igraph':

    normalize
library(sf)
Linking to GEOS 3.13.0, GDAL 3.8.5, PROJ 9.5.1; sf_use_s2() is TRUE

Set the weorking directory

setwd('./Week7')

Load in a classified land cover layer of the study area created from a Random Forest model.

#actual classified land cover layer
classif<-rast("classif_RF.tif")

From this classified raster we will isolate the woodland class as the designated habitat class and calculate the overall habitat connectivity score for the study area and the contribution of each patch in the same way as in the previous practical.

#isolate the woodland class
woodland=classif

#set all values that are not 1 to zero
woodland[woodland!=1]=0

# use the patches() function to
woodland<-patches(woodland,directions=8,zeroAsNA=T)


#polygonize patches for cost distance analysis
patchesBool<-as.polygons(woodland,dissolve=TRUE)
patchesBool$Area<-expanse(patchesBool)

#subset patches >= 0.5 ha
patchesBool=patchesBool[patchesBool$Area>=5000]

Next we assign costs to each class in the land cover map.

#set the coasts for each land cover
landCosts=c(1,10,2,10,5)

#create a Boolean layer of landscape cost
booleanCost=classify(classif,cbind(c(1,2,3,4,5),landCosts))

#plot
plot(booleanCost)

Given that the M62 motorway runs through the study area and we can expect this to have much greater cost than the rest of the urban class, we will set this feature an order of magintude higher than the simple “urban” class.

#get mask of motorway (high cost)
m62<-vect("M62.shp")

#rasterise m62 to combine with the cost analysis
m62Cost<-rasterize(m62,field=1,classif,background=0)

We can also set this landscape feature to zero in terms of habitat suitability.

#set the motorway cost to 100
booleanCost[m62Cost!=0]=100

Now we are ready to start modelling so first we build a transition layer as in previous weeks.

library(gdistance)
#create a transition layer from the Boolean cost layer.
trBool=transition(raster(booleanCost),transitionFunction = function(x) 1/mean(x),directions = 4)


#get patch centroids for least cost path delineation
sitesBool<-SpatialPoints(crds(centroids(patchesBool)))

#create cost distance matrix

#init list for least cost paths 

costMatBool <- as.matrix(costDistance(trBool,sitesBool))

costMatBool=costMatBool*res(classif)[1]

costMatBool=costMatBool*9







# set alpha to determine colonization probability of the species (max dispersal capacity as 10km after Dennis et al. 2024)
alpha= log(0.05) / 10000



# adjacency graph
graph.Aprob_bool <- igraph::graph_from_adjacency_matrix(costMatBool, mode="undirected", weighted=T)

# negative exponential of colonization kernel x distance get probability
weightedMatBool <- exp(alpha * costMatBool)

# set diag to zero (so we we don't count movement from patches to themselves)
diag(weightedMatBool) <- 0

# create a graph from this weighted adjacency matrix
weightedGraphBool <- igraph::graph_from_adjacency_matrix(weightedMatBool, mode="undirected", weighted=T)

## calculate Connectivity metric (RFH)

# calculate all shortest paths between nodes
pstar.mat <- distances(weightedGraphBool, weights= -log(igraph::E(weightedGraphBool)$weight))

# back-transform to probabilities of connectedness
pstar.mat <- exp(-pstar.mat)

# get study area in m2
AL <- ncell(classif)*res(classif)[1]^2

# get area vector
areaBool <- patchesBool$Area


# get product of all patch areas ij and multiply by probabilities above
pcMat <- outer(areaBool,areaBool) * pstar.mat

pcMatSum <- sum(pcMat)

# divide by total area of the study squared to get the PC metric
pcMod <- (sqrt(pcMatSum) / as.numeric(AL))*100

#function for calculating dPC - contribution of each patch to PC. Here we simply remove each patch using a for loop and repeat the above function, adding the difference between PC above and each new calculation (minus patch "i") to am empty vector "dPC 

diff_connectivity <- function(prob.matrix,area,baseline,landarea){
  
  #each row is a patch
  N <- nrow(prob.matrix) 
  #empty vector to store results for each patch
  diffCon <- rep(NA, N) 
  
  
  #iterate over all patches
  for (i in 1:N) {
    #remove patch.i from the probability matrix
    prob.matrix.i <- prob.matrix[-i,-i]
    #remove patch.i from the patch areas
    area.i <-area[-i]
    #create adjacency matrix
    graph.i <- graph_from_adjacency_matrix(prob.matrix.i, mode="undirected", weighted=TRUE)
    #create all shortest paths between patches
    pstar.mat.i <- distances(graph.i, weights= -log(E(graph.i)$weight))
    # back-transform to probabilities of connectedness
    pstar.mat.i <- exp(-pstar.mat.i)
    #multiply all areas and movement probabilities together
    productMat <- outer(area.i, area.i)*pstar.mat.i
    #final calculation
    con.i <- sqrt(sum(productMat))/landarea*100
    
    diffCon[i] <- (baseline-con.i)/baseline*100
  }
  
  return(diffCon)
}

dPC_Bool <- diff_connectivity(prob.matrix=weightedMatBool,area=areaBool,landarea=AL,baseline=pcMod)

spNodesBool=vect(sitesBool)

dataDPCBool=data.frame(site=1:nrow(patchesBool),coords=crds(spNodesBool),dPC=dPC_Bool,
                       areaBool=areaBool)
library(RColorBrewer)

cols <- brewer.pal(7, "Greens")


pal <- colorRampPalette(cols)

patchesBool$dPC=dataDPCBool$dPC



plot(patchesBool, "dPC", type="continuous", col=pal(100))

###############################################################################################

Next we will work with the type one membership values to create a multivariate habitat model.

Load in layers containing membership values from Random Forest classifier

#woodland membership type-1 membership
woodMean<-rast("woodMeanClipped.tif")

#grassland membership
grassMean<-rast("grassMeanClipped.tif")

#shrub layer membership
shrubMean<-rast("shrubMeanClipped.tif")

#water class membership
waterMean<-rast("waterMeanClipped.tif")

#urban cover membership
urbanMean<-rast("urbanMeanClipped.tif")

Now that we have our type one layer inputs, we need to assign nesting and foraging weights to each cover type that reflect their suitability for the focal generic bird species.

#############################Woodland weights


woodWeightNesting=0.78125
woodWeightForaging = 0.826


##################################################################
######### Grassland weights


grassWeightNesting<-0
grassWeightForaging=0.001


#################################################
############shrub layer weights

shrubWeightNesting=0.43333
shrubWeightForaging=0.792



#######################################
#############Water weights

waterWeightNesting=0
waterWeightForaging=0.001



#####################################
#########################Urban weights

urbanWeightNesting=0.21111
urbanWeightForaging=0.187
#rasterise the motorway again but to use as habitat (setting habitat suitability to zero)
m62Hab<-rasterize(m62,field=0,classif,background=1)


#remove motorway from urban foraging potential
urbanMean<-urbanMean*m62Hab

Now we create a stacked raster with all layers combined.

By multiplying all layers by their respective nesting suitability values we can achieve a baseline multivariate habitat estimate for each cell in the landscape.

#sum all layers weighted by nesting suitability
nestHab<-(woodMean*woodWeightNesting)+(grassMean*grassWeightNesting)+(shrubMean*shrubWeightNesting)+(waterMean*waterWeightNesting)+(urbanMean*urbanWeightNesting)

Next we create a binary layer delimiting all cells greater than or equal to 0.5 as habitat patches. This is so a) we can subset our data a little to exclude very small patches and b) we can limit the spatial extent of our patches to this original nesting “baseline” throughout all steps.

#init binary mask of habitat patches with values >0.5 to create polygons and compute areas
  
  binaryPatchMask= nestHab
  binaryPatchMask[binaryPatchMask>=0.5]=1
  binaryPatchMask[binaryPatchMask!=1]=NA
  
  #combine adjacent habitat cells
  nestPatches<-patches(binaryPatchMask,directions=8)
  
  #polygonize patches for cost distance analysis
  nestPatches<-as.polygons(nestPatches,dissolve=TRUE)
  nestPatches$Area<-expanse(nestPatches)
  
  #subset patches >= 0.5 ha
  nestPatches<-nestPatches[nestPatches$Area>=5000]

Now we have some initial patches to work with, we will consider neighbourhood effects. As seen in the lecture, we can increase or decrease the habitat amount (~habitat quality) according to the influence of the surrounding matrix, where land uses with negative neighbourhood effects (“edge effects” in common parlance) reduce quality and land covers that offer complementary resources (i.e. foraging potential) have a positive influence on habitat quality.

We the patches tentatively defined above, the next step is to create everyone’s favourite bit of spatial ecology - the neighbourhood weights matrix.

The first of these will have the goal of parameterising negative edge effects which we will consider to stem from the urban and grassland layers (following the approach taken in the reading for this week and making the simplifying assumption that all grassland in the study area is improved agricultural grassland).

#simulate neighbourhood effects.


  #urban edge effect radius (in metres - from Eycott et al. 2011)
  radiusUrban<-76
  
  #neighbourhood weights matrix
  #get number of pixels required in each direction to cover 76 metres
  nPixUrban<-round(radiusUrban/res(classif)[1])
  
  #add one to ensure matrix is has odd number of columns and rows (i.e. so   the focal cell is bang in the middle)
  nPixUrban<-(nPixUrban*2)+1
  
  #now create the matrix
  matDistUrban<-matrix(1:nPixUrban^2,nrow=nPixUrban,ncol=nPixUrban)
  
  #get x and y position of focal cell
  x<-ceiling(ncol(matDistUrban)/2)
  y<-ceiling(nrow(matDistUrban)/2)
  
  #get focal cell from x and y
  focalCellUrban<-matDistUrban[x,y]
  
  #identify focal cell and its index in the matrix (cell number) 
  indFocalUrban<-which(matDistUrban==focalCellUrban,arr.ind = T)
  
  #creat a list to hold distance values
  distListUrban<-list()
  #compute distances
  for(i in 1:nPixUrban^2){
    ind.i=which(matDistUrban==i,arr.ind=T)
    diffX<-abs(ind.i[1,1]-indFocalUrban[1,1])*res(classif)[1]
    diffY<-abs(ind.i[1,2]-indFocalUrban[1,2])*res(classif)[1]
    
    dist.i<-sqrt(diffX^2+diffY^2)
    distListUrban[[i]]=dist.i
    
  }
  
  #add distance values
  matDistUrban[]<-unlist(distListUrban)
  #set cells outside search radius to zero
  matDistUrban[matDistUrban>radiusUrban]=NA
  #calculate edge effect decay rate based on Eycott et al. 
  
  #get constant for kernel shape based on max 76 metre edge effect (for urban). We set 1/1000000 just to get a very small value (but >0) fo edge effects at 76 metres.
  alphaUrban=log(1/1000000)/76
  
  #compute cell edge effect values
  matKernelUrban<-exp(alphaUrban * matDistUrban)
  

  #set focal cell to zero - because we are only interested in the effects of neighbouring cells
  matKernelUrban[x,y]<-0

With the neighbourhood weights set up, we can pass these to the focal() function to compute the edge effect values applied to each cell in the landscape. We will use the extend() function to increas the size of our raster at the edges by 10 pixels and giving those pixel a value of zero - otherwise the focal function will remove cells near the edge as they would be involve in calculations with NA cells (because the 76 metre neighbourhood would extend beyond the study area boundary).

  #sum edge effects from all surrounding cells
  focalUrban<-focal(extend(urbanMean,c(10,10),fill=0),matKernelUrban,fun=sum)
 
  #crop to orginal extent
  focalUrban<-crop(focalUrban,ext(classif))
  
  plot(focalUrban)

Now just repeat the same process but for grassland for which we apply a 30 metre edge effect according to the same literature as above.

  ############repeat for grassland cover
  
  #set up radius and equivalent number of pixels
  radiusGrass<-30
  nPixGrass<-round(radiusGrass/res(classif)[1])
  nPixGrass<-(nPixGrass*2)+1
  
  #create distance/neighbourhood matrix
  distMatGrass<-matrix(1:nPixGrass^2,nrow=nPixGrass,ncol=nPixGrass)
  
  #get row and column number of the focal cell
  x<-ceiling(ncol(distMatGrass)/2)
  y<-ceiling(nrow(distMatGrass)/2)
  
  #identify focal cell and its index in the matrix (cell number) 
  focalCellGrass<-distMatGrass[x,y]
  indFocalGrass<-which(distMatGrass==focalCellGrass,arr.ind = T)
  
  #init list for distances
  distListGrass<-list()
  
  for(i in 1:nPixGrass^2){
    ind.i=which(distMatGrass==i,arr.ind=T)
    diffX<-abs(ind.i[1,1]-indFocalGrass[1,1])*res(classif)[1]
    diffY<-abs(ind.i[1,2]-indFocalGrass[1,2])*res(classif)[1]
    
    dist.i<-sqrt(diffX^2+diffY^2)
    distListGrass[[i]]<-dist.i
    
  }
  
  #add distances to the matrix
  distMatGrass[]=unlist(distListGrass)

  distMatGrass[distMatGrass>radiusGrass]=NA
  #plot(rast(distMatG))
  #res(wood.i)[1]^2
  
   #get constant for kernel shape based on max 30 metre edge effect (for urban). We set 1/1000000 just to get a very small value (but >0) fo edge effects at 30 metres.
  alphaGrass=log(1/1000000)/30
  
  #compute cell edge effect values
  matKernelGrass<-exp(alphaGrass * distMatGrass)
 
  #set focal cell to zero
  matKernelGrass[x,y]<-0
 
  #compute focal layer
  focalGrass<-focal(extend(grassMean,c(10,10),fill=0),matKernelGrass,fun=sum)
  
  #crop to original extent
  focalGrass<-crop(focalGrass,ext(classif))
  
  plot(focalGrass)

With the negative neighbourhood effects done for habitat suitability, we can now focus on setting our cost values based on type 1 membership. We do this in the same way as for computing nesting suitability values - weighting each cell as a function of its land cover type and the cost assosiated with that type.

  ############################Create cost layers
  

  #assign cost weights to other cover types
  woodCostWeighted<-woodMean
  grassCostWeighted<-grassMean*10
  urbanCostWeight<-urbanMean*5
  waterCostWeight<-waterMean*10
  shrubCostWeight<-shrubMean*2

Now add these together for a total weighted cost per cell.

  type1Cost<-woodCostWeighted+grassCostWeighted+urbanCostWeight+waterCostWeight+shrubCostWeight

To this initial cost calculation we can add in neighbourhood effects as for habitat delineation. Here we just use the neighbourhood effect surfaces created above, weight these by the corresponding cost values and add to the mix.

  costUrbanFocal<-focalUrban*5
  costGrassFocal=focalGrass*10

We should also consider the motorway again though, so create a separate layer for this (assuming the same edge effect distance for urban but applying our much higher cost value that we set up earlier).

    #get contribution of motorway to functional cost
  focalM62Cost<-focal(extend(m62Cost,c(30,30),fill=0),matKernelUrban,fun="sum")
 
  #crop
  focalM62Cost<-crop(focalM62Cost,woodMean)

  #weight according to cost
  focalM62Cost<-focalM62Cost*100

  
#add in neighbourhood effects on cost and the motorway cover
  type1CostEdge<-type1Cost+costUrbanFocal+costGrassFocal+focalM62Cost

Foraging potential

The next step in the functional habitat delineation process is to assign foraging potential values for all cells and add these to our original habitat suitability based on nesting potential.

First, create a layer to present the total foraging potetial of each cell by summing the type one menbership values of all land cover class weighted by the foraging values set up at the start of this practical.

#sum all foraging potential values
forageWeights=woodMean*woodWeightForaging+grassMean*grassWeightForaging+shrubMean*shrubWeightForaging+waterMean*waterWeightForaging+urbanMean*urbanWeightForaging

The next step is to assess the accessibility of these foraging resources around each cell. This requires a little more thought than the computation of the negative neighbourhood effects carried out earlier. This is because we have to consider organismal movement as part of the process. In other words, whereas the negative neighbourhood effects can be considered as a function of Euclidean distance, in the case of positive neighbourhood effects, the ability to access such resources depends on the cost of moving through the immediate landscape. So, we have to build our neighbourhood weights matrix on cost distances rather than straight-line distance. That means we need to build a transition layer based on our newly created functional cost layer.

So we create a new transition layer based on the more functional derivation created in the previous step (where costs are now a weighted some of type 1 membership values).

#
  trFunc=transition(raster(type1CostEdge),transitionFunction = function(x) 1/mean(x),directions = 4)

Now, to get the cost of moving out from each of our habitat patches (which are currently contained in the binaryPatchMask object created earlier) towards neighbouring resources, we compute the cumulative cost of moving out from each habitat cell.

      #get cumulative cost of moving from each patch out into the matrix
      cumulativeCost=accCost(trFunc,crds(binaryPatchMask))*9
      
      #assign a crs to this layer
      crs(cumulativeCost)="EPSG:27700"
      
      # this sets shape of neg. exp. kernel to compute movement probability
      distForage = -log(0.05) / 250
      # this creates the raster kernel
    
      #compute movement probabilities
      forageRast <- exp(-distForage * cumulativeCost)
      
      #set cells with a value of 1 to zero (to only consider neighbouring cells when calculating positive neighbourhood effects)
      forageRast[forageRast==1]<-0

To complete our calculation of positive neighbourhood effects (foraging potential) we need to multiply our layer of movement probability by the foraging potential layer, then pass this to the focal() function to sum all values around each cell.

      #multiply the movement probability layer by the foraging weights layer
      forage<-(rast(forageRast)*forageWeights)
      
      #create a neighbourhood weights matrix to pass to the focal() function
      winForage=matrix(1,ncol=round(250/res(classif)[1])+1,nrow=round(250/res(classif)[1])+1)

In the next step we will ensure that the weights matrix for positive neighbourhood effects sums to one so that positive neighbourhood effects are returned as a probability (range from 0-1). This avoids having very large numbers that would give unrealistically high habitat values (ultimately we want the final habitat suitability score for each cell to be between 0 and 1). Also, we can’t assume that all resources are equally and continuously accessible, so truncating positive neighbourhood effects as 0-1 gives a more conservative estimate and gives us an idea of the potential foraging suitability.

      #make sure the weights matrix sums to one
      winForage=winForage/sum(winForage)
    
      #pass all this to the focal() function to caluclate sum of all foraging potential in the neighbourhood
      focalForageSum<-focal(extend(forage,10,fill=0),w=winForage,fun="sum")
      
      #crop toorginal extent
      focalForageSum<-crop(focalForageSum,woodMean)
     
  
  #keep only habitat cells by applying the binary mask
  forage<-focalForageSum*binaryPatchMask

Finally, get the habitat suitability scores for each cell by add positive and removing negative neighbourhood effects.

  #get final habitat values by remove negative and adding positive neighbourhood effects
  funcHab<-nestHab-focalUrban-focalGrass+forage

Next, in order to work with this layer to use in a connectivity analysis, we need to delineate patches as usual.

  #any values that have crept >1 need to be set to max of 1 
  funcHab[funcHab>1]<-1
  
  #use a 0.5 threshold to delineate habitat cells
  funcHab[funcHab<0.5]<-0

  
  #get patches of adjacent cells based on the final functional habitat amount
  patchesFunc<-patches(funcHab,direction=8,zeroAsNA=TRUE)
  
  #convert to polygons
  patchesFunc<-as.polygons(patchesFunc,dissolve=TRUE)
  
  #subset patches >=0.5 ha
  patchesFunc$Area<-expanse(patchesFunc)
  
  patchesFunc<-patchesFunc[patchesFunc$Area>=5000,]

As per the graph theoretic approach to conectivity, we need to compute patch area for the equation. In the case of functional habitat however, we want to use the “amount” of habitat (the sum of all suitability scores within the patch) rather than the area of the contiguous area of the patch. We do this just by using the extract function to sum all cell values within the patch.

  #get habitat amount based on functional habitat values per cell
  habAmount<- extract(funcHab,patchesFunc,method="simple",fun="sum")
  #"amount" = cell suitability values multiplied by the cell area
  habAmount<-habAmount[2]*(res(classif)[2]^2)
  
  #add to the patch polygon object for use later on.
  patchesFunc$habAmount<-habAmount

With this set up, we can proceed to computing the cost distance matrix and habitat connectivity metric.

  #get patch centroids for least cost path delineation
  sitesFunc<-SpatialPoints(crds(centroids(patchesFunc)))
  
  #create cost distance matrix
  costMatFunc <- as.matrix(gdistance::costDistance(trFunc,sitesFunc))

  costMatFunc=costMatFunc*res(classif)[1]
  
  # set alpha to determine colonization probability of the species (max dispersal capacity as 10km after Dennis et al. 2024)
  alphaDisp= log(0.05) / 10000

 
  # negative exponential of colonization kernel x distance get probability
  weightedMatFunc <- exp(alphaDisp * costMatFunc)
  
  # set diag to zero (so we we don't count movement from patches to themselves)
  diag(weightedMatFunc) <- 0
  
  # create a graph from this weighted adjacency matrix
  weightedGraphFunc <- igraph::graph_from_adjacency_matrix(weightedMatFunc, mode="undirected", weighted=T)

  
 
  ## calculate Connectivity metric (RFH)
  
  # calculate all shortest paths between nodes
  pstar.matFunc <- distances(weightedGraphFunc, weights= -log(igraph::E(weightedGraphFunc)$weight))
  
  # back-transform to probabilities of connectedness
  pstar.matFunc <- exp(-pstar.matFunc)
  
  # get study area in m2
  AL <- ncell(classif)*res(classif)[1]^2

  
  # get product of all patch areas ij and multiply by probabilities above
  rhMat <- outer(patchesFunc$habAmount,patchesFunc$habAmount) * pstar.matFunc
  
  rhMatSum <- sum(rhMat)
  
  # divide by total area of the study squared to get the PC metric
  RFH <- (sqrt(rhMatSum) / as.numeric(AL))*100
  

#compute contribution of each patch to RFH  
  weighted_dRH <- diff_connectivity(prob.matrix=weightedMatFunc,area=patchesFunc$habAmount,landarea=AL,
                                    baseline = RFH)
  
dataDRH=data.frame(site=1:nrow(patchesFunc),areaBool=expanse(patchesFunc),
                   dRH=weighted_dRH,habAmount=patchesFunc$habAmount)
   

spNodesFunc=vect(sitesFunc)

Next we do some plotting using the RClolourBrewer library for help with colour ramps.

library(RColorBrewer)

cols <- brewer.pal(7, "Greens")


pal <- colorRampPalette(cols)

Finally plot the Boolean and Functional habitat patches according to their respective estimates of patch contribution to connectivity.

#add dRH values to the functional patches object
patchesFunc$dRH=dataDRH$dRH

plot(patchesBool, "dPC", type="continuous", col=pal(100))

plot(patchesFunc, "dRH", type="continuous", col=pal(100))

We can check the relative influence also of patch area (in the Boolean sense) to patch contribition to cnnectivity.

#plot Boolean area against dRH
plot(dataDRH$areaBool,dataDRH$dRH)

#compare to woodland-only Boolean connectivity estimate of patch contributions
plot(dataDPCBool$areaBool,dataDPCBool$dPC)

################################################################################################################