Section 1.
1
1.1 What Is R and Why R in Geoinformatics?
This section opens the book. It does not assume you have ever programmed. Instead, it builds,
layer by layer, an understanding of what a programming language is, how R fits into the
landscape of computing, and why this particular language has become an indispensable tool for
modern geoinformatics. By the end, you will not only have written your first lines of R—you will
understand exactly what happens between your fingers and the machine.
1.1.1 The Idea of a Programming Language
Before we type a single character, let us ask a fundamental question: What is a programming
language, and why does a geospatial scientist need one?
A programming language is a formal system of communication designed to express
computations. Just as natural languages (English, Chinese) allow humans to share ideas, a
programming language allows a human to instruct a computer in unambiguous steps. The
computer—a machine that executes basic arithmetic and logical operations at incredible speed—
has no intuition, no visual understanding, and no tolerance for ambiguity. It requires precise,
sequential instructions written in a grammar it can parse.
Every interactive software you have used (QGIS, ArcGIS, ENVI) is ultimately built upon a
programming language. The graphical interface is a convenient mask: when you click a buffer
tool, you are issuing a pre-written set of instructions. Learning to program yourself removes that
mask. You become the director, not just a user of someone else’s workflow.
In the context of geoinformatics, programming is not merely an optional skill—it is the gateway
to reproducibility, automation, and methodological creativity. A graphical GIS can perform a
buffer on one layer. A program can perform a buffer on ten thousand layers, apply a statistical
test, and email you a report, all without human intervention. It can document every step in
human-readable text, so that your analysis can be verified, replicated, and built upon by others.
These are the requirements of modern science.
Thus, our first goal is not to learn a specific package or spatial function. It is to understand how
to express a computation in a language that a computer can execute, and to grasp the mental
model that underpins that process.
1.1.2 R: A Language and an Environment for Data-Driven Science
R is both a programming language and an interactive environment. It was born in the 1990s at
the University of Auckland from the work of Ross Ihaka and Robert Gentleman, who designed it
as a modern implementation of the S language developed at Bell Laboratories. Since then, R has
been maintained by an international core team and distributed freely under the GNU General
Public License.
Philosophically, R belongs to the family of interpreted, functional, array-oriented languages.
Interpreted means that R executes your code line by line, without a separate compilation
step. You write a line, press a key, and see the result instantly. This makes the learning
cycle extraordinarily fast: experiment, observe, correct.
Functional means that the primary unit of computation is the function—a self-contained
block that takes input, performs operations, and returns output. Almost everything that
happens in R (even arithmetic) is a function call under the hood.
Array-oriented means that R treats data in vectors, matrices, and arrays as first-class
citizens. There is no such thing as a scalar value in R; a single number is simply a vector
of length one. This design decision is so natural for spatial data—where coordinates come
in pairs, raster pixels form grids, and time series are sequences—that it almost feels as
though R was pre-adapted for geoinformatics.
R is not just a language specification; it is a complete computing environment. The base
installation includes a command-line interpreter, a comprehensive set of statistical and
mathematical functions, and high-quality graphical capabilities. Its power is multiplied by a
package system that makes thousands of community-contributed extensions available through
the Comprehensive R Archive Network (CRAN). Among these are the modern spatial libraries—
sf, terra, stars—that have transformed R into a full Geographic Information System.
1.1.3 Why R for the Geoinformatics Scientist?
As a student of geoinformatics, you already possess a conceptual understanding of spatial data,
projections, and analysis. You may wonder why you should invest time in learning R when
graphical GIS tools already exist. The answer lies in the intersection
of statistics, reproducibility, and the nature of spatial data.
Statistical depth. R was created by statisticians for statistical computing. Methods that
are essential for spatial analysis—hypothesis testing, regression, bootstrap, spatial
autocorrelation, kriging—are not external add-ons; they are the native vocabulary of the
language. When we later model landslide susceptibility or interpolate soil properties, we
are not wrestling with an external library; we are extending core R concepts.
Data-frame-centric spatial data. The modern spatial package sf stores geographic
features in an ordinary data frame. The geometry column—containing points, lines, or
polygons—sits alongside attribute columns just like any other variable. This unification
means that all the data wrangling skills you will acquire (filtering, joining, summarising)
apply directly to spatial layers without any special syntax. The barrier between a CSV
table and a shapefile dissolves.
End-to-end reproducibility. A typical GIS project requires many steps: download data,
clip, reproject, overlay, classify, map. If you perform these steps with a graphical
interface, the sequence of operations exists only in your memory and perhaps some
inconsistent notes. R allows you to write every step in a script—a plain-text document
that can be rerun at any time. Combined with R Markdown, you can produce a final
report that contains your text, code, figures, and citations in a single file. This is
increasingly a requirement for publication and scientific integrity.
Same engines, programmable interface. Beneath its R syntax, the sf and terra packages
call the same geospatial libraries—GDAL, GEOS, PROJ—that power QGIS, PostGIS,
and GRASS. You sacrifice none of the robustness of industry-standard geometry
processing. You gain the ability to loop, branch, and customise in ways no graphical tool
can match.
Community and longevity. R’s package ecosystem for spatial analysis is developed and
maintained by active researchers. Cutting-edge methods often appear in an R package
alongside the journal article that proposes them. By learning R, you plug directly into the
international conversation of spatial statistics and computational geography.
In summary, R is not a replacement for all GIS software; it is a profoundly flexible spatial data
science laboratory. It shines precisely where graphical tools reach their limits: statistical
modelling, automation, and transparent documentation.
1.1.4 A First Conceptual Map of the R Environment
Before we touch the keyboard, let us create a mental map of the two components you will work
with: R and RStudio.
R is the language engine. It is a program that runs in the background, interprets your
code, manages memory, and performs calculations. You could, in principle, interact with
R through a simple terminal window, typing commands and reading text output.
RStudio is an Integrated Development Environment (IDE). It wraps R in a visual
interface that makes writing, running, debugging, and documenting code far more
comfortable. RStudio provides four principal panes (which we will explore in Section
1.2), but for now, it is enough to know that it is a helper, not the language itself.
When you install R and RStudio (detailed in Appendix A), you open RStudio and find yourself
facing a structured workspace. The theoretical model is simple: you write instructions in a script,
send them to the console (the R interpreter), and R responds with output, plots, or messages.
Everything that exists in R’s memory during a session—variables, functions, data—is stored in
an environment that you can inspect.
1.1.5 The Grammar of R: Syntax as a System of Rules
Every language, natural or formal, has a syntax—a set of rules that govern how valid sentences
are formed. R’s syntax is compact and logical. Understanding these rules as a system rather than
as arbitrary constraints will accelerate your learning.
1. Expressions and statements. An R program is a sequence of statements, each typically
occupying one line. A statement is an expression that R can evaluate to produce a value.
For example, 2 + 3 is an expression that evaluates to 5. x <- 2 + 3 is a statement that
evaluates the expression and assigns the result to a variable x.
2. Assignment is the act of binding a value to a name. In R, the preferred operator
is <- (read as “gets”). The expression on the right is fully evaluated before the name on
the left is bound to it. Example: elevation <- 8848. The name must begin with a letter (or
a dot) and can contain letters, digits, dots, and underscores. It cannot contain spaces.
Good names are descriptive: slope_degrees, ndvi_mean.
3. Comments begin with # and extend to the end of the line. They are ignored by the R
interpreter but are indispensable for human readers. A well-commented script is a
scholarly document, not a cryptic code.
4. Function calls are the primary mechanism of computation. A function is invoked by its
name followed by parentheses: sqrt(144). Arguments are passed inside the parentheses,
either by position or by name. round(3.14159, digits = 2) rounds pi to two decimal places.
5. Case sensitivity. A and a are distinct. R is consistent in this: every variable, function, and
package name must be typed with the exact case.
6. Vectorisation. Because there are no scalars, many operations are implicitly vectorised.
If x is a vector of numbers, x + 1 adds 1 to every element. This natural parallelism
eliminates the need for explicit loops in many situations and aligns beautifully with raster
and point-cloud thinking.
7. Objects and classes. Everything in R’s memory—numbers, vectors, data frames,
functions—is an object. Every object has a class that determines how it behaves and how
it is printed. Knowing the class of your data is as important in R as knowing whether a
map layer is vector or raster.
These rules may seem abstract now, but they will become second nature within a few chapters.
The key is to treat them not as obstacles but as the logic of a language you are learning to speak.
1.1.6 The Dialogue with the Machine: Console and Script
Now we connect theory to practice. R provides two primary ways to execute code:
the Console and the Script Editor. Their roles are distinct, and understanding when to use each
is fundamental.
The Console is R’s interactive command line. It displays the prompt > and waits for your input.
You type a statement, press Enter, and R evaluates it immediately, printing any result. The
console is ideal for quick experiments: checking the value of a variable, testing a short function,
or viewing a small dataset. However, the console has no memory of its own—once you close the
session, the sequence of commands is lost (unless retrieved from the History).
The Script Editor is where you build lasting work. A script is a plain-text file (typically with
a .R extension) containing a sequence of R statements. You write, edit, and save your script, and
you run its lines either individually (Ctrl+Enter or Cmd+Enter) or all at once (the Source button).
The script is your lab notebook: permanent, shareable, and completely transparent.
The relationship between script and console embodies the scientific ideal of reproducibility. You
develop your analysis in the script, using the console only for inspection. At the end, you have a
document that records every step, from data import to final map, in human-readable form.
Let us now open RStudio and make this dialogue real.
1.1.7 First Contact: Arithmetic, Variables, and Functions
We will now write genuine R code. Do not worry about spatial data yet; these simple exercises
establish the pattern of all later work: assign, compute, inspect.
Open RStudio and locate the Console (the pane with the > prompt). Type the following
expressions, pressing Enter after each, and observe the result:
r
# Arithmetic — R as a calculator
5+7
12 - 3
4*6
20 / 7
20 %/% 7 # integer division
20 %% 7 # remainder
2^3 # exponentiation
sqrt(144) # square root
log(100) # natural logarithm
Now let us store results in variables using <-:
r
radius <- 5
area <- pi * radius^2 # area of a circle
area
The variable area now holds the computed value. You can inspect it simply by typing its name.
Finally, let us convert some coordinates from degrees to radians—a tiny preview of map
projection math:
r
lat <- 39.9042 # Beijing
lon <- 116.4074
lat_rad <- lat * pi / 180
lon_rad <- lon * pi / 180
lat_rad
lon_rad
Notice the pattern: create meaningful names, apply functions or arithmetic, view the result.
This is the fundamental cycle of data analysis.
1.1.8 The Promise of Reproducible Geospatial Analysis: A Glimpse Ahead
We have now written and executed code in the console. In a moment, we will transfer the same
logic to a permanent script. But first, let us articulate the larger promise that drives this entire
textbook.
By the end of this course, you will not merely “know R.” You will be able to:
Read hundreds of shapefiles and raster tiles in a single loop.
Derive terrain indices from digital elevation models and model erosion risk.
Apply spatial statistical tests that have no button in any graphical GIS.
Build interactive web maps that allow stakeholders to explore your results.
Write a research paper where every figure, every number, and every table is generated
directly from the underlying data, with zero manual cut-and-paste.
All of this flows from the simple act of writing instructions in a script. The spatial superstructure
comes later. What you are learning now—the grammar of expression, assignment, and function
calls—is the unshakeable foundation.
Hard Practice 1.1 – “Your First R Script”
Objective:
Move from the console to a saved script. Practice assignment, arithmetic, and function calls
while beginning to think about how code documents a workflow.
Theoretical Context:
A script is a plain-text file that records your computational steps. In scientific research, the script
is part of the scholarly record. It must be clear, commented, and organised so that another
researcher—or your future self—can understand and rerun it. This practice instills those habits
from the very first exercise.
Instructions:
1. In RStudio, create a new R script (File → New File → R Script). Save it immediately
as practice_1_1.R in a folder reserved for this course,
e.g., R_Geospatial_Course/Chapter1/.
2. Write a header comment at the top of the script:
r
# Practice 1.1 – My First R Script
# Author: [Your Name]
# Date: 2026-06-19
# Purpose: To practice basic syntax, assignment, and function calls.
3. Assign your age (in years) to a variable my_age and your height (in centimeters)
to my_height. Add a comment above each line explaining what the variable represents.
4. Compute and store the following derived quantities, each with a descriptive comment:
o Age in months: age_months <- my_age * 12
o Height in meters: height_m <- my_height / 100
o The area of a circle with radius equal to your height in meters: circle_area <- pi *
height_m^2
5. Use the round() function to round circle_area to 2 decimal places: area_rounded <-
round(circle_area, digits = 2).
6. Print the three derived values by writing their names on separate lines. Run the script line
by line (Ctrl+Enter) and verify that the values appear in the console.
7. Run the entire script at once by clicking Source (or Ctrl+Shift+Enter). Observe that only
explicitly printed values appear in the console. This behaviour reinforces the difference
between assignment (silent) and inspection (visible output).
8. Locate the Environment pane and verify that all five variables
(my_age, my_height, age_months, height_m, circle_area, area_rounded) exist with the
expected values.
9. Reflection (to be written in your course notebook):
o In your own words, what is the function of the <- operator? Why does the
textbook recommend it over =?
o What is the difference between a script and the console, and why is a script
essential for scientific reproducibility?
o Try typing My_Age instead of my_age. What happens? How does this
demonstrate the case sensitivity rule?
o Think ahead: the variable circle_area is a single number. How might the concept
of a vector (a sequence of numbers) be useful when working with many
geographic features simultaneously?
Expected Outcome:
You have composed, saved, and executed a complete (if small) R script. You can distinguish
between the console and the editor, you understand the grammar of assignment and function
calls, and you have begun to think of code as a form of scientific communication.
With this foundation firmly in place, we are now ready to explore the RStudio environment in
detail, to install the geospatial packages that will accompany us through the book, and to
discover how R’s help system makes you a self-sufficient learner. That is the subject of Section
1.2.
1.2 Installing R, RStudio, and Essential Geospatial Dependencies (GDAL, GEOS, PROJ)
In Section 1.1 we understood what R is and why a geoinformatics scientist needs it. We also
wrote our first lines of code. However, any powerful tool requires a solid foundation. This
section ensures that your computer is equipped with the complete R ecosystem, including the
invisible libraries that will later handle geographic data with industrial-grade precision. We will
proceed with full theoretical explanations first, then precise technical steps.
1.2.1 The Modular Architecture of Open-Source Geospatial Computing
To appreciate what we are about to install, it is essential to understand the layered
architecture of modern geospatial software. A programming language like R does not work in
isolation; it builds upon lower-level libraries written in languages like C and C++ that handle
computationally intensive tasks such as reading large files, performing geometric operations, and
transforming coordinates.
The three pillars that support all spatial work in R—and indeed in many other GIS platforms—
are:
GDAL (Geospatial Data Abstraction Library): The universal translator for raster and
vector data. GDAL reads and writes over 200 raster formats and its sibling library OGR
(integrated since GDAL 2.0) handles dozens of vector formats, including Shapefile,
GeoJSON, GeoPackage, and PostGIS. Whenever we use sf::st_read() or terra::rast(), we
are invoking GDAL under the hood.
GEOS (Geometry Engine – Open Source): The computational geometer. GEOS
performs spatial operations on vector geometries: buffer, intersection, union, difference,
convex hull, and all the topological predicates (contains, touches, crosses). It is the C++
implementation of the OGC Simple Features specification. sf calls GEOS functions for
every st_intersects(), st_buffer(), and st_union().
PROJ: The cartographic library that manages coordinate reference systems and
transformations. PROJ knows the mathematical formulas for converting coordinates
between datums and projections. It maintains a vast database of CRS definitions (EPSG
codes). When you call sf::st_transform() or terra::project(), PROJ is doing the heavy
lifting.
These three libraries are not part of R. They exist as independent, community-maintained
projects that are used by QGIS, PostGIS, GRASS GIS, Python’s geopandas/rasterio, and
countless other applications. By installing the R packages sf and terra, we obtain compiled
bindings that connect R’s high-level interface to these battle-tested engines. The result is that R
inherits the robustness of a global software infrastructure while adding its own strengths in
statistics, data manipulation, and reproducibility.
Therefore, before we can do any spatial work in R, we must ensure that R itself is
installed, RStudio is available as our IDE, and GDAL, GEOS, and PROJ are present on our
system. The installation method differs slightly across operating systems, but the conceptual goal
is identical.
1.2.2 The R Environment: Base R and Its Extension System
R is available from the Comprehensive R Archive Network (CRAN) at [Link]
Pre-compiled installers exist for Windows, macOS, and Linux. When you install R, you get:
The R interpreter ([Link] on Windows, a binary on Unix).
A basic graphical user interface (the R GUI) that we will not use, because RStudio is far
superior.
Core packages (base, stats, graphics, utils, etc.) that contain essential functions for
statistics, data manipulation, and plotting.
The package management system that allows you to install additional packages from
CRAN.
R follows a bi-annual release cycle (e.g., R 4.4.x, R 4.5.x), with minor updates for bug fixes.
For this book, we require R version 4.2.0 or later because the modern spatial packages depend
on recent improvements in the R core. You will need administrative rights on your computer to
install R.
RStudio is a separate product, developed by Posit (formerly RStudio, PBC). Its free Desktop
edition is an Integrated Development Environment that wraps R with a powerful code editor,
workspace browser, plot viewer, and help system. RStudio does not include R; you must install R
first, then RStudio, as the latter relies on the former to function.
The combination of R + RStudio is our daily working environment. We will open RStudio for
every session, and it will automatically connect to the R engine installed on your system.
1.2.3 The External Geospatial Libraries: GDAL, GEOS, PROJ
For a beginning user, the most confusing step can be ensuring that GDAL, GEOS, and PROJ are
properly installed. Fortunately, the R community has streamlined this:
On Windows: The sf and terra packages ship with pre-compiled binary packages that
include embedded copies of GDAL, GEOS, and PROJ. When you
run [Link]("sf") in R, it downloads a ZIP file that contains everything needed.
No separate installation is required.
On macOS: Binary packages for the latest macOS (both Intel and Apple Silicon) are also
available on CRAN and include the necessary libraries. For older macOS versions, you
may need to install the libraries via Homebrew, but we will provide instructions.
On Linux: The package sf requires the development files for GDAL, GEOS, and PROJ
to be installed at the system level. The exact commands vary by distribution
(Ubuntu/Debian, Fedora, openSUSE). This is because CRAN does not distribute
pre-compiled binary packages for Linux; packages are built from source on your
machine.
The important theoretical point is that these libraries are not an optional extra. Without
them, sf and terra cannot be loaded, and all the spatial functionality of this book will be
unavailable. The installation step is therefore foundational. We will now provide detailed, tested
instructions for each major operating system.
1.2.4 Installation Instructions
We assume you have an internet connection and administrative rights to install software. The
instructions below are valid as of mid-2026. Should there be minor version changes, the core
steps remain the same.
[Link] Windows
1. Install R:
o Go to [Link]
o Download the installer for the latest R release (e.g., [Link]).
o Run the installer. Accept the default options, but ensure “Save version in registry”
is selected.
o Complete the installation.
2. Install RStudio:
o Go to [Link]
o Download the free RStudio Desktop installer for Windows.
o Run the installer and accept the defaults.
o Launch RStudio to confirm it opens and connects to the R engine you installed.
3. Install Geospatial Dependencies (automatic):
o Because CRAN provides pre-compiled binaries, GDAL, GEOS, and PROJ are
included in the sf package. No separate steps are needed.
o However, we will verify the installation in the next sub-section.
[Link] macOS
1. Install R:
o Go to [Link]
o Download the .pkg file for the latest R release. For Apple Silicon (M1/M2/M3)
there is a dedicated build ([Link]); for Intel Macs, use R-4.4.2-
x86_64.pkg.
o Open the .pkg file and follow the installer.
o R will be installed in /Applications/.
2. Install RStudio:
o Download RStudio Desktop for macOS from [Link]
desktop/.
o Open the .dmg file and drag the RStudio icon to Applications.
o Launch RStudio.
3. Install Command Line Tools (if needed):
o Some R packages require compilation from source if binaries are not available for
your R version. Open Terminal (Applications → Utilities → Terminal) and type:
xcode-select --install
o A dialog will appear; click “Install”. This provides the compilers needed.
4. Install Geospatial Dependencies:
o For most recent macOS versions, the CRAN binaries for sf include GDAL,
GEOS, and PROJ internally. However, if you encounter errors, you can install the
libraries system-wide using Homebrew. First, install Homebrew by
visiting [Link] and following the one-line command. Then in Terminal:
brew install gdal geos proj
o Restart RStudio after this step. The package sf will automatically detect the
system-level libraries if present.
[Link] Linux (Ubuntu/Debian example)
Linux users often prefer to install R from the official CRAN repositories rather than the
distribution’s default packages, which may be outdated.
1. Add the CRAN repository and install R:
bash
# Update system
sudo apt update
sudo apt install --no-install-recommends software-properties-common dirmngr
# Add CRAN GPG key and repository (example for Ubuntu 22.04/24.04)
wget -qO- [Link] | sudo tee -a
/etc/apt/[Link].d/cran_ubuntu_key.asc
sudo add-apt-repository "deb [Link] $(lsb_release -cs)-
cran40/"
sudo apt update
sudo apt install r-base r-base-dev
2. Install RStudio:
o Download the .deb package from [Link] and
install with sudo dpkg -i rstudio-*.deb. If there are dependency issues, run sudo
apt --fix-broken install.
3. Install Geospatial Dependencies (system libraries):
bash
sudo apt install libgdal-dev libgeos-dev libproj-dev
For Fedora, use sudo dnf install gdal-devel geos-devel proj-devel.
4. Verify GDAL version (optional):
bash
gdalinfo --version
1.2.5 Verifying Your Installation
After installing R, RStudio, and the required libraries (if applicable), we will perform a
verification test. This test also serves as a gentle introduction to the concept of R packages and
the loading of extensions, which we will cover thoroughly in Section 1.5.
Launch RStudio. In the Console, type the following commands one by one:
r
# Check R version
[Link]
# Install the sf package (this will also install dependencies like terra)
[Link]("sf")
# Load the package
library(sf)
# Verify that sf can see the external libraries
sf_extSoftVersion()
The function sf_extSoftVersion() returns the versions of GDAL, GEOS, and PROJ that sf is
using. Look for output similar to:
text
GEOS GDAL proj.4 GDAL_with_GEOS
"3.12.1" "3.8.4" "9.4.1" "true"
If you see version numbers without error messages, your installation is complete and correct.
You are now ready to proceed with the entire textbook.
If you encounter an error, do not panic. Common issues include:
“there is no package called ‘sf’”: You forgot to run [Link]("sf") or the
installation failed due to network issues. Check your internet connection and try again.
**“cannot load shared object...”: ** This usually means the underlying libraries (GDAL,
GEOS, PROJ) are missing or misconfigured. On Linux, re-run the system library
installation. On macOS, consider the Homebrew route. On Windows, ensure you are
using the official CRAN binary.
Version mismatch: If the reported versions are very old, you may have an outdated R or
package repository. Update R or install from a fresh CRAN snapshot.
The verification step is not merely technical; it reinforces the layered architecture we discussed.
The R function sf_extSoftVersion() is your window into the hidden libraries that will power
every spatial operation from now on.
1.2.6 A Reproducible Setup Script
To embed good practices from the start, we will now write a small R script that can be used on
any machine to confirm the environment is ready. Create a new script in RStudio (File → New
File → R Script) and save it as verify_installation.R. Copy the following lines:
r
# verify_installation.R
# Author: [Your Name]
# Date: 2026-06-20
# Purpose: Confirm that R, RStudio, and geospatial dependencies are correctly installed.
# 1. Check R version
cat("R version:", [Link], "\n")
# 2. Install sf if not present (uncomment the line if needed)
# [Link]("sf")
# 3. Load sf
library(sf)
# 4. Print external library versions
cat("External geospatial libraries:\n")
print(sf_extSoftVersion())
# 5. Create a trivial spatial object to confirm end-to-end functionality
point <- st_point(c(0, 0))
sf_point <- st_sf(geometry = st_sfc(point, crs = 4326))
cat("Test point created:\n")
print(sf_point)
cat("\nAll checks passed. Your geospatial R environment is ready.\n")
Run the script with source("verify_installation.R") or by clicking Source. If it executes without
error and prints a test point, your environment is fully functional. Keep this script as part of your
course materials.
Hard Practice 1.2 – “Complete Environment Setup and Diagnostic Report”
Objective:
Install all required software, verify the geospatial stack, and produce a diagnostic report. Develop
the habit of documenting your computational environment, a requirement for reproducible
research.
Theoretical Context:
A scientific study’s results can depend on software versions. Journals increasingly require a
statement of the computational environment used. Knowing how to query and record your R
version, package versions, and external library versions is a fundamental skill. This practice also
ensures that every student has an operational system before we proceed to the next chapter.
Instructions:
1. If you have not already done so, install R (≥ 4.2.0), RStudio Desktop, and the system
libraries (Linux) following the guidelines in Section 1.2.4. Take your time; do not
proceed until RStudio launches and shows a > prompt.
2. In RStudio, install the sf package by running [Link]("sf") in the Console.
Observe the messages printed during installation. On Windows, notice the mention of
GDAL and GEOS DLLs. On Linux, watch the compilation from source. This is your first
encounter with the hidden infrastructure.
3. Create a new script named diagnostic_report.R. Use it to collect and print the following
information (use comments to structure your script):
o R version string ([Link])
o RStudio version (available from the menu Help → About RStudio, write it
manually in a comment)
o Operating system (use [Link]()["sysname"] and [Link]()["release"])
o The output of sf::sf_extSoftVersion()
o The list of installed packages and their versions: [Link]()[,
c("Package", "Version")] (warning: this list is long; you may choose to comment
it out after first run, but keep it in the script for completeness)
4. Add a section that creates a simple spatial object (point, line, or polygon) to confirm that
geometry creation works. For example:
r
library(sf)
my_city <- st_point(c(121.4737, 31.2304)) # Shanghai
my_city_sf <- st_sf(name = "Shanghai", geometry = st_sfc(my_city, crs = 4326))
print(my_city_sf)
5. Run the script. Verify that all outputs are correct and no errors occur.
6. Reflection:
o In your notebook, explain in one paragraph why we needed to install GDAL,
GEOS, and PROJ even though we are using R.
o What is the purpose of the sf_extSoftVersion() function, and why might you
include it in a research paper’s appendix?
o Imagine a collaborator tries to run your script on a computer without GDAL
installed. What error would they likely encounter, and how would you help them
diagnose it?
Deliverable:
Save your diagnostic_report.R and the console output (you can copy-paste it into a text file or
use R Markdown, which we will learn later). This report is the certificate that your geospatial R
laboratory is open for business.
1.3 The RStudio Interface: Console, Script, Environment, and Plots
You have installed R, RStudio, and the geospatial libraries. Now you face the RStudio window—
a collection of panes, tabs, and menus. This section transforms that seemingly complex interface
into a familiar, logical workspace. We will approach it theoretically first: what is an Integrated
Development Environment, and why is its design important for scientific computing? Then we
will explore each pane’s function, its role in the geospatial scientist’s daily workflow, and the
fundamental practices that ensure your work remains clean, reproducible, and efficient.
1.3.1 The IDE as a Cognitive Tool
An Integrated Development Environment (IDE) is not merely a text editor with a “Run”
button. It is a cognitive extension of the programmer: it organizes the chaos of code, data, output,
and documentation into a structured visual field that reduces cognitive load and prevents errors.
For a scientist, the IDE serves as the digital laboratory bench—the place where instruments
(code), specimens (data), measurements (results), and lab notebooks (scripts and comments) are
all within arm’s reach.
RStudio is purpose-built for R. Its layout reflects a deep understanding of the data analysis cycle:
1. Write code in the script editor.
2. Execute it interactively in the console.
3. Inspect the resulting objects in the environment.
4. Visualise the output in the plots pane.
5. Consult documentation in the help pane.
These five actions, repeated thousands of times in a project, map to the major panes of RStudio.
Understanding the function and philosophy of each pane before we start heavy coding ensures
that you will never waste time hunting for a variable or a plot. It also instills from the start the
discipline that separates a reliable scientific programmer from a casual user.
1.3.2 The Default Layout and Its Logic
When you open RStudio for the first time, you typically see three or four panes. The default
arrangement on a wide screen is:
Left: Console (bottom) and Script Editor (top). If no script is open, the console fills the
entire left side.
Top-right: Environment / History / Connections / Tutorial.
Bottom-right: Files / Plots / Packages / Help / Viewer.
You can rearrange panes via View → Panes → Pane Layout, but the default is carefully
designed. The left side is for input (scripts) and immediate feedback (console). The right side is
for meta-information: what is in memory (Environment), what have I done (History), the output
graphics (Plots), and the reference manual (Help).
Let us now explore each component in depth.
1.3.3 The Console: Direct Dialogue with the Interpreter
The Console is the pane with the > prompt. It is the interactive command line to the R
interpreter. This is the part of RStudio that is R: when you type a command and press Enter, R
processes it and displays the result. The console is:
Immediate: The result appears instantly, making it ideal for quick tests, exploring a
dataset, or checking a function’s behavior.
Ephemeral: Commands typed directly in the console are not saved as part of your
project’s permanent record. While the History pane (described later) retains recent
commands, relying on the console alone means that your workflow vanishes when the
session ends.
Stateful: Variables you create in the console persist in the current R session and appear in
the Environment. You can build a complex analysis line by line in the console, but doing
so is risky—there is no easy way to recreate the exact sequence if you close RStudio.
In geospatial work, the console is frequently used to quickly inspect spatial objects. For
example, after reading a large shapefile with st_read(), you might type its name in the console to
see a summary of its attributes and geometry type. You might also test a projection string or
check the extent of a raster. However, all commands that form part of your final analysis should
be composed and saved in a script.
A useful keyboard shortcut: Ctrl+L (Cmd+L on macOS) clears the console, removing all text but
not deleting any variables from memory. This is helpful when the console becomes cluttered.
1.3.4 The Script Editor: The Scientific Lab Notebook
The Script Editor (also called the Source pane) is a text editor designed for writing and editing
R code. It appears in the top-left quadrant when you open a new or existing .R file. The script
editor is not a mere convenience; it embodies the principle of reproducibility.
A script is a plain-text document that records your commands in the order they should be
executed. It is:
Permanent: You save it to disk. You can reopen it months later and understand what you
did, provided you wrote clear comments.
Editable: You can move lines, correct errors, and restructure the analysis without
re-typing everything.
Executable in whole or in part: You can run one line (Ctrl+Enter), a selected block, or
the entire file (Source).
Commentable: Lines starting with # are ignored by R but are essential for human
understanding. We will treat comments as part of the scientific narrative.
The script editor provides syntax highlighting: numbers, strings, comments, and functions appear
in different colors, helping you quickly parse code structure. It also supports code folding
(collapsing sections), auto-completion, and find-and-replace.
For the geospatial scientist, the script is where the entire workflow lives. A typical script might
begin with comments describing the project, then load packages (library(sf)), import data,
perform spatial operations, create maps, and save results. By the end of this book, you will have
a collection of well-structured scripts that together form a complete geospatial analysis portfolio.
Important shortcut: Ctrl+Enter (Cmd+Enter) sends the current line or selected block from the
script to the console and executes it. If no text is selected, it runs the line containing the cursor.
This is the most frequent action you will perform; we will use it so often that it will become
muscle memory.
1.3.5 The Environment Pane: A Map of R’s Memory
The Environment tab (top-right pane) displays all objects currently in R’s global workspace. An
“object” can be a simple number, a vector, a data frame, a spatial sf object, a raster SpatRaster, or
a function you have defined. The Environment pane shows:
Object name
Type (num, int, chr, factor, list, etc.)
Length (number of elements) or dimensions (rows × columns)
Size in memory
A brief preview (for data frames, it shows the first few rows and columns)
For geospatial data, the Environment pane is particularly valuable because spatial objects can be
large. You can see at a glance that admin_boundaries is an sf object with 245 features and 12
fields, occupying 3.2 MB. This helps you manage memory and avoid accidentally overwriting
important data.
The Environment pane also offers a broom icon to clear all objects from the workspace. While
useful, we advocate a different practice: do not save and reload workspace images (.RData).
Instead, always start with a clean environment and rebuild your objects by running the script.
This ensures that your results are genuinely reproducible from the raw data.
Next to the Environment tab is the History tab. It records every command submitted to the
console (including those sent from scripts). You can double-click a historical command to
re-execute it, or send it back to the console or script. The History is a fallback, not a substitute
for a script. We will not rely on it; our scripts will be our true history.
1.3.6 The Multi-Tab Pane: Files, Plots, Packages, Help, Viewer
The bottom-right pane is a tabbed container with five or more tabs. Each serves a distinct
purpose:
Files: A simple file browser showing your computer’s files and folders. You can navigate
to your project directory, open scripts, rename files, and set the working directory. For
geospatial work, this is where you will locate shapefiles, raster files, and CSV data.
Double-clicking a .R file opens it in the script editor. You can also import datasets via the
“Import Dataset” button, but we will do it programmatically because that is reproducible.
Plots: The graphical output display. Every static plot you create—base R
graphics, ggplot2, tmap—appears here. The Plots tab includes navigation arrows to view
previous plots (R keeps a plot history within the session) and an Export button to save
the current plot as an image (PNG, PDF, etc.). For the geospatial scientist, the Plots pane
is where your maps come to life. We will produce hundreds of maps, and this pane is our
visual feedback loop.
Packages: Shows a list of all R packages installed on your system. You can see which are
loaded (checked box). You can load or unload packages by toggling the checkbox, but we
will always use library() in scripts for clarity and reproducibility. The Packages pane also
has an Install button that opens a dialog to install new packages from CRAN. We will
usually use [Link]() in the console, as we did with sf.
Help: The built-in documentation viewer. When you type ?functionname (e.g., ?mean),
the help page appears here. This is an extremely powerful learning tool. Every R function
and package is documented. A help page typically contains:
o Description: What the function does.
o Usage: The function signature with all arguments.
o Arguments: Detailed explanation of each argument.
o Details: Technical notes.
o Value: What the function returns.
o References: Academic citations.
o Examples: Runnable code demonstrating the function.
For geospatial packages, the examples often show small spatial workflows. You
can run the example code directly by clicking the “Run examples” link.
Viewer: Used for interactive web content, such as maps produced
with leaflet or mapview. These are not static images; you can pan and zoom. The Viewer
tab also displays HTML widgets and R Markdown output.
These tabs are your constant companions. As you progress, you will switch between them
seamlessly: find a dataset in Files, read its help in Help, write a script to analyse it in the Editor,
run it in the Console, see the resulting map in Plots, and check the object size in Environment.
1.3.7 Customising the Workspace for Geospatial Work
You can tailor RStudio’s appearance to reduce eye strain and improve focus. We recommend:
Color theme: Go to Tools → Global Options → Appearance and select a theme you
find comfortable. Many developers prefer dark themes (e.g., “Tomorrow Night Bright”),
but for teaching materials, a light background is often clearer. Choose what works for
you.
Pane layout: In View → Panes → Pane Layout, you can move panes to different
positions. A popular alternative for wide screens is to place the Console and Script
side-by-side on the left, with Environment and Plots stacked on the right. Experiment, but
the default is a safe starting point.
Zoom: Ctrl+Plus / Ctrl+Minus (or trackpad pinch) changes the editor font size. This is
helpful when presenting code to a classroom.
For geospatial work, ensure your Plots pane is large enough to inspect map details, and keep the
Environment pane visible to monitor large raster and vector objects.
1.3.8 A Clean R Session: Saving Scripts, Not Workspaces
A common beginner mistake is to save the R workspace (the .RData file) upon exit and rely on it
to restore the session. RStudio will ask, “Save workspace image to ~/.RData?” We strongly
recommend you answer “No” and set the global option to never save.
Why? Because saved workspaces hide the exact sequence of operations that produced them. A
workspace is a black box; a script is transparent. When you start a new session, your script
should reconstruct every object from the raw data. This ensures that no undocumented manual
tweak contaminates the analysis. We will always work from scripts, starting with a fresh R
session.
To set RStudio to never save workspaces by default:
Go to Tools → Global Options → General.
Set “Save workspace to .RData on exit:” to Never.
Uncheck “Restore .RData into workspace at startup.”
This discipline makes your geospatial analyses truly reproducible
1.3.9 First Tour: A Geospatial Preview in the RStudio Interface
To cement your understanding of how these panes collaborate, we will perform a tiny geospatial
operation that involves every pane. Do not worry about the details of the code; we will fully
unpack it later. For now, observe how the interface is used.
1. In the Files tab, navigate to a convenient folder (e.g., R_Geospatial_Course/Chapter1/).
Click “More” → “Set As Working Directory.”
2. Create a new script via File → New File → R Script. Write the following:
r
library(sf)
# Create a point (Beijing)
bj <- st_sfc(st_point(c(116.4074, 39.9042)), crs = 4326)
# Print the object
print(bj)
# Plot the point
plot(bj, pch = 16, col = "red", main = "Beijing")
3. Run the lines one by one with Ctrl+Enter. Observe:
o The Console shows the print output and any messages.
o The Environment now contains bj as a sfc_POINT of length 1.
o The Plots pane displays a simple map with a red dot.
4. Type ?st_point in the Console and press Enter. The Help pane shows the documentation
for the st_point function. Read the “Arguments” section.
You have just used every major pane for a spatial task. The script recorded your work; the
console executed it; the environment held the object; the plots showed the map; the help
documented the function. This integrated workflow is what makes RStudio powerful.
Hard Practice 1.3 – “Navigate Your Geospatial Laboratory”
Objective:
Gain complete familiarity with RStudio’s panes, customise the interface, and practice the basic
actions of scripting, executing, inspecting, plotting, and using help. Develop the discipline of
working entirely from scripts without saving the workspace.
Theoretical Context:
An IDE is a laboratory. The scientist must know where the instruments are, how to read their
displays, and how to keep the lab notebook. This practice ensures that the interface becomes
invisible, so you can focus entirely on the science.
Instructions:
1. Open RStudio and ensure you have a clean workspace (Environment pane empty). If
there are objects, click the broom icon to clear them.
2. Create a new R script. Save it as exploring_rstudio.R in your Chapter1/ folder.
3. In the script, write a header comment with your name, date, and a brief description:
“Script to practice RStudio interface.”
4. Use the script to perform the following tasks, executing each line as you go:
o Load the sf package with library(sf).
o Create a point at the coordinates of your university or any place of significance:
my_point <- st_point(c(longitude, latitude))
my_sfc <- st_sfc(my_point, crs = 4326)
o Print my_sfc to see its structure.
o Plot my_sfc with plot(my_sfc, pch = 16, col = "blue").
o Create a second point for another location (e.g., a different city) and combine
them with c(my_sfc, second_sfc) into two_points. Plot two_points in red.
5. After running the code, locate the following in RStudio:
o The Console: where did the print output appear? What prompt is shown?
o The Environment: what are the types and lengths of my_sfc and two_points?
o The Plots: use the left/right arrows in the Plots tab to switch between the two
maps you created.
o The History: find the commands you just ran. Double-click one to re-send it to
the console.
o The Help: type ?st_sfc in the console and examine the documentation. Find the
“Usage” section. Run the examples at the bottom of the help page.
6. Customise your interface:
o Change the color theme to something you like (Tools → Global Options →
Appearance).
o Resize the panes by dragging the borders.
o Enable “Show line numbers” in Code options (Tools → Global Options → Code
→ Display).
7. Close RStudio. When prompted to save the workspace, click Don’t Save. Re-open
RStudio. Notice that the Environment is empty. Re-open exploring_rstudio.R and run the
entire script with Source. Verify that the Environment is repopulated exactly as before.
This is the essence of reproducibility.
8. Reflection:
o In your notebook, explain why we refuse to save the workspace image. How does
this practice relate to the scientific principle of reproducibility?
o Describe the purpose of each of the four main panes (left, top-right, bottom-right)
in your own words.
o Why is it beneficial to run code from a script rather than typing everything
directly in the console?
o Look ahead: How might the Help pane be useful when you encounter a new
geospatial function for the first time?
Expected Outcome:
You now navigate RStudio with confidence. You understand the role of each pane, you can write,
run, and save scripts, and you have adopted the discipline of reconstructing your work from
scripts rather than saved workspaces. The interface has become a familiar laboratory, ready for
the geospatial science to come.
1.4 Writing Your First R Script and Running Code
You have learned what R is, you have installed it, and you have toured the RStudio interface. You
have even executed a few commands. Now we formalize the practice of writing a complete R
script—the fundamental unit of reproducible scientific computing. This section teaches you how
to structure a script, what makes a script “good” for geospatial science, and how to execute it
reliably. The theoretical emphasis is on the script as a scientific narrative; the technical focus is
on the mechanics of writing, saving, and running code from a file.
1.4.1 The Script as a Scientific Narrative
A script is more than a sequence of R commands. It is a story you tell about your data: what you
started with, what operations you performed, what decisions you made, and what results you
obtained. Like any good scientific report, a well-written script is structured, annotated, and
self-contained. A reader—be it a colleague, a reviewer, or your future self—should be able to
follow the logic without asking you questions.
In geoinformatics, this narrative quality is essential because spatial analyses often involve
multiple, interdependent steps: downloading data, reprojecting, clipping, joining attributes,
computing indices, and generating maps. If any step is performed manually or remains
undocumented, the entire chain of evidence is broken. A script preserves that chain as executable
text.
Think of each script as a lab notebook entry. It should answer:
What is the objective of this analysis?
What data are being used, and where are they stored?
What specific operations are applied, and in what order?
What outputs are generated, and where are they saved?
What conclusions or observations can be drawn?
In R, this narrative is achieved through a combination of comments (lines beginning with #)
and executable statements. Comments are for humans; statements are for the computer.
Together, they form a document that is both a computational program and a scientific
explanation.
1.4.2 The Anatomy of a Good R Script
Let us examine the components of a well-structured R script. Open a new script in RStudio and
observe its blank slate. Before writing any analysis-specific code, a disciplined scientist includes
several standard sections:
1. Header comments – The title, author, date, and a brief description of the script’s
purpose. This is the script’s identity card.
2. Package loading – Calls to library() for each required add-on package. These should be
placed near the top so that a reader immediately knows which extensions are needed.
Base R functions require no such loading.
3. Setting the working directory and file paths – A statement of where data files are
located, or better, a reproducible path relative to the project folder. We will discuss this in
detail in Section 1.6, but the script should at least contain a commented note about the
expected data location.
4. Data import and preparation – Commands that read files, assign them to variables, and
perform initial cleaning.
5. Analysis and computation – The core geographic or statistical operations.
6. Output generation – Saving results to files (maps, tables, reports) and printing key
summaries.
7. Closing comments – Any final notes or observations.
A script does not need all these sections every time, but the habit of structuring your code in this
way from the beginning will save you immense confusion later. An unstructured script that
dumps commands without comments is extremely difficult to debug or share.
1.4.3 Writing and Saving a Script
To create a script, open RStudio and go to File → New File → R Script. A blank editor pane
appears. You can immediately begin typing R commands and comments.
Save the script early with a meaningful name. Use File → Save As and navigate to your project
folder, e.g., R_Geospatial_Course/Chapter1/. The file should have the extension .R. Good
naming conventions include:
Descriptive names: calculate_ndvi.R, point_pattern_analysis.R
No spaces (use underscores or hyphens): import_landsat.R
A consistent style: snake_case or camelCase
Avoid names like script1.R or test.R; you will not remember what they do a week later.
Once saved, you can edit the script freely. RStudio automatically highlights syntax, numbers,
strings, and comments in different colors, making the structure visually apparent. You can fold
code sections (click the small arrow next to line numbers in a braced block) and use the outline
feature to navigate large scripts.
1.4.4 Running Code from a Script
There are several ways to execute the code in your script. Each has a specific use case:
Run a single line: Place the text cursor anywhere on the line (no need to select the whole
line) and press Ctrl+Enter (Windows/Linux) or Cmd+Enter (macOS). R sends that line
to the console and executes it, then advances the cursor to the next line. This method is
ideal for stepping through a script line-by-line, inspecting the environment after each
command, and checking for errors incrementally.
Run a selected block: Highlight multiple lines with the mouse or keyboard, then press
Ctrl+Enter. The entire selection is executed as a batch. This is useful when you want to
re-run a logical block of code (e.g., all lines that create a plot).
Run the entire script (Source): Click the Source button at the top of the script editor, or
press Ctrl+Shift+Enter. This runs every line in the script from top to bottom in a fresh,
clean environment. No output is printed unless you explicitly use print() or the line
returns a value that is auto-printed (only when sourced in a non-interactive manner,
auto-printing is suppressed; actually, source() does not print outputs unless you
use print() or the expression is an assignment-less call that is explicitly shown? In R,
sourcing a script does not print the results of expressions unless they are printed
explicitly, but in RStudio's Source button, it prints outputs as if in the console. I should
clarify: the behavior of Source in RStudio is to run the entire script and display outputs in
the console, just like if you had typed them. The source() function, when echo=TRUE,
prints expressions. We'll explain the difference.)
Let us clarify the behavior, because it confuses beginners. When you run a line interactively
(Ctrl+Enter), R prints the result automatically if the line is an expression whose value is not
assigned. For example, 2+2 prints [1] 4. When you assign the result x <- 2+2, nothing is printed.
When you source the entire script, R also prints outputs for unassigned expressions. So you can
control what appears by using print() or simply writing variable names.
In geospatial analysis, you will often use the line-by-line method when developing a script,
because you want to view intermediate maps, check coordinate reference systems, and verify that
operations succeeded before proceeding. Once the script is complete and tested, you may source
it to reproduce the entire analysis in one go.
1.4.5 The Working Directory and File Paths
When your script reads external data (e.g., a shapefile), R needs to know where to find it. It looks
in the working directory unless you specify a full path. The working directory is a folder on
your computer that R treats as its default location for file input and output.
You can check the current working directory with getwd() and set it with setwd("path/to/folder").
However, using setwd() inside a script makes the script non-portable: if you move the script to
another computer, the path will be wrong. Modern best practice is to use relative paths from a
project root, or to use the here package (which we will introduce later). For now, the simplest
approach is to create a project in RStudio (File → New Project) and store all files inside that
project folder. Then you can read files with paths like "data/study_area.shp" without worrying
about the absolute directory.
We will cover project management in depth in Section 1.6. For the remainder of this chapter, we
will only write scripts that do not depend on external files, so that you can focus on syntax and
structure without worrying about paths.
1.4.6 Comments as Documentation
Comments are lines that begin with #. R ignores them completely, but humans rely on them. In
scientific programming, comments are not optional decoration; they are part of the method. A
good comment explains why a line exists, not just what it does (the code already tells you what).
For example:
r
# Convert temperature from Celsius to Kelvin
kelvin <- celsius + 273.15
# Poor comment: "Add 273.15 to celsius" (redundant)
Use comments to:
Describe the purpose of a section of code.
Explain non-obvious choices (e.g., “Use 0.05 significance level because... ”).
Record data sources and dates.
Note assumptions or limitations.
Temporarily disable a line of code without deleting it (“commenting out”).
A script that is densely commented is a pleasure to read and easy to debug. We will enforce this
practice in all hard practices.
1.4.7 A Complete First Script: From Coordinates to Distance
Let us now write a complete, well-commented script that performs a simple geospatial
computation: calculating the Euclidean distance between two points on a plane. (We will ignore
Earth curvature for now; true geographic distance comes in later chapters.) This script will
demonstrate every principle discussed above.
Create a new script and type the following exactly:
r
# =====================================================
# Script: distance_calculator.R
# Author: [Your Name]
# Date: 2026-06-20
# Description: Compute Euclidean distance between two
# points on a Cartesian plane and present the result.
# =====================================================
# ---- 1. Define Point Coordinates ----
# Coordinates of point A (in meters, local grid)
x1 <- 500
y1 <- 300
# Coordinates of point B
x2 <- 1200
y2 <- 800
# ---- 2. Compute Distance ----
# Euclidean distance formula: d = sqrt((x2-x1)^2 + (y2-y1)^2)
dx <- x2 - x1
dy <- y2 - y1
distance <- sqrt(dx^2 + dy^2)
# ---- 3. Display Results ----
# Print the coordinates and the computed distance
cat("Point A coordinates: (", x1, ",", y1, ")\n")
cat("Point B coordinates: (", x2, ",", y2, ")\n")
cat("Euclidean distance between A and B: ", distance, " meters\n")
# ---- 4. (Optional) Visual Check ----
# Plot the points and a connecting line
plot(c(x1, x2), c(y1, y2),
xlim = c(0, 1500), ylim = c(0, 1000),
xlab = "X (meters)", ylab = "Y (meters)",
main = "Distance between Two Points",
pch = 19, col = "red", cex = 1.5)
lines(c(x1, x2), c(y1, y2), lty = 2, col = "blue")
text(x1, y1, "A", pos = 4)
text(x2, y2, "B", pos = 4)
# ---- End of Script ----
# Note: The plot appears in the Plots pane.
Save this script as distance_calculator.R in your Chapter1/ folder. Now run it line by line
(Ctrl+Enter). Observe:
The Console shows the cat statements and their output. The plot appears in
the Plots pane.
The Environment now contains x1, y1, x2, y2, dx, dy, and distance.
The Plots pane shows a simple scatter plot with a dashed line connecting the points.
Now close RStudio without saving the workspace. Re-open RStudio,
open distance_calculator.R, and run the entire script with Source. Confirm that the same objects
reappear and the same plot is generated. This reproducibility is the core lesson.
1.4.8 Debugging: What to Do When Something Goes Wrong
Even the most careful programmers make mistakes. A crucial skill is reading R’s error messages
and locating the problem in your script. Common beginner errors include:
Missing parentheses or quotes: If R prints a + prompt instead of >, it means it expects
more input because a parenthesis or quote is not closed. Press Esc to cancel and check
your syntax.
Typos in variable names: If you type diatance instead of distance, R says object
'diatance' not found. Check your spelling.
Case sensitivity: Distance is not distance.
Commas in numbers: In many human languages, 2,5 means two point five. In R, a
comma is a separator, so c(2,5) creates two elements. Use the decimal point: 2.5.
Missing # in comments: If you intend a comment but forget the #, R will try to execute
your English sentence as code, producing a confusing error.
When an error occurs, read the message carefully. It usually tells you the line and the nature of
the problem. Use the script editor’s line numbers to jump to the offending line. You can also run
the script up to a certain point and then inspect the Environment to see if values are as expected.
Hard Practice 1.4 – “Write, Document, and Execute a Geospatial Script”
Objective:
Compose a complete, well-commented R script that performs a multi-step computation using
variables, functions, and a plot. Practice running the script line-by-line and via Source, and
experience debugging a deliberate error.
Theoretical Context:
A scientific script is an executable method section. By writing a script that anyone can re-run,
you are practicing the highest standard of computational science. This exercise cements the
discipline of structure, commenting, and reproducibility.
Instructions:
1. Create a new R script and save it as practice_1_4_coordinate_conversion.R in
your Chapter1/ folder.
2. Include a header section with your name, date, and a brief description of what the script
does. The description should mention that the script converts geographic coordinates
from decimal degrees to radians, computes the difference in longitude and latitude
between two cities, and plots them.
3. In the script, do the following (use comments to label each section):
o Section 1: Define Coordinates
Choose two cities anywhere in the world. Create
variables lon1, lat1, lon2, lat2 with their decimal degree coordinates. Write
a comment for each city.
o Section 2: Convert Degrees to Radians
Use the formula radians = degrees * pi / 180.
Create lon1_rad, lat1_rad, lon2_rad, lat2_rad. Add a comment explaining
why radian conversion is sometimes necessary (anticipating later spherical
distance calculations).
o Section 3: Compute Differences
Compute delta_lon and delta_lat in both degrees and radians.
o Section 4: Display Results
Use cat() to print a clear summary of the coordinates and their differences.
o Section 5: Plot the Cities
Use plot() to create a simple scatter plot of the two cities on a
longitude-latitude grid (x = longitude, y = latitude). Add a title, axis labels,
and change point color and character. Add a dashed connecting line
with lines().
o Section 6: A Simple Calculation
Compute the average latitude of the two cities and print it with a
meaningful message.
4. Save the script. Run it line by line using Ctrl+Enter. Confirm that the Environment fills
with the expected variables, the Console shows the cat outputs, and the Plots pane shows
the map.
5. Close RStudio without saving the workspace. Re-open RStudio, load the script, and
Source it. Verify that you obtain the identical output.
6. Deliberate error: In the script, intentionally introduce a typo in one variable name (e.g.,
change lat2_rad to lat2_ard in one of the computation lines). Run the script. Read the
error message that appears in the Console. Understand what it means. Then correct the
typo and re-run.
7. Reflection:
o In your notebook, explain why we use cat() to print output inside a script rather
than just writing variable names. (Hint: What happens to print vs auto-printing
when sourcing?)
o Why is it good practice to label sections with # ---- Section Name ----?
o How does running a script line by line differ from sourcing it in terms of
debugging?
o If you shared this script with a classmate who has never visited your computer,
could they run it? What information would they need (packages, data) that is not
yet included? (This question anticipates package management and data
distribution.)
Deliverable:
The completed script practice_1_4_coordinate_conversion.R and your reflective notebook
answers.
1.5 R Packages: Installation, Loading, and the {sf} Prelude
You now know how to write and run a script. But R’s true power lies in its extensibility. A core
principle of R is that everything—from reading a shapefile to performing a kriging interpolation
—can be added via packages. This section explains what packages are, how the package
ecosystem works, how to install and load them, and why they are the lifeblood of geoinformatics
in R. We conclude with a first, intentional glimpse of the sf package, the backbone of vector
spatial data in this book, to give immediate meaning to the concept of a package.
1.5.1 The Package as a Unit of Reproducible Research
An R package is a structured collection of functions, data sets, documentation, and compiled
code that extends the capabilities of base R. Think of it as a self-contained module that solves a
specific set of problems. Base R comes with a set of core packages (base, stats, graphics, utils,
etc.) that handle fundamental tasks—mathematics, statistics, basic plotting, and data
manipulation. Everything beyond this foundation is contributed by the community as add-on
packages.
The philosophy behind R’s package system is deeply aligned with scientific values:
Modularity: A package does one thing well. The sf package handles vector spatial data;
the terra package handles raster data; the ggplot2 package creates visualizations. They do
not interfere with each other, but they can be combined.
Openness and Peer Review: Packages hosted on CRAN (the Comprehensive R Archive
Network) undergo automated checks and, in many cases, human review. The source code
is freely available, meaning anyone can inspect, verify, and improve it.
Citation and Credit: Every package has a standard citation. When you use a package for
a published analysis, you can cite it, giving proper credit to the developers and enabling
others to replicate your work.
Reproducibility: Because a package encapsulates a specific version of its functions, you
can specify exact package versions in your scripts. This ensures that your analysis can be
re-run years later with the same software environment, even if newer versions of the
packages have changed.
For the geoinformatics scientist, packages are not optional extras. They are the mechanism by
which R becomes a GIS. Without the sf and terra packages, R has no concept of a coordinate
reference system, a shapefile, or a GeoTIFF. With them, R becomes a fully programmable spatial
data science platform.
1.5.2 The Comprehensive R Archive Network (CRAN) and Repository System
CRAN is a global network of servers that distributes R itself, along with over 20,000 contributed
packages. When you run [Link]("sf"), R connects to a CRAN mirror, downloads the
package, and installs it on your system. CRAN enforces strict quality standards: every package
must pass automated checks on multiple operating systems, contain complete documentation,
and have no undeclared dependencies.
Beyond CRAN, packages can also be installed from other repositories or directly from source
code:
Bioconductor: A repository for bioinformatics packages, but it also contains some spatial
transcriptomics tools.
GitHub/GitLab: Many developers host development versions of their packages on these
platforms. The remotes package allows installation directly from a GitHub repository.
Local source: You can install a package from a .[Link] file on your hard drive
using [Link]("path/to/[Link]", repos = NULL, type = "source").
For this book, we will almost exclusively use CRAN packages, as they are the most stable and
widely supported. When a package is not yet on CRAN (rare for our needs), we will provide
explicit instructions for alternative installation.
1.5.3 Installing Packages: The [Link]() Function
The command to install a package from CRAN is:
r
[Link]("package_name")
The package name must be enclosed in quotes because it is a character string. You can install
multiple packages at once:
r
[Link](c("sf", "terra", "ggplot2"))
When you run this command for the first time, R may ask you to select a CRAN mirror. Choose
one geographically close to you, or select the “0-Cloud” option for automatic redirection. You
can set a default mirror permanently in RStudio’s Tools → Global Options → Packages or by
adding a line to your .Rprofile.
A package needs to be installed only once per R installation (though you should periodically
update packages with [Link]()). After installation, it resides in a library folder on your
hard drive, ready to be loaded into an R session.
Important: Never place [Link]() inside a script that you intend to share or source
repeatedly. Installing a package is a setup task, akin to installing software on your computer.
Running [Link]() every time a script is sourced is inefficient and, on some systems,
may prompt unnecessary dialog boxes. The best practice is to install packages interactively in the
console before you begin a project, and then only call library() in your scripts.
1.5.4 Loading Packages: The library() Function
Once a package is installed, it must be loaded into the current R session to make its functions
and datasets available. This is done with the library() function:
r
library(sf)
Notice that the package name inside library() does not require quotes (though quotes are
accepted if you prefer). Loading a package accomplishes several things:
It makes the package’s exported functions available by name. For example,
after library(sf), you can call st_read() directly.
It may print a startup message, often indicating the version and any important changes.
The sf package, for instance, prints the versions of GDAL, GEOS, and PROJ that it is
linked against.
It may attach a new environment to the search path, placing the package’s functions and
data in scope. This is the mechanism by which R resolves function names.
A script should load all required packages at its beginning, immediately after the header
comments. This ensures that a reader (and R) knows exactly which extensions are needed before
any analysis begins.
There is a related function, require(), which returns a logical value and is sometimes used inside
functions to conditionally load a package. For clarity and simplicity, this book will always
use library() for loading packages in scripts.
1.5.5 Package Namespaces and Function Conflicts
Each package has its own namespace, a collection of function names that it exports. Sometimes
two different packages export functions with the same name. For
example, dplyr::filter() and stats::filter() are completely different functions. R handles this by
masking: when you load a second package, its functions may mask those from a previously
loaded package. R prints a message when this occurs.
You can resolve conflicts by using the double-colon operator :: to specify the package explicitly:
r
dplyr::filter(data, condition) # use filter from dplyr
stats::filter(time_series, ...) # use filter from stats
In geospatial work, conflicts are rare but can occur. The terra package, for instance, provides
a plot() method for SpatRaster objects, which extends the base plot() function without conflict.
As we progress, we will note any potential conflicts and how to handle them.
1.5.6 A First Glimpse of {sf}: The Simple Features Package
Now that you understand what a package is, let us deliberately load sf and examine what it
provides. This is not yet a tutorial on using sf (that begins in earnest in Chapter 11); it is a
motivational preview that transforms your abstract understanding of packages into a concrete
spatial tool.
Open RStudio and run the following commands in the console. Read the comments and observe
the output:
r
# Install sf if you haven't already (do this only once)
# [Link]("sf")
# Load the package
library(sf)
Notice the startup message. It tells you the versions of GDAL, GEOS, and PROJ that sf is using.
This information is invaluable for troubleshooting and for reporting in a research paper.
Now let us create a spatial object using sf functions:
r
# Create a point geometry (longitude, latitude) for Beijing
bj_point <- st_point(c(116.4074, 39.9042))
# Wrap it in a simple feature geometry column (sfc) with a CRS (EPSG:4326)
bj_sfc <- st_sfc(bj_point, crs = 4326)
# Make it a full sf object with an attribute (name of the city)
bj_sf <- st_sf(name = "Beijing", geometry = bj_sfc)
# Print the object
print(bj_sf)
# Plot the object
plot(bj_sf, pch = 19, col = "red", main = "A Spatial Point in sf")
In just a few lines, we have created a geographic point, assigned it a coordinate reference system
(WGS84, EPSG code 4326), attached an attribute (the city name), and plotted it on a map. All of
this happened because the sf package defines the st_point, st_sfc, and st_sf functions, and
because these functions rely on GDAL, GEOS, and PROJ—the same libraries we ensured were
installed in Section 1.2.
This tiny example demonstrates the profound philosophy of R’s spatial ecosystem: geographic
data lives in a data frame. The object bj_sf is a data frame with a special column (geometry)
that holds the point. We can, in later chapters, filter, join, and transform this object using the
same dplyr functions we will learn for ordinary tables. The barrier between “GIS data” and
“statistical data” has been removed.
1.5.7 Managing Packages for Reproducible Research
A final theoretical note: a reproducible analysis must record not only which packages were used,
but also their versions. The sessionInfo() function prints a complete snapshot of your R session,
including loaded packages and their version numbers. You will often see this output in the
appendix of a scientific paper or in the README of a project repository.
At the end of any analysis, you can run:
r
sessionInfo()
This prints (among other things) lines like sf_1.0-16. Keep this output with your project. In the
future, you can use tools like renv or packrat to lock package versions, but for now, knowing
about sessionInfo() is enough.
Hard Practice 1.5 – “Exploring Packages and a First Spatial Object”
Objective:
Practice installing and loading packages, explore the sf package’s basic functionality, and
reinforce the concept of a package as a module of reusable spatial functions. Develop the habit of
documenting package versions for reproducibility.
Theoretical Context:
A package is a self-contained extension of R that adds specialized functionality. Understanding
how to install, load, and query packages is fundamental to all geospatial work in R. This exercise
connects the abstract concept of a package to the concrete ability to create and plot a spatial
point.
Instructions:
1. If you have not already done so, install the sf package by running [Link]("sf") in
the console. Also install the rnaturalearth package, which provides world map
data: [Link]("rnaturalearth").
2. Create a new R script and save it as practice_1_5_packages_sf.R in
your Chapter1/ folder. Write a header with your name, date, and a description: “Practice
loading packages, creating an sf point, and exploring package documentation.”
3. In the script, load the sf package with library(sf). Immediately after, add a comment that
records the purpose of the package: # sf: Simple Features for R – vector spatial data.
4. Use sf functions to perform the following tasks, each preceded by a comment explaining
the step:
o Create a point for your current location (or any location of interest)
using st_point().
o Convert it to an sfc object with st_sfc() and assign the WGS84 CRS
(EPSG:4326).
o Create an sf object with a simple attribute, such as place_name.
o Print the resulting sf object.
o Plot the object with plot() and customize the color and point character.
5. Load the rnaturalearth package with library(rnaturalearth). Use its
function ne_countries() to obtain a low-resolution world map as an sf object. Plot the
world map, then add your point to it using plot(..., add = TRUE).
6. Run the script line by line, verifying that each step works. Then close RStudio without
saving the workspace, reopen, and Source the entire script. Ensure reproducibility.
7. At the end of the script, add a line to call sessionInfo(). Run that line and examine the
output. Note the version numbers of sf, rnaturalearth, and their dependencies.
8. Reflection:
o In your notebook, explain in your own words what a package is and why R uses
packages instead of including all functionality in base R.
o Why is it important to document package versions (via sessionInfo()) in a
research project?
o What is the difference between [Link]() and library()? When would you
use each?
o Look at the help page for st_point (run ?st_point). What other geometry types are
listed in the “See Also” section? This gives you a preview of the vector data types
we will explore in Chapter 12.
Expected Outcome:
You have installed, loaded, and used multiple packages. You have created your first sf spatial
object and placed it on a world map. You understand the role of packages as the building blocks
of R’s geospatial ecosystem, and you have begun to document your computational environment.
The package mechanism is now demystified, and you are ready to delve deeper into R’s core
data structures, starting with vectors in Chapter 2.
1.6 R Help System and Self-Learning Strategies
You now possess the environment, syntax, script discipline, and package awareness to begin
serious geospatial computing. But there is one more meta-skill to master before we dive into
data structures: the ability to teach yourself. R’s built-in help system is extraordinarily rich, and
knowing how to use it transforms you from a dependent student into an autonomous scientist.
This section explains how to access documentation, interpret help pages, find relevant packages,
and develop the mindset of a self-sufficient R programmer.
1.6.1 The Philosophy of Self-Sufficiency in Scientific Computing
No textbook—no matter how comprehensive—can cover every function, every dataset, or every
geospatial challenge you will encounter in your career. The true value of learning a programming
language lies not in memorizing syntax, but in developing the ability to find, understand, and
apply new functions independently. R’s help system is designed to support exactly this process.
It provides immediate, structured, and example-driven information about every function and
package in your environment.
Scientific reproducibility also demands that you understand the tools you use. If you
call st_transform() to reproject a layer, you should know what the arguments mean and where the
underlying transformation parameters come from. The help system gives you this transparency.
Thus, this section is not a mere appendix; it is a core competency. By the end, you will be able to
read any R help page with confidence, use its examples, search across packages, and seek help
from the wider community when necessary.
1.6.2 The ? and ?? Operators: Immediate Access to Documentation
The primary way to access documentation is the ? operator followed by a function or package
name. In the console, type:
r
?mean
A help page appears in the Help pane of RStudio (or in your browser if using the plain R
interface). The page for mean() includes its description, usage, arguments, details, value,
references, and examples. This structure is standard across all R help pages.
If you want to search the help system for a term (rather than a specific function), use the
double-question-mark operator ??. For example:
r
??georeference
This opens a window listing all help pages containing the word “georeference” in their text. It is
an excellent way to discover functions you do not yet know exist, especially when you are
unsure which package implements a particular geospatial operation.
You can also access help via the Help pane’s search bar or by typing help("function_name") in
the console, which is equivalent to ?function_name.
1.6.3 Anatomy of a Help Page
Every R help page follows a consistent structure, defined by the package author. Learning to read
this structure efficiently saves enormous time. The typical sections are:
Description: A concise summary of what the function does.
Usage: The function signature, showing the name, arguments, and default values. This is
your quick reference for the correct order of arguments.
Arguments: A detailed list of each argument, its expected type (numeric, character,
logical), and its meaning. This is where you learn, for instance,
that st_transform() takes x (an sf object) and crs (the target coordinate reference system).
Details: Technical notes on algorithmic implementation, edge cases, or mathematical
formulas.
Value: Describes the object returned by the function. Crucially, it tells you the class of
the output (e.g., “an object of class SpatRaster”), so you know what you can do next.
References: Academic citations or links to external documentation. In geospatial
packages, this often includes links to the GDAL, GEOS, or PROJ documentation, or to
the OGC standards.
See Also: Links to related functions. This is one of the most valuable sections for
learning. If you are looking at st_buffer, the See Also will point you
to st_intersection, st_union, and other geometric operations.
Examples: Executable R code demonstrating typical usage. You can run these examples
directly from the help page by clicking the “Run examples” link, or you can copy and
paste them into the console. Examples are often the fastest way to understand a function’s
behavior without reading dense technical descriptions.
Whenever you encounter a new function in this book, we encourage you to pull up its help page
and read at least the Description, Usage, and Arguments. Over time, this habit will become
automatic.
1.6.4 Package-Level Help and Vignettes
A package itself has a help page that lists all its functions. Access it with ?
package_name or help(package = "package_name"). For example:
r
help(package = "sf")
This opens a page with links to every function, categorized by topic. It is an excellent way to get
an overview of a package’s capabilities.
Many packages also include vignettes—long-form documentation that explains the package’s
design philosophy, common workflows, and advanced features. You can list all vignettes for a
package with vignette(package = "sf"). To open a specific vignette:
r
vignette("sf1", package = "sf")
Vignettes are often the best starting point for understanding how a package approaches a
geospatial problem conceptually, before you consult individual function help pages for details.
For the sf package, the vignettes cover topics like “Reading, Writing and Converting Simple
Features” and “Spatial Operations”. We will refer to specific vignettes throughout the book.
1.6.5 Getting Help Online and in the Community
The R help system extends far beyond the built-in documentation. The global R community
maintains several resources:
Stack Overflow: The programming Q&A site where thousands of R and spatial
questions have been answered. Use the tags [r], [sf], [spatial] when searching or asking.
R-sig-Geo: A dedicated mailing list for spatial data in R, archived and searchable. Many
core developers of sf and terra are active participants.
GitHub Repositories: The development home of packages
like sf ([Link]/r-spatial/sf) and terra ([Link]/rspatial/terra). If you believe you
have found a bug, you can report it there after reading the issue guidelines.
The R Journal and JSTAT Software: These journals publish articles about R packages,
including spatial ones. They provide peer-reviewed, in-depth expositions.
Before posting a question online, always:
Search the help system (? and ??) and vignettes.
Create a minimal, self-contained, reproducible example (a “reprex”) that demonstrates
your problem. The reprex package can help.
Show the output of sessionInfo() so others know your software environment.
This discipline of seeking help effectively will serve you throughout your career.
1.6.6 A Reproducible Workspace Strategy
This section also marks the final piece of the reproducible workspace puzzle. By combining
scripts (Section 1.4), packages (Section 1.5), and the help system, you can now set up a project
that is entirely self-documenting. To close Chapter 1, we will create a project that integrates
everything:
1. Create a new RStudio project (File → New Project → New Directory → New Project).
Name it R_Geospatial_Course. This creates a .Rproj file and makes the project folder the
default working directory.
2. Inside the project, create folders: Chapter1/, data/, scripts/, output/.
3. Move all your Chapter 1 scripts into Chapter1/.
4. Write a master script run_all_chapter1.R that sources each section script in order.
5. At the end, run sessionInfo() and save the output.
This structured approach—project, scripts, versioned packages—will be our standard throughout.
Hard Practice 1.6 – “Become Your Own Teacher”
Objective:
Learn to use R’s documentation independently. Practice reading help pages, searching for
functions, and using vignettes. Build confidence in self-learning, the most valuable skill in
scientific computing.
Instructions:
1. Open RStudio and load the sf package.
2. Use the ? operator to open the help page for st_read. In your notebook, answer:
o What does st_read do? (Read the Description.)
o What are the three most important arguments? (Check Usage and Arguments.)
o What class of object does st_read return? (Check Value.)
3. Use ?? to search for “coordinate reference system” (or “crs”). Note how many help pages
appear. Pick one that is not from sf (e.g., from terra) and open it. Summarize its purpose
in one sentence.
4. List all vignettes available for the sf package using vignette(package = "sf"). Open the
one titled “Reading, Writing and Converting Simple Features” (or similar) and read the
introduction. Write one paragraph in your notebook about what you learned that is not
obvious from the basic help pages.
5. Create a script practice_1_6_self_learning.R. In it, use
the rnaturalearth::ne_countries() function to obtain a world map. Read the help page
for ne_countries to understand the scale and returnclass arguments. Create two maps: one
with scale = "small" and one with scale = "medium". Plot them side-by-side (you can
use par(mfrow = c(1,2)) before plot(); we will learn more about plot layout later, but
experiment now using the help system).
6. Reflect: Imagine you need to compute the area of each country in square kilometers.
Using the help system, find a function in the sf package that computes area. (Hint: search
for “area” in the sf package help.) Write down the function name and the help page’s
caution about units and projections.
Deliverable:
The script practice_1_6_self_learning.R and your notebook answers. This exercise demonstrates
that you can now teach yourself new R functions without direct guidance.
Chapter 1 Review Problems
These problems are designed to consolidate and extend your mastery of Chapter 1. Solve them in
R, using scripts, comments, and the help system as needed. The easy problems focus on core
syntax and environment, the medium problems involve packages and simple spatial objects, and
the challenging problems require synthesis, documentation search, and critical thinking about
geospatial concepts.
Easy Problems (1–5)
1. Basic Arithmetic and Assignment
Write an R script that:
Assigns the value 6371 (Earth’s approximate radius in km) to a variable earth_radius.
Computes the volume of a sphere using the formula V=43πr3V=34πr3 and assigns it
to earth_volume.
Prints the result with a clear message using cat().
Runs without errors and is properly commented.
2. Console vs. Script
In your own words, explain the difference between typing a command directly in the console and
running it from a script. As part of your answer, create a short script that demonstrates the
difference: include a line x <- 5 and then a line that prints x, and explain what you observe when
running line-by-line versus sourcing the entire file.
4. Working Directory
Write a script that:
Prints the current working directory with getwd().
Lists all files in the working directory with [Link]().
Creates a new folder named test_dir (use [Link]() if it doesn’t exist) and then sets the
working directory temporarily to that folder.
Finally, resets the working directory back to the original (store it first).
Include comments explaining each step.
5. Package Loading and Version Check
Load the sf package. Use the sessionInfo() function to find the version number of sf. Write a
script that loads sf, prints a message with its version (extracted manually from sessionInfo() or
using packageVersion("sf")), and then prints the versions of GDAL, GEOS, and PROJ
using sf_extSoftVersion(). All outputs must be clearly labeled.
6. Your First sf Point
Write a script that creates an sf point for the location of the Great Wall of China (approximately
40.4319°N, 116.5704°E for Badaling). Set the CRS to EPSG 4326. Plot the point alone, then
load rnaturalearth’s ne_countries(scale = 110) (high resolution) and add your point to the map of
China (you may need to subset the world map to China using indexing; try china <-
world[world$admin == "China", ]). The script should run and produce a plot showing the point
on China’s outline.
Medium Problems (6–10)
6. Calculating the Distance Between Two Cities
Write a script that:
Defines the latitudes and longitudes (in degrees) of two cities: Beijing (39.9, 116.4) and
Shanghai (31.2, 121.5).
Converts these coordinates to radians.
Computes the spherical law of cosines distance:
d=R×arccos(sin(lat1)×sin(lat2)+cos(lat1)×cos(lat2)×cos(Δlon))d=R×arccos(sin(lat1)×sin(
lat2)+cos(lat1)×cos(lat2)×cos(Δlon)), where R=6371R=6371 km.
Prints the distance in kilometers.
Plots the two cities on a simple longitude-latitude plot and adds a title showing the
computed distance.
7. Exploring the sf Help System
Without using the internet outside of R’s help, answer the following by consulting
the sf documentation:
What does the function st_union() do?
How many arguments does it have, and which are required?
Look at the “See Also” section: list three related functions.
Copy and run one of the examples from the help page, adding your own comments to
explain each line. Submit the script.
8. Package Research:
terra
The terra package will be our main raster tool. Install terra if you haven’t. Using only the
help system (?terra::rast, vignette(package = "terra")), write a script that:
Creates a simple 10×10 pixel raster with random values using rast() and something
like runif(100).
Plots the raster.
Adds a title “My First Raster” to the plot.
Prints the raster’s resolution and extent.
All steps should be derived from the help pages; do not copy code from outside sources.
Comment your script to show which help page you used for each step.
9. Reproducibility Check
Imagine you receive a script from a collaborator that begins
with setwd("C:/Users/Alice/MyData") and then reads a shapefile from that path. You are on a
Mac. Explain in a paragraph why this script will fail on your computer, and suggest a better
practice (relative paths, projects, the here package). Then write a corrected version of the
script that uses [Link]() to build a relative path from a project root, assuming the shapefile
is in a data/ subfolder. Your answer should be a commented script that would work on any
operating system.
10. Create a World Map with Multiple Points
Use rnaturalearth::ne_countries(scale = "small") to get a world map. Define three points:
your birthplace, your current location, and one “dream destination”. Create an sf object with
these three points and a column indicating the name of each place. Plot the world map and
overlay these points in three different colors, with a legend. (Use base plot; legend placement
can be done with legend() after plotting.) Comment each step.
Challenging Problems (11–15)
11. Develop a Custom Function: Spherical Distance
Write a function spherical_distance(lon1, lat1, lon2, lat2, R = 6371) that returns the great-circle
distance between two points on a sphere using the Haversine formula:
a=sin2(Δlat/2)+cos(lat1)cos(lat2)sin2(Δlon/2)a=sin2(Δlat/2)+cos(lat1)cos(lat2)sin2(Δlon/2)
c=2×arctan2(a,1−a)c=2×arctan2(a,1−a)
d=R×cd=R×c
All inputs in degrees. The function should convert degrees to radians internally. Test it on the
Beijing–Shanghai pair from Problem 6 and compare the result with the spherical law of cosines.
Add error handling: if any coordinate is outside the range -90 to 90 (latitude) or -180 to 180
(longitude), the function should stop with an informative error message. Provide a fully
commented script.
12. Investigate CRS through Help and Experiment
The sf package internally uses the PROJ library. Using ??proj and the sf help, find the
function st_crs() and read its documentation. Then write a script that:
Creates a point at (0°, 0°) with CRS 4326.
Uses st_crs() to extract the WKT (Well-Known Text) representation of that CRS.
Transforms the point to the Web Mercator projection (EPSG:3857) using st_transform().
Prints the coordinates in the new projection. Are they still in degrees? Explain why the
values are large numbers.
Discuss (in comments) why Web Mercator is used by web mapping services despite its
distortion.
This problem forces you to combine help, spatial theory, and code.
13. Batch Processing: Distance Matrix
Using your custom spherical_distance function from Problem 11, write a script that:
Defines a vector of city names and their coordinates: Beijing (39.9, 116.4), Shanghai
(31.2, 121.5), Guangzhou (23.1, 113.3), Chengdu (30.6, 104.1), Urumqi (43.8, 87.6).
Computes the pairwise distance matrix (5×5) between all city pairs using a double loop
(do not use dist yet). Store the result in a matrix.
Prints the matrix with row and column names.
Finds and prints the two cities that are farthest apart.
Bonus: create a heatmap of the distance matrix using image() or a simple ggplot2 call
(you will need to install ggplot2 and use geom_tile(); this is a stretch goal that reinforces
package use).
14. Exploring Spatial Vignettes and Summarizing
Open the sf package vignette “2: Reading, Writing and Converting Simple Features” (or the
equivalent numbering). Read it thoroughly. Then write a one-page (in comments) summary that
explains:
The difference between st_read() and read_sf().
What a “geometry column” is and how it differs from ordinary columns.
How to convert a regular data frame with longitude and latitude columns into
an sf object.
What file formats are supported.
In your script, demonstrate each concept with a small example using in-memory data, not
external files.
15. Create Your Own Chapter 1 Cheat Sheet
Compile a “cheat sheet” as an R script file that is entirely commented except for a few
demonstration commands. The cheat sheet should:
List the essential R syntax rules from Section 1.1.5.
Provide examples of assignment, arithmetic, and function calls.
Show the minimal code to install and load a package, and to create a simple sf point.
Include the sessionInfo() command.
Be a single, clean, well-structured script that you could refer to at any time in the future.
The quality of the cheat sheet—its clarity, organization, and usefulness—is the primary
metric. Treat it as a piece of teaching material you might give to a new student.
Chapter 2: Atomic Vectors and Data Types – The Breath of Every Object
2.1 Variables and Assignment: The <- Operator
In Chapter 1 you learned to write and run R scripts, to load packages, and even to create a
simple spatial point. But that point was built from numbers: a longitude and a latitude. Where do
those numbers live in R’s memory? How does R handle thousands of coordinates, a
field-sampled soil pH profile, or a satellite band of millions of pixels? The answer lies in
the atomic vector, the foundational data structure of the R language. This chapter dissects
atomic vectors, their types, how to create and manipulate them, and why their design is a perfect
match for the tabular and gridded data of geoinformatics. We will move slowly, theory first, then
technique. Section 2.1 establishes the most fundamental act of programming: giving a name to a
value, and understanding exactly what that means inside the R system.
2.1.1 The Idea of a Variable in Mathematics and Computing
Before we type a single assignment statement, we must clarify what a variable is. In
mathematics, a variable is a symbol that stands for an unknown or general quantity. In
computing, a variable is a named storage location that holds a value—and that value can be
changed during the execution of a program. The word “variable” therefore carries a dual sense: it
is a name we use to refer to data, and it is the data itself, which may vary over time.
In geoinformatics, we think in variables constantly:
elevation might hold the height of a point above sea level.
slope might hold the steepness derived from a DEM.
land_cover might hold a categorical label.
Programming with R makes these abstract concepts concrete: we declare a name, assign a value,
and then use that name in further calculations. Understanding exactly what happens when we
write elevation <- 8848 is the first step toward mastering R’s memory model and avoiding
common errors.
2.1.2 Memory and Symbols: The Mechanics of Assignment
When you execute a statement like
r
elevation <- 8848
R performs a precise sequence of internal actions:
1. Evaluation of the right-hand side: R first evaluates the expression 8848. This is a literal
numeric constant, so R creates a numeric vector of length 1 in memory, storing the
double-precision value 8848. (All numbers in R are floating-point by default.)
2. Name binding: R then creates a binding between the symbol elevation and the memory
location containing that vector. The symbol is entered into the current environment—by
default, the global environment. The symbol is not the data itself; it is a reference that
points to the data.
3. Invisible return: The assignment expression returns the value invisibly. This is why,
when you run elevation <- 8848 in the console, nothing is printed. But the name now
exists and can be inspected.
4. No locking: The binding is not permanent. You can later re-assign the same name to a
different value. The old value may persist in memory for a while (until garbage
collection), but the name now points to the new value.
The conceptual takeaway: assignment is the act of associating a human-readable name with
a piece of data stored in the computer’s memory. This act creates the first component of our
scientific record: a name that appears in subsequent formulas, making code readable and
auditable.
2.1.3 The Assignment Operator <-: History and Community Convention
R offers several ways to assign values to names, but one is overwhelmingly preferred:
<- : The standard assignment operator. It is typed as a less-than sign followed by a
hyphen, with no space between them. It reads naturally as “gets”: elevation <-
8848 means “elevation gets 8848”.
= : The equals sign can also be used for assignment at the top level: elevation = 8848 is
technically valid. However, = is conventionally reserved for specifying function
arguments. Inside a function call, = does not assign to the global environment; it binds
an argument name to a value. Using = for top-level assignment blurs this distinction and
is discouraged in the R community.
-> : A right-to-left assignment operator, e.g., 8848 -> elevation. It exists for completeness
but is rarely used.
In this book, we will always use <- for assignment. The syntactic rule is simple: the left side
of <- must be a valid R name, and the right side can be any expression that produces a value.
2.1.4 Rules for Valid Names
A valid R name (a syntactic name) must:
Start with a letter (A–Z, a–z, and in R’s current implementation, letters from many
scripts including Chinese characters, though we recommend sticking to ASCII for
portability) or a dot (.), provided the dot is not immediately followed by a number.
Contain only letters, digits, dots (.), and underscores (_).
Not be one of R’s reserved words
(e.g., if, else, for, function, TRUE, FALSE, NULL, NA, Inf, etc.).
Examples of valid names:
slope_degrees
ndvi_mean
population2023
.hidden_var (though this is conventionally used for hidden variables)
城 市 (valid, but for compatibility and readability, English names are preferred in
international scientific code)
Examples of invalid names:
2nd_layer — starts with a number
mean-ndvi — hyphen is not allowed (R interprets it as subtraction)
_private — underscore at the start is not allowed unless paired with a letter or dot
total area — space is not allowed
We will adopt the snake_case style for multi-word names: all lowercase letters with words
separated by underscores. This style is widely used in the R community and is highly readable.
2.1.5 Assignment of Different Data Types
The right-hand side of <- can produce any type of R object. The simplest case is a literal numeric
value, but assignment works identically for integers, character strings, logical values, and
expressions of any complexity:
r
# Numeric (double)
radius <- 6371
# Integer (explicitly specified with L)
count <- 42L
# Character
city <- "Kunming"
# Logical
is_valid <- TRUE
# Result of an expression
volume <- (4/3) * pi * radius^3
In each case, the name is bound to the resulting vector. The Environment pane in RStudio
displays the name, type, and a brief preview of the value. This immediate visual feedback is
invaluable during interactive analysis.
2.1.6 Re-assignment and Overwriting
A variable’s value is not permanent. Re-assigning a new value to an existing name is perfectly
legal and silently overwrites the old binding:
r
x <- 10
x <- 20 # x now holds 20, the old value 10 is orphaned
This is a double-edged sword. It allows you to reuse generic names like x or temp during
interactive exploration, but it also means you can accidentally overwrite a critical dataset if you
are not careful. In a script, reading from top to bottom, the most recent assignment to a name
determines its value at that point.
A good practice: for important data, use descriptive, unique names (land_cover_2023, not x). If
you need to reuse a name intentionally, you can remove the previous binding
with rm(name) before re-assigning.
2.1.7 The Concept of Lazy Evaluation (A Glimpse)
R is a lazy language in one important respect: function arguments are not evaluated until they are
actually used. For simple assignment like x <- 5, the right-hand side is evaluated immediately
because the value is needed to bind to the name. However, the underlying mechanism—that
expressions can exist unevaluated in certain contexts—becomes relevant later when we work
with large raster datasets that are processed in chunks. For now, it is enough to know that the
assignment statement itself triggers the evaluation of its right-hand side, and the resulting value
is what gets bound.
2.1.8 The Primacy of Vectors: No Scalars in R
One of the most profound features of R is that there are no scalar values. The number 8848 is
not a scalar; it is a numeric vector of length 1. This is not merely a philosophical nuance—it has
practical consequences:
Any operation that works on a vector of length 1 automatically works on a vector of
length n with identical syntax.
When you write x + 1, R adds 1 to every element of x, whether x has one element or one
million.
This vectorised design eliminates an entire class of explicit loops and is one of the
reasons R is so powerful for spatial data, where we routinely apply the same formula to
millions of grid cells.
We will explore this vectorised nature throughout this chapter. For now, whenever you see a
single number, say to yourself: “That is a vector of length one.” This mindset will pay dividends
as we scale from a single point to a full raster.
2.1.9 Technical Demonstration: Assignment in the R Console
Let us now open RStudio and practice assignment interactively, observing the behavior described
above. Type each of the following lines in the console, pressing Enter after each, and read the
comments:
r
# 1. Simple numeric assignment
elevation <- 8848
elevation # prints [1] 8848 (the [1] means this is element 1 of a vector)
# 2. Assigning the result of an expression
radius <- 6371
volume <- (4/3) * pi * radius^3
volume # prints the computed volume
# 3. Re-assigning: no error, simply overwrites
radius <- 7000
radius # now 7000
# 4. Character assignment
city <- "Kunming"
city # prints [1] "Kunming"
# 5. Logical assignment
is_high <- elevation > 8000
is_high # TRUE
# 6. Checking existence and type
ls() # lists all objects in the global environment
typeof(elevation) # "double"
typeof(city) # "character"
typeof(is_high) # "logical"
# 7. Removing an object
rm(radius)
ls() # radius is gone
Observe the Environment pane in RStudio as you execute these commands. Notice how each
new name appears, how its type is reported, and how re-assignment changes the value. This live
feedback is a powerful learning aid; use it constantly.
2.1.10 The Assignment Statement in Scripts
In a script, assignment statements are the backbone. A typical geospatial script begins with a
series of assignments that define parameters, file paths, or coordinate references:
r
# Define the Earth's radius (spherical approximation)
earth_radius_km <- 6371
# Central point (longitude, latitude) of the study area
center_lon <- 116.4074
center_lat <- 39.9042
# Number of samples
n_samples <- 100
These lines create variables that can be used throughout the rest of the script. If a parameter
changes, you change it in one place—the assignment line—and all downstream computations
automatically adjust. This is the essence of parameterisation: making your code flexible and
maintainable.
2.1.11 Common Mistakes and How to Avoid Them
Using = instead of <-: While = works in many cases, it leads to confusion when used
inside function calls. Adopt <- now, and you will never have to guess.
Forgetting that assignment is silent: After x <- 10, nothing appears in the console
unless you explicitly type x. This is by design; use print() or simply type the variable
name to inspect it.
Overwriting a variable unintentionally: If you use a generic name like data and later
assign a different dataset to data, the old dataset is lost. Use descriptive, unique names.
Assigning inside a function call by accident: mean(x <- c(1,2,3)) does two things: it
assigns c(1,2,3) to x in the current environment AND computes the mean. This side-effect
is generally considered bad practice. Avoid assignments inside other function calls unless
you have a specific reason and document it clearly.
Hard Practice 2.1 – “Assignment Gym: Parameterizing a Spatial Calculation”
Objective:
Practice the mechanics of assignment, re-assignment, and the use of descriptive variable names
in a script that mimics the setup of a geospatial analysis.
Scenario:
You are about to analyze a set of sampling points around a central location. The first step is to
define the central coordinate, the number of sampling rings, and the radius increment. You will
then compute the radii for each ring—all using assignment statements and simple vector
arithmetic. No explicit loops are needed.
Instructions:
1. Create a new R script and save it as practice_2_1_assignment.R. Write a header
comment.
2. Assign the central coordinates (longitude, latitude) of your study area
to center_lon and center_lat. Choose any location you like.
3. Assign the number of sampling rings to n_rings (e.g., 5). Assign the radius increment (in
meters) to increment_m (e.g., 500).
4. Using R’s vector creation, generate a vector ring_radii that contains the radii of all rings:
the first ring is at increment_m, the second at 2 * increment_m, and so on. Use
the seq() function to create this vector elegantly. (Hint: seq(from, to, by) or seq(from, by,
[Link]))
5. Assign a comment-based variable pi? No, just use R’s built-in pi. Now compute the
circumference of each ring (2π × radius) and store it as ring_circumferences.
6. Assign the total number of points you plan to distribute across all rings
to total_points (say, 200). Compute a very rough point density: points_per_meter <-
total_points / sum(ring_circumferences). Then compute an approximate number of points
per ring: points_per_ring <- round(points_per_meter * ring_circumferences). (We don’t
need precision here; this is a thought exercise.)
7. Print all key variables with cat() statements, clearly labeled.
8. Re-assign n_rings to a different value (e.g., 8) and re-compute
the ring_radii, ring_circumferences, and points_per_ring. Observe that the later
assignments use the updated n_rings because the script runs sequentially. Run the script
from the beginning (Ctrl+Shift+Enter) to see the effect.
9. Reflection: In your notebook, answer:
o Why did changing n_rings and re-running the entire script produce different
results?
o What is the advantage of defining increment_m as a variable rather than
writing 500 directly in the seq() call?
o How does the concept of a variable as a “named storage location” manifest in this
exercise?
Deliverable:
The script practice_2_1_assignment.R, with comments, that runs without errors and prints the
requested information.
2.2 Numeric, Integer, Character, and Logical Vectors
In Section 2.1 you learned to give names to values. But what kinds of values can you name? The
fundamental data containers in R are atomic vectors—ordered sequences of elements all of the
same basic type. Four of these types carry almost all the weight in geospatial
analysis: numeric (double-precision floating-point), integer, character (text),
and logical (true/false). In this section we examine each type’s nature, how to create vectors of
each type, how to inspect them, and why these simple structures are the perfect raw material for
coordinates, attributes, and masks in spatial data. We will also touch on what happens when
types mix—a preview of coercion that we will treat rigorously in Section 2.5—and we will end
with a Hard Practice that cements your ability to create and examine vectors of every type
without touching a single GIS file.
2.2.1 The Four Atomic Types as the Geospatial Scientist’s Raw Material
Imagine you are preparing a field survey of soil pH across a watershed. You will record:
pH values: numbers like 5.8, 6.2, 4.9. These are numeric.
Sample IDs: whole numbers 1, 2, 3, … to label each sample. These are integer.
Site descriptions: text labels such as "Ridge", "Valley", "Slope". These are character.
Quality flags: TRUE if the sample was collected under standard
conditions, FALSE otherwise. These are logical.
In R, each of these columns of data is stored as an atomic vector—a sequence of elements all
belonging to the same fundamental type. Unlike many other programming languages, R does not
have scalars: a single pH value is simply a numeric vector of length 1. This design choice unifies
the handling of single values and large arrays, and it aligns beautifully with the way we think
about raster bands (millions of pixels) or attribute tables (thousands of rows). Everything you
learn in this section about a small vector will apply identically to a large one.
The four atomic types we now study are:
1. Numeric (double): 64-bit floating-point numbers. The default type for all numbers.
2. Integer: 32-bit signed integers. Explicitly created with L suffix.
3. Character: Strings of text, enclosed in single or double quotes.
4. Logical: The values TRUE, FALSE, and NA (not available). Internally stored as 1, 0,
and NA, but they behave as a separate type with special logical operators.
The other two atomic types—complex (complex numbers) and raw (byte sequences)—are rarely
used in geoinformatics and will not be covered in this chapter.
2.2.2 Numeric (double) Vectors: The Workhorse of Quantitative Analysis
Every number you type in R without further qualification is a numeric (double) value. R uses the
IEEE 754 double-precision floating-point standard, which provides about 15–17 significant
decimal digits and a huge range (approximately 10−30810−308 to 1030810308). This precision
is sufficient for coordinates, elevations, reflectance values, statistical estimates, and almost every
quantitative measurement in geospatial science.
You create a numeric vector with the combine function c():
r
elevations <- c(8848, 8611, 8586, 8516, 8481) # heights of the five highest peaks on Earth
elevations # prints [1] 8848 8611 8586 8516 8481
The [1] in the output indicates that the following number is the first element of the printed line. If
the vector is long, multiple lines are printed, each starting with the index of its first element.
Numeric vectors can also be generated by sequence functions (seq, :) and random number
generators (runif, rnorm), which we will cover in Section 2.2.6. For now, know that any
arithmetic operation on numeric vectors produces numeric results.
2.2.3 Integer Vectors: Counting and Indexing
An integer vector stores whole numbers. In R, you must explicitly indicate that a number is an
integer by appending an L (for “long”, a historical term):
r
sample_ids <- c(1L, 2L, 3L, 4L, 5L)
typeof(sample_ids) # "integer"
If you omit the L, R treats the number as a double, even if it looks like an integer:
r
x <- c(1, 2, 3)
typeof(x) # "double", not "integer"
Why use integers? For one, they are memory-efficient (4 bytes per element instead of 8 bytes for
double). More importantly, they serve as indices for subsetting (Section 2.3) and as categorical
codes in factors (Chapter 4). Many geospatial operations, such as reclassification tables in terra,
expect integer codes. In practice, you will often create integer vectors automatically via the colon
operator :, which produces an integer sequence:
r
1:10 # integer vector from 1 to 10
However, be aware that : may produce a numeric vector if the operands are numeric. R silently
coerces the result to the most economical type: if both arguments are integers, the result is
integer; if either is double, the result is double. To ensure integer output, you can use [Link]().
2.2.4 Character Vectors: Labels, Names, and Text Data
Character vectors hold strings of text. They are created with single or double quotes (the two are
equivalent, but the R community prefers double quotes for readability and consistency):
r
city_names <- c("Beijing", "Shanghai", "Guangzhou", "Chengdu")
city_names
Character vectors are everywhere in geoinformatics:
Place names, soil classes, land cover types.
File paths: "data/[Link]".
Column names in attribute tables.
Codes such as "SF", "NY" for administrative regions.
R stores character vectors as sequences of bytes; it has full support for UTF-8 encoding, so you
can use characters from any language, including Chinese:
r
cities_cn <- c("北京", "上海", "广州", "成都")
This is essential for working with local geographical names and labels.
One important nuance: character vectors are not factors. A factor is a special R data structure for
categorical variables that stores integer codes and a set of levels; it will be studied in Chapter 4.
For now, we stick to plain character vectors for free-form text.
2.2.5 Logical Vectors: Truth, Falsehood, and Masking
A logical vector can contain only three values: TRUE, FALSE, and NA (missing logical). They
are the result of comparisons and tests:
r
elevations > 8500 # [1] TRUE TRUE TRUE TRUE FALSE
Logical vectors are the key to conditional subsetting (Section 2.3) and raster masking (Part
III). They are also used to control program flow with if() and ifelse().
Internally, TRUE is stored as 1 and FALSE as 0. This allows arithmetic on logical vectors:
r
sum(elevations > 8500) # 4 (number of TRUEs)
mean(elevations > 8500) # 0.8 (proportion TRUE)
This property is extremely useful for rapid summaries of large spatial datasets: count the number
of pixels above a threshold with a simple sum().
Logical vectors can be created directly:
r
valid <- c(TRUE, TRUE, FALSE, TRUE, FALSE)
or by combining conditions with & (and), | (or), ! (not). We will explore logical indexing in depth
in Section 2.3.
2.2.6 Creating Vectors with c(), seq(), rep(), and Random Generators
Now that we have met the four types, let us systematize the methods for creating vectors of any
length.
c(...) – The combine function concatenates its arguments into a single vector. The
arguments are coerced to a common type if they differ (see 2.2.8).
r
ndvi <- c(0.34, 0.56, 0.78, 0.12)
seq(from, to, by) and seq(from, to, [Link]) – Generate regular sequences of numeric
values. Essential for creating coordinate grids.
r
longitudes <- seq(100, 120, by = 0.5)
latitudes <- seq(30, 40, [Link] = 50) # 50 points between 30 and 40
from:to – A shorthand for integer sequences (but be aware of type). 1:10 generates 1 2 ...
10.
rep(x, times) and rep(x, each) – Replicate elements of a vector. Useful for generating
repeated measurement designs or replicating categorical labels.
r
land_use <- rep(c("Forest", "Urban", "Water"), times = c(10, 5, 3))
# "Forest" repeated 10 times, then "Urban" 5 times, etc.
Random number generators – R provides many. The most common are:
o runif(n, min, max) – Uniform distribution.
o rnorm(n, mean, sd) – Normal (Gaussian) distribution.
o sample(x, size, replace) – Random sample from a given vector.
r
synthetic_elev <- rnorm(1000, mean = 4500, sd = 1500)
These functions are the construction toolkit for all the synthetic data we will use in practice
exercises before we graduate to real geospatial files.
2.2.7 Inspecting Vectors: typeof(), is.*(), str(), length(), head(), tail()
After creating a vector, you must verify that it has the expected type and content. R provides a
consistent set of inspection functions:
typeof(x) – Returns the internal atomic type as a character
string: "double", "integer", "character", "logical".
class(x) – Returns the higher-level class, which for pure atomic vectors is usually the
same as the type, but can differ (e.g., factor, Date). We will rely on typeof for type
checking.
[Link](x), [Link](x), [Link](x), [Link](x) – Type-testing functions that
return TRUE or FALSE. Note that [Link](1L) returns TRUE because integers are a
subtype of numeric; to test strictly for double, use [Link](x).
length(x) – Returns the number of elements in the vector. This is one of the most
frequently used functions.
str(x) – A compact, human-readable display of the object’s structure, showing type,
length, and the first few elements. Indispensable for quick inspection.
r
x <- c(1.2, 3.4, 5.6)
str(x) # num [1:3] 1.2 3.4 5.6
head(x, n) and tail(x, n) – Show the first or last n elements. Default n = 6.
These functions will be your constant companions in every R session. Make it a habit to
check typeof and length immediately after importing or creating data.
2.2.8 A First Look at Type Coercion and the Hierarchy
When you combine elements of different types with c(), R must choose a single type for the
resulting vector. It follows a coercion hierarchy:
character > double > integer > logical
The less flexible type is converted (“coerced”) to the more flexible type:
r
c(TRUE, 2.5) # logical + double -> double: 1.0 2.5
c(2L, 3.14) # integer + double -> double: 2.00 3.14
c(TRUE, "text") # logical + character -> character: "TRUE" "text"
c(1, 2, "three") # double + character -> character: "1" "2" "three"
Coercion is convenient but dangerous: it happens silently, and you may end up with a character
vector when you expected a numeric one. This is a common source of errors when reading
external data files where a single stray text entry can convert an entire numeric column to
character. We will learn how to detect and correct this with explicit conversion functions
([Link](), [Link](), etc.) in Section 2.5. For now, the lesson is: always check the type
of your vectors after creation, especially if you are combining elements from different
sources.
2.2.9 Geospatial Context: Coordinates, Labels, and Flags
Let us connect these atomic types explicitly to geospatial thinking.
Numeric vectors hold spatial coordinates (longitude, latitude, easting, northing),
elevation, slope, aspect, spectral reflectance, and statistical summaries. A raster band,
when read into R’s memory as a vector of cell values, is a numeric vector (or an array
thereof).
Integer vectors hold category codes for land cover classification (1 = forest, 2 = water,
etc.), pixel counts, and spatial indices. They are the native output of many classification
algorithms.
Character vectors store place names, soil taxonomy labels, sensor IDs, and file paths.
They appear as non-spatial attribute columns in vector GIS layers.
Logical vectors are the basis of spatial filtering: select all points within a polygon, mask
all pixels with NDVI < 0, flag all survey records with missing coordinates. When you
apply a condition like slope > 30, you are creating a logical vector that can be used to
subset or mask.
Thus, even before we touch the sf or terra packages, the atomic vectors are already performing
the fundamental operations of spatial data handling: storing, labeling, and filtering.
2.2.10 Technical Demonstration: Creating and Inspecting Vectors of All Types
Open RStudio and run the following code in the console or, better, in a new script. Observe each
output and compare with the theoretical descriptions above.
r
# ---------- Numeric vectors ----------
elev <- c(8848, 8611, 8586, 8516, 8481)
typeof(elev) # "double"
length(elev) #5
[Link](elev) # TRUE
str(elev) # num [1:5] 8848 8611 8586 8516 8481
# ---------- Integer vectors ----------
ids <- c(1L, 2L, 3L, 4L, 5L)
typeof(ids) # "integer"
[Link](ids) # TRUE
# Note: 1:5 also produces integer, but 1.0:5.0 produces double
typeof(1:5) # "integer"
typeof(1.0:5.0) # "double"
# ---------- Character vectors ----------
sites <- c("Ridge", "Valley", "Slope", "Ridge", "Valley")
typeof(sites) # "character"
length(sites) #5
nchar(sites[1]) # 5 (number of characters in "Ridge")
# ---------- Logical vectors ----------
is_high <- elev > 8500
is_high # TRUE TRUE TRUE TRUE FALSE
typeof(is_high) # "logical"
sum(is_high) # 4 (count of TRUEs)
# ---------- Sequence and replicate ----------
coords <- seq(0, 100, by = 10) # numeric
reps <- rep(c("A", "B"), each = 3) # character
# ---------- Random generation ----------
[Link](42) # for reproducibility
vals <- runif(100, min = 0, max = 1)
head(vals)
Notice that each function’s output appears in the console, and the Environment pane updates
with the new objects. This interactive feedback loop is how you will build confidence.
Hard Practice 2.2 – “Type Gym: Building a Synthetic Survey Dataset”
Objective:
Create a complete, multi-type synthetic dataset that mirrors the structure of a field survey. Use
only atomic vector creation functions (c(), seq(), rep(), runif(), etc.) and inspection functions. No
external data files, no sf or terra packages. The goal is to become comfortable with the four
atomic types, their creation, and their inspection.
Scenario:
You are planning a field campaign to measure soil organic carbon across a transect. Before going
to the field, you simulate a dataset to test your analysis scripts. The synthetic dataset should
contain:
A sequence of sample IDs (integer).
A numeric vector of longitudes and a numeric vector of latitudes along a transect.
A numeric vector of elevation values (simulated as random normal around a mean).
A character vector of land cover types, repeated in a pattern.
A logical vector indicating whether the measurement passed quality control.
A constant (single-element) vector for the transect name.
Instructions:
1. Create a new script practice_2_2_types.R. Write a header comment describing the
purpose.
2. Sample IDs: Generate an integer vector sample_id with values 1 through 50
(use 1:50 or seq(1, 50); note that 1:50 produces integer by default).
3. Coordinates: Create longitude as a numeric vector starting at 116.0°E and increasing by
0.02° for each sample (so 50 values). Create latitude as 39.9°N plus a small random jitter:
use runif(50, min = -0.01, max = 0.01) added to 39.9, to simulate slight north-south
offsets. (Remember to use [Link](123) at the top for reproducibility.)
4. Elevation: Simulate elevation in meters from a normal distribution with mean 4500 and
standard deviation 200, using rnorm(50, mean = 4500, sd = 200). Round the values to 1
decimal place using round(..., 1).
5. Land cover: Create a character vector land_cover by repeating the pattern c("Forest",
"Grassland", "Bare") using rep(). Ensure the length is 50. (Hint: times or [Link] can
be adjusted.)
6. Quality flag: Simulate a logical vector qc_pass where each element has a 90% chance of
being TRUE. Use runif(50) < 0.9 to generate a logical vector. (Explain in a comment how
this works: runif gives numbers in [0,1]; comparing to 0.9 yields TRUE with probability
0.9.)
7. Transect name: Create a character vector transect_name of length 1 containing "North-
South Transect 1". Note that even a single element is a vector.
8. Inspection: After creating all vectors, use the following functions to inspect each one,
printing the results to the console with print() or cat():
o typeof(), length(), head(..., 5), str() for each vector.
o sum(qc_pass) to count how many samples passed QC.
o [Link](longitude) and [Link](land_cover) to verify types.
o Combine longitude and latitude into a single numeric vector of length 100 (just
using c()) and check its type. What happens? (It remains numeric, because both
are double.)
9. Comment your observations: At the end of the script, add a comment block
summarizing:
o Which function(s) you used to create an integer vector vs a numeric vector.
o What happens to the type when you combine two numeric vectors with c().
o How rep() behaves with a character vector input.
10. Run the script and verify that the environment contains all seven vectors with correct
types and lengths. Debug any errors using the console.
Deliverable:
A fully commented script practice_2_2_types.R that generates the synthetic dataset, inspects it,
and runs without errors.
Reflection (in your notebook):
Why is it important to check typeof() after creating a vector, even if you think you know
its type?
In what real geospatial scenario would a logical vector like qc_pass be used?
How does the concept of a vector of length 1 (the transect name) differ from a simple
character string in, say, Excel? What advantage does it offer in R?
2.3 Vector Creation, Indexing, and Logical Subsetting
You now possess the raw material of data: vectors of numbers, text, and logical values. But a
dataset is not merely a collection of elements; it is a structure from which we must extract
relevant parts, modify values, and filter observations. In a GIS, you select features by clicking or
by constructing attribute queries. In R, this act of selection is performed through indexing—the
mechanism by which you access individual elements or slices of a vector using their positions,
names, or a logical condition. Indexing is the single most important operation you will perform,
because it allows you to ask questions of your data: “Which pixels have NDVI > 0.5?” “What
are the elevations at sample locations 10 through 20?” “Replace all negative values with NA.”
This section introduces the full repertoire of indexing techniques for atomic vectors, each
explained in theory and demonstrated with code. By the end, you will be able to manipulate
vectors with the precision of a spatial query language, and you will have laid the groundwork for
subsetting data frames and spatial objects in later chapters.
2.3.1 Integer Indexing by Position
The most direct way to retrieve an element from a vector is by its position, i.e., its index. R
uses 1-based indexing: the first element is at position 1, the second at position 2, and so on. This
is a deliberate design choice that aligns with natural counting and reduces off-by-one errors
common in 0-based languages.
The Indexing Operator [ ]
To extract elements, you place the vector of indices inside square brackets after the vector name:
r
temperatures <- c(12.5, 14.0, 16.2, 18.1, 20.3, 22.6)
temperatures[1] # 12.5 (first element)
temperatures[3] # 16.2
temperatures[6] # 22.6
The result is always a vector. Even if you select a single element, the output is an atomic vector
of length 1—there is no scalar. This consistency preserves the vectorized nature of all subsequent
operations.
Extracting Multiple Elements
You can supply a vector of indices to select multiple elements in any order:
r
temperatures[c(1, 3, 5)] # 12.5 16.2 20.3
temperatures[c(6, 1)] # 22.6 12.5
Duplicating indices repeats the corresponding element, which is occasionally useful for sampling
with replacement:
r
temperatures[c(2, 2, 2, 5)] # 14.0 14.0 14.0 20.3
Using seq() and : for Index Ranges
You can generate index sequences with : or seq() and use them directly inside [ ]:
r
temperatures[2:4] # 14.0 16.2 18.1
temperatures[seq(2, 6, by = 2)] # 14.0 18.1 22.6 (every second element)
The length() function combined with : can select the last few elements without knowing the
length:
r
n <- length(temperatures)
temperatures[(n-2):n] # last three elements
Out-of-Bounds Indices
If an index exceeds the vector’s length, R does not throw an error; instead, it returns NA for that
position:
r
temperatures[10] # NA
This silent tolerance can hide bugs, but it also allows you to extend a vector by assigning to
out-of-bounds indices (Section 2.3.6).
Geospatial Analogy
Think of a vector as a list of pixel values along a single raster row. Integer indexing is like
selecting pixels by column number: row[50] gives you the 50th pixel. This literal mapping makes
indexing intuitive for anyone who has worked with grid coordinates.
2.3.2 Negative Integer Indexing: Excluding Elements
By prefixing an integer with a minus sign, you exclude that position from the result. This is akin
to deleting a row from a table or masking out a bad pixel.
r
temperatures[-1] # all except the first: 14.0 16.2 18.1 20.3 22.6
temperatures[-c(2,4)] # all except 2nd and 4th: 12.5 16.2 20.3 22.6
Negative and positive indices cannot be mixed in a single subset; doing so throws an error. The
exclusion operation is non-destructive: it returns a new vector without the specified elements,
leaving the original intact.
Negative indexing is particularly useful for removing outliers or unwanted control
measurements. For example, if a sensor recorded a spurious value at position 100 in a time
series, you could compute statistics on the remaining points with data[-100].
2.3.3 Logical Subsetting: The Power of Masks
Logical subsetting is the most powerful and conceptually important indexing method in R.
Instead of specifying positions by number, you provide a logical vector of the same length as the
original. Every position where the logical vector is TRUE is retained; positions where it
is FALSE are dropped.
Creating the Logical Mask
The logical vector is usually the result of a comparison:
r
elevations <- c(8848, 8611, 8586, 8516, 8481, 620, 1500)
high_mask <- elevations > 8000
high_mask # TRUE TRUE TRUE TRUE TRUE FALSE FALSE
elevations[high_mask] # 8848 8611 8586 8516 8481
You can combine multiple conditions with & (and), | (or), and ! (not):
r
# Peaks between 8500 and 8800 meters
elevations[elevations > 8500 & elevations < 8800] # 8611 8586 8516
Direct Use of Conditions
You need not save the intermediate logical vector; you can place the condition directly inside [ ]:
r
elevations[elevations > 8500 & elevations < 8800] # same result
This compact style is idiomatic in R and is used extensively throughout geospatial scripts.
Logical Subsetting as a GIS Mask
In a GIS raster analysis, you would apply a mask raster (containing 1s and 0s) to extract only
those cells within a region of interest. In R, the mask is a logical vector. The
operation ndvi_values[ndvi_values > 0.3] is exactly the same idea: keep only the pixels that
satisfy a condition. This congruence between R’s core data manipulation and spatial analysis is
one of the reasons sf and terra feel so natural.
Counting and Proportion of TRUEs
Because TRUE is stored as 1 and FALSE as 0, you can use sum() to count the number of
elements meeting a condition, and mean() to calculate the proportion:
r
sum(elevations > 8000) # 5 peaks above 8000 m
mean(elevations > 8000) # 0.7142857 (71.4% are above 8000 m)
which(): Finding the Indices of TRUEs
The function which() returns the integer indices where a logical vector is TRUE:
r
which(elevations > 8000) # 1 2 3 4 5
which(elevations < 1000) # 6
You can then use these indices for further operations, such as replacing those specific values.
2.3.4 Named Vectors: Access by Labels
A vector can be given a names attribute, transforming it from a sequence of anonymous values
into a dictionary of labeled entries. This is especially valuable for statistical outputs, lookup
tables, and any situation where you want to refer to values by meaningful identifiers.
Creating a Named Vector
You can assign names during creation:
r
precip <- c(Beijing = 576, Shanghai = 1160, Guangzhou = 1730, Chengdu = 870)
Or you can add or modify names after creation with names():
r
pop <- c(21.5, 26.3, 18.7)
names(pop) <- c("Beijing", "Shanghai", "Guangzhou")
Indexing by Name
Once named, you can extract elements by their character names using [ ]:
r
precip["Shanghai"] # 1160
precip[c("Guangzhou", "Beijing")] # 1730 576
Name indexing is case-sensitive and exact. If a name does not exist, R returns NA and does not
warn you, so be vigilant.
Advantages for Geoinformatics
Named vectors are ideal for storing summary statistics per region, lookup tables for land cover
class codes, or any situation where you need to retrieve a value by a known key. For example,
you might store the mean elevation of each province in a named vector and retrieve it
with mean_elev["Yunnan"].
2.3.5 Iterative Indexing and Replacement
Indexing is not only for extraction; when placed on the left side of an assignment, it
allows in-place modification of specific elements.
Replacing by Position
r
x <- 1:5
x[3] <- 100
x # 1 2 100 4 5
Multiple positions can be replaced simultaneously:
r
x[c(2, 4)] <- 0
x # 1 0 100 0 5
Replacing by Logical Condition
A logical mask can be used to conditionally replace values—an operation that is the basis of data
cleaning:
r
ndvi <- c(0.34, 0.56, -0.05, 0.78, 0.92, -0.02)
ndvi[ndvi < 0] <- NA # replace physically implausible negative NDVI with missing value
ndvi # 0.34 0.56 NA 0.78 0.92 NA
This single line performs what would otherwise require a loop and a conditional inside it. For
large raster vectors with millions of cells, the vectorized implementation in R’s underlying C
code is orders of magnitude faster.
Extending a Vector by Assigning Beyond Its Length
You can assign to an index greater than the current length; R will extend the vector, filling the
gaps with NA:
r
x <- 1:3
x[5] <- 10
x # 1 2 3 NA 10
While sometimes convenient, this practice can lead to accidental NA insertion. It is generally
safer to build a vector of known size using vector() or rep() and then populate it by index.
Replacing with Named Access
If a vector has names, you can also replace by name:
r
pop["Beijing"] <- 21.6 # update population
This makes the code self-documenting and less error-prone than numeric indices.
2.3.6 Technical Demonstration: Indexing Workflow
Open an R script and run the following, observing how each indexing technique manipulates the
vectors. Use comments to track what is happening.
r
# A vector of monthly precipitation (mm) for one year
rainfall <- c(4, 6, 12, 23, 45, 78, 102, 95, 67, 32, 11, 5)
months <- [Link] # built-in vector "Jan", "Feb", ..., "Dec"
# ------- Integer indexing -------
rainfall[1] # January (4)
rainfall[6:9] # June to September: 78 102 95 67
rainfall[c(12, 1)] # December and January: 5 4
# ------- Negative indexing -------
rainfall[-1] # all except January
rainfall[-c(1, 12)] # exclude first and last (winter months)
# ------- Logical subsetting -------
high_rain <- rainfall > 50
rainfall[high_rain] # months with >50 mm: 78 102 95 67
rainfall[rainfall < 10 | rainfall > 100] # very dry or very wet: 4 6 102 5
# ------- Named vector -------
names(rainfall) <- months
rainfall["Jun"] # 78
rainfall[c("Jan", "Feb", "Mar")] # 4 6 12
# ------- Replacement -------
rainfall_copy <- rainfall
rainfall_copy[rainfall_copy < 10] <- NA # set low rain to NA
rainfall_copy
# Extend by assignment
rainfall_copy[13] <- 8 # imagine a 13th month? fills with NA, then 8
rainfall_copy
names(rainfall_copy)[13] <- "Extra"
rainfall_copy
Notice how rainfall_copy evolved. The Environment pane shows the names alongside values for
named vectors.
Hard Practice 2.3 – “Indexing Mastery: Filtering and Cleaning a Spectral Dataset”
Objective:
Apply every indexing technique—integer, negative, logical, named, and replacement—to a
synthetic spectral dataset. Solve realistic geospatial data manipulation tasks without using any
explicit loops or external packages.
Scenario:
You are working with a hypothetical satellite sensor that records reflectance in six spectral bands
for a transect of 200 pixels. The data are stored in six numeric
vectors: blue, green, red, nir, swir1, swir2. Some pixels are contaminated (negative reflectance
due to atmospheric correction error), and some are saturated (reflectance > 1.0). You must clean
the data, compute NDVI, and extract summary statistics for healthy vegetation pixels.
Instructions:
1. Data generation:
In a new script practice_2_3_indexing.R, set a seed: [Link](42).
Create 200 reflectance values for each band using:
r
n <- 200
blue <- runif(n, 0.05, 0.15)
green <- runif(n, 0.10, 0.25)
red <- runif(n, 0.05, 0.20)
nir <- runif(n, 0.30, 0.60)
swir1 <- runif(n, 0.15, 0.35)
swir2 <- runif(n, 0.10, 0.30)
Then introduce errors: randomly select 10% of the indices (use sample()) for each band and set
those values to -9999 (a missing value code) for the first half of the band vector, and
to 2.0 (saturation) for the other half? Simplify: for each band, set 5 random positions to -
0.1 (negative) and 5 other random positions to 1.5 (saturation). Use integer indexing on the left
side of assignment.
2. Create a named vector of indices:
To keep track of pixels, create a named integer vector pixel_id <- 1:n and name it with paste("P",
1:n, sep = ""). Verify that pixel_id["P42"] returns 42.
3. Logical masks:
For each band, create a logical vector valid_blue, valid_green, etc., that is TRUE where
reflectance is between 0 and 1 inclusive, and FALSE otherwise. Combine them into a single
overall valid mask all_valid that is TRUE only if all six bands are valid (use &).
4. Clean the data:
Create copies of the band vectors (blue_c, green_c, etc.). In each copy, replace all invalid
values (those that fail the valid mask for that band) with NA. Use logical subsetting on
the left side.
5. Compute NDVI:
Using the cleaned red_c and nir_c vectors, compute NDVI as (nir_c - red_c) / (nir_c +
red_c). Note: arithmetic on vectors containing NA yields NA where either operand
is NA. Thus, NDVI will be NA for invalid pixels automatically.
6. Subset by NDVI threshold:
Create a logical vector vegetation that is TRUE where ndvi > 0.4 (moderate to dense
vegetation) and FALSE otherwise, ignoring NA (i.e., NA in NDVI yields NA in the
comparison, which is treated as FALSE for subsetting? Actually ndvi > 0.4 yields NA for
NA entries. Using which() or logical subset with [ ] will drop NAs unless [Link]? No,
logical subsetting with [ ] drops the NA elements because NA is not TRUE.
So ndvi[vegetation] will return only valid TRUE values, dropping NAs. That's fine.
o Extract the pixel IDs of these vegetation pixels using the named vector: veg_ids
<- pixel_id[vegetation]. Print the first 20 vegetation pixel IDs.
o Compute the mean NDVI of the vegetation pixels: mean(ndvi[vegetation], [Link]
= TRUE). Explain why [Link] = TRUE is needed (because NDVI contains NAs
from invalid pixels; but vegetation subset already excludes them?
Actually ndvi[vegetation] will contain only non-NA values because vegetation is
defined from ndvi > 0.4 and where NDVI was NA, the condition gives NA, which
in subsetting drops the element. So ndvi[vegetation] is NA-free. So mean would
work without [Link]. But it's safe.
7. Using negative indexing:
Simulate the removal of a known bad detector pixel at index 88. Create a new
vector nir_cleaned_detector by excluding pixel 88 from nir_c using negative
indexing: nir_cleaned <- nir_c[-88]. But note that this changes the length and alignment
with other bands. In reality, you'd keep the same length and set to NA. Here we'll just
practice: remove indices c(10, 20, 30) from red_c using negative indexing and store the
result. Compute its length (should be 197).
8. Final summary:
Using the original red vector (not cleaned), count the number of invalid pixels per band
using sum() on the logical mask. Print the counts.
9. Reflection (add as comments):
o Why is logical subsetting a natural fit for quality-control filtering?
o What is the advantage of using a named vector pixel_id? Could you do the same
with integer indices?
o How does R’s recycling rule help when creating the logical mask all_valid from
multiple bands?
Deliverable:
The script practice_2_3_indexing.R with comprehensive comments, outputting the requested
results.
2.4 Operations on Vectors: Recycling and Vectorization Principles
The atomic vector is more than a storage container; it is a computational surface. R’s genius lies
in its ability to apply operations directly across that entire surface without explicit iteration. This
section explores the two interlocking principles that make R code concise, readable, and fast for
spatial data: vectorization and recycling. Vectorization means that arithmetic, comparison, and
function application operate element-by-element on entire vectors at once. Recycling is the rule
that automatically expands shorter vectors to match longer ones during these operations.
Together, they eliminate the need for loops in most data-processing tasks and align perfectly
with the cell-by-cell logic of raster analysis. We will examine each principle in theory, illustrate
them with geospatially inspired examples, and warn of the subtle traps that await the unwary. By
the end, you will see your vectors not as inert lists but as active fields on which you can write
mathematical expressions as naturally as on a whiteboard.
2.4.1 Element-Wise Operations: The Vectorized Paradigm
Every arithmetic operator in R—+, -, *, /, ^, %% (modulo), %/% (integer division)—and every
comparison operator—>, <, ==, !=, >=, <=—is vectorized. When you apply an operator to two
vectors of equal length, the operation is performed element by element, position by position,
and the result is a new vector of that same length.
r
a <- c(10, 20, 30, 40)
b <- c(1, 2, 3, 4)
a + b # 11 22 33 44
a * b # 10 40 90 160
a > 25 # FALSE FALSE TRUE TRUE
This is not a loop written in R syntax; the computation is dispatched directly to compiled C or
Fortran code that processes the entire vector in one pass. The result is that a + b on vectors of
length one million runs essentially at the speed of adding two numbers—plus a small overhead
for memory allocation.
The Geospatial Intuition
Imagine a satellite image as a two-dimensional array of reflectance values. When we correct for
atmospheric scattering by subtracting a constant offset, we want the same offset applied to every
pixel. In R, if band is a numeric vector holding all the pixel values and offset is a single number,
we write:
r
band_corrected <- band - offset
The single value offset is automatically treated as a vector of length 1 and reused across all
elements of band. This is recycling at work, but the underlying mechanism is the vectorized
minus operator. In a traditional procedural language, you would write a for loop over rows and
columns. In R, the loop is implicit, and the syntax mirrors the mathematical notation.
Functions Are Also Vectorized
Most built-in mathematical functions—sqrt(), log(), sin(), cos(), exp(), abs()—are vectorized.
They accept a vector of any length and return a vector of the same length with the function
applied to each element:
r
slope_radians <- c(0.0, 0.3, 0.6, 0.9)
slope_degrees <- slope_radians * 180 / pi
sin(slope_radians) # element-wise sine
This means the conversion of an entire field of slope measurements from radians to degrees is a
single line of code. The computational cost grows linearly with vector length, but the
programmer’s mental cost remains constant.
2.4.2 The Recycling Rule: Mechanism and Spatial Warnings
When two vectors in an element-wise operation have different lengths, R applies the recycling
rule:
1. The shorter vector is repeated (recycled) until its length matches that of the longer vector.
2. If the longer vector’s length is not an exact multiple of the shorter, R still recycles
fractionally but issues a warning.
3. A vector of length 1 is always recycled without warning, because it is a natural scalar
constant.
Recycling a Length-1 Vector
The most common and intuitive case: a constant is added to every element.
r
elevation_m <- c(1200, 3500, 890, 6700)
elevation_ft <- elevation_m * 3.28084 # 3.28084 recycled four times
elevation_ft # 3937.008 11482.940 2919.948 21981.628
No warning, because the longer length (4) is a multiple of the shorter length (1). This is the
pattern we use constantly: apply a scale factor, add a constant, threshold with a single value.
Recycling a Longer Pattern
You can deliberately create a repeating pattern with a vector of length > 1:
r
x <- 1:6
y <- c(1, 2)
x + y # 1+1=2, 2+2=4, 3+1=4, 4+2=6, 5+1=6, 6+2=8
y of length 2 is recycled three times to match the length of x (6). Because 6 is a multiple of 2, no
warning.
This technique can be useful for applying alternating corrections, such as a two-sensor
intercalibration that flips between sensor A and sensor B for successive lines in a scanning array.
However, it requires absolute confidence that the length relationship is exact and intentional.
The Partial Recycling Warning
If the longer length is not a multiple of the shorter, R still recycles but warns:
r
x <- 1:5
y <- c(1, 2)
x + y # Warning: longer object length is not a multiple of shorter object length
# Result: 2 4 4 6 6 (1+1, 2+2, 3+1, 4+2, 5+1)
The warning is there because partial recycling is usually a mistake—it means the programmer
expected vectors of equal length but was wrong. In spatial data, this can happen when you
inadvertently use a vector of elevation corrections from one sensor that has fewer lines than the
image from another sensor. The result will be silently wrong except for the warning, which many
beginners overlook. Always read your warnings. You can promote warnings to errors
with options(warn = 2) during script development.
Recycling in Data Frame Contexts (Preview)
When we later work with data frames and dplyr, recycling does not occur in the same way
because data frames enforce equal column lengths. But for atomic vectors, recycling is always
active. It underpins the behavior of many R functions, including the matrix and array operations
we will meet in Chapter 3.
2.4.3 Vectorized Logical Operations and Aggregators
Logical operators & (and), | (or), and ! (not) are also vectorized. Given two logical vectors of
equal length, & returns a logical vector where each element is the logical AND of the
corresponding pair:
r
cloud_free <- c(TRUE, TRUE, FALSE, TRUE)
high_sun_angle <- c(TRUE, FALSE, TRUE, TRUE)
usable <- cloud_free & high_sun_angle
usable # TRUE FALSE FALSE TRUE
To check whether any element meets a condition, use any(); to check whether all do, use all():
r
any(cloud_free) # TRUE (at least one is cloud-free)
all(cloud_free) # FALSE (not all are cloud-free)
These functions reduce a logical vector to a single logical value and are indispensable in
quality-control checks: “Are there any negative reflectances in this band?” any(band < 0).
which() returns the integer indices of the TRUE positions:
r
which(!cloud_free) # 3 (the third observation is cloudy)
For geospatial data, which() allows you to locate specific problematic pixels and inspect their
surroundings.
2.4.4 ifelse(): Vectorized Conditional
The function ifelse(test, yes, no) is a vectorized form of the if–else statement. It evaluates
the test vector element by element: where test is TRUE, it returns the corresponding element
of yes; where FALSE, it returns the corresponding element of no. All three arguments are
recycled to a common length.
r
elevation <- c(1200, 500, 2100, 800, 1500)
class <- ifelse(elevation > 1000, "Highland", "Lowland")
class # "Highland" "Lowland" "Highland" "Lowland" "Highland"
ifelse is a workhorse for reclassifying raster values and recoding categorical variables. You can
nest ifelse calls for multi-category classification, though for many
categories cut() or dplyr::case_when() may be clearer. But for binary or simple three-class
schemes, nested ifelse is perfectly sufficient:
r
class2 <- ifelse(elevation > 2000, "High",
ifelse(elevation > 1000, "Medium", "Low"))
class2 # "Medium" "Low" "High" "Low" "Medium"
Because ifelse is vectorized, it processes an entire column of 10 million pixels as quickly as it
processes 10, provided you have enough RAM.
2.4.5 Efficiency and the Avoidance of Explicit Loops
R is an interpreted language. When you write an explicit for loop, R must interpret the loop body
at each iteration, calling R functions and dispatching methods anew. This overhead is significant
when the loop runs millions of times. Vectorized functions, by contrast, hand the entire vector to
a tightly written C or Fortran subroutine that iterates in compiled code, with no R-level
interpretation per element.
Consider the task of computing the square of every integer from 1 to 10,000,000. A loop in R
might take seconds; the vectorized (1:1e7)^2 takes a fraction of a second.
The R community articulates this as: “Keep your loops in C.” That is, write your R code so that
the heavy lifting is done by vectorized base functions or by packages whose core is implemented
in compiled languages. In geospatial work, the terra and sf packages follow exactly this
principle: their functions are thin R wrappers around the C++ libraries GDAL, GEOS, and PROJ.
A pragmatic note: small loops (a few tens or hundreds of iterations) are perfectly fine in R. The
vectorization dogma applies when the number of iterations is large (tens of thousands or more).
In the early chapters, we will eschew loops altogether to develop your vectorized thinking. Later,
we will reintroduce loops where they are the natural solution, but always with an eye on
performance.
2.4.6 Technical Demonstration: Vectorization in Action
Create a new script and run the following code step by step, observing the outputs and the
absence of any for loop.
r
# ---------- Vectorized arithmetic ----------
rainfall_mm <- c(4, 6, 12, 23, 45, 78, 102, 95, 67, 32, 11, 5)
rainfall_cm <- rainfall_mm / 10 # divide every element by 10
rainfall_cm
# ---------- Recycling with a constant ----------
base_temp <- c(5, 7, 12, 18, 24, 28, 30, 29, 25, 19, 11, 6)
temp_adjusted <- base_temp + 2.5 # add 2.5°C correction to all months
temp_adjusted
# ---------- Recycling with a pattern ----------
# Imagine a two-camera system taking alternating pixels
pixels <- 1:10
dark_signal <- c(0.02, 0.03) # offset for camera A, camera B
corrected <- pixels - dark_signal # recycled: subtract 0.02, 0.03, 0.02, ...
corrected # 0.98 1.97 2.98 3.97 4.98 5.97 6.98 7.97 8.98 9.97
# ---------- Vectorized logical operations ----------
temp_above_20 <- base_temp > 20
temp_above_20 # FALSE FALSE FALSE FALSE TRUE TRUE TRUE TRUE TRUE FALSE
FALSE FALSE
sum(temp_above_20) # 5 warm months
# ---------- ifelse for classification ----------
season <- ifelse(temp_above_20, "Summer", "Not Summer")
season # "Not Summer" ... "Summer" ...
# ---------- Vectorized function: sqrt ----------
sqrt(rainfall_mm) # square root of each month's rainfall
# ---------- Efficiency glimpse: time a vectorized operation ----------
[Link]({
x <- 1:1e7
y <- x^2
})
# Compare with a loop (only if you're curious; this will be slower)
# [Link]({
# y2 <- numeric(1e7)
# for (i in 1:1e7) y2[i] <- x[i]^2
# })
# (The loop might be very slow; you can skip running it on low-memory machines.)
Notice that the looped version, if you run it, is dramatically slower. The vectorized x^2 is nearly
instantaneous.
Hard Practice 2.4 – “Vectorized Computation of Spectral Indices and Masking”
Objective:
Apply vectorization, recycling, logical operations, and ifelse() to compute vegetation indices and
quality masks for a synthetic Landsat-like dataset—without a single explicit loop.
Scenario:
You are given four vectors representing the red, near-infrared (NIR), and shortwave infrared
(SWIR1, SWIR2) bands of a 500-pixel transect. Some pixels contain clouds (high reflectance in
all bands) and must be masked before computing the Normalized Difference Vegetation Index
(NDVI) and the Normalized Burn Ratio (NBR). You will also classify vegetation density.
Instructions:
1. Data generation:
In a script practice_2_4_vectorization.R, set [Link](100) and create 500-element
vectors:
r
n <- 500
red <- runif(n, 0.05, 0.25)
nir <- runif(n, 0.20, 0.65)
swir1 <- runif(n, 0.10, 0.40)
swir2 <- runif(n, 0.05, 0.35)
These represent typical surface reflectance values.
2. Introduce cloud contamination:
Use sample() to select 30 random pixel indices. For those indices, set red, nir, swir1,
and swir2 all to a high value (e.g., 0.9) to simulate thick cloud. Do this using logical
indexing or integer indexing on the left side.
3. Cloud mask:
Create a logical vector cloud that is TRUE for any pixel where all four bands exceed 0.8.
Use &. (Note: actual cloud masking is more complex, but this is a simplification.) Print
the number of cloudy pixels with sum(cloud).
4. Clean the data (mask out clouds):
Create copies red_c, nir_c, swir1_c, swir2_c where cloudy pixels are replaced with NA.
Use cloud to index and assign NA.
5. Compute NDVI:
Using the cleaned vectors, compute ndvi <- (nir_c - red_c) / (nir_c + red_c).
Because of NA propagation, cloudy pixels become NA in NDVI automatically. No need
to subset explicitly.
6. Compute NBR (Normalized Burn Ratio):
nbr <- (nir_c - swir2_c) / (nir_c + swir2_c). This index highlights burned areas (low
NBR).
7. Classify vegetation density using ifelse:
Create a character vector veg_class of length n with:
o "Dense" if NDVI > 0.5
o "Moderate" if NDVI > 0.3 (and ≤ 0.5)
o "Sparse" if NDVI > 0.1 (and ≤ 0.3)
o "Non-veg" otherwise
Use nested ifelse. Handle NA: any pixel with NA NDVI should become NA. You
can check [Link](ndvi) first and assign NA_character_, or simply let the
nested ifelse return NA if the test yields NA (which it does). Test: ndvi has
NAs, ifelse([Link](ndvi), NA_character_, ...) is explicit.
8. Compute summary statistics (vectorized):
o Mean NDVI of Dense vegetation pixels (excluding NAs).
o Count of pixels in each vegetation class (use sum(veg_class == "Dense", [Link] =
TRUE), etc.).
o Mean NBR of the Dense vegetation pixels.
9. Vectorized conditional correction (more recycling):
Suppose a calibration drift means all red values need to be reduced by 0.01, but only for
pixels that were not cloudy (i.e., where !cloud). Use ifelse to create a corrected red
vector: red_corrected <- ifelse(cloud, red_c, red_c - 0.01). Observe that red_c already has
NAs for cloudy pixels, but the cloud logical is used to decide. Print the first 20 values
of red_corrected alongside red and cloud.
10. Reflection (add as comments):
o How did recycling simplify the application of constants in NDVI and NBR
formulas?
o What is the advantage of ifelse over an explicit loop for vegetation classification?
o Why did we need to handle NA explicitly in the vegetation classification, and
how does ifelse behave when test is NA? (Try ifelse(NA, 1, 2) in the console; the
result is NA, which is often what you want.)
Deliverable:
The script practice_2_4_vectorization.R with full comments, producing printed summaries and
no loops.
2.5 Missing Values, Infinities, and Type Coercion
Data in geoinformatics is rarely perfect. Satellite sensors saturate, field instruments fail,
digitised boundaries contain gaps. In R, imperfection is represented systematically through a
small set of special values: NA (Not Available), NaN (Not a Number), Inf and -Inf (infinity),
and NULL (the empty object). Knowing how these values arise, how they propagate through
calculations, and how to handle them is essential for robust spatial analysis. Equally important
is understanding type coercion—the automatic or manual conversion of data from one atomic
type to another. Coercion can be a powerful tool or a hidden source of error, particularly when
reading external data into R. This section treats each concept in depth, moving from theoretical
definition to practical technique, always with an eye toward the raster bands, vector attributes,
and field-survey tables that populate our geospatial workflows.
2.5.1 NA: The Sentinel of Missingness
NA stands for “Not Available”. It is a sentinel value that indicates the absence of a legitimate
data point. In a vector of soil-moisture measurements, NA might mean the probe malfunctioned.
In a land-cover classification, NA might label a pixel obscured by cloud. Unlike some systems
that use impossible numeric codes (e.g., -9999), R has a dedicated, type-aware missing-value
system.
Typed NA
NA exists for every atomic
type: NA_real_ (numeric), NA_integer_, NA_character_, NA_logical_, and NA_complex_. In
practice, you rarely need to specify the type explicitly, because NA will be coerced to the
appropriate type when combined with other values. However, understanding the typed nature is
important: a logical NA is not identical to a numeric NA in terms of storage, but both represent
missingness.
r
typeof(NA) # "logical" (the default when standalone)
typeof(NA_real_) # "double"
The Contagion Principle
The defining behavior of NA is contagion: any arithmetic, comparison, or mathematical
operation involving NA returns NA. This ensures that missingness propagates transparently
through an analysis. If a single pixel in a time-series stack is cloudy, any index computed from
that pixel’s bands should also be missing for that date. R implements this automatically.
r
x <- c(1, 2, NA, 4)
x + 10 # 11 12 NA 14
sum(x) # NA (because sum of NA is NA unless [Link] = TRUE)
mean(x) # NA
Logical operations with NA yield NA as well, which is a subtle source of truth-table complexity:
r
NA > 5 # NA
NA == NA # NA (cannot test equality with itself; use [Link]() instead)
To detect NA, use [Link](). This function is vectorized and returns TRUE for every NA element:
r
[Link](x) # FALSE FALSE TRUE FALSE
which([Link](x)) #3
sum([Link](x)) # 1 (count of missing values)
Removing or Ignoring NA
Most summary functions offer the argument [Link] = TRUE, which instructs the function to
remove NA values before computing:
r
mean(x, [Link] = TRUE) # 2.333333
sum(x, [Link] = TRUE) #7
When subsetting, you can explicitly exclude NA with ![Link]():
r
x_clean <- x[] # 1 2 4
You can also use the convenience functions [Link](x) or [Link](x), which return the vector
without NA and also attach a residual attribute used in modeling contexts. For simple cleaning,
logical subsetting is direct and transparent.
The Significance for Geospatial Data
Raster analysis frequently encounters NA. The terra package, for instance, uses NA to mark
pixels outside a mask or cloud-covered cells. When you compute the mean NDVI over a region,
you naturally set [Link] = TRUE to ignore those pixels. Failing to do so would make the entire
mean NA. The discipline of handling NA explicitly—checking for it, counting it, deciding
whether to remove it or impute it—is a mark of a careful spatial scientist.
2.5.2 NaN and NULL: Undefined and Empty
NaN — Not a Number
NaN is a special numeric value that represents the result of a mathematically undefined
operation, such as 0/0, Inf - Inf, or sqrt(-1) (in real arithmetic). NaN is a numeric value and can
be stored in numeric vectors.
r
z <- c(1, 0/0, 3)
z # 1 NaN 3
typeof(z) # "double"
[Link](z) returns TRUE for the NaN element. Importantly, [Link](NaN) also returns TRUE,
because NaN is considered a type of missing value. But [Link](NA_real_) returns FALSE. The
nesting is: all NaN are NA (of numeric type), but not all NA are NaN.
For geospatial purposes, NaN may appear in band math where a denominator is zero (e.g., a
division by zero in an index). You typically treat NaN like NA: exclude or set to NA explicitly.
NULL — The Empty Object
NULL is a special object in R that represents the absence of any value—not a missing number,
but the absence of an object altogether. It has zero length and no type.
r
[Link](NULL) # TRUE
length(NULL) #0
c(1, NULL, 2) # 1 2 (NULL is silently dropped)
NULL is often used to test whether an object exists or has been initialised. In the Environment
pane, NULL objects are not displayed. In geospatial programming, you might
encounter NULL as the default return of a function that fails to compute a result
(e.g., st_intersection() with no intersecting geometry returns NULL). It is not the same as NA;
you cannot place NULL inside a numeric vector—if you try, it is simply ignored.
2.5.3 Inf and -Inf: Infinite Values
R can represent positive infinity (Inf) and negative infinity (-Inf). These arise from operations
such as:
r
1/0 # Inf
log(0) # -Inf
1000^1000 # Inf (overflow)
Inf is a numeric value, and it behaves in many arithmetic operations as you might expect from
limits: Inf + 1 = Inf, 1 / Inf = 0. Comparisons work: Inf > 10^10 is TRUE. You can test for finite
values with [Link]() and for infinite values with [Link]().
In geospatial analysis, infinite values can appear when computing topographic indices. For
example, the slope of a perfectly vertical cliff is infinite; a flat area yields a zero slope but an
infinite contributing area in some formulations. Such physical impossibilities are usually filtered
or clipped. When you encounter Inf unexpectedly, it often signals a division by zero in your
formula. Always check with any([Link](x)) after index calculations.
2.5.4 Type Coercion: The Hierarchy of Atomic Types
When you combine elements of different atomic types in a vector, R automatically converts all
elements to a common type following a strict hierarchy:
text
character > double > integer > logical
The “higher” type absorbs the “lower” one. The rule is applied recursively to all elements.
r
c(TRUE, 2.5) # logical + double → double: 1.0 2.5
c(1L, 3.14) # integer + double → double: 1.0 3.14
c(TRUE, "text") # logical + character → character: "TRUE" "text"
c(1, 2, "three") # double + character → character: "1" "2" "three"
Coercion is silent—R does not warn you that your TRUE has become 1.0. This convenience can
become a trap when importing data. Suppose you read a CSV file with a column of population
counts. One cell contains the string "unknown". The entire column will be read as character, and
subsequent arithmetic like pop * 1000 will fail with an obscure error. We will learn to guard
against this with explicit type checking (str(), typeof()) and conversion functions.
Explicit Coercion Functions
You can explicitly convert between types with functions of the form [Link]():
[Link]() — Convert to double. Character strings like "1.2" become 1.2; non-numeric
strings become NA with a warning.
[Link]() — Convert to integer. Truncates decimal parts.
[Link]() — Convert to character. The most forgiving: everything becomes a string
representation.
[Link]() — Convert to logical. Numeric 0 becomes FALSE, all non-zero
become TRUE. Strings "TRUE", "true", "T", etc., are recognised; others give NA.
r
x <- c("1.2", "3.4", "5.6")
[Link](x) # 1.2 3.4 5.6
y <- c(0, 1, 0, 5)
[Link](y) # FALSE TRUE FALSE TRUE
z <- c(3.14, 2.71, 1.62)
[Link](z) # "3.14" "2.71" "1.62"
These functions are crucial when cleaning field data. A common workflow: read a dataset,
examine str(), find columns that should be numeric but are character, and apply [Link]().
The Summary Functions is.*()
To safely test whether a vector is of a given type before coercion, use:
[Link](x) — returns TRUE for both double and integer.
[Link](x) — returns TRUE only for double.
[Link](x)
[Link](x)
[Link](x)
Using these prevents accidental coercion that might introduce NAs.
Geospatial Relevance
In GIS attribute tables, a column may be stored as text (e.g., "12.5") when it should be numeric.
Failing to coerce it leads to the entire column being ignored in statistical summaries or, worse,
treated as categories. A disciplined spatial data scientist always runs str() on an
imported sf object and applies [Link]() to any column that should be quantitative. The same
vigilance applies to coordinates: if a CSV file stores longitude with a stray comma
(e.g., "116,407" instead of 116.407), [Link]() will return NA, alerting you to the problem.
2.5.5 Technical Demonstration: Missingness and Coercion in Practice
Let us simulate a small, messy field dataset and clean it using the techniques of this section. Run
the following code in a script, reading comments carefully.
r
# -------- 1. Create a mixed-type vector experiencing coercion --------
mixed <- c(1, 2, "3", TRUE, 5.5)
mixed # "1" "2" "3" "TRUE" "5.5" — all character
typeof(mixed) # "character"
# -------- 2. Explicit conversion attempts --------
# Suppose we thought mixed was numeric and tried to convert back
num <- [Link](mixed)
num # 1.0 2.0 3.0 NA 5.5
# The "TRUE" becomes NA with warning. Always inspect.
# -------- 3. Working with NA and NaN --------
rain <- c(23, 45, NA, 67, 0/0, 32)
rain # 23 45 NA 67 NaN 32
[Link](rain) # FALSE FALSE TRUE FALSE TRUE FALSE (NaN is NA)
[Link](rain) # FALSE FALSE FALSE FALSE TRUE FALSE
mean(rain, [Link] = TRUE) # 41.75 (both NA and NaN removed)
# -------- 4. Inf and -Inf --------
slope_percent <- c(5, 10, 0, 15, 100) # slope in percent
# Compute a simple topographic index that divides by slope
index <- 100 / slope_percent
index # 20.0 10.0 Inf 6.6667 1.0
# Inf appears where slope was 0.
# Filter out infinite values:
index_finite <- index[[Link](index)]
index_finite # 20.0 10.0 6.6667 1.0
# -------- 5. NULL vs NA --------
v <- c(1, NULL, 3)
v # 1 3 — NULL is dropped, no NA
length(v) # 2
# -------- 6. Checking a vector after cleaning --------
cleaned <- c(12, NA, 8, 15, -99) # -99 is an error code
cleaned[cleaned == -99] <- NA # replace error with NA
cleaned # 12 NA 8 15 NA
sum([Link](cleaned)) # 2 missing values now
# -------- 7. Coercion in the context of a data frame (preview) --------
# (We will cover data frames in Chapter 4, but this shows the importance)
df <- [Link](
id = 1:3,
value = c("5.1", "6.2", "unknown")
)
str(df) # 'value' is Factor (in older R) or character — we'll handle factors later.
# Convert to numeric:
df$value_num <- [Link](df$value)
df$value_num # 5.1 6.2 NA (with warning)
Hard Practice 2.5 – “Sanitizing a Messy Field Survey Dataset”
Objective:
Apply NA handling, NaN/Inf detection, and explicit type coercion to clean a synthetic field
survey that contains deliberate errors, missing codes, and inconsistent types. Use only vectorized
operations; no loops.
Scenario:
You receive a dataset of 200 soil samples with the following variables, all stored as character
vectors because the data logger malfunctioned:
sample_id: should be integer, but contains a few "MISSING" entries.
pH: should be numeric, but some entries are "NA" (as a string), "err", or ">14".
organic_carbon_percent: should be numeric, but some entries are negative, some
are "Inf", and some are empty strings "".
texture: a character field with categories "Sand", "Silt", "Clay", but some are blank or "?".
Your task is to convert each column to its correct type, replacing invalid values with
appropriate NA, counting errors, and computing summary statistics for the cleaned
numeric columns.
Instructions:
1. Data generation:
Create a script practice_2_5_coercion.R. Set [Link](99). Generate 200 samples.
r
n <- 200
sample_id <- [Link](1:n)
# Introduce missing IDs: set 10 random positions to "MISSING"
sample_id[sample(n, 10)] <- "MISSING"
# pH: normal around 6.5, sd 0.5, then inject errors
pH <- round(rnorm(n, mean = 6.5, sd = 0.5), 2)
pH <- [Link](pH)
pH[sample(n, 5)] <- "NA" # string NA
pH[sample(n, 5)] <- "err" # error string
pH[sample(n, 3)] <- ">14" # out-of-range
# Organic carbon: normal around 2.0, sd 0.8, inject errors
oc <- round(rnorm(n, mean = 2.0, sd = 0.8), 2)
oc <- [Link](oc)
oc[sample(n, 8)] <- "" # empty string
oc[sample(n, 4)] <- "Inf" # infinity string
oc[sample(n, 5)] <- "-0.5" # negative, physically impossible
# Texture
texture <- sample(c("Sand", "Silt", "Clay"), n, replace = TRUE)
texture[sample(n, 6)] <- ""
texture[sample(n, 4)] <- "?"
2. Clean sample_id:
Convert to integer with [Link](sample_id). The "MISSING" entries become NA with
a warning. Count how many NAs result. Store the cleaned integer vector as id_clean.
3. Clean pH:
Convert to numeric with [Link](pH). All non-numeric strings ("err", ">14", "NA")
become NA. Then, identify any numeric values outside the plausible pH range 0–14 and
set them to NA using logical subsetting. Count the total NAs produced (including both
non-numeric conversions and out-of-range values). Store as ph_clean.
4. Clean organic_carbon:
Convert to numeric. Empty strings ("") become NA. "Inf" becomes Inf. Then:
o Replace any Inf with NA.
o Replace any negative values with NA (organic carbon cannot be negative).
Count the total NAs. Store as oc_clean.
5. Clean texture:
Replace blanks "" and "?" with NA_character_ using logical subsetting. Count the NAs.
Store as texture_clean.
6. Summary statistics (vectorized):
Using the cleaned numeric vectors, compute:
o Mean and standard deviation of pH (with [Link] = TRUE).
o Mean and standard deviation of organic carbon.
o Count of samples in each texture class (use table(texture_clean, useNA =
"ifany") to include NA count).
7. Create a complete-case logical vector:
A sample is complete if none of id_clean, ph_clean, oc_clean, texture_clean is NA.
Create complete <-  &  &  & !
[Link](texture_clean). Count the number of complete cases.
8. Print the first 20 rows of a cleaned data summary:
Use [Link]() to combine the cleaned vectors into a temporary data frame and print its
first 20 rows. (This is only for inspection; the data frame itself is not required for further
analysis.)
9. Reflection:
o Why is it safer to use [Link]() on character columns rather than assuming the
column is numeric?
o What is the difference between the string "NA" and the logical value NA? Why
must you convert the string "NA" explicitly?
o Why did we replace Inf with NA in the organic carbon data? In what geospatial
situation might Inf be a valid result (e.g., slope calculation)?
o How does the logical vector complete rely on the vectorized & operator?
Deliverable:
The script practice_2_5_coercion.R, fully commented, with all computations and printed results.
Chapter 2 Review Problems
Congratulations on completing Chapter 2. You now command the atomic vector—the
fundamental unit of data in R. The following problems are designed to solidify your ability to
create, index, vectorise, and clean vectors, all within geospatially inspired scenarios. Solve each
problem in a clearly commented R script. Resist the temptation to use loops; rely on
vectorisation, recycling, logical subsetting, and the ifelse() function. The problems are arranged
in three tiers of difficulty.
Easy Problems (1–5)
1. Vector Creation and Simple Arithmetic
Create a numeric vector elevations containing the heights (in metres) of the following eight
mountains:
8848, 8611, 8586, 8516, 8481, 8188, 8125, 8091.
Convert all elevations to feet (multiply by 3.28084) and store the result as elevations_ft.
Compute the difference in elevation between each peak and the highest peak (use
vectorised subtraction).
Print both results.
2. Logical Masking
Using the same elevations vector, create a logical vector above_8500 that is TRUE for peaks
taller than 8500 m. Use this mask to:
Extract the heights of only those peaks.
Count how many peaks exceed 8500 m.
Print both the extracted values and the count.
3. Character Vectors and Indexing
Create a character vector months of the twelve English month abbreviations (use the built-in
constant [Link]).
Use integer indexing to extract the months of the northern hemisphere’s meteorological
summer (June, July, August).
Use negative indexing to remove the winter months (December, January, February) and
store the result as non_winter.
Print both selections.
4. Sequence and Recycling
Generate a sequence of longitudes from 0° to 350° in steps of 10° (i.e., 0, 10, 20, …, 350)
using seq(). Store this as lon.
Create a vector lon_rad that converts every longitude to radians (lon * pi / 180).
Now add a correction of 0.01 radians to every element using recycling (a single constant).
Store as lon_rad_corrected.
Print the first and last five values of lon_rad_corrected.
5. Missing Value Handling
A precipitation vector (mm) for ten stations contains some failed readings:
precip <- c(1200, NA, 890, 1450, NA, 670, 980, NA, 1100, 1350).
Count the number of NA values with sum().
Compute the mean precipitation after removing NAs with mean(..., [Link] = TRUE).
Replace all NAs with the mean value using logical indexing.
Print the original, the count, and the filled vector.
Medium Problems (6–10)
6. Conditional Reclassification with ifelse
A researcher measured soil pH at 30 sites:
ph <- c(5.1, 6.8, 7.2, 4.9, 5.5, 6.0, 8.1, 7.5, 4.2, 5.8, ...) — you can generate a synthetic vector of
length 30 with runif(30, 4, 8) after [Link](42).
Using nested ifelse(), classify each site as "Acidic" (pH < 5.5), "Neutral" (5.5 ≤ pH ≤
7.5), or "Alkaline" (pH > 7.5).
Count the number of sites in each category using sum() on logical comparisons.
Print a table showing the first 15 sites’ pH and class.
7. Spatially Aware Indexing
You are given two parallel vectors of the same length: lon (longitude) and lat (latitude). Create
them as follows:
r
n <- 100
lon <- seq(100, 120, [Link] = n)
lat <- seq(30, 40, [Link] = n)
Use logical subsetting to extract all points with longitude between 105° and
115° and latitude greater than 35°. Store the extracted longitudes and latitudes
in sel_lon and sel_lat.
Compute the number of selected points.
Print the first 10 selected coordinate pairs (paste them as strings with paste()).
8. Recycling with a Spectral Correction
Imagine a satellite sensor with an alternating gain error: every odd-numbered pixel needs a
correction of +0.02 reflectance, and every even-numbered pixel needs –0.02.
Generate a vector of 50 raw reflectance values: raw <- runif(50, 0.1, 0.4) after [Link](7).
Create a correction vector of length 2: c(0.02, -0.02).
Use recycling to apply the correction to raw and store the result in corrected.
Verify that the first six values show the alternating pattern (print them).
Explain in a comment why recycling works without a warning.
9. Vectorised Quality Control
A vector of 200 NDVI values (after [Link](1)) is generated: ndvi <- rnorm(200, mean = 0.4, sd
= 0.15). Some values fall outside the physically plausible range [–1, 1].
Create a logical vector valid that is TRUE only when ndvi is between –1 and 1
(inclusive).
Set invalid values to NA in a copy of the vector called ndvi_clean.
Compute the percentage of values that were flagged as invalid (i.e., the proportion that
became NA).
Print the mean NDVI before and after cleaning (using [Link] = TRUE after cleaning).
Use which() to list the indices of the first five invalid pixels.
10. Type Coercion and Data Cleaning
A student recorded river discharge (m³/s) but some entries contain typos. The character vector is:
discharge <- c("450", "389", "0", "876", "err", "1200", "NA", "999", "544", "invalid")
Convert the vector to numeric with [Link](). Observe the warning (you can suppress
it with suppressWarnings() if you like).
Count how many values became NA because of conversion failures.
Of the remaining numeric values, replace any that are exactly 0 with NA (since 0
discharge is unphysical for this river).
Compute the mean discharge of the valid readings (ignore NAs).
Print the final cleaned vector and the mean.
Challenging Problems (11–15)
11. Building a Regular Grid and Computing Distances
Create a regular spatial grid of points covering a rectangular area.
Generate longitudes: lon <- seq(100, 120, by = 0.5) and latitudes: lat <- seq(30, 40, by =
0.5).
Use [Link](lon, lat) to create a data frame of all grid points. Assign column
names lon and lat.
Without using any loop, compute the Euclidean distance (on a plane, using the
Pythagorean formula, ignoring Earth curvature) from each grid point to a reference point
at (110°E, 35°N). Store the result as a new column dist.
Print the first 10 rows of the data frame.
Count how many grid points are within 5 degrees of the reference point.
12. Outlier Detection via IQR Method
Simulate 10,000 elevation values from a normal distribution with mean 2500 m and standard
deviation 800 m (rnorm).
Compute the first quartile (Q1), third quartile (Q3), and the interquartile range (IQR = Q3
– Q1).
Define an outlier as any value below Q1 – 1.5 * IQR or above Q3 + 1.5 * IQR.
Create a logical vector outlier that flags those values.
Replace all outliers with NA in a copy of the elevation vector.
Print the number of outliers removed, and compare the mean and standard deviation of
the original and cleaned vectors (ignore NAs in the cleaned version).
Explain in comments how the IQR method is insensitive to extreme values.
13. Moving Average with Vectorised Indexing (No Loops)
Implement a 5-point centred moving average for a temperature time series of 100 days.
Generate the series: temp <- 20 + 10 * sin(seq(0, 2*pi, [Link] = 100)) + rnorm(100, 0,
1) (seasonal cycle + noise).
Create a matrix where each column is a shifted version of temp: column 1 is temp[3:100],
column 2 is temp[2:99], column 3 is temp[1:98], column 4 is temp[4:101]? Actually, for a
5-point window you need five shifts. The standard approach: create a matrix of
appropriate sub-sequences and use rowMeans. Design a vectorised solution. At the edges
(first two and last two days), the window is incomplete; set those values to NA.
Plot the original series and the smoothed series overlaid on the same graph (base plot,
lines).
No explicit loops allowed; rely on vectorised indexing and rowMeans().
14. Uncertainty Propagation via Monte Carlo Simulation
A geodetic distance is measured as 5000 m with a standard deviation of 0.1 m. The measurement
is repeated 100 times, but you want to simulate the uncertainty.
Generate 10,000 realisations of the distance from a normal distribution (rnorm(10000,
mean = 5000, sd = 0.1)).
Without a loop, compute the 2.5th and 97.5th percentiles of the simulated distances
using quantile().
Now simulate a second distance (8000 m, sd = 0.15) in the same way, and use
vectorisation to create a new vector representing the sum of the two distances (each draw
from the first added to the corresponding draw from the second).
Compute the 95% uncertainty interval (2.5th, 97.5th percentiles) for the sum.
Print both intervals.
Explain in comments how vectorisation makes Monte Carlo simulation efficient in R.
15. A Spatially Explicit Error Detector
In a quality control script for a raster, a vector values of length 10,000 contains pixel values.
Some are negative (coding errors), some are zero (missing data), and some exceed 5000
(unphysical for this sensor).
Generate values with [Link](123); values <- c(rnorm(9800, mean = 2000, sd = 800),
rep(-1, 100), rep(5001, 100)).
Use logical subsetting to flag all erroneous pixels and print the total count.
Create a corrected version where negatives become 0, values > 5000 become 5000
(clamping), and zeros are left unchanged.
Compute the percentage change in the mean caused by the correction.
Finally, simulate a binary quality layer: 1 for valid original values, 0 for corrected (i.e.,
originally erroneous). Store this as an integer vector and report the proportion of invalid
pixels.
Chapter 3: Matrices, Arrays, and the Art of Higher-Dimensional Data
Atomic vectors are the breath of data, but the world is not one-dimensional. A satellite image
has rows and columns; a digital elevation model is a grid of heights; a time series of NDVI
images stacks bands into a cube. R captures these structures with matrices (two-dimensional)
and arrays (multi-dimensional). This chapter teaches you to create, manipulate, and think in
these higher-dimensional forms while retaining the vectorised mindset of Chapter 2. Because a
matrix is, at its heart, an atomic vector with a dim attribute, everything you learned about types,
indexing, and vectorisation transfers seamlessly. Here we add the spatial logic of rows, columns,
and layers—the essential bridge to the raster data you will master in Part III.
3.1 Creating Matrices and Arrays: matrix() and array()
Before we can compute on grids, we must build them. This section introduces the two
fundamental constructors for higher-dimensional data: matrix() for two-dimensional tables
and array() for three or more dimensions. We examine how these functions wrap a flat atomic
vector into a structured shape, the rules that govern filling order, and the critical concept that a
matrix is still a vector—a fact that allows vectorised operations to work across rows and
columns with astonishing ease. We ground every abstraction in geospatial imagery: a matrix as
a single raster band, an array as a multispectral stack. By the end, you will be able to create,
inspect, and mentally rotate these structures as effortlessly as you once looked at a table of
numbers.
3.1.1 From Vector to Grid: The Idea of Dimensionality
A vector of length 100 could represent 100 soil pH measurements, but it could equally represent
a 10 × 10 grid of pixels from a satellite sensor—if only we tell R how to fold that vector into
rows and columns. The act of giving a vector a dimension attribute transforms it into a matrix
or an array without copying the data. This is the fundamental insight: a matrix is an atomic
vector whose elements are arranged in two dimensions.
Why is this important for geoinformatics? Because every raster dataset you will ever load
with terra or process with sf is, deep down, an array of numbers. A single-band GeoTIFF is a
matrix. A multi-band Landsat scene is a three-dimensional array (rows × columns × bands). A
climate model output over time adds a fourth dimension. R’s array architecture is perfectly
aligned with these data structures.
The constructor functions matrix() and array() are the gateways to this world. They take a flat
vector and a specification of dimensions, and they return an object with structure. Under the
hood, they merely set the dim attribute; the data remains the same atomic vector. You can verify
this by calling typeof() on a matrix—it will return "double", "integer", etc., not "matrix". The
class is "matrix" "array", but the storage mode is unchanged.
3.1.2 Creating a Matrix with matrix()
The function matrix() has the following signature:
r
matrix(data = NA, nrow = 1, ncol = 1, byrow = FALSE, dimnames = NULL)
data: an atomic vector that provides the matrix elements. If it is shorter than nrow * ncol,
it is recycled to fill the matrix. If longer, only the first nrow * ncol elements are used with
a warning.
nrow: number of rows.
ncol: number of columns.
byrow: logical. If FALSE (the default), the matrix is filled column-wise (down each
column in order). If TRUE, the matrix is filled row-wise (across each row in order).
dimnames: optional list of row and column names.
Let us create a simple 3 × 3 matrix of numbers 1 through 9, filled column-wise:
r
m <- matrix(1:9, nrow = 3, ncol = 3)
m
# [,1] [,2] [,3]
# [1,] 1 4 7
# [2,] 2 5 8
# [3,] 3 6 9
Observe the output: row indices are shown as [1,], [2,], column indices as [,1], [,2]. The numbers
1, 2, 3 fill the first column; 4, 5, 6 the second; 7, 8, 9 the third. This is the default column -major
order inherited from Fortran, which R uses for compatibility with numerical libraries like
LAPACK. Most mathematical software and GIS raster formats also store data column-wise or
row-wise depending on convention; R’s default is column-major.
To fill row-wise, set byrow = TRUE:
r
m_row <- matrix(1:9, nrow = 3, ncol = 3, byrow = TRUE)
m_row
# [,1] [,2] [,3]
# [1,] 1 2 3
# [2,] 4 5 6
# [3,] 7 8 9
The choice of byrow depends on how your data are ordered. If you have a vector of pixel values
read from a row-major image format, you might need byrow = TRUE. We will later use
the terra package to read rasters, which handles orientation automatically, but understanding this
internal logic is vital for debugging.
You can also create a matrix by specifying only nrow or ncol, and R will infer the other
dimension from the length of data:
r
matrix(1:12, nrow = 4) # infers ncol = 3
matrix(1:12, ncol = 4) # infers nrow = 3
If the data length is not a multiple of the specified dimension, R recycles with a warning. For
example:
r
matrix(1:5, nrow = 2, ncol = 3) # recycles: 1,2,3,4,5,1... fills with 1 again? Actually 5 elements,
need 6, so recycles first element.
# Warning: data length [5] is not a sub-multiple or multiple of the number of rows [2]
Recycling in matrix creation is occasionally useful for generating patterned grids, but it often
signals a mistake in data preparation. Always check your dimensions match the intended data
length.
3.1.3 The Matrix as a Vector: dim Attribute and Vector Access
A matrix remains a vector with dimensions. You can strip the dimensions and retrieve the
underlying flat vector with [Link]() or simply by removing the dim attribute. More
importantly, you can index a matrix with a single index as if it were a vector, accessing elements
in column-major order:
r
m[5] # 5th element in column-major order: 1,2,3,4,5 -> 5? Let's check: m matrix was
# [,1] [,2] [,3]
# [1,] 1 4 7
# [2,] 2 5 8
# [3,] 3 6 9
# column-major: 1,2,3,4,5,6,7,8,9 -> m[5] = 5. Indeed, the element at row 2, column 2.
This property means that all vectorised operations still apply. If you add a vector to a matrix,
recycling operates on the underlying column-major vector. We will explore this in detail in
Section 3.2.
You can also examine the dimensions with dim(), nrow(), ncol(), and the total number of
elements with length() (which returns nrow * ncol). The str() function gives a compact structure:
r
str(m)
# int [1:3, 1:3] 1 2 3 4 5 6 7 8 9
Note: it prints the matrix as a vector but notes the dimensions in brackets. This reinforces the
vector-with-dim attribute model.
3.1.4 Creating an Array with array()
While matrix() handles two dimensions, array() generalises to any number of dimensions. The
syntax is:
r
array(data = NA, dim = length(data), dimnames = NULL)
data: the atomic vector to fill the array.
dim: a numeric vector specifying the length along each dimension (rows, columns, bands,
time steps, etc.).
dimnames: optional list of names for each dimension.
For a 3D array representing a multispectral image of 100 rows, 100 columns, and 6 bands, you
might write:
r
spectral_cube <- array(runif(100 * 100 * 6), dim = c(100, 100, 6))
The dimensions are always specified in the order (row, column, band) if we follow R’s
convention, though note that many geospatial formats store band as the first
dimension. terra handles this translation; for our own arrays, we will consistently use (row,
column, layer).
Arrays, like matrices, are filled column-wise: the first dimension varies fastest, then the second,
then the third. In our cube, the first 100 elements fill the first column of the first band; the next
100 fill the second column of the first band; after 100×100 elements, the first band is complete,
and the next 100×100 fill the second band. This ordering is crucial when you later extract subsets
by band.
You can create higher-dimensional arrays for temporal stacks or multi-variable data. For
example, a climate data cube with dimensions (longitude, latitude, time, variable) would be a 4D
array. The principles remain identical.
3.1.5 Row and Column Names
Both matrices and arrays can carry dimension names, making them self-describing. For a matrix,
provide a list of two character vectors:
r
mat <- matrix(1:9, nrow = 3, ncol = 3,
dimnames = list(c("R1", "R2", "R3"), c("C1", "C2", "C3")))
mat
# C1 C2 C3
# R1 1 4 7
# R2 2 5 8
# R3 3 6 9
Dimension names are particularly valuable for confusion matrices in accuracy assessment, for
cross-tabulation tables, and for any matrix where rows and columns have distinct meanings (e.g.,
origin–destination flow matrices). They also enable indexing by name: mat["R2", "C3"].
For arrays, the dimnames argument takes a list with as many elements as dimensions. For
example:
r
cube <- array(1:24, dim = c(2,3,4),
dimnames = list(c("row1","row2"), c("col1","col2","col3"), paste0("band",1:4)))
While we will not overuse dimension names—spatial objects in sf and terra have their own
naming conventions—knowing the mechanism is important for tabular outputs.
3.1.6 Geospatial Context: From Grids to Images
Let us now explicitly map these abstractions onto geospatial concepts. A single-band satellite
image (e.g., a panchromatic band) can be represented as a numeric matrix where:
Each row corresponds to a scan line (y-axis, latitude or northing).
Each column corresponds to a pixel position (x-axis, longitude or easting).
Each cell value is a reflectance, radiance, or digital number.
A multispectral image with k bands is a 3D array of dimensions (nrow, ncol, k). If you want to
extract the 3rd band, you would take image[, , 3]—a matrix. To extract a single pixel’s spectral
profile across all bands, you take image[row, col, ]—a vector of length k.
A digital elevation model (DEM) is a matrix of elevation values. Slope, aspect, and hillshade are
derived by local operations on this matrix, using the relationships between a cell and its
neighbours—an operation we will simulate in Chapter 3’s later sections and implement fully
with terra in Part III.
A time series of NDVI images for the same area becomes a 3D array (nrow, ncol, time).
Computing the mean NDVI over time reduces the third dimension to a single matrix via apply(X,
c(1,2), mean, [Link] = TRUE). We will learn apply() in Section 3.4.
Thus, every spatial data structure you will use ultimately rests on the matrix and array
fundamentals we are building now. Master the constructor and the mental model, and you will
find the transition to terra’s SpatRaster and sf’s geometries a natural progression.
3.1.7 Technical Demonstration: Constructing and Inspecting Grids
Open an R script and run the following code block by block, observing how each matrix and
array is built and displayed.
r
# ---------- 1. Simple matrix, column-wise fill ----------
elev <- matrix(c(1500, 1600, 1700, 1800, 1900, 2000), nrow = 2, ncol = 3)
elev
# [,1] [,2] [,3]
# [1,] 1500 1700 1900
# [2,] 1600 1800 2000
# ---------- 2. Matrix with row-wise fill ----------
elev_row <- matrix(c(1500, 1600, 1700, 1800, 1900, 2000), nrow = 2, ncol = 3, byrow = TRUE)
elev_row
# [,1] [,2] [,3]
# [1,] 1500 1600 1700
# [2,] 1800 1900 2000
# ---------- 3. Inspecting dimensions ----------
dim(elev) #23
nrow(elev) #2
ncol(elev) #3
length(elev) #6
str(elev) # num [1:2, 1:3] 1500 1600 1700 1800 1900 2000
# ---------- 4. Underlying vector access ----------
[Link](elev) # 1500 1600 1700 1800 1900 2000 (column-major)
elev[4] # 1800 (the 4th element in column-major order)
# ---------- 5. Adding row and column names ----------
rownames(elev) <- c("Transect1", "Transect2")
colnames(elev) <- c("P1", "P2", "P3")
elev
# P1 P2 P3
# Transect1 1500 1700 1900
# Transect2 1600 1800 2000
elev["Transect1", "P2"] # 1700
# ---------- 6. Creating a 3D array (multispectral miniature) ----------
# Simulate a tiny 3x3 pixel image with 3 bands (RGB)
r_band <- matrix(c(0.1, 0.2, 0.3, 0.1, 0.2, 0.3, 0.1, 0.2, 0.3), nrow = 3, byrow = TRUE)
g_band <- matrix(rep(0.5, 9), nrow = 3)
b_band <- matrix(seq(0.9, 0.1, [Link] = 9), nrow = 3, byrow = TRUE)
# Combine into a 3x3x3 array
rgb_cube <- array(c(r_band, g_band, b_band), dim = c(3, 3, 3))
rgb_cube
# Examine dimensions
dim(rgb_cube) # 3 3 3
# Extract the green band (2nd layer)
green <- rgb_cube[, , 2]
green
# ---------- 7. Recycling in matrix creation (with care) ----------
pattern <- matrix(c(1, 0), nrow = 4, ncol = 4) # length 2 recycled to 16, fills column-wise
pattern
# [,1] [,2] [,3] [,4]
# [1,] 1 1 1 1
# [2,] 0 0 0 0
# [3,] 1 1 1 1
# [4,] 0 0 0 0
# Note: this creates a pattern of alternating rows, not columns, due to column-wise fill.
# To get alternating columns, use byrow=TRUE and recycle appropriately.
Hard Practice 3.1 – “Building a Synthetic Satellite Image Cube”
Objective:
Construct matrices and arrays that mimic a multispectral satellite image and a DEM. Practice
using matrix(), array(), dimension inspection, and vector-matrix duality. No external data are
used; this is pure construction and manipulation.
Scenario:
You are developing a teaching tool that simulates a small 10-row × 10-column study area with
four spectral bands (Blue, Green, Red, NIR) and a DEM. You will build these layers as matrices
and assemble them into an array, then extract subsets.
Instructions:
1. Create a new script practice_3_1_matrix_array.R. Add a header comment.
2. Create a DEM matrix:
o Set [Link](123). Generate a 10×10 matrix dem with values from a normal
distribution with mean 2500 m and standard deviation 500 m: rnorm(100, mean =
2500, sd = 500). Use matrix() to shape it into 10 rows and 10 columns, filled
column-wise.
o Assign row names R1 through R10 and column
names C1 through C10 (use paste0).
3. Create spectral band matrices:
o Blue band: uniform noise between 0.05 and 0.15 (runif(100, 0.05, 0.15)).
o Green band: uniform noise between 0.10 and 0.25.
o Red band: uniform noise between 0.05 and 0.20.
o NIR band: uniform noise between 0.30 and 0.60.
Shape each into a 10×10 matrix, column-wise. Use a different [Link] for each
band or run them sequentially; just ensure reproducible randomness.
4. Assemble into a 3D array:
Combine the four bands into a single array image_cube with dimensions (10, 10, 4).
Name the third dimension c("Blue", "Green", "Red", "NIR") using dimnames.
Use array() and pass the flattened matrices in order: c(blue, green, red, nir). Ensure the
filling order is correct: column-wise within each band, then band by band.
5. Inspect the objects:
o Print dim(image_cube) and dimnames(image_cube).
o Print the DEM matrix with row and column names.
o Use str() on the image cube.
6. Extract subsets:
o Extract the NIR band (4th layer) and assign to nir_band.
o Extract the pixel at row 5, column 7 for all bands (a spectral profile).
o Extract the entire row 3 from the DEM using matrix indexing.
7. Vector-matrix duality check:
o Show that image_cube[47] gives the same value as image_cube[7, 5, 1]? Let’s
compute: element 47 in column-major order of the entire 3D array. First, the flat
vector length is 100*4=400. The indexing goes: all rows of col1 band1 (10), then
col2 band1 (10), ... up to 100 for band1; then band2, etc. Element 47 is in band1,
at (row=7, col=5) because 46 = 4 full columns (40) + 6 rows into the 5th column?
Let’s just let students experiment. The point is to demonstrate vector indexing
works.
8. Add a simple mask:
o Create a logical matrix high_elevation that is TRUE where dem > 2600. Print the
matrix and count the number of high-elevation pixels.
9. Comment your observations:
o At the end, write a comment block describing how array() orders elements across
bands, and why the vector access still works.
Reflection (in notebook):
Why is a matrix called a “vector with dimensions”? How does this help in geospatial
analysis?
What is the difference between column-wise and row-wise fill, and why does it matter
when you read a raster file into a matrix?
How would you add a fifth band (e.g., SWIR) to the existing cube without rebuilding
from scratch? (Hint: abind or simply create a new array, but think about the principle.)
Deliverable:
The script practice_3_1_matrix_array.R with all steps and comments, running without errors.
3.2 Row- and Column-wise Operations, Matrix Algebra
A matrix is more than a passive grid of numbers; it is a computational surface on which you can
perform operations that respect its two-dimensional structure. You can add a constant to every
cell, multiply two matrices element-wise, compute the sum of each row, or perform true matrix
multiplication. This section introduces the three fundamental ways R operates on
matrices: element-wise vectorised operations, row- and column-wise summary
with apply() (and its faster relatives), and matrix algebra (multiplication, transposition,
inversion). Each operation is explained in theory, demonstrated with code, and linked explicitly
to geospatial tasks: applying a calibration gain to a satellite band, computing zonal statistics,
and solving the normal equations of a least-squares adjustment in photogrammetry. By the end,
you will see matrices not as tables but as mathematical objects ready to be transformed.
3.2.1 Element-Wise Operations: The Vectorised Matrix
Because a matrix is an atomic vector with a dim attribute, every vectorised operation from
Chapter 2 applies directly to matrices—element by element, in column-major order. When you
write mat + 10, R adds 10 to every cell, exactly as it would to a flat vector. The result is a matrix
of the same dimensions.
Arithmetic and Comparison with a Scalar
A single number (a vector of length 1) is recycled over the entire matrix:
r
m <- matrix(1:9, nrow = 3)
m + 10
# [,1] [,2] [,3]
# [1,] 11 14 17
# [2,] 12 15 18
# [3,] 13 16 19
All arithmetic (+, -, *, /, ^) and comparison operators (>, <, ==) are vectorised and respect the
matrix shape:
r
m>5
# [,1] [,2] [,3]
# [1,] FALSE TRUE TRUE
# [2,] FALSE TRUE TRUE
# [3,] FALSE TRUE TRUE
The output is a logical matrix. This is the direct analogue of a raster threshold operation: dem >
3000 returns a binary mask of all cells above 3000 m.
Element-Wise Operations Between Two Matrices
If two matrices have the same dimensions, arithmetic and comparison are performed cell by cell,
aligning by position:
r
a <- matrix(1:6, nrow = 2)
b <- matrix(rep(10, 6), nrow = 2)
a+b
a * b # element-wise multiplication, not matrix multiplication
This is how you combine two spectral bands pixel by pixel, for example computing NDVI
as (NIR - Red) / (NIR + Red), assuming NIR and Red are matrices of equal size.
Recycling Between a Matrix and a Vector
When you add a vector to a matrix, the vector is recycled in column-major order to match the
matrix length. This is critical and sometimes counter-intuitive.
r
m <- matrix(1:9, nrow = 3)
v <- c(10, 20, 30)
m+v
# [,1] [,2] [,3]
# [1,] 11 24 37
# [2,] 22 15? Wait, let's compute:
# m: 1 2 3 4 5 6 7 8 9 in column-major: column1: 1,2,3; column2: 4,5,6; column3: 7,8,9.
# v = c(10,20,30) recycled: 10,20,30,10,20,30,10,20,30.
# So m+v:
# column1: 1+10=11, 2+20=22, 3+30=33
# column2: 4+10=14, 5+20=25, 6+30=36
# column3: 7+10=17, 8+20=28, 9+30=39
# Result:
# [,1] [,2] [,3]
# [1,] 11 14 17
# [2,] 22 25 28
# [3,] 33 36 39
If you want to add a different constant to each row, you can supply a vector of row-length and it
will recycle column-wise, effectively adding row-wise corrections because columns are filled
down. But to add a different constant to each column, you must supply a matrix or transpose
appropriately. We will explore this nuance in the Hard Practice.
The key warning: recycling along columns can produce unexpected results if you think
row-wise. Always verify with a small example before applying to large rasters.
The sweep() function offers a safer, more explicit alternative for row- or column-wise operations
with a vector, which we will introduce shortly.
3.2.2 Row and Column Summaries with apply()
The apply() function is the primary tool for collapsing one or more dimensions of an array by
applying a function. Its signature for matrices is:
r
apply(X, MARGIN, FUN, ...)
X: the matrix or array.
MARGIN: an integer indicating which dimensions to retain. 1 means operate over rows
(the result has one value per row); 2 means operate over columns; c(1,2) for
multi-dimensional summaries.
FUN: the function to apply, e.g., mean, sum, sd, quantile, or a custom function.
...: additional arguments passed to FUN, such as [Link] = TRUE.
Row-wise and Column-wise Statistics
Given a DEM matrix dem of 100×100 cells, apply(dem, 1, mean) returns a vector of length 100
containing the mean elevation of each row. apply(dem, 2, mean) returns the mean of each
column.
r
dem <- matrix(rnorm(16, mean = 2500, sd = 500), nrow = 4)
row_means <- apply(dem, 1, mean) # length 4
col_means <- apply(dem, 2, mean) # length 4
You can also use sum to get total precipitation per month from a matrix where rows are days and
columns are months, or max to find the peak elevation per profile line.
Passing Arguments to FUN
To ignore NAs, pass [Link] = TRUE via the ...:
r
dem[2, 3] <- NA
apply(dem, 1, mean, [Link] = TRUE)
Beyond mean and sum: Any Function Works
You can use quantile to get the 5th and 95th percentiles per row, or a custom function defined
with function(x) { ... }. For instance, the range (max – min) per column:
r
apply(dem, 2, function(x) diff(range(x, [Link] = TRUE)))
This flexibility makes apply() a workhorse for spatial statistics.
Efficiency Note: rowSums(), colMeans() etc.
While apply() is general, specialised functions
like rowSums(), rowMeans(), colSums(), colMeans() are implemented in C and are significantly
faster for large matrices. For example:
r
rowSums(dem, [Link] = TRUE)
colMeans(dem, [Link] = TRUE)
Whenever your operation is a simple sum or mean, prefer these functions. For arbitrary
functions, apply() remains indispensable.
3.2.3 The sweep() Function: Explicit Row or Column Operations
sweep() is designed exactly for the case where you want to subtract, add, multiply, or divide each
row or column by a corresponding value from a vector—without the ambiguity of recycling.
r
sweep(X, MARGIN, STATS, FUN, ...)
MARGIN: 1 for rows, 2 for columns.
STATS: the vector of values to be swept out (one per row if MARGIN=1, one per column
if MARGIN=2).
FUN: the operation, typically "-", "+", "*", "/".
Suppose you have a matrix of band values and you want to subtract the mean of each column
(column-wise centering):
r
band_mat <- matrix(rnorm(20), nrow = 5)
col_means <- colMeans(band_mat)
centered <- sweep(band_mat, 2, col_means, FUN = "-")
This is safer and more readable than relying on recycling. Geospatial example: subtract the
atmospheric path radiance (a different constant per band) from each column of a matrix
representing multispectral pixels.
3.2.4 Matrix Algebra: Multiplication, Transposition, Inversion
R has a complete suite of matrix algebra operators, which are essential for spatial statistics
(kriging, regression) and photogrammetric adjustments.
Transposition
t(X) returns the transpose of a matrix, swapping rows and columns.
r
A <- matrix(1:6, nrow = 2)
A
# [,1] [,2] [,3]
# [1,] 1 3 5
# [2,] 2 4 6
t(A)
# [,1] [,2]
# [1,] 1 2
# [2,] 3 4
# [3,] 5 6
Transposition is used constantly in linear models and covariance matrix manipulations.
Matrix Multiplication
%*% is the operator for true matrix multiplication, not element-wise. For two matrices A (m ×
n) and B (n × p), A %*% B produces an (m × p) matrix.
r
A <- matrix(1:4, nrow = 2)
B <- matrix(5:8, nrow = 2)
A %*% B # 2x2 * 2x2 = 2x2
# [,1] [,2]
# [1,] 23 31
# [2,] 34 46
Matrix multiplication is associative but not commutative. It is fundamental for:
Computing the inverse of a design matrix in least squares: (X'X)^{-1} X' y.
Applying a rotation matrix to coordinates.
Transforming feature spaces in multivariate statistics.
Matrix Inversion
solve(A) returns the inverse of a square, non-singular matrix A. It is numerically stable
when A is well-conditioned.
r
A <- matrix(c(2, 1, 1, 3), nrow = 2)
A_inv <- solve(A)
A_inv
# [,1] [,2]
# [1,] 0.6 -0.2
# [2,] -0.2 0.4
A %*% A_inv # identity
solve(A, b) can also solve the linear system A x = b directly, which is more efficient than
inverting A. For example, solving for coefficients in a linear regression:
r
X <- matrix(c(1, 1, 1, 1, 2, 3), nrow = 3) # design matrix with intercept and slope
y <- c(2, 4, 6)
coeff <- solve(t(X) %*% X, t(X) %*% y) # (X'X)^-1 X'y
The Identity Matrix and Diagonal Operations
diag(x) can either extract the diagonal of a matrix, or create a diagonal matrix from a vector:
r
diag(3) # 3x3 identity matrix
diag(c(1,2,3)) # diagonal matrix with 1,2,3 on diagonal
diag(A) # extract diagonal of A
These are used in variance-covariance matrices and in spatial weights normalisation.
Eigenvalues and Singular Value Decomposition
eigen(A) returns eigenvalues and eigenvectors of a symmetric matrix. svd(A) performs singular
value decomposition, useful in Principal Component Analysis (PCA) of multispectral imagery.
PCA reduces the dimensionality of a stack of correlated bands by finding orthogonal axes of
maximum variance—a core remote sensing technique. In R, you can directly apply prcomp() on
a matrix of pixels × bands, but under the hood it’s svd or eigen. Understanding the matrix algebra
behind these methods is part of high-level geospatial competence.
3.2.5 Geospatial Context: Matrix Algebra in Photogrammetry and Spatial Statistics
Matrix operations are not abstract exercises. They are the mathematical machinery of:
Bundle adjustment in photogrammetry: solving for camera parameters and 3D point
coordinates via iterative linearisation and normal equations (solve).
Spatial regression (Chapters 19): the maximum likelihood estimation of SAR and SEM
models involves log-determinants of large sparse matrices.
Kriging (Chapter 20): solving the kriging system requires inversion of the covariance
matrix among sample points.
Radiometric calibration: applying a gain-offset correction to a raw image is an
element-wise matrix operation: calibrated <- gain * raw + offset,
where gain and offset are constants or, more generally, matrices matching the sensor’s
pixel-wise response.
Thus, matrix algebra is not a detour from geoinformatics; it is the language in which spatial
algorithms are written.
3.2.6 Technical Demonstration: Matrix Operations in Practice
Run the following code in a script, observing each output and relating it to the theory above.
r
# ---------- 1. Element-wise operations ----------
m <- matrix(1:9, nrow = 3)
m+2 # add scalar
m*3 # multiply scalar
m>4 # logical matrix
# Element-wise between two matrices
m2 <- matrix(rep(10, 9), nrow = 3)
m + m2
m * m2
# ---------- 2. Recycling ambiguity ----------
v <- c(1, 2, 3)
m + v # column-wise recycling: adds 1,2,3 to each column
# To add row-wise, you need to transpose or use sweep
t(t(m) + c(1,2,3)) # trick: transpose, add, transpose back
# Or use sweep
sweep(m, 1, c(1,2,3), "+") # add 1,2,3 to rows 1,2,3 respectively
# ---------- 3. Row and column summaries ----------
mat <- matrix(rnorm(20, mean=10, sd=2), nrow=5)
rowMeans(mat)
colSums(mat)
apply(mat, 1, median)
apply(mat, 2, sd)
# ---------- 4. Matrix algebra ----------
A <- matrix(c(2,3,1,4), nrow=2)
B <- matrix(c(1,0,2,1), nrow=2)
A %*% B # matrix product
t(A) # transpose
solve(A) # inverse
A %*% solve(A) # identity
# ---------- 5. Solving linear system ----------
X <- cbind(1, 1:5) # design matrix: intercept + variable
y <- c(3, 5, 7, 9, 11)
beta <- solve(t(X) %*% X, t(X) %*% y)
beta # intercept=1, slope=2
# ---------- 6. Eigen decomposition ----------
cov_mat <- matrix(c(2,1,1,2), nrow=2)
eigen(cov_mat)
Hard Practice 3.2 – “Matrix Operations on a Synthetic Satellite Band”
Objective:
Perform element-wise calibration, row/column statistics, and matrix algebra on a synthetic
single-band satellite image. Use apply(), sweep(), and matrix multiplication to simulate parts of a
radiometric and geometric correction pipeline.
Scenario:
You have a raw digital number (DN) image from a push-broom sensor: a 20-row × 30-column
matrix. Each row was scanned by a different detector element that has its own gain and offset.
You must convert DN to radiance, correct a column-wise striping pattern, and compute a simple
sharpening filter.
Instructions:
1. Create the raw image:
Set [Link](42). Generate raw_DN <- matrix(sample(50:200, 20*30, replace = TRUE),
nrow = 20, ncol = 30). Print the first 5 rows and columns.
2. Detector-specific calibration (row-wise):
Assume each of the 20 detectors has a gain gain_det and offset offset_det. Create these as
random vectors:
gain_det <- runif(20, 0.8, 1.2)
offset_det <- runif(20, -5, 5)
Apply the calibration to convert DN to radiance: radiance = gain * DN + offset. Because
each row corresponds to a detector, you need a row-wise operation. Use sweep() to
achieve this without loops. (Hint: first multiply each row by its gain, then add the
offset. sweep(raw_DN, 1, gain_det, "*") then sweep(..., 1, offset_det, "+").) Store result
as radiance.
3. Remove column striping (column-wise):
After calibration, the image still shows vertical stripes due to column-varying
illumination. Compute the mean radiance of each column with colMeans(). Subtract these
column means from the radiance matrix using sweep() (or by recycling via t(t(radiance) -
col_means)—try both and verify they agree). This is a simple destriping. Store
as corrected.
4. Row and column statistics:
o Compute the mean radiance per row (the across-track profile) using rowMeans().
Plot it with plot(row_means, type = "l").
o Compute the standard deviation per column using apply(corrected, 2, sd). Plot it.
o Find the row with the maximum mean radiance and the column with the
minimum standard deviation.
5. Apply a simple smoothing filter via matrix multiplication:
Create a 3×3 averaging filter matrix F:
F <- matrix(1/9, nrow = 3, ncol = 3)
A full 2D convolution with F on the corrected image is not a simple matrix
multiplication; it requires handling edges. Instead, we'll do a 1D smoothing on the
columns: multiply the image (20×30) by a 30×30 smoothing matrix? Not straightforward.
As a simplified exercise, use matrix multiplication to apply a 1D filter to the rows: Create
a 30×30 tridiagonal smoothing matrix S where each row has weights (0.25, 0.5, 0.25)
centered on the diagonal. Then smoothed <- corrected %*% S. This blurs each row.
Interpret the result.
To construct S, use diag():
r
S <- diag(0.5, 30)
S[abs(row(S) - col(S)) == 1] <- 0.25
S[1,1] <- 0.75; S[1,2] <- 0.25 # handle first row edge
S[30,30] <- 0.75; S[30,29] <- 0.25 # last row
Then compute smoothed_rows <- corrected %*% S.
6. Verify smoothing effect:
Compute the standard deviation of each row before and after smoothing. Compare the
first row’s values.
7. Reflection:
o Why did we use sweep() instead of direct recycling for the detector calibration?
o Explain the difference between element-wise multiplication A * B and matrix
multiplication A %*% B.
o In what real geospatial scenario would matrix inversion be used? (Hint: think
about kriging.)
Deliverable:
The script practice_3_2_rowcol_ops.R with comments, plots, and printed comparisons.
3.3 Array Indexing and Slicing for Multispectral Thinking
Now that you can build a matrix or an array, the next essential skill is to extract precisely the
data you need. A multispectral image cube contains rows, columns, and spectral bands; a
spatio-temporal stack adds time. The ability to select a single band as a matrix, a single pixel’s
spectral profile as a vector, or a spatial subset of all bands is the equivalent of cropping, band
extraction, and point query in a desktop GIS. This section introduces R’s indexing system for
arrays of any dimensionality, explains the logic of slicing, and pays special attention to
the drop argument that prevents R from silently collapsing dimensions you wish to keep. We will
think in terms of geographic coordinates (row = y, column = x, layer = band) and build an
intuition that directly transfers to the terra and stars packages later. By the end, you will be able
to navigate a data cube as fluently as you read a map.
3.3.1 The Indexing System for Multi-Dimensional Data
R’s array indexing extends the single-bracket [ ] operator to multiple dimensions, separated by
commas. For a 3D array A, the syntax is:
r
A[rows, columns, layers]
Each comma-separated position can be:
A positive integer vector: selects those specific indices.
A negative integer vector: excludes those indices.
A logical vector of the same length as that dimension: selects positions where TRUE.
Empty (blank): selects all elements along that dimension.
A character vector if dimension names exist.
This system is identical in principle to vector indexing (Section 2.3), but now you must specify a
selection for each dimension. Missing a dimension or using a single index treats the array as a
flat vector in column-major order, which we touched on in Section 3.1. For deliberate
work, always use the multi-index form to avoid confusion.
The order of dimensions in R is (row, column, layer). In geospatial imagery, rows correspond to
northing (y), columns to easting (x), and layers to spectral bands. While the convention is not
universal—some GIS formats store band as the first dimension—R’s terra and stars packages
standardise on (y, x, band) after import, aligning with this indexing logic. You can therefore think
of A[5, 10, 3] as “the pixel at row 5, column 10, in band 3”.
3.3.2 Indexing with [ , , ]: Rows, Columns, and Layers
Let us create a small 3D array to serve as our working model:
r
# A 4-row, 5-column, 3-band array
cube <- array(1:60, dim = c(4, 5, 3))
dim(cube) # 4 5 3
The values 1:60 fill the array column-wise, then band-wise, but as long as we index by position,
we need not worry about the internal order.
Selecting a single element:
r
cube[2, 3, 1] # row 2, col 3, band 1
Selecting a whole row: (all columns, one band)
r
cube[2, , 1] # vector of length 5 (row 2, all columns, band 1)
Selecting a whole column: (all rows, one band)
r
cube[, 3, 1] # vector of length 4 (all rows, column 3, band 1)
Selecting an entire band as a matrix:
r
band2 <- cube[, , 2] # 4x5 matrix
Selecting a spectral profile (all bands) for a single pixel:
r
cube[2, 3, ] # vector of length 3: values at row=2, col=3 across all bands
Selecting multiple rows and columns:
r
cube[1:2, c(3,5), 1] # rows 1-2, columns 3 and 5, band 1 => 2x2 matrix
Using negative indices to exclude:
r
cube[-1, , 1] # all rows except the first, all columns, band 1 => 3x5 matrix
cube[, -c(2,4), 2] # all rows, all columns except 2 and 4, band 2 => 4x3 matrix
The ability to mix integer ranges, vectors, and negative indices in a single [ ] call is what makes
array slicing so powerful.
3.3.3 Slicing by Dimension: Extracting Bands, Profiles, and Sub-Regions
The real art of working with data cubes is in extracting sub-cubes (spatial subsets of all bands),
spectral transects, and individual pixel time-series. Let us formalise these operations.
Extracting a Band (Matrix)
As shown, cube[, , k] returns a matrix of the k-th band. This is equivalent to splitting a
multi-band GeoTIFF into separate single-band rasters. In terra, rast[[k]] does the same
conceptually.
Extracting a Spatial Subset (Sub-Cube)
To extract a rectangular region of interest (ROI) across all bands, you provide row and column
ranges and leave the band index blank:
r
roi <- cube[2:3, 1:4, ] # rows 2 to 3, columns 1 to 4, all bands => 2x4x3 array
This is the array analogue of cropping a raster. In a GIS, you would draw a box; in R, you
specify the numeric indices. If you know the real-world coordinates, you can first find the
nearest row and column indices and then slice.
Extracting a Spectral Profile (Vector)
cube[r, c, ] returns a vector of band values at that pixel. For a satellite image, this is the spectrum
of that ground location. You can later plot it with plot(spectrum, type = "l") to visualise the
spectral signature—a key step in remote sensing analysis.
Extracting a Spectral Transect (Matrix)
If you want to see how reflectance changes along a line of pixels, you can extract a single row
and all bands: cube[r, , ]. This yields a matrix with dimensions columns × bands. Similarly, a
column transect is cube[, c, ] (rows × bands). These matrices can be plotted as multi-line charts
showing spectral variation across space.
3.3.4 Using Logical Masks in Array Indexing
Logical subsetting, so useful on vectors, extends naturally to arrays. You can pass a logical array
of the same dimensions to select elements—but the result is a flat vector of values where the
mask is TRUE, not a structured array. This is useful for extracting all pixels that satisfy a
condition across the whole cube.
r
# Where in band 1 is the value > 20?
mask <- cube[, , 1] > 20 # logical matrix (4x5)
# Select those positions across all bands?
# This is trickier: you want the rows and columns where band1 > 20, for all bands.
To extract a spatial subset based on a condition in one band, you can generate the logical matrix
from that band, then use which(mask, [Link] = TRUE) to get row-column indices, and then
extract from the cube. However, a more direct R idiom is to use mask to subset the rows and
columns in the first two dimensions, but R’s [ ] does not accept a logical matrix directly to subset
both rows and columns simultaneously; you need to convert to integer indices.
The cleaner approach is:
r
idx <- which(mask, [Link] = TRUE) # two-column matrix of row,col
rows <- idx[,1]; cols <- idx[,2]
# Now we can't simply use cube[rows, cols, ] because that would create a grid of all
combinations.
# Instead, we can extract a data frame of pixel spectra:
spectra <- apply(idx, 1, function(rc) cube[rc[1], rc[2], ]) # matrix: bands x n_pixels
This is a preview of the kind of extraction you would do to build a training set for classification:
find all pixels with a known land cover, extract their spectral values, and assemble a matrix for
model fitting.
For simpler cases, you might just want to mask out certain values, replacing them with NA. That
is best done with element-wise replacement:
r
cube_clean <- cube
cube_clean[cube_clean < 0] <- NA
This works because the [ ] with a logical array of the same dimensions returns a vector of the
elements to replace, and assignment works in place.
3.3.5 Advanced: Dropping Dimensions and the drop Argument
When you select a single layer, R by default drops that dimension: cube[, , 1] returns a matrix,
not a 3D array with a singleton third dimension. Similarly, cube[2, , 1] returns a vector, not a
1-row matrix. This dropping is usually what you want, but occasionally you need to preserve the
dimensionality, especially when passing results to functions that expect a certain number of
dimensions.
The drop argument inside [ ] controls this behaviour. By default drop = TRUE. To keep
dimensions, set drop = FALSE:
r
cube[, , 1, drop = FALSE] # 4x5x1 array (still 3D)
cube[2, , 1, drop = FALSE] # 1x5 matrix (rows=1, cols=5)
cube[2, 3, , drop = FALSE] # 1x1x3 array
In geospatial analysis, you might need drop = FALSE when you want to select a single band but
still treat it as a 3D array for a function that iterates over layers, or when you want to maintain
the class of a SpatRaster object. The habit of specifying drop = FALSE when in doubt prevents
hard-to-diagnose errors where a function receives a matrix when it expects an array.
3.3.6 Indexing with Matrices and Arrays
R allows you to index an array with a matrix where each row is a set of coordinates. For a 3D
array, a matrix with three columns (row, col, layer) extracts the specific elements at those
coordinates:
r
coords <- rbind(c(1,1,1), c(2,3,2), c(4,5,3))
cube[coords] # vector of 3 values
This is a highly efficient way to extract scattered points from a data cube, such as the values at
GPS-tagged field sample locations. When you have a data frame of x, y coordinates and want to
extract all bands for those points, you can compute row/col indices (if the raster grid is regular)
and then build a coordinate matrix to get the values quickly.
This technique is the basis of the cellFromXY and extract functions in terra, which do the heavy
lifting of converting spatial coordinates to array indices.
3.3.7 Geospatial Context: The Spectral Cube as a Working Model
Let us concretise these operations with a realistic miniature scene. Suppose you have a Landsat-8
OLI 3×3 pixel image with 7 bands. You have already constructed such a cube in Hard Practice
3.1. Now consider the kinds of questions you can answer with array indexing:
Display a natural-color composite: Extract bands 4 (Red), 3 (Green), 2 (Blue) as
matrices and use rgb() to create a color image (we'll do this later with ggplot2 or
base plotRGB).
Compute NDVI: ndvi <- (nir - red) / (nir + red) where nir is cube[, , 5] and red is cube[, ,
4]. This operation is vectorised and produces a matrix.
Plot a spectral signature: plot(cube[2, 2, ], type = "l") shows the reflectance curve of the
central pixel.
Select training pixels: Identify water pixels using a condition like cube[, , 5] < 0.1 (low
NIR), then extract their spectra for classification.
Mask clouds: Using a logical matrix from band 1 (coastal aerosol) or a thermal band, set
those pixels to NA across all bands: cube[cloud_mask, ] <- NA?
Actually, cube[cloud_mask, ] would treat cloud_mask as a vector index, not a 2D mask.
To mask out entire spatial pixels, you need to replicate the mask across bands and
use cube[rep(cloud_mask, times = 7) & ...]? The simpler way is to use a loop or apply,
but we will see how terra handles this elegantly.
But even at this raw array level, the mental model is the same: each band is a matrix; a pixel’s
spectrum is a vector; a spatial subset is a sub-array. When you later use terra::rast(), these
operations become functions like [[, extract, and crop, but the underlying logic remains.
3.3.8 Technical Demonstration: Slicing a Data Cube
Let us put the indexing into practice with a small, manually constructed cube that we can inspect
thoroughly.
r
# ---------- 1. Build a 3x4x3 array (rows, cols, bands) ----------
[Link](1)
cube <- array(runif(3*4*3, 0, 1), dim = c(3, 4, 3))
# Give dimension names for clarity
dimnames(cube) <- list(
row = paste0("R", 1:3),
col = paste0("C", 1:4),
band = c("Blue", "Green", "Red")
)
cube
# ---------- 2. Extract single band ----------
red_band <- cube[, , "Red"]
red_band # 3x4 matrix
class(red_band) # "matrix" "array"
# ---------- 3. Extract spectral profile ----------
pixel_R2C3 <- cube["R2", "C3", ]
pixel_R2C3 # Blue Green Red for that pixel
# ---------- 4. Spatial subset ----------
subset <- cube[1:2, 2:4, ] # rows 1-2, cols 2-4, all bands
dim(subset) # 2 3 3
# ---------- 5. Logical mask on one band ----------
blue_mask <- cube[, , "Blue"] > 0.5
blue_mask # logical matrix
# Get row-col indices of bright pixels in blue
pos <- which(blue_mask, [Link] = TRUE)
pos
# Extract spectra for those pixels (each row of pos is a pixel)
spectra <- t(apply(pos, 1, function(rc) cube[rc[1], rc[2], ]))
spectra # matrix: n_pixels x 3 bands
# ---------- 6. Using drop = FALSE ----------
one_band_array <- cube[, , "Red", drop = FALSE]
dim(one_band_array) # 3 4 1 (still a 3D array)
one_row_matrix <- cube["R1", , , drop = FALSE]
dim(one_row_matrix) # 1 4 3
# ---------- 7. Coordinate matrix indexing ----------
coord_mat <- rbind(c(1,2,1), c(3,4,2)) # (row, col, band)
cube[coord_mat] # values at those exact coordinates
Observe how each extraction yields a result of the expected dimensionality. The use
of dimnames makes the output self-documenting.
Hard Practice 3.3 – “Extracting Spectral Signatures and Spatial Subsets from a Synthetic
Landsat Cube”
Objective:
Construct a multi-band image cube, then use array indexing to extract bands, pixel spectra,
spatial subsets, and masked pixel collections. Simulate a mini classification workflow by
selecting training pixels based on spectral conditions.
Scenario:
You have a 10×10 pixel, 4-band image (Blue, Green, Red, NIR) simulating a subset of a Landsat
scene. You will identify water, vegetation, and bare soil pixels based on simple spectral rules,
extract their spectra, and compute class means.
Instructions:
1. Create the data cube:
In a script practice_3_3_array_indexing.R, set [Link](55).
Create a 10×10×4 array img using runif(400, 0, 1) (reflectance). Use array() with dim =
c(10, 10, 4).
Name the dimensions: rows 1:10, columns 1:10, bands c("Blue", "Green", "Red", "NIR").
2. Basic extractions:
o Extract the NIR band as a matrix nir.
o Extract the spectral profile of the pixel at row 5, column 8.
o Extract a spatial subset: rows 3–7, columns 2–6, all bands. Print its dimensions.
3. Compute NDVI using matrix operations (no loops):
ndvi <- (img[,,"NIR"] - img[,,"Red"]) / (img[,,"NIR"] + img[,,"Red"]).
Print the NDVI matrix.
4. Identify water pixels:
Water has very low NIR reflectance. Use water_mask <- nir < 0.2 (adjust threshold as
needed).
Get the row-column indices of water pixels using which(water_mask, [Link] = TRUE).
Extract the spectra of all water pixels as a matrix water_spectra (each row a pixel,
columns bands). Hint: use apply on the index matrix as in the demonstration.
5. Identify vegetation pixels:
Vegetation has high NDVI. Use veg_mask <- ndvi > 0.5.
Extract veg_spectra similarly.
6. Identify bare soil pixels:
Bare soil has moderate NIR and low NDVI? For simplicity, use soil_mask <- nir > 0.3 &
ndvi < 0.3.
Extract soil_spectra.
7. Compute mean spectra for each class:
Use colMeans() on each spectra matrix (handle the possibility of zero pixels by using if).
Print the results.
8. Mask out water pixels from the cube:
Create a cleaned cube img_clean where water pixels are set to NA across all bands. One
method: replicate water_mask along the band dimension: water_mask_3d <-
array(water_mask, dim = dim(img)). Then img_clean <- img;
img_clean[water_mask_3d] <- NA. Verify that the water pixel positions are
now NA in img_clean.
9. Use drop = FALSE to extract a single-band cube:
Extract the Blue band as a 3D array of size 10×10×1. Confirm dimensions.
10. Reflection:
o How does R’s ability to index with a coordinate matrix simplify extracting spectra
for selected pixels?
o Why would drop = FALSE be useful in a function that always expects a 3D array?
o Imagine you had a 4D array (rows, cols, bands, time). How would you extract the
NDVI time series for a specific pixel? (Write the indexing expression.)
Deliverable:
The script practice_3_3_array_indexing.R with full comments, printed summaries, and plots if
desired.
3.4 Applying Functions Over Dimensions with apply()
In Section 3.3 you learned to slice a data cube into bands, spectra, and spatial subsets. But a
geospatial scientist does not merely extract data; one must summarise, transform, and reduce it.
How do you compute the mean reflectance of each band across an entire image? How do you
calculate the temporal trend of NDVI for every pixel? How do you apply a custom function to
each row of a matrix without writing a loop? R’s answer is the apply() family—functions that
iterate over the margins of an array, applying any function you choose. This section
examines apply() in depth, then introduces its row- and column-optimised relatives
(rowSums, colMeans, etc.) and the sweep operation. Every concept is tied directly to raster
band statistics, per-pixel time series, and spatial neighbourhood summaries—the foundational
operations of remote sensing and DEM analysis. By the end, you will be able to collapse
multidimensional data along any dimension, a skill that scales directly to
the terra::app() and stars::st_apply() functions we will use on real geospatial rasters in Part III.
3.4.1 The Philosophy of apply(): Thinking in Margins
In R, apply() is the functional gateway to dimension-wise computation. Its conceptual model is
simple: you have an array of data, and you want to apply a function to each slice along one or
more dimensions, producing a result of lower dimensionality. This is exactly the mental
operation of “summarise all columns”, “average over time for each pixel”, or “find the maximum
across bands”.
The word margin here follows the statistical and matrix-algebra sense: a margin is a dimension
over which the function iterates. When you apply over margin 1 (rows), you collapse columns;
when you apply over margin 2 (columns), you collapse rows; when you apply over margin 3
(layers), you collapse the layer dimension, leaving a matrix. For a 3D spatio-spectral array (rows,
columns, bands), applying over margin c(1,2) with mean returns a matrix where each cell is the
mean reflectance across all bands—a single-band summary of the entire cube.
This functional style eliminates explicit loops, aligns with vectorised thinking, and makes the
intention of the code transparent. apply(X, MARGIN, FUN) reads as: “Over the margins of X,
apply FUN.”
3.4.2 The apply() Function: Syntax and Semantics
r
apply(X, MARGIN, FUN, ...)
X: an array (matrix, 3D array, or higher). If you pass a data frame, apply will coerce it to
a matrix first, which can be dangerous if columns have different types; we will
use lapply and friends for data frames later.
MARGIN: an integer vector specifying which dimensions to retain in the output. For a
matrix (2D):
o MARGIN = 1 → function applied to each row; output has one element per row.
o MARGIN = 2 → function applied to each column; output has one element per
column.
o MARGIN = c(1,2) → function applied to each cell? Actually, for a
matrix, c(1,2) iterates over each element; but that’s rarely useful; FUN receives a
single element. For a 3D array, c(1,2) iterates over the third dimension: for each
row-column combination, you get a vector of values across layers, and the result
is a matrix. This is the key to per-pixel summary.
FUN: the function to apply. It must accept a vector (or array slice) as its first argument.
Built-ins like mean, sum, sd, quantile, range work directly.
...: additional arguments passed to FUN, e.g., [Link] = TRUE.
Crucial behaviour: apply returns an object whose dimensions are determined by MARGIN.
If MARGIN is a single integer, the result is a vector (or a 1-dimensional array). If MARGIN has
length > 1, the result is an array of the corresponding dimensions.
3.4.3 Row-wise and Column-wise Operations on a Matrix
Start with a simple matrix:
r
m <- matrix(1:12, nrow = 3, ncol = 4)
m
# [,1] [,2] [,3] [,4]
# [1,] 1 4 7 10
# [2,] 2 5 8 11
# [3,] 3 6 9 12
Row means:
r
apply(m, 1, mean) # [1] 5.5 6.5 7.5
R returns a vector of length 3. Each element is the mean of the corresponding row.
Column sums:
r
apply(m, 2, sum) # [1] 6 15 24 33
Row-wise custom function: e.g., the range (max - min) per row:
r
apply(m, 1, function(x) diff(range(x))) # [1] 9 9 9
Note how we can pass an anonymous function defined on the fly. This is extremely powerful for
calculating per-row statistics that aren’t pre-packaged.
Passing extra arguments: to handle NAs, pass [Link] = TRUE through ...:
r
m_na <- m; m_na[2,3] <- NA
apply(m_na, 1, mean, [Link] = TRUE) # row means ignoring NA
Output dimension when MARGIN has length > 1: For a matrix, applying over c(1,2) with a
function that returns a scalar simply returns the original matrix unchanged because it applies the
function to each single element. Not very useful. But for 3D arrays, it becomes essential.
3.4.4 Applying Over Layers of a 3D Array (Per-Pixel Statistics)
Consider a 3D array img with dimensions (rows, cols, bands). To compute the mean reflectance
across all bands for each pixel, you want to collapse the band dimension. The margin
you retain is the spatial dimensions (rows and columns). Therefore, MARGIN = c(1,2):
r
[Link](1)
img <- array(runif(3*4*3), dim = c(3,4,3))
mean_per_pixel <- apply(img, c(1,2), mean)
mean_per_pixel is a 3×4 matrix where each cell is the mean of the three band values at that
pixel. This is exactly how you create a panchromatic-like summary from a multispectral image.
You can also apply functions that return a vector, like quantile with probs = c(0.25, 0.75). Then
the output dimension expands: for a 3×4 spatial grid, quantile returns 2 values per pixel, so the
result is an array of dimension c(2, 3, 4) (the new dimension from the function’s output becomes
the first dimension). This behaviour is consistent but requires attention. For simplicity, we often
use apply with scalar-returning functions first.
Per-band statistics (collapsing space): To get the mean of each band (collapsing rows and
columns), you apply over margins 1 and 2? Wait, you want to retain the band dimension,
so MARGIN = 3:
r
band_means <- apply(img, 3, mean)
# result: vector of length 3
This gives the mean reflectance of each band across the entire image, a common radiometric
summary.
Per-row (spatial transect) statistics: If you want the mean spectrum along each row (averaging
over columns and bands? or over columns, keeping bands?), you need to think about which
margin to apply. For a 3D array, apply(img, c(1,3), mean) returns a matrix of dimensions rows ×
bands containing the mean reflectance per row and per band, averaging over columns. This is a
spatial-spectral profile.
The key to using apply effectively is to visualise the output: which dimensions do you want to
keep? They become the MARGIN. The function is applied to slices that collapse the remaining
dimensions.
3.4.5 Optimised Relatives: rowSums, colMeans, etc.
For the most common operations—sum and mean—R provides optimised functions that are
significantly faster than apply() because they bypass the R-level loop and call compiled C code
directly:
rowSums(X, [Link] = FALSE) / colSums(X, [Link] = FALSE)
rowMeans(X, [Link] = FALSE) / colMeans(X, [Link] = FALSE)
These work on matrices and arrays, and for sums/means they are the preferred tool. For example,
to compute the total precipitation per station (rows) from a matrix of stations × months:
r
total <- rowSums(precip_matrix, [Link] = TRUE)
To compute the mean temperature per month (columns):
r
monthly_mean <- colMeans(temp_matrix)
For a 3D array, you might combine these with apply when rowSums doesn’t directly apply to
higher dimensions. However, rowSums can be used on arrays if you reshape them, but it’s
usually simpler to use apply with sum. The performance difference matters for very large arrays
(e.g., a 10000×10000 matrix). For the data sizes we work with in teaching, both are fine, but
using the optimised functions shows awareness of performance.
3.4.6 Sweeping: Explicit Row- or Column-wise Operations Without Ambiguity
sweep() is a specialised function for “sweeping out” a summary statistic from an array, i.e.,
subtracting the row means, dividing by column standard deviations, etc. It avoids the recycling
pitfalls we discussed in Section 3.2.
r
sweep(X, MARGIN, STATS, FUN = "-", ...)
MARGIN: 1 for rows, 2 for columns.
STATS: a vector of values to be swept out, length must match the corresponding
dimension.
FUN: usually "-" (subtract), "+", "*", "/".
Example: centre each row of a matrix by subtracting its mean:
r
mat <- matrix(rnorm(20), nrow=4)
row_means <- rowMeans(mat)
centered <- sweep(mat, 1, row_means, FUN = "-")
For column-wise scaling (divide each column by its standard deviation):
r
col_sds <- apply(mat, 2, sd)
scaled <- sweep(mat, 2, col_sds, FUN = "/")
In geospatial contexts, sweep can be used to apply a per-detector gain, to remove atmospheric
path radiance per band, or to normalise spectral indices. It is explicit and readable.
3.4.7 Geospatial Application: Band Statistics, Temporal Reduction, and DEM Derivatives
Let us anchor these functions in real tasks.
Band statistics: Given a multi-band image cube (rows, cols, bands), you can compute the mean
and standard deviation per band for quality assessment:
r
band_means <- apply(cube, 3, mean, [Link] = TRUE)
band_sds <- apply(cube, 3, sd, [Link] = TRUE)
Temporal reduction: If you have a 3D array where the third dimension is time (e.g., monthly
NDVI images stacked as layers), you can compute the long-term mean NDVI per pixel:
r
mean_ndvi <- apply(ndvi_stack, c(1,2), mean, [Link] = TRUE)
And the standard deviation over time (a measure of seasonality or variability):
r
sd_ndvi <- apply(ndvi_stack, c(1,2), sd, [Link] = TRUE)
DEM focal operations: While apply works on the entire matrix, focal (neighbourhood)
operations like slope require a moving window. R’s raster and terra packages have focal() for
that, but conceptually you can simulate a simple focal operation with apply on a pre-constructed
array of neighbourhoods (harder). For now, apply handles global per-pixel operations.
Classification: For each pixel, find the band with maximum reflectance (crude “dominant
band”):
r
max_band <- apply(cube, c(1,2), [Link])
This returns a matrix of integer band indices. You could use that to create a simple classification
map.
3.4.8 Technical Demonstration: apply in Action on a Spectral Cube
We will build a small 4×5 pixel, 4-band cube and perform several reductions.
r
# ---------- 1. Create a 4x5x4 cube (Blue, Green, Red, NIR) ----------
[Link](42)
nrows <- 4; ncols <- 5; nbands <- 4
cube <- array(runif(nrows * ncols * nbands, min = 0, max = 1),
dim = c(nrows, ncols, nbands),
dimnames = list(row = 1:nrows, col = 1:ncols,
band = c("Blue", "Green", "Red", "NIR")))
cube
# ---------- 2. Mean reflectance per band (collapse space) ----------
band_means <- apply(cube, 3, mean)
band_means # vector of length 4
# ---------- 3. Mean reflectance per pixel (collapse bands) ----------
pixel_means <- apply(cube, c(1,2), mean)
pixel_means # 4x5 matrix
# ---------- 4. Standard deviation per pixel across bands ----------
pixel_sds <- apply(cube, c(1,2), sd)
pixel_sds
# ---------- 5. NDVI computation (element-wise on extracted bands) ----------
ndvi <- (cube[,,"NIR"] - cube[,,"Red"]) / (cube[,,"NIR"] + cube[,,"Red"])
ndvi # 4x5 matrix
# ---------- 6. Apply a custom function: dominant band index per pixel ----------
dominant <- apply(cube, c(1,2), function(x) [Link](x))
dominant # matrix with values 1-4
# ---------- 7. Row-wise summary: mean spectrum along row 1 (averaging over columns)
----------
row1_mean_spectrum <- apply(cube[1,,], 1, mean) # cube[1,,] is a 5x4 matrix (cols x bands),
apply over margin 1 (columns) gives mean per band for that row.
row1_mean_spectrum
# ---------- 8. Sweep: subtract band means from all pixels ----------
cube_centered <- sweep(cube, 3, band_means, FUN = "-")
# Check: colMeans of band1 should be ~0
mean(cube_centered[,,1]) # near zero
# ---------- 9. Fast column means of a matrix using colMeans ----------
colMeans(cube[,,1]) # mean of each column for Blue band
Run this code line by line, inspecting each output. The Environment pane will show the
dimensions of each result, reinforcing the margin logic.
Hard Practice 3.4 – “Time-Series Reduction and Spectral Statistics for a Mini NDVI Cube”
Objective:
Apply apply(), sweep(), and fast summary functions to a synthetic NDVI time-series cube.
Compute per-pixel statistics, identify pixels with strong seasonality, and normalise the data.
Scenario:
You have a 10×10 pixel study area with monthly NDVI images over one year (12 layers). The
NDVI values have been simulated with a seasonal cycle and noise. You will compute the annual
mean NDVI, the range (amplitude of seasonality), the coefficient of variation, and then
normalise each pixel’s time series by subtracting its mean and dividing by its standard deviation
(z-score). This is a miniature version of a phenology analysis.
Instructions:
1. Generate the NDVI cube:
In practice_3_4_apply.R, set [Link](100).
Create a 10×10×12 array ndvi_cube. For each pixel (i,j), generate a 12-month time series
as:
r
base <- 0.3 + 0.2 * sin(2 * pi * (1:12) / 12) # seasonal cycle with mean 0.3, amplitude 0.2
noise <- rnorm(12, mean = 0, sd = 0.05)
ts <- base + noise
To fill the cube, use nested loops or sapply? Actually, we want to avoid loops as much as
possible. We can use array operations: create a base matrix of the sine wave for all pixels by
recycling, then add a noise array. Let's construct elegantly:
r
nrow <- 10; ncol <- 10; ntimes <- 12
# Create sinusoidal base for each month
month_seq <- 1:ntimes
base_cycle <- 0.3 + 0.2 * sin(2 * pi * month_seq / 12)
# Replicate to 10x10 grid
base_grid <- array(base_cycle, dim = c(nrow, ncol, ntimes)) # recycling will duplicate across
space? Actually array(base_cycle, dim=...) recycles base_cycle along all dimensions, resulting in
each pixel having the same monthly pattern, which is fine.
# Add spatially uncorrelated noise
noise_grid <- array(rnorm(nrow * ncol * ntimes, mean = 0, sd = 0.05), dim = c(nrow, ncol,
ntimes))
ndvi_cube <- base_grid + noise_grid
# Ensure NDVI within plausible range 0-1 (clamp if needed, but likely fine)
2. Annual mean NDVI per pixel:
Use apply() with c(1,2) and mean to compute the mean NDVI over the 12 months for
each pixel. Store as mean_ndvi. Print the first few rows and columns.
3. Annual range (max - min) per pixel:
Write a custom function calc_range <- function(x) diff(range(x)) and apply it to
get range_ndvi. Print.
4. Coefficient of variation (CV) per pixel:
CV = standard deviation / mean. Use apply twice: once for SD and once for mean, then
element-wise division. (Or define a custom function that returns SD/mean and apply it
directly.) Print.
5. Normalise each pixel’s time series (z-score):
For each pixel, subtract its temporal mean and divide by its temporal standard deviation.
This is a sweep operation across the third dimension (time), but sweep expects a vector of
stats. For each pixel, you have a mean and SD. However, sweep can’t directly take a
matrix of stats for a 3D array. You could use apply to perform the
normalisation: apply(ndvi_cube, c(1,2), function(x) (x - mean(x)) / sd(x)) will return an
array with dimensions (times, rows, cols) because the function returns a vector of length
12. The result’s dimension order will be (times, rows, cols). You can then aperm() to
reorder back to (rows, cols, times). Or use a loop—but to practice apply, we'll do the
functional way.
o Apply the function over margins c(1,2) that returns a 12-element vector. The
output array norm_cube will be 12×10×10. Use aperm(norm_cube, c(2,3,1)) to
get back to 10×10×12. Print the dimensions.
6. Fast column means of the first time slice:
Use colMeans(ndvi_cube[,,1]) to get the mean NDVI of the first month for each column
(a spatial trend). Plot it using plot().
7. Find the pixel with the maximum annual range:
Use which(range_ndvi == max(range_ndvi), [Link] = TRUE) to find its coordinates.
Extract its time series and plot it, highlighting the range.
8. Reflection:
o Explain how MARGIN = c(1,2) collapses the time dimension.
o Why is apply with a custom function sometimes more flexible than rowMeans?
o How would you compute the per-pixel mean growing season NDVI (e.g., months
4–9) without creating a new sub-cube? (Hint: subset the cube inside the function
passed to apply.)
Deliverable:
The script practice_3_4_apply.R with all steps, comments, and plots.
Chapter 3 Review Problems
Congratulations on completing Chapter 3. You have progressed from vectors to matrices to
multidimensional arrays, and you have learned to slice, summarise, and transform these
structures with apply(), sweep(), and matrix algebra. The following problems are designed to
consolidate these skills, each set in a geospatial context that mirrors real satellite or DEM
analysis. Solve each problem in a clearly commented R script. Avoid explicit loops unless
specifically permitted; rely on vectorised matrix operations, apply(), and the indexing
techniques you have mastered.
Easy Problems (1–5)
1. Matrix Creation and Simple Arithmetic
Create a 4×5 matrix dem with values generated from rnorm(20, mean = 1500, sd = 300) after
setting [Link](10).
Convert all elevations from metres to feet by multiplying by 3.28084.
Add a constant offset of 50 metres to every cell (use recycling).
Print the resulting matrix and its dimensions.
2. Row and Column Statistics
Using the same dem matrix (the original, not the modified), compute:
The mean elevation of each row using rowMeans().
The maximum elevation of each column using apply() with max.
Print both results.
3. Extracting a Spectral Profile from an Array
Create a 5×5×3 array rgb of random uniform values (0 to 1) with dimensions named rows,
columns, and c("Red", "Green", "Blue"). Use [Link](7).
Extract the entire Green band as a matrix.
Extract the spectral profile of the pixel at row 3, column 2 (all three bands).
Print both extractions.
4. Logical Mask on a Matrix
Using the Red band from the rgb array (extract it as a matrix), create a logical matrix bright that
is TRUE where Red > 0.7.
Count the number of bright pixels using sum().
Replace those bright pixels with NA in a copy of the Red matrix.
Print the number of bright pixels and the first few rows of the cleaned matrix.
5. Matrix Transposition and Multiplication
Create two matrices:
A <- matrix(c(1, 2, 3, 4), nrow = 2)
B <- matrix(c(0, 1, 1, 0), nrow = 2)
Compute A %*% B (matrix multiplication).
Compute A * B (element-wise multiplication).
Transpose A and print the result.
Explain in a comment the difference between %*% and *.
Medium Problems (6–10)
6. Building a Regular Coordinate Grid and Distance Calculation
Create a 10×10 grid of points representing a study area.
Generate x <- seq(0, 900, [Link] = 10) and y <- seq(0, 900, [Link] = 10).
Use [Link]() to create a data frame of all (x,y) pairs.
Reshape the x-coordinates into a 10×10 matrix x_mat using matrix(x, nrow = 10) (ensure
row-wise or column-wise ordering as appropriate; check with byrow). Do the same for
y-coordinates as y_mat.
Compute the distance from every grid point to the centre point (450, 450) using the
Euclidean formula on the matrices directly: dist_mat <- sqrt((x_mat - 450)^2 + (y_mat -
450)^2). No loops.
Print the distance matrix.
7. Band Ratio and NDVI-style Index
Construct a synthetic 8×8 pixel image with two bands: red and nir.
red <- matrix(runif(64, 0.05, 0.2), nrow = 8)
nir <- matrix(runif(64, 0.25, 0.6), nrow = 8)
Compute the NDVI matrix: ndvi <- (nir - red) / (nir + red).
Create a logical matrix vegetation where NDVI > 0.4.
Use which(vegetation, [Link] = TRUE) to find the row-column coordinates of vegetated
pixels.
Extract the NIR values at those vegetated coordinates using a two-column index matrix
(as demonstrated in Section 3.3).
Print the number of vegetated pixels and their mean NIR reflectance.
8. Column-wise Normalisation with sweep
Create a 6×4 matrix raw_DN of random integers between 50 and 200: raw_DN <-
matrix(sample(50:200, 24), nrow = 6).
Compute the mean and standard deviation of each column using colMeans() and apply().
Use sweep() to centre each column (subtract its mean).
Use sweep() again to scale each centred column by its standard deviation (divide).
Print the original, centred, and scaled matrices.
Verify that the scaled columns have mean approximately 0 and standard deviation 1 by
recomputing column statistics.
9. Per-Pixel Temporal Trend of a Synthetic NDVI Cube
Create a 5×5×10 array ndvi_ts where the third dimension is time (10 years).
For each pixel, simulate a simple linear trend plus noise: the value at year t (1 to 10)
is 0.3 + 0.02 * t + rnorm(1, 0, 0.03). Construct the cube without explicit loops over space
—hint: create a base trend array using outer() or recycling.
Use apply() with c(1,2) and a custom function to compute the slope of NDVI over time
for each pixel. The slope can be obtained via linear regression: coef(lm(y ~ year))[2].
Pass year <- 1:10 inside the function.
The result will be a 5×5 matrix of slopes. Print it.
Identify the pixel with the steepest positive trend.
10. Masking and Replacing in a 3D Array
Create a 4×4×3 array cube of random normal values with mean 0 and sd 1 (rnorm).
Create a logical matrix mask from the first layer (band) where values are below –1.
Replicate this mask across the third dimension to create a 3D logical array of the same
dimensions (use array(mask, dim = dim(cube))).
Set all values corresponding to the mask to NA in a copy of the cube.
Print the original and masked cubes, noting the positions of NA.
Challenging Problems (11–15)
11. Construct a Distance Matrix for Spatial Autocorrelation
You have five sample points with coordinates:
coords <- matrix(c(0, 0, 2, 0, 0, 3, 4, 0, 2, 2), ncol = 2, byrow = TRUE).
Compute the Euclidean distance between every pair of points and store the result in a 5×5
matrix D.
Use matrix operations to avoid a loop: hint, the squared distance matrix can be computed
as outer(rowSums(coords^2), ...) or via dist() and [Link](). But explicitly use dist() is
fine, but then convert to matrix. (The challenge is to think vectorised.)
Create a spatial weights matrix W where W[i,j] = 1 if D[i,j] < 3 and i != j, else 0.
Row-normalize W: divide each row by its sum using sweep() or element-wise division
after computing row sums.
Print the distance matrix, the weights matrix, and the row-normalized weights matrix.
12. Image Convolution with a 3×3 Sobel Filter (Edge Detection)
Create a 10×10 matrix img representing a simple elevation gradient: the value at (i,j) is i + j (row
index + column index).
Define a 3×3 horizontal edge detection kernel:
r
Sobel_x <- matrix(c(-1, 0, 1, -2, 0, 2, -1, 0, 1), nrow = 3, byrow = TRUE)
Perform a 2D convolution of img with Sobel_x without using a convolution function.
Instead, manually extract sub-matrices for each pixel’s 3×3 neighbourhood using array
indexing and compute the gradient. For pixels on the edge, you can either set the result
to NA or use zero-padding. One approach: create an expanded matrix with zero padding
(use cbind(0, rbind(0, img, 0), 0)) and then use vectorised operations on shifted
sub-matrices. (Hint: the gradient at (i,j) is sum(Sobel_x * img_padded[i:(i+2), j:(j+2)]) —
you can compute this for all interior points using a loop over valid indices, or even
vectorise by constructing shifted matrices.)
The challenge is to implement convolution from scratch, but a small loop over the valid
interior indices is acceptable for the sake of clarity. However, aim to minimise loops.
Plot the original image and the resulting gradient magnitude
using image() or [Link]().
13. Simulated Landsat Time Series: Per-Pixel Linear Detrending
Create a 6×6×36 array representing 36 monthly NDVI observations (3 years) for 6×6 pixels.
For each pixel, simulate a seasonal cycle: 0.4 + 0.25 * sin(2 * pi * (1:36) / 12) + 0.02 *
(1:36) / 36 + rnorm(36, 0, 0.04). This includes a small linear trend.
Use apply() with c(1,2) and a custom function that fits a linear model lm(y ~
month) (where month <- 1:36) and returns the residuals from that model. The function
should output a 36-element vector per pixel.
The output array detrended_cube will be 36×6×6 after apply; permute it to 6×6×36
using aperm().
Compute the mean of the detrended time series for each pixel (now the trend is removed)
using apply().
Print the original and detrended mean NDVI for a sample pixel to show the effect.
14. Matrix Inversion for Least-Squares Estimation (Photogrammetry)
A small photogrammetric block adjustment requires solving for three unknown parameters (e.g.,
camera orientation angles). The design matrix A (8 rows, 3 columns) and observation vector y (8
rows) are given:
r
A <- matrix(c(1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 1, 1, 0, 1, 0, 1, 0), nrow=8, ncol=3,
byrow=TRUE)
y <- c(2.1, 1.9, 2.0, 3.0, 2.8, 3.1, 4.0, 3.9)
Compute the least-squares estimate of parameters β = (A'A)^{-1} A'y using matrix
algebra: A' is t(A), inverse via solve().
Compute the residuals r = y - A %*% β and the residual standard error sqrt(sum(r^2) / (n
- p)) where n = 8, p = 3.
Print the estimated parameters, residuals, and residual standard error.
15. Zonal Statistics on a Matrix Using a Zone Map
Create a 10×10 elevation matrix dem <- matrix(rnorm(100, mean = 2000, sd = 400), nrow = 10).
Create a zone matrix zones <- matrix(sample(1:3, 100, replace = TRUE), nrow =
10) representing three land-cover zones.
Compute the mean elevation for each zone without using a loop over zones.
Use tapply() or a combination of split() and sapply(), but since the data is a matrix, you
can convert dem and zones to vectors and use tapply(dem_vec, zones_vec, mean). That is
efficient and vectorised.
Compute the standard deviation per zone similarly.
Create a new matrix zonal_mean_filled where each pixel’s value is replaced by the mean
elevation of its zone. Use indexing: for each zone, use a logical mask to assign the zone
mean. You may use a small loop over the three zones—this is acceptable.
Print the zone means and the first few rows of the filled matrix.
4.1 Lists: Heterogeneous Collections and Recursive Structures
Before we can build the attribute tables and spatial objects that populate a Geographic
Information System, we must understand R’s most flexible data container: the list. A list can hold
anything—a single number, a character string, a matrix of elevation values, an entire data
frame, even another list. In a vector, all elements must be of the same atomic type; in a list, each
element is an independent object with its own type, length, and identity. This heterogeneity
makes the list the glue that binds together the different pieces of a complex geospatial analysis:
the metadata, the coordinates, the attribute columns, the projection string. In fact, when you
examine the internal structure of an sf spatial object or inspect the result of a statistical model,
you are looking at a list. This section establishes the theoretical nature of lists, the syntax for
creating and manipulating them, and the mental model that will make them feel as natural as a
vector—because a list, at its heart, is a vector whose elements can be anything. We will then
apply this knowledge to a scenario that mimics the assembly of a spatial dataset from disparate
parts.
4.1.1 The Conceptual Gap: Why Vectors Are Not Enough
In Chapter 2 you mastered the atomic vector. A numeric vector holds many numbers of the same
type; a character vector holds many strings; a logical vector holds many TRUE/FALSE values.
This uniform structure is extraordinarily efficient for computation, and it maps perfectly onto
gridded spatial data. But real geographical information is composite.
Consider a single field sample. It might consist of:
A sample ID (integer).
The date and time of collection (a special date-time object).
The coordinates (a numeric vector of length 2 for longitude and latitude).
The measured soil pH (numeric).
The land cover classification (character).
A quality flag (logical).
If you were forced to store this sample in a vector, you would have to coerce everything to a
single type—probably character, losing all numeric meaning. Even if you could store mixed
types, the coordinates are two numbers, not one; they need their own internal structure. A
vector’s flat, homogeneous nature cannot represent this hierarchical, multi-typed entity.
R’s answer is the list. A list is an ordered collection of elements (also called components), each
of which can be any R object whatsoever. A list is still a vector in the technical sense—
[Link]() and typeof() reveal its true nature—but its elements are not values; they
are references to other objects. This is a profound shift. A numeric vector stores its bytes directly
in a contiguous block of memory. A list stores pointers to separate objects that reside elsewhere.
The list itself is an array of references, and each reference can point to an object of any size and
any type.
This pointer-based design has three crucial consequences for geoinformatics:
1. Heterogeneity: You can place a numeric vector, a character matrix, and a logical scalar in
the same list.
2. Hierarchy: A list can contain another list, and that list can contain another list, enabling
deeply nested structures. A GeoJSON file, for instance, is essentially a nested list of
coordinates, properties, and metadata.
3. Mutability of components: The objects that a list points to can be modified in place
(though R’s copy-on-modify semantics usually create copies). More importantly, you can
extract, replace, and append components without affecting the others.
When you load a shapefile with sf::st_read(), the resulting object is an sf data frame—a
list-based structure with a special class. The geometry column inside that data frame is itself a
list of individual geometric objects (points, lines, polygons), each of which is a list of coordinate
matrices. The entire edifice of modern spatial R rests on the list. Understanding lists therefore is
not an optional detour; it is a prerequisite for understanding how sf represents the world.
4.1.2 Creating Lists with list()
The constructor list() creates a list from its arguments. Each argument becomes a component of
the list. You can name the components, either by providing names directly in the call or by
assigning them afterward with names().
r
# An unnamed list of three components
L1 <- list(1:5, "soil", c(TRUE, FALSE, TRUE))
L1
# [[1]]
# [1] 1 2 3 4 5
#
# [[2]]
# [1] "soil"
#
# [[3]]
# [1] TRUE FALSE TRUE
The double brackets [[1]], [[2]], [[3]] in the printed output indicate the component indices. This
notation is deliberate: [[ ]] is the extraction operator for single list elements, which we will
study in depth in Section 4.1.4.
Named components:
r
sample_info <- list(
id = 42L,
date = "2024-03-15",
coords = c(116.4074, 39.9042),
ph = 6.8,
land_cover = "Grassland",
qc_pass = TRUE
)
sample_info
# $id
# [1] 42
#
# $date
# [1] "2024-03-15"
#
# $coords
# [1] 116.4074 39.9042
#
# $ph
# [1] 6.8
#
# $land_cover
# [1] "Grassland"
#
# $qc_pass
# [1] TRUE
Named components are printed with $name instead of [[index]], which is far more readable. In
geospatial work, you should always name your list components. A list of unnamed elements is
an accident waiting to happen; a list with named components is a self-documenting record.
Empty list:
r
empty_list <- list()
length(empty_list) # 0
An empty list can be grown by appending components, though for efficiency we often
pre-allocate with vector("list", n).
List of lists (nested):
r
nested <- list(
site = "A01",
measurements = list(
ph = c(6.5, 6.7, 6.6),
moisture = c(0.32, 0.35, 0.31)
)
)
nested
The component measurements is itself a list. This nesting is the foundation of hierarchical data
formats.
List of matrices:
r
raster_bands <- list(
blue = matrix(runif(9, 0.05, 0.15), nrow = 3),
green = matrix(runif(9, 0.10, 0.25), nrow = 3),
red = matrix(runif(9, 0.05, 0.20), nrow = 3),
nir = matrix(runif(9, 0.30, 0.60), nrow = 3)
)
Here each band is a separate matrix, stored in a named list. This is a simple multi -band image
representation that mirrors the structure of an sf data frame’s geometry column or a stars object.
4.1.3 The List as a Vector: Length, Type, and Structure
A list is a vector, but its typeof() is "list", not "numeric" or "character". The length() of a list is
the number of top-level components, not the total number of atomic elements inside them.
r
L <- list(a = 1:10, b = c("x", "y"), c = matrix(1:6, nrow = 2))
length(L) # 3 (three components)
typeof(L) # "list"
[Link](L) # TRUE
[Link](L) # TRUE
The str() function is indispensable for exploring lists, especially nested ones. It provides a
compact tree view:
r
str(sample_info)
# List of 6
# $ id : int 42
# $ date : chr "2024-03-15"
# $ coords : num [1:2] 116.4 39.9
# $ ph : num 6.8
# $ land_cover: chr "Grassland"
# $ qc_pass : logi TRUE
str() shows the name, type, length, and a preview of each component. For deeply nested lists, it
indents the output to show hierarchy. Use str() liberally when debugging.
4.1.4 Extracting Components: [ ] vs. [[ ]] vs. $
The distinction between single-bracket [ ] and double-bracket [[ ]] is one of the most important
syntactic lessons in R, and it applies to lists with particular force. Confusing them is a frequent
source of errors.
[ ] (single bracket): Returns a sublist—a list containing the selected components. The
output is always a list, even if you select a single component.
[[ ]] (double bracket): Returns the object inside the selected component—the
component’s value, stripped of its list container. The output is whatever type that
component happens to be.
$ (dollar): Equivalent to [[ ]] but for named components. L$name extracts the component
named "name". The name need not be quoted; partial matching is supported but
discouraged.
Let us illustrate with sample_info:
r
# Single bracket: returns a sublist
sample_info[1] # list of 1 component (id)
sample_info[1:2] # list of 2 components (id, date)
sample_info["ph"] # list of 1 component (ph)
# Double bracket: returns the component's value
sample_info[[1]] # integer 42
sample_info[["ph"]] # numeric 6.8
# Dollar: named extraction
sample_info$ph # numeric 6.8
sample_info$coords # numeric vector c(116.4074, 39.9042)
Why does this matter? If you use [ ] when you mean [[ ]], you get a list where you expected a
number, and subsequent arithmetic fails with a cryptic error about a non-numeric argument. A
common geospatial mistake:
r
# Wrong: st_coordinates returns a matrix; we want the first point
coords <- st_coordinates(sf_object)[1] # returns a sublist? Actually st_coordinates returns a
matrix, so [1,] is correct, but if it were a list...
# If coords is a list of coordinate vectors, coords[1] is still a list.
The mental model: [ ] slices the list, preserving the container; [[ ]] dives into the container and
retrieves the contents. In spatial work, when you extract a geometry from an sf data frame, you
use $geometry or [[ ]] to get the geometry list column; when you extract a single geometry from
that list, you use [[ ]] again to get the coordinate matrix.
Partial matching with $: sample_info$ph works even if you type sample_info$p (partial
match), but this is dangerous in scripts. Always use the full name. RStudio’s autocomplete helps.
4.1.5 Adding, Removing, and Modifying Components
Lists are mutable in the sense that you can assign new components or overwrite existing ones
using $, [[ ]], or [ ].
Adding a new component:
r
sample_info$elevation <- 1500 # add numeric component
sample_info[["slope"]] <- 5.2 # equivalent using [[ ]]
sample_info
If the name does not exist, it is appended. If it does exist, it is overwritten.
Removing a component:
Assign NULL to a component to remove it:
r
sample_info$qc_pass <- NULL
sample_info # qc_pass is gone
length(sample_info) # 7 (was 8)
Modifying a component:
r
sample_info$ph <- 7.1 # update pH value
sample_info[["coords"]][1] <- 116.4100 # modify first element of the coordinate vector
Appending multiple components:
r
sample_info <- c(sample_info, list(collector = "Alice", method = "A"))
c() on lists concatenates them. If you want to add a single component that is itself a list, wrap it
in list().
Pre-allocating a list of known length:
When you know how many components you will need (e.g., a list to hold the results of
processing 100 satellite scenes), pre-allocate:
r
results <- vector("list", 100) # list of 100 NULLs
names(results) <- paste0("scene", 1:100)
# Then fill in a loop (or with lapply, which we'll meet later)
results[[5]] <- some_analysis_output
This is more efficient than growing a list incrementally.
4.1.6 The Recursive Nature of Lists: A Prelude to Spatial Geometries
A list can contain a list, which can contain a list, and so on—lists are recursive. This property is
what allows R to represent arbitrarily complex spatial data structures.
Consider the structure of a single polygon geometry as stored in sf:
An sf data frame has a geometry column, which is a list (class sfc).
Each element of that list is a single geometry (class sfg).
For a polygon, that single geometry is a list of matrices: the outer ring and possibly inner
rings (holes).
Each ring is a matrix with columns for coordinates.
Schematically:
text
sf data frame (list of columns)
└─ geometry column (list of sfg objects)
└─ polygon 1 (list of matrices)
├─ outer ring (matrix: lon, lat)
└─ hole 1 (matrix: lon, lat)
└─ polygon 2 (list of matrices)
└─ outer ring (matrix: lon, lat)
Without lists, this nested, heterogeneous structure would be impossible to represent in a single
column of a rectangular table. The list is the data structure that makes sf possible.
We will not yet create such geometries—that is Chapter 12—but the theoretical understanding of
recursion prepares you for that day. For now, let us practice with a simpler nested structure that
mimics a field campaign metadata record.
4.1.7 Technical Demonstration: Building a Field Sample Metadata List
Let us create a realistic nested list that represents the metadata for a single soil sample, including
its location, measurements, and quality information. Run the following code in the console or a
script, observing how each extraction behaves.
r
# ---------- 1. Create a nested list for a single sample ----------
sample_1 <- list(
sample_id = 101L,
date = "2024-06-15",
location = list(
lon = 116.4074,
lat = 39.9042,
elevation_m = 1520
),
measurements = list(
ph = c(topsoil = 6.5, subsoil = 6.8),
organic_carbon_percent = c(topsoil = 2.3, subsoil = 1.9),
texture = "Silt loam"
),
qc = list(
flags = c(cloudy = FALSE, disturbed = TRUE),
analyst = "Liu Wei"
)
)
# ---------- 2. Inspect the structure ----------
str(sample_1)
# ---------- 3. Extract components at different depths ----------
sample_1$sample_id # 101 (integer)
sample_1$location$lon # 116.4074
sample_1$measurements$ph["topsoil"] # 6.5
sample_1$qc$flags["cloudy"] # FALSE
# ---------- 4. Using [[ ]] for extraction ----------
sample_1[["location"]][["elevation_m"]] # 1520
sample_1[[c("measurements", "ph")]] # named vector c(topsoil=6.5, subsoil=6.8)
# Note: [[ ]] can accept a vector of names for recursive extraction!
sample_1[[c("qc", "analyst")]] # "Liu Wei"
# ---------- 5. Modify a nested value ----------
sample_1$measurements$ph["topsoil"] <- 6.6
sample_1$location$elevation_m <- 1525
# ---------- 6. Add a new nested component ----------
sample_1$measurements$bulk_density <- c(topsoil = 1.2, subsoil = 1.4)
# ---------- 7. Remove a component ----------
sample_1$qc$flags <- NULL
str(sample_1) # flags component removed
# ---------- 8. Combine with another sample in a higher-level list ----------
sample_2 <- list(
sample_id = 102L,
location = list(lon = 116.4080, lat = 39.9050, elevation_m = 1510),
measurements = list(ph = c(topsoil = 6.2, subsoil = 6.5))
)
field_campaign <- list(sample_1 = sample_1, sample_2 = sample_2)
str(field_campaign, [Link] = 2)
Pay attention to the use of [[c("name1", "name2")]] for recursive extraction—this is a powerful
shortcut when navigating deep lists.
Hard Practice 4.1 – “Building a Multi-Site Field Campaign Metadata Structure”
Objective:
Construct a deeply nested list that represents a complete field campaign with multiple sites, each
containing multiple samples, each with location, measurements, and quality metadata. Practice
extraction, modification, and recursive navigation. No external data or spatial packages are used.
Scenario:
You are the data manager for a soil carbon survey conducted across three sites. Each site has a
name and contains two sampling points. Each point has a unique ID, coordinates, elevation, and
measurements of soil pH and organic carbon at two depths. Some points have quality flags. You
will assemble this entire dataset as a single nested list, then extract summary information.
Instructions:
1. Create a new script practice_4_1_lists.R. Add a header comment.
2. Build the list bottom-up: Start by creating a list for a single point, e.g., point_A1 with:
o point_id: character, e.g., "A1".
o coords: numeric vector of length 2 (longitude, latitude).
o elevation_m: numeric scalar.
o measurements: a list containing:
ph: named numeric vector with topsoil and subsoil.
organic_carbon_percent: named numeric vector with topsoil and subsoil.
o qc_flag: logical (TRUE if the sample passed quality control).
Create two such points for Site A (A1, A2) with slightly different coordinates and measurements
(invent plausible values).
3. Combine points into a site list: Create a list site_A with
components site_name (character) and points (a list containing the two point lists). Do the
same for Site B and Site C.
4. Combine sites into a campaign list: Create campaign <- list(site_A = site_A, site_B =
site_B, site_C = site_C).
5. Inspect the structure: Use str(campaign, [Link] = 3) to display the tree. Copy the
output into a comment block in your script for future reference.
6. Extract the following and print them with informative labels:
o The name of Site B.
o The elevation of point A2.
o The subsoil pH of point C1.
o The organic carbon content (topsoil) of all points across all sites. (Hint: you can
extract by navigating to each point or, for an extra challenge, write a
small sapply call over the sites and points. But for three sites, manual extraction is
acceptable. The conceptual goal is to practice deep list navigation.)
o Whether point B1 passed QC.
7. Modify the list:
o Change the elevation of point A1 to a new value.
o Add a new measurement bulk_density (named vector with topsoil and subsoil) to
point B2.
o Remove the qc_flag from point C2.
8. Write a small summary function (stretch): Using sapply() or lapply() (we haven’t
formally covered them, but you can experiment), extract a vector of all topsoil pH values
across the campaign. (lapply applies a function over a list; unlist flattens the result.) This
is a preview of Chapter 5’s functional programming.
9. Reflection: At the end of your script, add a comment block answering:
o Why is a list better than a matrix for storing a field campaign dataset?
o What is the difference between [ ] and [[ ]] when extracting from a list, and why
does it matter if you want to perform arithmetic on an extracted value?
o How does the recursive nature of lists help represent hierarchical spatial data (like
a country composed of provinces, composed of districts)?
Deliverable:
The script practice_4_1_lists.R with the full list construction, all extractions and modifications,
and the reflection comments.
4.2 Data Frames: The Prototype of Attribute Tables
The atomic vector holds a single column of uniform data. The list holds an arbitrary collection
of heterogeneous objects. A data frame is the elegant synthesis of the two: a list of vectors, all of
the same length, arranged as a rectangular table. Each column can be a different atomic type—
numeric, character, logical, factor, date—but every column must have exactly as many rows as
the others. This structure is the direct analogue of the attribute table in a GIS: each row is a
feature (a point, a line, a polygon), and each column is a property of that feature (name,
population, area, class). In R’s spatial ecosystem, an sf object is a data frame with a special
list-column for geometry. Therefore, mastering the base R data frame is not merely a preliminary
exercise; it is the prerequisite for understanding how spatial objects are built, queried, and
transformed. In this section we explore the creation, structure, inspection, and fundamental
manipulation of data frames, always with an eye toward the spatial attribute tables that await us
in Part III.
4.2.1 The Data Frame as a Rectangular List
Formally, a data frame is a list of vectors of equal length, with the class "[Link]". This
means:
typeof(df) returns "list".
length(df) returns the number of columns.
Each column can be extracted with $ or [[ ]] just like a list.
But a data frame also has dim(): the number of rows and columns, so it behaves like a
matrix in many subsetting operations.
This dual nature—list and matrix—is the data frame’s power. You can think of it column-wise
(as a list of variables) or row-wise (as a collection of observations). In geospatial thinking, each
row is a spatial feature, and each column is an attribute of that feature.
The key constraint is that all columns must have the same number of rows. This enforces the
rectangular shape that allows data frames to be displayed as a table, indexed by row and column,
and passed to statistical functions that expect a matrix of predictors and a response vector.
The data frame predates the tidyverse; it has been part of R since the beginning. The tidyverse
later introduced the tibble, an enhanced data frame with better printing and stricter behaviour.
We will encounter tibbles in Chapter 6, but the base data frame is the foundation, and many
spatial packages still return base data frames. Learning the base version first ensures you
understand what a tibble is improving upon.
4.2.2 Creating a Data Frame with [Link]()
The constructor [Link]() takes named arguments, each of which becomes a column. The
arguments are recycled to a common length (with the usual recycling rules), and character
vectors are, by default in older R versions, converted to factors—a behaviour we will override.
r
# A simple data frame of three columns
soil_df <- [Link](
sample_id = 1:5,
ph = c(6.5, 6.8, 7.2, 6.1, 5.9),
texture = c("Silt", "Clay", "Clay", "Sand", "Silt"),
stringsAsFactors = FALSE # keep character as character
)
soil_df
# sample_id ph texture
#1 1 6.5 Silt
#2 2 6.8 Clay
#3 3 7.2 Clay
#4 4 6.1 Sand
#5 5 5.9 Silt
Always set stringsAsFactors = FALSE to prevent automatic conversion of character vectors to
factors. (In R ≥ 4.0, this is the default, but we include it for clarity and backward compatibility.)
Factors are useful for categorical data but are not a replacement for character vectors; we will
study them in Section 4.4.
Row names: By default, rows are given integer names 1, 2, 3, .... You can supply your own with
the [Link] argument, though storing meaningful data in row names is now discouraged; it is
better to have an explicit column for identifiers. We will not use row names extensively.
Creating a data frame column by column: You can start with an empty data frame and assign
columns:
r
df <- [Link]()
df$id <- 1:3
df$value <- c(10.1, 10.2, 10.3)
Or use [Link]() with vectors of equal length.
From a list: [Link]() converts a list to a data frame, provided the list components are of
equal length (or recyclable to the same length).
4.2.3 Inspecting a Data Frame
Because a data frame is both a list and a matrix-like object, many inspection functions apply:
dim(df) — returns c(nrow, ncol).
nrow(df), ncol(df), length(df) (length = number of columns).
names(df) — column names.
head(df, n), tail(df, n) — first or last few rows.
str(df) — the single most useful function: displays the class, number of observations and
variables, and for each column its type and a preview of values.
summary(df) — provides a five-number summary for numeric columns and frequency
counts for factors/characters.
r
str(soil_df)
# '[Link]': 5 obs. of 3 variables:
# $ sample_id: int 1 2 3 4 5
# $ ph : num 6.5 6.8 7.2 6.1 5.9
# $ texture : chr "Silt" "Clay" "Clay" "Sand" ...
This immediately tells you the number of rows, the column names, their types, and the first few
values. In a geospatial attribute table, str() is the first thing you should run after st_read() to
verify that numeric columns are indeed numeric, and that character columns haven’t been
accidentally converted.
Viewing in RStudio: View(df) (with capital V) opens a spreadsheet-like viewer in the Source
pane. This is excellent for interactive exploration but never use it in a script; it is a GUI action.
4.2.4 Indexing and Subsetting Data Frames
Because of the dual list/matrix nature, a data frame can be subset using list syntax ($, [[ ]], [ ] for
columns) and matrix syntax ([rows, columns]).
[Link] Selecting Columns
df$colname — returns the column as a vector. This is the most common extraction
method.
r
soil_df$ph # numeric vector 6.5 6.8 7.2 6.1 5.9
df[[i]] or df[["colname"]] — same as $, returns the column as a vector.
r
soil_df[["ph"]] # equivalent to soil_df$ph
df[i] — returns a data frame (single-column data frame), not a vector. This preserves the
rectangular structure.
r
soil_df[2] # data frame with one column (ph)
soil_df["ph"] # same
[Link] Selecting Rows and Subsets
Using matrix-style indexing [rows, columns]:
df[1:3, ] — first three rows, all columns.
df[, "ph"] — all rows, column ph, returned as a vector by default (because drop =
TRUE). To preserve as data frame, use df[, "ph", drop = FALSE].
df[df$ph > 6.5, ] — rows where pH exceeds 6.5, all columns. This uses a logical
condition on the ph column to filter rows. This is the single most important subsetting
pattern for attribute tables: “select all features where attribute X satisfies condition Y”.
Logical row subsetting combined with column selection:
r
soil_df[soil_df$texture == "Clay", c("sample_id", "ph")]
# sample_id ph
#2 2 6.8
#3 3 7.2
This reads like a SQL query: SELECT sample_id, ph FROM soil_df WHERE texture = 'Clay'.
The R syntax is concise and vectorised. This is the essence of attribute querying in a GIS.
[Link] Using subset()
R also provides the subset() function for readability:
r
subset(soil_df, texture == "Clay", select = c(sample_id, ph))
subset() is convenient but can behave unexpectedly inside functions due to non-standard
evaluation. For script-based, reproducible workflows, the bracket method is more robust, and we
will use it as our primary tool. The dplyr::filter() function (Chapter 6) resolves many of these
issues and is the modern standard.
4.2.5 Adding, Removing, and Modifying Columns
Adding a new column: Simply assign a vector of the correct length to a new name via $ or [[ ]].
r
soil_df$organic_carbon <- c(2.3, 1.9, 2.1, 1.5, 2.8)
The vector is recycled if it is shorter. If you assign a scalar, it is recycled to all rows.
Removing a column: Assign NULL to the column name.
r
soil_df$organic_carbon <- NULL
Modifying a column: Assign a new vector of the same length (or one that recycles) to an
existing column name.
r
soil_df$ph <- soil_df$ph + 0.1 # shift all pH values
Creating a derived column: You can compute new columns from existing ones using vectorised
operations:
r
soil_df$ph_category <- ifelse(soil_df$ph < 6.5, "acidic", "neutral")
In a spatial attribute table, this is how you compute new attributes: population density from
population and area, a slope category from a slope angle, or a vegetation index from red and NIR
reflectance columns.
4.2.6 Combining Data Frames
Row binding (rbind()): combines data frames with the same columns (same names and
types) by stacking rows.
r
df_more <- [Link](sample_id = 6:7, ph = c(6.3, 7.0), texture = c("Loam", "Clay"))
soil_df <- rbind(soil_df, df_more)
Column binding (cbind()): combines data frames side-by-side. Must have the same
number of rows, and column names must be unique (otherwise they are made unique
with suffixes). Be careful: cbind does not check row ordering; use merge() for joining on
key columns.
Merging (merge()): the equivalent of a SQL JOIN. Merges two data frames by common
columns (or by specified keys). We will cover joins thoroughly in Chapter 6 with dplyr.
4.2.7 Data Frames in the Geospatial World: The Attribute Table
Every vector GIS layer has an attribute table. When you load a shapefile with sf::st_read(), the
result is an sf data frame. The non-spatial columns are ordinary data frame columns: a mix of
numeric, character, factor, and logical vectors. You can subset, transform, and analyse these
columns using exactly the methods of this section—before you ever touch the geometry.
For example, a dataset of world cities might have columns:
name (character)
country (character)
population (numeric)
is_capital (logical)
geometry (list of points)
Using base R data frame operations, you can filter to capitals only: cities[cities$is_capital, ]. You
can compute the log of population: cities$log_pop <- log(cities$population). You can find the
row of the largest city: cities[[Link](cities$population), ].
These operations are the same whether the data frame is an sf object or a plain data frame. The
only difference is that an sf data frame carries a geometry column that is a list-column and is
printed specially. But the attribute manipulation is identical. This unification is one of the great
design triumphs of the sf package, and it means that every skill you acquire here scales directly
to spatial vector data.
4.2.8 Technical Demonstration: Building and Querying an Attribute Table
Let us create a small data frame that mimics the attribute table of a point dataset of weather
stations, and perform typical GIS-like queries.
r
# ---------- 1. Create the data frame ----------
stations <- [Link](
station_id = c("STA01", "STA02", "STA03", "STA04", "STA05"),
name = c("Downtown", "Airport", "Hilltop", "Lakeside", "Forest"),
elevation_m = c(15, 35, 520, 5, 230),
temperature_C = c(28.5, 27.9, 22.1, 29.3, 24.8),
active = c(TRUE, TRUE, FALSE, TRUE, TRUE),
stringsAsFactors = FALSE
)
stations
# ---------- 2. Inspection ----------
dim(stations) #55
names(stations) # column names
str(stations) # structure summary
summary(stations) # statistical summary per column
# ---------- 3. Column selection ----------
stations$name # character vector
stations[["elevation_m"]] # numeric vector
stations[c("station_id", "temperature_C")] # data frame with 2 columns
# ---------- 4. Row filtering (logical subsetting) ----------
# Active stations only
active_stations <- stations[stations$active, ]
active_stations
# Stations above 100m elevation
high_stations <- stations[stations$elevation_m > 100, ]
high_stations
# Active stations with temperature > 25°C
stations[stations$active & stations$temperature_C > 25, ]
# ---------- 5. Adding a derived column ----------
stations$temp_category <- ifelse(stations$temperature_C > 26, "Warm", "Cool")
stations
# ---------- 6. Which station is the highest? ----------
stations[[Link](stations$elevation_m), ]
# ---------- 7. Remove a column ----------
stations$temp_category <- NULL
# ---------- 8. Rename a column (using names()) ----------
names(stations)[names(stations) == "temperature_C"] <- "temp_C"
stations
Execute these lines one by one in the console, and observe the Environment pane, which now
shows stations as a data frame with its dimensions and a preview.
Hard Practice 4.2 – “Building and Querying a Field Sample Attribute Table”
Objective:
Construct a data frame from scratch that mimics the attribute table of a soil sampling campaign.
Practice data frame creation, inspection, column and row subsetting, derived columns, and basic
statistical summaries. No spatial packages are required; this is pure attribute table manipulation.
Scenario:
You have collected soil samples at 12 locations across a watershed. For each sample, you
recorded: sample ID (character), date (character, for now), easting and northing (UTM
coordinates, numeric), elevation (m), soil pH, organic carbon content (%), and land cover type
(character). You will assemble this as a data frame, query it, and compute summary statistics by
land cover class.
Instructions:
1. Create a new script practice_4_2_dataframes.R. Add a header comment.
2. Define the columns as vectors of length 12 with plausible values (you can invent them or
use rep() and seq() for systematic patterns):
o sample_id: "S01", "S02", …, "S12".
o date: all "2024-07-22" (as character for now).
o easting: a sequence from 500000 to 500220 by 20 (12 values).
o northing: a sequence from 4300000 to 4300220 by 20, but with some random
jitter (use runif(12, -10, 10) added).
o elevation_m: random normal with mean 1800 and sd 100.
o ph: random uniform between 5.5 and 8.0.
o organic_carbon_percent: random uniform between 1.0 and 4.0.
o land_cover: character vector randomly chosen from c("Forest", "Grassland",
"Wetland", "Cropland"), repeated or sampled.
Use [Link](42) before random generation.
3. Assemble the data frame using [Link](..., stringsAsFactors = FALSE). Print the
data frame.
4. Inspect the data frame with str(), dim(), names(), and summary(). Write a comment about
what each function tells you.
5. Column extraction:
o Extract the ph column as a vector and compute its mean and standard deviation
(ignoring any NA that might be introduced; none yet).
o Extract a sub-dataframe containing only sample_id, elevation_m, and land_cover.
6. Row filtering (logical subsetting):
o Create a new data frame forest_samples containing only those rows
where land_cover == "Forest". Print it.
o Find all samples with elevation above 1900 m and pH less than 6.5. Print their
sample IDs and pH.
o Count how many samples are in "Grassland" land cover.
7. Adding derived columns:
o Add a column ph_category using ifelse() that is "low" if pH < 6.0, "medium" if
pH < 7.0, "high" otherwise (nested ifelse). Print the first few rows to verify.
o Add a column carbon_stock defined as organic_carbon_percent * elevation_m /
100 (an arbitrary index). Print its summary.
8. Basic grouped summary (without dplyr, using base R):
o Compute the mean pH for each land cover class using tapply(ph, land_cover,
mean). Print the result.
o Compute the mean elevation per land cover class similarly.
9. Modify the data frame:
o Change the elevation of sample "S05" to a new value (use which(sample_id ==
"S05") to find its row, then assign).
o Remove the date column by assigning NULL.
o Rename easting to utm_e and northing to utm_n using names().
10. Reflection: In comments, answer:
o How does a data frame resemble both a list and a matrix? Give an example of
using each aspect.
o Why is the logical row subsetting pattern (df[condition, ]) so powerful for spatial
attribute tables?
o What would happen if you tried to add a column with only 5 values to a data
frame with 12 rows? (Try it briefly in the console and report.)
Deliverable:
The script practice_4_2_dataframes.R with all steps, printed outputs, and reflection comments.
4.3 Indexing, Subsetting, and Merging Data Frames
You can now create a data frame and perform simple extractions. But the attribute table of a real
GIS project is rarely a single, self-contained table. You may have a shapefile of administrative
boundaries with a population column, and a separate CSV file of health indicators, and you need
to join them by district code. You may have a table of field samples and a table of laboratory
results that must be matched by sample ID. You may need to select specific rows by coordinate,
by attribute range, or by exclusion of missing data. This section formalises the full repertoire of
data frame indexing—row, column, and combined—and introduces the critical operation
of merging (joining) two data frames by common keys. All techniques are presented in base R,
establishing the principles that dplyr elegantly streamlines in Chapter 6. The section closes with
a geospatial case study: merging field measurements to a location table, exactly as you would
join a non-spatial table to a GIS attribute table before spatial analysis.
4.3.1 The Indexing Matrix: [rows, columns] in Full Detail
The bracket operator [ ] on a data frame accepts up to two arguments, separated by a
comma: df[rows, columns]. Both rows and columns can be:
Empty (select all).
Positive integer vector (select by position).
Negative integer vector (exclude by position).
Character vector (select by name, for columns; for rows, only if row names are set).
Logical vector (select where TRUE; the length must match the number of
rows/columns).
[Link] Row Indexing
Rows are selected primarily by logical condition or by integer position. Using row names is
possible but uncommon in modern tidy data.
r
df <- [Link](
id = 1:6,
value = c(10, 15, 12, 18, 9, 14),
group = c("A", "B", "A", "B", "A", "B")
)
# By position
df[1:3, ] # first three rows
df[c(2, 4, 6), ] # specific rows
# By logical condition
df[df$value > 12, ] # rows where value > 12
df[df$group == "A", ] # rows where group is "A"
# Combining conditions
df[df$value > 10 & df$group == "B", ] # value > 10 AND group B
df[df$value < 10 | df$value > 15, ] # value < 10 OR value > 15
The logical condition is evaluated as a logical vector of length nrow(df), and [ returns only those
rows where the vector is TRUE. This is the fundamental query operation.
[Link] Column Indexing
Columns are selected by name, position, or logical vector (less common).
r
df[, c("id", "value")] # by character vector of names
df[, 2:3] # by position
df[, c(TRUE, FALSE, TRUE)] # by logical vector (select id and group)
The default drop = TRUE causes a single column selection to return a vector, not a data frame.
To guarantee a data frame, use drop = FALSE:
r
df[, "value", drop = FALSE] # returns a 6x1 data frame
[Link] Combined Row and Column Indexing
Combining row and column conditions in a single [ , ] allows precise extraction:
r
df[df$group == "A", "value"] # values for group A (vector)
df[df$value > 12, c("id", "group")] # id and group for high values
df[1:3, c("id", "value")] # first three rows, two columns
This is the syntax that directly answers questions like “what are the IDs of all stations above
1000 m elevation in region X?”.
[Link] Assignment to Subsets
Indexing on the left side of <- allows in-place modification of selected rows and columns:
r
# Set value to NA for group B
df[df$group == "B", "value"] <- NA
df
# Create a new column only for a subset (others become NA)
df[df$group == "A", "score"] <- c(80, 90, 85)
df # score is NA for group B
This pattern is useful for targeted data cleaning: replace implausible values with NA based on a
condition, or add a column that only applies to a subset of features.
4.3.2 Using subset() and with()
R provides two convenience functions that reduce repetition of the data frame name:
subset(df, subset, select): evaluates subset and select in the context of the data frame, so
you can write subset(df, value > 12, select = c(id, value)) instead of df[df$value > 12,
c("id", "value")]. It is readable but is intended for interactive use; inside functions it can
cause scoping problems.
with(df, expression): allows you to use column names directly inside the expression. For
example, with(df, value[group == "A"]) returns the value vector for group A.
We mention these for completeness, but for reproducible scripts we will prefer the explicit
bracket notation or, in Chapter 6, dplyr::filter() and dplyr::select(). The bracket method always
works and has unambiguous semantics.
4.3.3 Removing Rows and Columns
Remove columns: assign NULL to a column name, or use negative indexing: df[, -c(2,
3)] removes the 2nd and 3rd columns.
Remove rows: use negative row indexing: df[-c(1, 5), ] removes the 1st and 5th rows.
More commonly, you create a logical condition that keeps rows: df[df$value >=
0, ] (removes rows with negative value). To physically delete rows, you assign the subset
back to the variable: df <- df[df$value >= 0, ].
4.3.4 Ordering and Sorting
order() returns the indices that would sort a vector. To sort a data frame by one or more columns,
use these indices on the row dimension:
r
# Sort by value ascending
df_sorted <- df[order(df$value), ]
# Sort by group, then by value descending
df_sorted <- df[order(df$group, -df$value), ]
The - sign before a numeric column indicates descending order. order() is a base R workhorse
that predates dplyr::arrange().
4.3.5 Merging Data Frames: The Conceptual Need
In geospatial analysis, data frequently arrive from multiple sources:
A shapefile of county boundaries (with county codes and names).
A CSV file of annual crop yields (with county codes and yield values).
A table of soil properties sampled at point locations (with coordinates).
To analyse how soil properties relate to crop yields, you must combine the soil table with the
county table based on a common key column (e.g., county code) or by spatial location (spatial
join, Chapter 14). The non-spatial join is performed with merge() in base R, or
with *_join() functions in dplyr.
The key concept is the join key: a column (or columns) that uniquely identifies each row in at
least one of the tables. The join matches rows with equal keys and combines their columns
side-by-side. A one-to-one join matches exactly one row in table A with exactly one row in table
B. A one-to-many join matches one row in A to multiple rows in B (e.g., one county to many
sampling points). Merging handles both.
4.3.6 The merge() Function
The base R function merge() performs SQL-style joins. Its key arguments:
x, y: the two data frames.
by: the column name(s) to join on. If by is omitted, merge uses all common column
names.
all.x, all.y, all: logical flags controlling whether to keep rows with no match (outer joins).
By default (all = FALSE), only rows with matches in both tables are kept (inner join).
Inner join: merge(x, y, by = "key") returns only rows where the key exists in both tables.
Left join: merge(x, y, by = "key", all.x = TRUE) returns all rows from x, adding columns
from y where the key matches; unmatched rows get NA in the y columns.
Right join: all.y = TRUE keeps all rows from y.
Full outer join: all = TRUE keeps all rows from both, filling with NA where no match exists.
Example:
r
# Table of county codes and names
counties <- [Link](
county_id = c(1, 2, 3, 4),
county_name = c("Alpha", "Beta", "Gamma", "Delta"),
stringsAsFactors = FALSE
)
# Table of crop yields (some counties missing, one county repeated)
yields <- [Link](
county_id = c(1, 2, 2, 4),
yield = c(3.4, 2.1, 2.3, 4.0),
stringsAsFactors = FALSE
)
# Inner join: only counties 1,2,4 appear; county 3 is dropped because no yield data
merge(counties, yields, by = "county_id")
# Left join: keep all counties, yield is NA for county 3
merge(counties, yields, by = "county_id", all.x = TRUE)
# Right join: keep all yield records, county_name is duplicated for county 2
merge(counties, yields, by = "county_id", all.y = TRUE)
In the resulting data frame, the key column appears once. If the key column has different names
in the two tables, use by.x and by.y:
r
merge(counties, yields, by.x = "county_id", by.y = "county_id")
# Or if the column names differ: by.x = "id", by.y = "county_id"
Merging on multiple keys: If rows are uniquely identified by a combination of columns
(e.g., county_id and year), supply a vector to by.
4.3.7 Merging in the Geospatial Workflow
The non-spatial merge is the precursor to the spatial join. A typical workflow:
1. Load a shapefile of study area polygons (an sf data frame) with a column admin_code.
2. Load a CSV of census data with columns admin_code and population.
3. Merge the CSV into the sf object using merge(sf_object, census_df, by = "admin_code").
Because an sf object is a data frame, merge() works directly on it, and the result is still
an sf object. This is how you add non-spatial attributes to spatial layers.
The same merge operation can attach field sample data to a site table, link sensor metadata to a
time series, or combine statistical model predictions back to a spatial grid’s attribute table. The
bracket and merge operations you learn here on plain data frames will be used identically
on sf objects.
4.3.8 Technical Demonstration: Indexing and Merging in Practice
We will create two data frames representing a simplified GIS scenario: one table of monitoring
sites and one table of water quality measurements taken at those sites at different dates.
r
# ---------- 1. Sites table ----------
sites <- [Link](
site_id = c("S01", "S02", "S03", "S04", "S05"),
river = c("Yangtze", "Yellow", "Yangtze", "Pearl", "Yellow"),
elevation = c(450, 1200, 500, 80, 1300),
stringsAsFactors = FALSE
)
sites
# ---------- 2. Measurements table (multiple per site) ----------
meas <- [Link](
site_id = c("S01", "S01", "S02", "S03", "S03", "S03", "S05"),
date = c("2024-01", "2024-02", "2024-01", "2024-01", "2024-02", "2024-03", "2024-01"),
ph = c(7.1, 7.0, 8.2, 6.8, 6.9, 6.7, 8.1),
stringsAsFactors = FALSE
)
meas
# ---------- 3. Left join: attach site info to each measurement ----------
meas_with_site <- merge(meas, sites, by = "site_id", all.x = TRUE)
meas_with_site
# ---------- 4. Inner join: only sites that have measurements ----------
sites_with_meas <- merge(sites, meas, by = "site_id")
sites_with_meas # site S04 is dropped (no measurements)
# ---------- 5. Subsetting joined data: measurements at high elevation sites ----------
high_meas <- meas_with_site[meas_with_site$elevation > 1000, ]
high_meas
# ---------- 6. Compute mean pH per river using indexing and tapply ----------
mean_ph_by_river <- tapply(meas_with_site$ph, meas_with_site$river, mean)
mean_ph_by_river
# ---------- 7. Sorting the joined data by elevation descending ----------
meas_with_site_sorted <- meas_with_site[order(-meas_with_site$elevation), ]
meas_with_site_sorted
Run this code and observe how the join replicates the site information for each measurement, just
as a spatial join would replicate polygon attributes for each point inside the polygon.
Hard Practice 4.3 – “Merging Field Data to Site Metadata and Querying”
Objective:
Practise indexing, subsetting, and merging on a realistic multi-table field campaign dataset.
Combine a table of site locations with a table of repeated measurements, perform conditional
queries, and compute grouped summaries.
Scenario:
You are given two tables:
1. sites: site ID, latitude, longitude, elevation, and a protection status (logical).
2. samples: a long-format table of soil pH measurements taken at each site on different
dates, with site ID, date, and pH.
Some sites have no samples; some samples may have missing pH values. You will merge, clean,
subset, sort, and summarise.
Instructions:
1. Create the sites data frame (15 sites):
r
[Link](123)
sites <- [Link](
site_id = paste0("S", sprintf("%02d", 1:15)),
lat = runif(15, 30, 40),
lon = runif(15, 110, 120),
elevation = round(rnorm(15, mean = 1500, sd = 400)),
protected = sample(c(TRUE, FALSE), 15, replace = TRUE),
stringsAsFactors = FALSE
)
2. Create the samples data frame (30 measurements, some sites repeated, site S10–S15
have no samples):
r
[Link](456)
sample_sites <- sample(sites$site_id[1:10], 30, replace = TRUE)
samples <- [Link](
site_id = sample_sites,
date = sample(seq([Link]("2024-03-01"), [Link]("2024-09-30"), by = "month"), 30, replace =
TRUE),
ph = round(runif(30, 5.0, 8.5), 1)
)
# Introduce some missing pH values
samples$ph[sample(1:30, 4)] <- NA
3. Inspect both tables with str() and summary().
4. Left join: Merge samples with sites so that every sample gets its site coordinates and
attributes. Assign to samples_full. How many rows does it have? Are there any sites from
the sites table that do not appear? (They shouldn’t, because it’s a left join on samples.)
5. Right join: Merge sites with samples keeping all sites (i.e., left join on sites). Assign
to sites_full. Print the rows where ph is NA—these are the sites with no samples.
6. Filtering and cleaning:
o From samples_full, remove any rows where ph is NA (we cannot analyse missing
pH). Store as samples_clean.
o Subset samples_clean to only those sites that are protected. How many
measurements are from protected sites?
o Find all measurements taken after June 2024 at sites above 1800 m elevation.
(Hint: [Link]("2024-06-30") for comparison.)
7. Sorting: Sort samples_clean by pH descending. Print the top 5 most alkaline
measurements with their site IDs, dates, and elevation.
8. Derived column: Add a column ph_category to samples_clean using ifelse(): "acidic" if
pH < 6.5, "neutral" if pH between 6.5 and 7.5, "alkaline" if pH > 7.5. Count how many
samples fall into each category.
9. Grouped summaries (base R):
o Compute the mean pH per site using tapply(samples_clean$ph,
samples_clean$site_id, mean). Print it.
o Compute the mean pH for protected vs. non-protected sites using tapply on
the protected column.
10. Modification challenge: Use row indexing to set the pH of all samples from
site "S03" to NA (simulate a sensor failure). Then recompute the mean pH per site and
observe the change.
11. Reflection:
o In what situation would a left join produce NA values in the merged data frame?
o Why is the ability to merge tables by a key column fundamental to GIS?
o How does logical indexing on a merged data frame enable spatial-like queries
before we even use spatial packages?
Deliverable:
The script practice_4_3_indexing_merging.R with all steps, printed outputs, and comments.
4.4 Factors: Categorical Data Handling (Essential for Land Cover)
In a GIS attribute table, some columns contain continuous measurements—elevation,
reflectance, temperature—but others contain discrete categories: land cover class, soil type,
geological formation, administrative region. These categorical variables are not merely
character strings; they have a fixed set of possible values (the “levels”) that often carry an
intrinsic order or a numerical code used for analysis. In R, the factor is the data structure
designed explicitly for this purpose. Factors store categorical data as integers (the level codes)
paired with a set of character labels (the levels). This dual representation makes factors
memory-efficient, statistically aware, and essential for correct handling of classification maps,
survey responses, and thematic layers. This section explains what factors are, why they exist,
how to create and manipulate them, and the common pitfalls that await the unwary—especially
when numeric data masquerades as factor levels. We ground every concept in the spatial
context: land cover classification, ordered terrain classes, and the reclassification tables that
form the backbone of raster analysis. By the end, you will see factors as a precise tool for
categorical geographic data, not as an annoyance to be avoided with stringsAsFactors =
FALSE.
4.4.1 What Is a Factor? The Dual Representation
A factor is a vector of integers—the underlying storage mode—plus a levels attribute: a
character vector of unique category labels. The integer stored in position i of the factor represents
which level that element belongs to. R prints factors using their labels, not their integer codes, so
they appear as text to the user. Internally, however, they are numbers, which makes them efficient
for storage and for certain statistical computations that use dummy variables (model matrices).
Consider a vector of land cover observations:
r
land_cover <- c("Forest", "Water", "Forest", "Urban", "Water", "Forest")
If we store this as a plain character vector, every element is a separate string, consuming memory
for repeated text. If we convert it to a factor:
r
lc_factor <- factor(land_cover)
lc_factor
# [1] Forest Water Forest Urban Water Forest
# Levels: Forest Urban Water
R prints the labels, but internally the vector is stored as integers: Forest = 1, Urban = 2, Water = 3
(by default, levels are sorted alphabetically). The integer codes are invisible to the user but are
accessible with [Link]():
r
[Link](lc_factor) # 1 3 1 2 3 1
The levels are accessible with levels():
r
levels(lc_factor) # "Forest" "Urban" "Water"
This dual nature—labels for human readability, integers for computational efficiency—is the
essence of factors. It makes them ideal for thematic raster maps where pixel values are integer
codes that correspond to land cover classes. In the terra package, a categorical raster is stored as
a factor-like object: integer cell values with a raster attribute table (RAT) mapping codes to
labels. Understanding factors in base R directly prepares you for handling categorical rasters.
4.4.2 Creating Factors: factor(), ordered(), and cut()
The factor() Function
The basic constructor is factor(x, levels = ..., labels = ..., ordered = FALSE).
x: a vector of data, usually character or numeric.
levels: an optional vector of all possible levels. If not supplied, sort(unique(x)) is used.
Supplying levels explicitly is important when certain categories are absent from the data
but should still appear in summaries (e.g., a land cover class that did not occur in this
sample but exists in the region).
labels: optional character vector of names to replace the levels. Useful if data are stored
as integer codes and you want to attach meaningful names.
ordered: logical, FALSE by default. If TRUE, the factor is treated as ordinal (levels have
a meaningful order).
r
# Character input, automatic levels
lc <- factor(c("Forest", "Water", "Forest", "Urban"))
levels(lc) # "Forest" "Urban" "Water" (alphabetical)
# Explicit levels (including a category not present)
lc_full <- factor(c("Forest", "Water", "Forest"),
levels = c("Forest", "Urban", "Water", "Bare"))
levels(lc_full) # "Forest" "Urban" "Water" "Bare"
table(lc_full) # Bare has count 0
# Converting integer codes to labelled factor
soil_code <- c(1, 2, 1, 3, 1)
soil_type <- factor(soil_code,
levels = 1:3,
labels = c("Sand", "Silt", "Clay"))
soil_type # Sand Silt Sand Clay Sand
This last pattern is exceedingly common in geospatial work: raw data often arrive as integer
codes (e.g., a raster where 1=Forest, 2=Urban, 3=Water, 4=Bare), and you need to attach
human-readable labels. The levels and labels arguments provide the translation.
Ordered Factors: ordered() and factor(..., ordered = TRUE)
When categories have a natural order (e.g., slope classes: “Flat” < “Gentle” < “Moderate” <
“Steep” < “Very Steep”), an ordered factor should be used. Ordered factors are created
with ordered() or by setting ordered = TRUE in factor(). The order of levels is taken from
the levels argument.
r
slope_class <- factor(c("Moderate", "Steep", "Flat", "Gentle"),
levels = c("Flat", "Gentle", "Moderate", "Steep", "Very Steep"),
ordered = TRUE)
slope_class
# [1] Moderate Steep Flat Gentle
# Levels: Flat < Gentle < Moderate < Steep < Very Steep
The < signs in the printed output indicate the ordering. Ordered factors enable meaningful
comparisons (slope_class[1] < slope_class[2] returns TRUE) and are essential for ordinal
regression and proper table sorting.
Binning Numeric Data with cut()
cut() converts a numeric vector into a factor by dividing its range into intervals. This is the
primary tool for creating classified maps from continuous variables (e.g., elevation zones, NDVI
classes, slope categories).
r
elev <- c(120, 450, 890, 1500, 2300, 3400)
elev_class <- cut(elev,
breaks = c(0, 500, 1000, 2000, 5000),
labels = c("Low", "Mid", "High", "Alpine"),
[Link] = TRUE)
elev_class
# [1] Low Low Mid High High Alpine
# Levels: Low Mid High Alpine
breaks: a vector of cut points. With n intervals, you need n+1 breakpoints. Alternatively,
you can specify a single integer to create that many equal-width intervals.
labels: optional labels for the intervals. If omitted, the levels are the interval
notation (0,500] etc.
[Link] = TRUE: ensures the minimum value is included in the first interval.
cut() is used constantly in geospatial analysis for reclassifying raster values. For example, you
might convert a DEM into elevation zones for habitat modelling, or convert NDVI into
vegetation density classes. The terra::classify() function implements this same logic for entire
rasters, but the underlying principle is cut() applied to a vector of pixel values.
4.4.3 Working with Factors: Inspection, Releveling, and Recoding
Inspecting a Factor
class(x) — returns "factor".
typeof(x) — returns "integer" (the underlying storage).
levels(x) — returns the character vector of levels.
nlevels(x) — returns the number of levels.
table(x) — tabulates frequencies of each level.
[Link](x), [Link](x) — type checking.
r
class(lc_factor) # "factor"
typeof(lc_factor) # "integer"
nlevels(lc_factor) # 3
table(lc_factor)
Releveling: Changing the Order of Levels
The order of levels matters for plotting (the order in legends and bar charts) and for the baseline
category in statistical models (the first level is the reference). relevel() moves a specified level to
the first position.
r
# Move "Urban" to the first level
lc_relevel <- relevel(lc_factor, ref = "Urban")
levels(lc_relevel) # "Urban" "Forest" "Water"
You can also reorder all levels with factor(x, levels = new_order).
Recoding Levels: Renaming and Combining
To rename levels, assign directly to levels(x):
r
levels(lc_factor) <- c("F", "U", "W")
lc_factor
To combine categories (e.g., merge “Urban” and “Bare” into “Built-up”), you can
use fct_collapse() from the forcats package (tidyverse), or manually recode in base R:
r
# Convert to character, recode, convert back
lc_char <- [Link](lc_factor)
lc_char[lc_char == "Urban"] <- "Built-up"
lc_char[lc_char == "Bare"] <- "Built-up"
lc_new <- factor(lc_char)
We will meet the forcats package in Chapter 6, which provides a cleaner syntax for these
operations.
Dropping Unused Levels
After subsetting a factor, levels that are no longer present remain as unused levels, which can
cause empty bars in plots or zero-count entries in tables. Use droplevels() to remove them:
r
sub_lc <- lc_factor[1:3] # only Forest, Water, Forest
levels(sub_lc) # still shows "Urban" even though it doesn't appear
sub_lc <- droplevels(sub_lc)
levels(sub_lc) # "Forest" "Water"
In spatial subsetting (e.g., clipping a land cover map to a study area), droplevels() is essential to
reflect the new extent accurately.
4.4.4 Factors in Statistical Models and Spatial Analysis
Factors are the proper way to represent categorical predictors in statistical models. When you
pass a factor to lm(), glm(), or any modeling function, R automatically creates dummy variables
(0/1 indicator columns) for all levels except the first (the reference). This is called treatment
contrasts. The choice of reference level influences the interpretation of coefficients,
and relevel() allows you to select a meaningful reference (e.g., “Forest” as the baseline for land
cover change analysis).
r
# Example: soil pH modelled by land cover
[Link](123)
df <- [Link](
ph = rnorm(30, mean = rep(c(6.0, 7.0, 5.5), each = 10), sd = 0.3),
cover = factor(rep(c("Forest", "Grassland", "Wetland"), each = 10))
)
model <- lm(ph ~ cover, data = df)
summary(model) # coefficients for Grassland and Wetland relative to Forest
In spatial regression (Chapter 19), factors are the standard way to include categorical covariates
like geology type or land tenure regime.
For classification of remote sensing imagery, training labels (land cover classes at sample points)
are typically stored as factors. The classification model (e.g., random forest) uses these factor
levels as the response variable. The predicted output is a factor vector (or a categorical raster)
with the same levels.
4.4.5 The Danger: Factors Masquerading as Numbers
The most common beginner trap with factors is when a column of numbers is accidentally read
as a factor (often because a CSV file contains a stray character in a numeric column). If you
apply [Link]() to a factor, it does not return the original numeric values; it returns the
underlying integer codes!
r
num_factor <- factor(c("10", "20", "30"))
[Link](num_factor) # 1 2 3 (not what you want!)
To correctly convert a factor of numbers back to numeric, you must first convert to character,
then to numeric:
r
[Link]([Link](num_factor)) # 10 20 30
In geospatial data, this trap appears when an attribute table of a shapefile has a column
like "population" that was stored as a factor because of a data entry error. Always check
with str() and use the two-step conversion if needed. The tidyverse (readr) defaults
to stringsAsFactors = FALSE, which prevents this issue, but many spatial file readers (especially
older ones) may convert character columns to factors. Vigilance is essential.
4.4.6 Factors in the Spatial World: Land Cover and Thematic Maps
Let us solidify the geospatial role of factors. A thematic raster of land cover (e.g., a GeoTIFF
from a classification) stores integer pixel values. The mapping from integer to land cover class is
held in a raster attribute table (RAT). When you load such a raster into R with terra::rast(), the
RAT can be converted to a factor via [Link](). The result is a categorical raster whose levels
are the land cover class names. When you plot it, the legend automatically shows the class names
and colors.
In vector GIS, a column like land_use in a shapefile is best stored as a factor (or as a character
vector; sf allows both). Using factor ensures that summaries, plots, and models treat it as
categorical. When you perform a spatial join and aggregate land use by polygon, the resulting
table will have factor columns.
Thus, understanding factors now means that when you encounter RAT in terra or fct columns
in sf objects, you will not be confused by the integer-label duality.
4.4.7 Technical Demonstration: Creating, Inspecting, and Manipulating Factors
Run the following code in the console or a script to see factors in action.
r
# ---------- 1. Create a factor from character ----------
land_cover <- c("Forest", "Urban", "Water", "Forest", "Forest", "Urban")
lc <- factor(land_cover)
lc
levels(lc) # "Forest" "Urban" "Water"
[Link](lc) #123112
# ---------- 2. Ordered factor for slope stability ----------
stability <- factor(c("Low", "Medium", "High", "Low", "High"),
levels = c("Low", "Medium", "High"),
ordered = TRUE)
stability
stability[1] < stability[2] # TRUE
# ---------- 3. Using cut() to bin elevation ----------
elev <- c(250, 750, 1200, 2300, 3400, 4100)
elev_zone <- cut(elev,
breaks = c(0, 500, 1500, 3000, 5000),
labels = c("Lowland", "Hill", "Mountain", "Alpine"),
[Link] = TRUE)
elev_zone
table(elev_zone)
# ---------- 4. Explicit levels from integer codes ----------
codes <- c(1, 3, 2, 1, 3)
land_use <- factor(codes,
levels = 1:4,
labels = c("Agriculture", "Built-up", "Forest", "Water"))
land_use
levels(land_use) # includes "Water" even though it doesn't appear
# ---------- 5. Relevel and droplevels ----------
land_use <- relevel(land_use, ref = "Built-up")
levels(land_use) # "Built-up" now first
sub_use <- land_use[1:3]
levels(sub_use) # still shows "Water"
sub_use <- droplevels(sub_use)
levels(sub_use) # "Built-up" "Forest" "Agriculture"
# ---------- 6. The numeric trap ----------
bad_factor <- factor(c("100", "200", "300"))
[Link](bad_factor) # 1 2 3 (wrong)
[Link]([Link](bad_factor)) # 100 200 300 (correct)
# ---------- 7. Using a factor in a model ----------
df <- [Link](
yield = c(2.1, 2.3, 3.0, 3.1, 2.8, 3.2),
fertilizer = factor(c("None", "None", "Low", "Low", "High", "High"))
)
summary(lm(yield ~ fertilizer, data = df))
Observe each output, particularly the distinction between printed labels and underlying integer
codes, and the effect of ordering on comparisons.
Hard Practice 4.4 – “Classifying and Analysing Categorical Field Data”
Objective:
Create factors from numeric codes and continuous variables, manipulate levels, and use factors
in statistical summaries. Simulate a land cover classification training dataset and prepare it for
accuracy assessment.
Scenario:
You have field-observed land cover class codes (integers) and predicted class codes from a
satellite image classification for 50 validation points. You also have a continuous variable
(elevation) that needs to be binned into zones. You will build factor representations, compute a
confusion matrix using table(), and analyse classification accuracy.
Instructions:
1. Create a new script practice_4_4_factors.R. Add a header.
2. Observed land cover:
Set [Link](1). Create a vector observed_codes of length 50, randomly sampled from 1:4
with replacement. These integers represent observed land cover: 1=Forest, 2=Grassland,
3=Urban, 4=Water.
Convert observed_codes to a factor observed using the appropriate labels. Set the levels
in a logical order (alphabetical is fine, or you can customise). Print a frequency table.
3. Predicted land cover:
Simulate a classification result by perturbing the observed codes: for each observed code,
with 70% probability keep it, with 30% probability randomly change to a different code.
Store as predicted_codes. Convert to factor predicted with the same levels as observed.
Print a frequency table.
4. Confusion matrix:
Use table(predicted, observed) to create a confusion matrix. Print it. (The diagonal entries
are correct classifications.) Compute the overall accuracy: sum of diagonal divided by
total. Print the accuracy.
5. Elevation binning:
Create a numeric vector elevation of 50 random values from a normal distribution with
mean 1200 m and sd 400 m (rnorm). Use cut() to classify elevation into three zones:
“Low” (<800 m), “Medium” (800–1600 m), “High” (>1600 m). Store as
factor elev_zone. Print a frequency table.
6. Combine into a data frame:
Create a data frame validation with columns observed, predicted, elev_zone. Print the
first 10 rows.
7. Accuracy by elevation zone:
For each elevation zone, compute the classification accuracy (using tapply or logical
subsetting). Example: accuracy <- function(obs, pred) mean(obs == pred). Apply this per
zone using tapply or manually for each zone. Print the results.
8. Relevel for analysis:
Change the reference level of observed to "Urban" using relevel(). Verify with levels().
9. Dropping unused levels:
Suppose the "Water" class is absent from the first 30 points (create a subset).
Use droplevels() to clean the subset. Show the levels before and after.
10. Numeric trap prevention:
Create a factor bad_elev by converting elevation to character and then to factor (simulate
a mis-typed column). Convert it back to the original numeric values correctly (character
first, then numeric). Verify that the first few values match the original elevation.
11. Reflection:
o Why are factors the appropriate data type for land cover classification output?
o Explain the danger of using [Link]() directly on a factor.
o How does cut() enable reclassification of continuous spatial data, and why is this
important in thematic mapping?
Deliverable:
The script practice_4_4_factors.R with all steps, comments, and printed results.
4.5 Dates and Time Classes (POSIXct, Date)
Spatial data is not frozen in time. A satellite revisits the same location every few days. A field
survey records the hour of sampling. A weather station logs hourly precipitation. A GPS track
records a position every second. To work with these temporal dimensions—to filter by date,
compute time differences, aggregate by month, or align time series from different sensors—you
must understand R’s date and time classes. This section covers the two fundamental temporal
types: Date for calendar days and POSIXct (and its cousin POSIXlt) for date-time values with
a time zone. We will learn to create these objects from character strings and numeric
components, to format them for display, to extract components like year and month, to perform
arithmetic, and to handle the ever-present complication of time zones. Every concept is tied to
the temporal aspects of geoinformatics: time-stamped field observations, time-series satellite
imagery, and temporal queries on spatial databases. By the end, you will handle time as fluently
as you handle coordinates.
4.5.1 The Need for Temporal Types in Geospatial Data
A vector of character strings like "2024-07-22" is human-readable but computationally opaque. If
you try to sort such strings, "2024-08-01" will come before "2024-07-22" because "0" comes
before "1" in a dictionary. If you try to compute the number of days between two dates, you must
manually parse the strings and account for month lengths and leap years. R’s date and time
classes solve these problems by storing dates as numeric values (days or seconds since a fixed
origin) and providing methods for arithmetic, comparison, formatting, and extraction.
Geospatial applications are replete with time:
Satellite time series: Each scene of a Landsat or Sentinel-2 stack carries an acquisition
date. Filtering by growing season, computing change detection between two dates, or
aligning multi-sensor data requires date operations.
Field surveys: Each sample has a collection date and time. You may need to compute the
age of a sample, sort by recency, or match field data to the nearest satellite overpass.
GPS tracking: Each fix has a timestamp. Speed is distance divided by time difference.
Temporal aggregation reduces a track to daily summaries.
Climate data: NetCDF files contain time dimensions as “days since 1900-01-01”. R’s
date classes can decode these and align them with other data.
R provides two primary temporal classes:
Date — represents a calendar date (year, month, day) with no time component. Internally
stored as the number of days since 1970-01-01 (the Unix epoch).
POSIXct — represents a date and time to the nearest second, with an explicit time zone.
Internally stored as the number of seconds since 1970-01-01 00:00:00 UTC.
POSIXlt — an alternative representation of the same information as a list of components
(year, month, day, hour, minute, second, time zone, etc.). It is useful for extracting
specific components but less convenient for storage in data frames; we will primarily
use POSIXct.
4.5.2 The Date Class: Days Without Time
Creation from Character Strings
[Link]() converts a character vector to Date. You must specify the format of the input string
using percent-codes (Table 4.1).
r
d <- [Link]("2024-07-22") # default format is "%Y-%m-%d"
d
# [1] "2024-07-22"
class(d) # "Date"
The default format is ISO 8601: YYYY-MM-DD. If your strings use a different order
(e.g., "22/07/2024"), you must supply the format argument:
r
[Link]("22/07/2024", format = "%d/%m/%Y")
Common percent-codes for dates:
Code Meaning Example
%Y 4-digit year 2024
%y 2-digit year 24
%m Month (01–12) 07
%d Day (01–31) 22
%b Abbreviated month name Jul
%B Full month name July
If the format string is wrong, [Link]() returns NA with a warning. This is a common debugging
point when importing field data.
Creation from Integers and ISOdatetime
The ISOdate() function returns a POSIXct, but its output can be coerced to Date with [Link]().
For simple creation, you can compute the integer day count and use the origin argument:
r
# Days since 1970-01-01
[Link](19900, origin = "1970-01-01") # some day in 2024
But this is rarely needed; most dates are read from character strings.
The Internal Integer and Arithmetic
[Link](d) reveals the internal day count:
r
d <- [Link]("2024-07-22")
[Link](d) # 19933 (number of days since 1970-01-01)
This numeric representation means that subtraction of two Date objects returns a difftime object
representing the difference in days:
r
d1 <- [Link]("2024-07-01")
d2 <- [Link]("2024-08-15")
d2 - d1 # Time difference of 45 days
[Link](d2 - d1) # 45
Addition and subtraction with integers shift a date forward or backward by days:
r
d1 + 30 # 2024-07-31
This arithmetic is essential for temporal filters: “all samples collected within 30 days of the
satellite overpass”.
Sequences of Dates
seq() works directly on Date objects:
r
seq(from = [Link]("2024-01-01"), to = [Link]("2024-12-31"), by = "month")
# 2024-01-01 2024-02-01 2024-03-01 ...
You can also use by = "7 days", by = "1 year", etc. This is the standard way to generate a regular
time index for a time series of satellite images.
Extracting Components
The format() function converts a Date to a character string with the specified formatting codes:
r
format(d, "%Y") # "2024"
format(d, "%B") # "July"
format(d, "%m") # "07"
We can use this to create derived columns like year, month, and day_of_year in a data frame.
The lubridate package (outside base R) provides convenient functions year(), month(), etc.,
but format() suffices.
4.5.3 The POSIXct Class: Date and Time with Time Zones
When the time of day matters—hour, minute, second—Date is insufficient. POSIXct stores
date-time as the number of seconds since the epoch (1970-01-01 00:00:00 UTC). It is a compact
numeric vector with a class attribute and a time zone attribute (tzone).
Creation from Character Strings
[Link]() converts character strings to POSIXct. The default format is "%Y-%m-%d %H:
%M:%S" (ISO 8601 with a space). If your strings omit seconds or use a different delimiter,
specify format:
r
# Default format
tm <- [Link]("2024-07-22 14:30:00")
tm
# [1] "2024-07-22 14:30:00 CST" (CST or your local time zone)
# Custom format
[Link]("22/07/2024 14:30", format = "%d/%m/%Y %H:%M")
The time zone of the created object is the system’s local time zone unless you override it with
the tz argument:
r
# Specify UTC explicitly
tm_utc <- [Link]("2024-07-22 14:30:00", tz = "UTC")
tm_utc
Critical: Always be aware of the time zone of your POSIXct objects. If you combine data from
different time zones without conversion, you will introduce subtle errors. In geospatial work,
satellite data are often tagged in UTC, while field data may be in local time. Converting all
temporal data to UTC is a safe practice for analysis.
Common percent-codes for time
Code Meaning
%H Hour (00–23)
%M Minute (00–59)
%S Second (00–61)
%z Offset from UTC
%Z Time zone abbreviation
The Internal Numeric Value
r
[Link](tm_utc) # seconds since 1970-01-01 00:00:00 UTC
Arithmetic with integers adds or subtracts seconds:
r
tm_utc + 3600 # adds one hour
Difference between two POSIXct returns a difftime object in seconds (or automatically scaled to
larger units when printed):
r
t1 <- [Link]("2024-07-22 10:00:00", tz = "UTC")
t2 <- [Link]("2024-07-22 15:30:00", tz = "UTC")
t2 - t1 # Time difference of 5.5 hours
Sequences and Time Zones
seq() works for POSIXct:
r
seq(from = [Link]("2024-07-22 00:00:00", tz = "UTC"),
to = [Link]("2024-07-23 00:00:00", tz = "UTC"),
by = "3 hours")
Time zone conversion is done with [Link]() specifying a new tz, or with format() that
includes time zone? Actually, [Link](tm_utc, tz = "Asia/Shanghai") converts the numeric
value to the new time zone. The underlying seconds remain the same, but the printed
representation changes. To change the displayed time zone without changing the instant, you can
set attr(x, "tzone") or use lubridate::with_tz().
r
tm_utc
# [1] "2024-07-22 14:30:00 UTC"
tm_shanghai <- [Link]([Link](tm_utc), origin = "1970-01-01", tz = "Asia/Shanghai")
tm_shanghai
# [1] "2024-07-22 22:30:00 CST" (UTC+8)
However, a simpler way is to use format() to display in a specific time zone but that doesn't
change the object. The lubridate package makes this easier. In base R, the
idiom [Link](format(tm_utc, tz = "UTC"), tz = "Asia/Shanghai") works. We'll demonstrate
in the technical demo.
4.5.4 POSIXlt: The List Representation
POSIXlt stores date-time as a list with components sec, min, hour, mday (day of
month), mon (0-based month), year (years since 1900), wday, yday, isdst, zone, gmtoff. It is
created by [Link](). It is convenient for extracting individual components because you can
use $:
r
tm_lt <- [Link]("2024-07-22 14:30:00", tz = "UTC")
tm_lt$year # 124 (since 1900)
tm_lt$mon # 6 (0-based: July)
tm_lt$mday # 22
However, POSIXlt does not fit well inside data frames (it is a list, not a vector), so for data frame
columns, POSIXct is preferred. We will primarily use POSIXct and extract components
via format() or by converting temporarily to POSIXlt.
4.5.5 Parsing Dates from Messy Strings
Real-world data often contain dates in inconsistent formats. R provides several tools:
strptime(): converts character to POSIXlt using a format string, equivalent
to [Link]().
[Link](..., format = ...): as seen.
Multiple formats: If a single vector contains dates in multiple formats, you can try each
format in sequence and combine the results:
r
x <- c("2024-07-22", "22/07/2024", "July 22, 2024")
formats <- c("%Y-%m-%d", "%d/%m/%Y", "%B %d, %Y")
dates <- [Link](rep(NA, length(x)))
for (i in seq_along(formats)) {
tmp <- [Link](x, format = formats[i])
dates[[Link](dates)] <- tmp[[Link](dates)]
}
The lubridate package’s parse_date_time() handles this more elegantly, but understanding the
base R approach is essential for debugging.
4.5.6 Date Arithmetic and Filtering in Spatial Data Frames
With date columns properly converted, you can filter a data frame using logical subsetting:
r
# Filter samples collected after June 2024
samples[samples$date > [Link]("2024-06-30"), ]
You can group by month or year using format() and then apply tapply or later dplyr::group_by():
r
samples$month <- format(samples$date, "%Y-%m")
For satellite time series, you might have a data frame with
columns acquisition_date and ndvi_mean. You can compute the temporal trend by converting the
date to a numeric day count and using it as a predictor in a linear model.
4.5.7 Technical Demonstration: Working with Dates and Times
r
# ---------- 1. Date creation and inspection ----------
d1 <- [Link]("2024-03-15")
d2 <- [Link]("2024-09-10")
class(d1) # "Date"
[Link](d1) # internal day count
d2 - d1 # 179 days
d1 + 90 # 2024-06-13
# ---------- 2. Date sequences ----------
seq_dates <- seq(from = [Link]("2024-01-01"), to = [Link]("2024-12-31"), by = "month")
seq_dates
length(seq_dates) # 12
# ---------- 3. Formatting and extracting components ----------
format(d1, "%B %d, %Y") # "March 15, 2024"
format(d1, "%j") # day of year: 075
# ---------- 4. POSIXct creation and arithmetic ----------
tm1 <- [Link]("2024-07-22 09:00:00", tz = "UTC")
tm2 <- [Link]("2024-07-22 17:30:00", tz = "UTC")
tm2 - tm1 # 8.5 hours
tm1 + 7200 # add 2 hours: 11:00:00 UTC
# ---------- 5. Time zone conversion ----------
tm_local <- [Link]("2024-07-22 09:00:00", tz = "Asia/Shanghai")
tm_utc <- [Link](format(tm_local, tz = "UTC"), tz = "UTC")
tm_local
tm_utc # 01:00 UTC (CST is UTC+8)
# ---------- 6. POSIXlt for component extraction ----------
tm_lt <- [Link](tm_local)
tm_lt$hour #9
tm_lt$mon # 6 (July)
tm_lt$wday # 1 (Monday, 0=Sunday)
# ---------- 7. Filtering in a data frame ----------
[Link](10)
df <- [Link](
date = sample(seq([Link]("2024-01-01"), [Link]("2024-12-31"), by = "day"), 20),
value = rnorm(20)
)
# sort by date
df <- df[order(df$date), ]
# filter for July onwards
df_july_plus <- df[df$date >= [Link]("2024-07-01"), ]
df_july_plus
# ---------- 8. Aggregating by month ----------
df$month <- format(df$date, "%Y-%m")
tapply(df$value, df$month, mean)
Run this code, observe how dates and times are printed, and how arithmetic behaves.
Hard Practice 4.5 – “Temporal Analysis of Field Survey and Satellite Overpass Dates”
Objective:
Practice creating, formatting, filtering, and aggregating date and date-time objects in a realistic
multi-source temporal dataset. Simulate a campaign where field samples must be matched to the
nearest satellite acquisition.
Scenario:
You have a vector of field survey dates (as character strings in various formats) and a vector of
satellite overpass dates (as Date). You must: clean and convert the field dates, compute the time
difference between each field sample and the nearest satellite overpass, filter out samples taken
outside the satellite’s temporal window, and compute seasonal summaries.
Instructions:
1. Create a new script practice_4_5_dates.R. Add a header.
2. Field dates (messy format):
Create a character vector field_dates_raw of 20 simulated field collection dates in mixed
formats:
r
[Link](99)
d1 <- format(seq([Link]("2024-03-01"), [Link]("2024-10-31"), [Link] = 10), "%Y-%m-
%d")
d2 <- format(seq([Link]("2024-03-01"), [Link]("2024-10-31"), [Link] = 10),
"%d/%m/%Y")
field_dates_raw <- sample(c(d1, d2), 20)
Introduce a couple of NA values: field_dates_raw[sample(1:20, 2)] <- NA.
3. Convert to Date:
Try to convert the entire vector with the default format ("%Y-%m-%d"), and then fill
the NAs using the second format ("%d/%m/%Y"). Use the multi-format approach
described in Section 4.5.5. Store the result as a Date vector field_dates. Print the number
of successful conversions.
4. Satellite overpass dates:
Create a sequence of satellite overpass dates every 16 days starting from 2024-03-
01 to 2024-11-30 (Landsat temporal resolution). Store as sat_dates.
5. Match to nearest overpass:
For each field date, find the satellite overpass date that is closest in time. You can do this
by computing the absolute difference in days between the field date and all satellite dates,
and then selecting the date with the minimum difference. A small sapply loop over the
field dates is acceptable; or you can use outer() with subtraction and apply to find the
minimum. Example:
r
diffs <- outer(field_dates, sat_dates, difftime) # matrix of time differences
nearest_idx <- apply(abs(diffs), 1, [Link])
nearest_sat <- sat_dates[nearest_idx]
Compute the time difference (in days) between each field date and its nearest satellite overpass.
Store as delta_days.
6. Temporal filtering:
Keep only those field samples whose nearest satellite overpass is within ±3 days. Create a
data frame valid with columns field_date, nearest_sat_date, and delta_days for these valid
samples. Print it.
7. Seasonal aggregation:
For all field dates (before filtering), extract the month using format(field_dates, "%B").
Count how many field samples were collected in each month. Plot this count as a barplot
(optional, but barplot() is simple).
8. POSIXct for time of day:
Create a vector sample_times of 20 POSIXct objects representing the exact time of day
each field sample was taken (e.g., between 08:00 and 17:00 on the field date).
Use [Link](paste(field_dates, sample_hours)) with a random hour:minute string.
Add a column sample_time to the valid data frame (if the date is still valid after filtering;
otherwise to the original set). Compute the mean hour of sampling.
9. Reflection:
o Why must time zones be considered when comparing field data (local time) with
satellite data (usually UTC)?
o Explain the advantage of Date over character strings for temporal filtering.
o How would you aggregate data by growing season (e.g., April–September)
using format() and logical subsetting?
Deliverable:
The script practice_4_5_dates.R with all steps, comments, and printed results.
Chapter 4 Review Problems
Congratulations on completing Chapter 4. You have mastered the heterogeneous list, the
rectangular data frame, the indexed and merged attribute table, the categorical factor, and the
temporal Date and POSIXct types. Together, these form the complete toolkit for representing,
querying, and transforming the non-spatial attribute tables that accompany every GIS layer. The
following problems are designed to consolidate and extend these skills in realistic geospatial
scenarios. Solve each problem in a clearly commented R script. Use base R unless otherwise
noted; resist the temptation to use tidyverse functions before we meet them in Chapter 6.
Easy Problems (1–5)
1. Creating a Metadata List
Create a named list station_meta that describes a weather station. The list must contain:
station_id: character "WS001"
location: a named numeric vector with elements lon = 116.4 and lat = 39.9
elevation_m: numeric 1520
active: logical TRUE
sensors: a character vector c("temperature", "humidity", "pressure")
Print the list. Then extract and print the following:
The station’s elevation (as a numeric scalar) using $.
The longitude (as a numeric scalar) using [[ ]].
The second sensor type using integer indexing on the sensors component.
2. Data Frame Creation and Column Statistics
Create a data frame samples with 10 rows and the following columns:
sample_id: character vector "S01" through "S10" (use paste0 and 1:10).
ph: numeric vector of 10 values between 5.0 and 8.0 (use runif(10, 5,
8) after [Link](12) and round to 1 decimal).
texture: character vector randomly chosen from c("Sand", "Silt", "Clay") (use sample()).
Use stringsAsFactors = FALSE.
Print the data frame. Then:
Extract the ph column as a vector and compute its mean and standard deviation.
Extract a subset data frame containing only sample_id and texture for the first five rows.
3. Factor Creation and Frequency Table
A field survey recorded the following land cover types at 20 points:
cover <- c("Forest", "Urban", "Water", "Forest", "Forest", "Urban", "Grassland", "Water",
"Forest", "Grassland", "Urban", "Forest", "Water", "Grassland", "Forest", "Urban", "Water",
"Forest", "Grassland", "Forest")
Convert cover to a factor cover_f with levels in alphabetical order.
Print the factor and its levels.
Create a frequency table using table().
Use [Link]() on the factor and explain in a comment what the numbers mean
(referring to the levels order).
4. Date Conversion and Difference
Two satellite images were acquired on "2024-03-15" and "2024-09-10" (as character strings).
Convert both strings to Date objects.
Compute the number of days between the two acquisitions.
Using the first date, compute the date 90 days later (the next overpass window).
Extract the month name (full name) of the second date using format().
Print all results.
5. Merging Two Simple Data Frames
Create two data frames:
sites: columns site_id (c("A","B","C")) and river (c("Yangtze","Yellow","Pearl")).
quality: columns site_id (c("A","B","A","C")) and date (c("2024-01","2024-02","2024-
03","2024-01")) and do_mgl (dissolved oxygen, e.g., c(7.1, 8.2, 6.9, 7.5)).
Merge quality with sites by site_id keeping all quality records (left join on quality). Print
the result.
Then merge sites with quality keeping all sites (left join on sites). Identify which site has
no quality data by checking for NA in the merged result.
Medium Problems (6–10)
6. Nested List for a Multi-Site Campaign
Construct a nested list campaign representing two sites. Each site is a list
with site_name (character), location (list with lon and lat), and measurements (list with ph at two
depths: topsoil and subsoil). Invent plausible values.
Print the full list structure using str(campaign, [Link] = 3).
Extract the topsoil pH of the second site using $ and [[ ]] in a single line.
Use [ ] to create a sub-list containing only the location component of the first site. Verify
that this sub-list is still a list.
Add a new component qc_flag = TRUE to the first site’s measurements list.
7. Data Frame Logical Subsetting and Sorting
Generate a data frame sensors of 30 temperature readings:
r
[Link](456)
sensors <- [Link](
sensor_id = rep(paste0("SN", sprintf("%02d", 1:5)), each = 6),
date = rep(seq([Link]("2024-06-01"), [Link]("2024-06-06"), by = "day"), times = 5),
temp_C = round(rnorm(30, mean = 25, sd = 5), 1)
)
Filter the data frame to show only readings where temp_C > 28. Print the result.
Find all readings from sensor "SN03" on or after June 4. Print the subset.
Sort sensors by sensor_id (ascending) and then by temp_C (descending). Print the first 10
rows.
Add a logical column hot that is TRUE if temp_C > 30. Count how many readings are
hot per sensor using table().
8. Ordered Factor and Terrain Classification
A vector of slope angles (in degrees) was measured at 25 sites:
r
[Link](78)
slope <- round(runif(25, 0, 50), 1)
Use cut() to classify slope into an ordered factor slope_class with breaks c(0, 5, 15, 30,
50) and labels "Flat", "Gentle", "Moderate", "Steep". Ensure [Link] =
TRUE and ordered_result = TRUE (pass ordered_result = TRUE to cut() directly).
Print the factor and verify that the levels are in the correct order.
Tabulate the counts of each class.
Show that "Gentle" < "Moderate" evaluates to TRUE using the factor values.
9. Date Formatting and Monthly Aggregation
Create a data frame field with 40 random field collection dates in 2024:
r
[Link](90)
field <- [Link](
plot_id = 1:40,
date = sample(seq([Link]("2024-03-01"), [Link]("2024-10-31"), by = "day"), 40),
yield = round(rnorm(40, mean = 3.5, sd = 0.8), 2)
)
Add a column month containing the full month name of each date (use format(date,
"%B")).
Add a column weekday containing the abbreviated weekday name (format(date, "%a")).
Compute the mean yield for each month using tapply().
Compute the number of samples per weekday using table().
Filter field to show only samples collected in the meteorological summer (June, July,
August). Print this subset.
10. Merging with Handling of Unmatched Rows
Create two data frames:
parcels: 8 parcels with parcel_id = 1:8 and area_ha = round(runif(8, 1, 10), 1).
crops: 5 records with parcel_id = c(1,2,2,4,9) and crop =
c("Wheat","Rice","Rice","Maize","Barley") and yield = c(3.1, 4.2, 4.0, 5.5, 2.8). Note
that parcel 9 does not exist in parcels.
Perform an inner join (merge with default all = FALSE). How many rows does the result
have? Explain which parcels were kept.
Perform a left join keeping all parcels (all.x = TRUE). Identify parcels with no crop data
(those with NA in crop).
Perform a right join keeping all crop records (all.y = TRUE). Notice that parcel 9 appears
with NA in area_ha. Print the result.
Challenging Problems (11–15)
11. Deeply Nested List Extraction with lapply (Spectral Library)
Create a nested list spectral_library representing three land cover classes (Forest, Grassland,
Water). Each class is a list containing class_name (character) and spectra (a list of five numeric
vectors, each representing the reflectance profile of a sample pixel in 4 bands: Blue, Green, Red,
NIR). Generate the reflectance vectors using runif() with different ranges to simulate realistic
signatures (Forest: NIR high; Grassland: NIR moderate; Water: NIR low). Use [Link](55).
Print the structure with str(spectral_library, [Link] = 2).
Use lapply() to compute the mean spectrum (a vector of 4 values) for each land cover
class. The result should be a list of three numeric vectors.
Use sapply() to extract a matrix where each column is the mean spectrum of a class, and
rows are bands. Print this matrix with band names and class names.
For the Forest class, extract the NIR reflectance of all five sample pixels
using sapply() and compute their standard deviation.
12. Complex Data Frame Operations: Parcel Attribute Table
Simulate a land parcel attribute table parcels with 50 rows:
r
[Link](202)
parcels <- [Link](
parcel_id = 1:50,
area_ha = round(runif(50, 0.5, 20), 2),
elevation_m = round(rnorm(50, mean = 800, sd = 250)),
soil_type = sample(c("Clay", "Loam", "Sand", "Silt"), 50, replace = TRUE),
land_use = sample(c("Agriculture", "Forest", "Urban", "Wetland"), 50, replace = TRUE, prob =
c(0.4, 0.3, 0.2, 0.1)),
stringsAsFactors = FALSE
)
Convert soil_type and land_use to factors. For land_use, set the level order to c("Forest",
"Agriculture", "Wetland", "Urban") using the levels argument in factor().
Add a column elevation_zone by cutting elevation_m into three equal-width intervals
using cut() with breaks = 3. The labels should be "Low", "Mid", "High". This column is a
factor.
Compute the mean parcel area for each combination
of soil_type and land_use using tapply(area_ha, list(soil_type, land_use), mean). Print the
result as a table.
Find all parcels where area_ha > 10, elevation_zone == "Mid", and land_use ==
"Agriculture". Print their parcel_ids.
Relevel land_use so that "Urban" is the first (reference) level. Verify with levels().
13. Multi-Key Merge and Temporal Matching
Simulate a field campaign and a satellite acquisition log:
samples: 20 rows with site_id (factor of 5 sites), date (Date, random within 2024-06-01 to
2024-08-31), ph (numeric).
satellite: 8 rows with site_id (the same 5 sites, some sites have two
overpasses), overpass_date (Date), scene_id (character).
Generate these using [Link](777).
The goal is to match each sample to the satellite scene whose overpass_date is closest to
the sample’s date, but only if the difference is ≤ 5 days. If no satellite overpass is within
5 days, the sample should be excluded.
First, merge samples with satellite by site_id using merge(), creating all possible
sample-scene pairs for the same site.
Compute the absolute date difference for each pair.
For each sample, select the row with the minimum absolute difference.
Then filter out pairs where the minimum difference exceeds 5 days.
Print the final matched data frame with
columns sample_id, date, site_id, overpass_date, scene_id, delta_days.
14. Factor Recoding and Accuracy Assessment with table()
Simulate a land cover classification accuracy assessment:
r
[Link](33)
n <- 100
true_cover <- sample(c("Forest", "Grassland", "Urban", "Water"), n, replace = TRUE, prob =
c(0.4, 0.3, 0.2, 0.1))
predicted_cover <- true_cover
# Introduce errors: 20% of predictions are randomly changed
error_idx <- sample(1:n, size = round(0.2 * n))
predicted_cover[error_idx] <- sample(c("Forest", "Grassland", "Urban", "Water"),
length(error_idx), replace = TRUE)
Convert both vectors to factors with the same levels in the
order "Forest", "Grassland", "Urban", "Water". (Forest as reference for potential
modelling.)
Create a confusion matrix using table(predicted, true).
Compute overall accuracy: sum of diagonal divided by total.
Compute producer’s accuracy for each class: diagonal element divided by column total
(the true class total). Do this using diag() and colSums().
Compute user’s accuracy for each class: diagonal element divided by row total.
Use rowSums().
Recode the factor levels: combine "Water" and "Wetland"? No, we only have Water here.
Instead, create a new
factor broad_cover where "Forest" and "Grassland" become "Vegetation",
and "Urban" and "Water" become "Non-Vegetation". Do this by converting to character,
using ifelse(), and converting back to factor. Print a frequency table of the new broad
classes.
15. Temporal Data Frame with POSIXct: GPS Track Analysis
Simulate a GPS track of a field researcher walking a transect:
r
[Link](543)
n_fixes <- 50
start_time <- [Link]("2024-07-22 08:00:00", tz = "Asia/Shanghai")
# Generate timestamps roughly every 30 seconds with noise
time_deltas <- cumsum(round(runif(n_fixes, 20, 40))) # seconds
track_time <- start_time + time_deltas
# Coordinates moving east and north with random walk
lon <- 116.4000 + cumsum(rnorm(n_fixes, mean = 0.0002, sd = 0.0001))
lat <- 39.9000 + cumsum(rnorm(n_fixes, mean = 0.0001, sd = 0.00015))
Create a data frame track with columns fix_id = 1:n_fixes, time = track_time, lon, lat.
Convert track_time to UTC and add a
column time_utc (use format() with tz and [Link]() as shown in Section 4.5.7).
Compute the time difference (in seconds) between consecutive fixes. The first fix will
have NA. Use diff() and pad with NA at the beginning. Add this as a column dt_sec.
Compute the Euclidean distance (in degrees) between consecutive fixes
using lon and lat (treat as planar for simplicity; small area). Add as column dist_deg.
Calculate speed in degrees per second: dist_deg / dt_sec. Convert to a more intuitive unit:
approximate metres per second by multiplying by 111,000 (rough metres per degree).
Add column speed_ms.
Flag any fix where speed_ms > 1.5 (faster than walking speed, likely a GPS error). Create
a logical column speed_flag.
Extract only the flagged fixes and print their fix IDs, times, and speeds.
Compute the total elapsed time of the survey in minutes.
Chapter 5: Control Flow and Functions – Automating Spatial Workflows
5.1 Conditional Statements: if(), else, ifelse()
Every spatial analysis involves decisions. Is the slope angle steep enough to trigger a landslide
warning? Is the pixel’s NDVI above the vegetation threshold? Does a field sample fall inside or
outside a protected area? R provides three principal tools for expressing such decisions in code:
the if statement, the else clause, and the vectorised ifelse() function. The if statement controls
whether a block of code is executed at all; ifelse() applies a condition element-wise to an entire
vector and returns a vector of results. Together, they allow you to build branching logic into your
scripts—the computational equivalent of the Boolean queries and rule-based classifications that
underpin map algebra and spatial modelling. This section explains the syntax and semantics of
each construct, their use in functions and scripts, and their role in geospatial workflows such as
reclassifying rasters, handling missing data, and controlling iterative processes. By the end, you
will be able to write clear, efficient conditional logic that works on single values or on vectors of
millions of pixels.
5.1.1 The Nature of a Decision in Code
At the most fundamental level, a computer program makes a decision by evaluating a logical
expression—an expression that reduces to a single TRUE or FALSE value—and then choosing
between two paths. In R, the if statement is the mechanism for this. It is a control flow construct:
it does not return a value itself; it controls which lines of code are executed.
When you work with atomic vectors (Chapter 2), you rarely need explicit if because vectorised
operations handle conditionals implicitly: x[x > 0] selects positive elements without an if.
However, if becomes essential when you are:
Writing a function that must behave differently depending on its inputs (e.g., computing
area from a radius only if the radius is positive, otherwise raising an error).
Controlling a loop that processes files, stopping when a certain condition is met.
Branching a script to handle different data types or coordinate reference systems.
Guarding against invalid parameters before proceeding with a calculation.
The ifelse() function, by contrast, is vectorised: it takes a logical vector and returns a vector of
results, choosing elements from two alternatives. This is the workhorse for reclassifying raster
values and creating derived categorical columns in attribute tables.
Thus, the two tools are complementary: if for program-level branching, ifelse for element-wise
branching.
5.1.2 The if Statement: Syntax and Semantics
The basic form is:
r
if (condition) {
# code to execute when condition is TRUE
}
condition must be a single logical value (length 1). If it is a vector of length > 1, only the
first element is used and a warning is issued. This is a common beginner mistake;
use any() or all() to reduce a logical vector to a single condition if needed.
The curly braces {} are optional if the body consists of a single statement, but it is good
practice to always use them for clarity and to avoid errors when adding statements later.
Example: Guarding a division by zero
r
denominator <- 0
if (denominator != 0) {
result <- 100 / denominator
print(result)
}
# Nothing happens because denominator is zero.
If the condition is FALSE, the code block is skipped entirely. The if statement
returns NULL invisibly.
5.1.3 The else Clause
To specify an alternative action when the condition is FALSE, append an else block:
r
if (condition) {
# do this
} else {
# do that instead
}
The else must appear on the same line as the closing brace of the if body, or immediately after it
on the next line (R’s parser expects the else right after the }). It cannot start a new line unless the
whole structure is enclosed.
Example: Checking a coordinate range
r
lat <- 45.0
if (lat >= -90 && lat <= 90) {
message("Valid latitude")
} else {
message("Invalid latitude")
}
Here && is the scalar AND operator (as opposed to vectorised &). Using && ensures a single
logical result.
Chaining conditions with else if:
You can test multiple mutually exclusive conditions by chaining else if:
r
elevation <- 2500
if (elevation < 500) {
zone <- "Lowland"
} else if (elevation < 1500) {
zone <- "Hill"
} else if (elevation < 3000) {
zone <- "Mountain"
} else {
zone <- "Alpine"
}
zone # "Mountain"
This is the control-flow analogue of nested ifelse(), and it is clearer when you have a single value
(or a single iteration of a loop) and multiple outcome categories.
5.1.4 The Vectorised ifelse() Function
For element-wise conditional operations on vectors, ifelse(test, yes, no) is the correct tool. We
encountered it in Chapter 2, but here we formalise its role in control flow. Unlike if, which takes
a single logical value, ifelse() takes a logical vector and returns a vector of the same length,
choosing the corresponding element from yes or no.
test: a logical vector.
yes: values to return where test is TRUE. Recycled to length of test.
no: values to return where test is FALSE. Recycled.
r
ndvi <- c(0.4, 0.6, -0.1, 0.8, NA, 0.3)
cover <- ifelse(ndvi > 0.5, "Vegetation", "Non-vegetation")
cover # "Non-vegetation" "Vegetation" "Non-vegetation" "Vegetation" NA "Non-vegetation"
Notice that where ndvi is NA, the test ndvi > 0.5 yields NA, and ifelse returns NA for those
positions—a sensible default that preserves missingness.
ifelse() can be nested for multi-category classification:
r
zone <- ifelse(elevation < 500, "Lowland",
ifelse(elevation < 1500, "Hill",
ifelse(elevation < 3000, "Mountain", "Alpine")))
This works on vectors of any length, making it the primary tool for reclassifying raster layers and
attribute table columns without loops. However, for many
categories, cut() or dplyr::case_when() are more readable; ifelse() is best for 2–4 categories.
5.1.5 Conditional Execution in Functions and Scripts
In a function, if is used to validate inputs, choose between algorithms, or handle edge cases. A
typical spatial function might check that the coordinate reference system is valid before
projecting:
r
project_point <- function(x, y, from_crs, to_crs) {
if ( || ) {
stop("Coordinates must be numeric")
}
if (length(x) != length(y)) {
stop("x and y must have the same length")
}
# (projection code here, using sf)
}
In a script, if can control whether a section is executed based on interactive use or batch mode, or
whether a directory exists before writing output:
r
if () {
[Link]("output")
}
This prevents errors when writing files.
5.1.6 Scalar vs. Vectorised Logic: && vs &, || vs |
When building conditions for if, you usually want scalar logical operators:
&& : scalar AND (evaluates left to right, stops on first FALSE — short-circuiting).
|| : scalar OR (stops on first TRUE).
These ensure a single logical value. The vectorised versions & and | also work but produce a
vector, and if will only use the first element with a warning. For example:
r
x <- 5
if (x > 0 && x < 10) { ... } # correct
if (x > 0 & x < 10) { ... } # works but with warning if x is length >1; for length 1 it's okay but
not idiomatic.
Use && and || in if conditions; use & and | for vectorised subsetting.
5.1.7 Geospatial Context: Conditional Logic in Spatial Analysis
Conditionals are everywhere in geoinformatics:
Raster reclassification: A DEM is reclassified into slope stability classes
using ifelse() (or cut()). For example, creating a binary mask of areas where slope >
30° and elevation > 2000 m: mask <- ifelse(slope > 30 & elev > 2000, 1, 0).
Data quality control: In a loop over field samples, you might check if ([Link](ph)) to skip
a computation, or if (ph < 0) to flag an error.
Spatial joins: When computing area of intersection between two polygons, you might
first if (st_intersects(poly1, poly2, sparse = FALSE)) before proceeding, to avoid errors.
Parameter validation: In a function that computes Normalized Burn Ratio, you check
that NIR and SWIR2 bands have the same dimensions: if (!all(dim(nir) == dim(swir2)))
stop("Bands must match").
Thus, conditional statements are not mere programming formalities; they are the safety rails and
decision rules that make spatial scripts robust and intelligent.
5.1.8 Technical Demonstration: Conditional Logic in Practice
We will write and run a series of short examples that illustrate if, else, and ifelse() in isolation
and in combination.
r
# ---------- 1. Basic if/else ----------
temperature <- 32
if (temperature > 30) {
message("Heat warning")
} else {
message("Temperature normal")
}
# ---------- 2. Chained if/else for elevation zone ----------
elev <- 1850
if (elev < 500) {
zone <- "Lowland"
} else if (elev < 1500) {
zone <- "Hill"
} else if (elev < 3000) {
zone <- "Mountain"
} else {
zone <- "Alpine"
}
zone # "Mountain"
# ---------- 3. ifelse on a vector ----------
rainfall <- c(120, 45, 300, 88, 0, 210, 55)
category <- ifelse(rainfall > 100, "Heavy",
ifelse(rainfall > 50, "Moderate", "Light"))
category
# ---------- 4. Conditional function: safe division ----------
safe_divide <- function(x, y) {
if (y == 0) {
return(NA)
} else {
return(x / y)
}
}
safe_divide(10, 2) # 5
safe_divide(10, 0) # NA
# ---------- 5. Using if to validate input in a function ----------
compute_ndvi <- function(red, nir) {
if ( || ) {
stop("Inputs must be numeric")
}
if (length(red) != length(nir)) {
stop("red and nir must have the same length")
}
ifelse((nir + red) == 0, NA, (nir - red) / (nir + red))
}
ndvi <- compute_ndvi(c(0.1, 0.2, NA), c(0.3, 0.5, 0.4))
ndvi
# ---------- 6. if with any() for vector condition ----------
vals <- c(2, 5, 12, 18)
if (any(vals > 10)) {
message("Some values exceed 10")
}
Run these and observe the printed outputs and messages. Pay attention to how ifelse returns a
vector of the same length as the input, while if does not produce a direct output (it executes
code).
Hard Practice 5.1 – “Conditional Logic for Elevation Classification and Data Quality
Screening”
Objective:
Write conditional statements and functions that classify terrain, screen faulty sensor data, and
apply different formulas based on land cover type. Use both if/else (in functions and loops) and
vectorised ifelse() where appropriate.
Scenario:
You are processing a set of point observations from a soil survey. Each point has an elevation, a
land cover class, and a pH measurement. Some pH values are outside the plausible range (0–14)
and must be flagged. You will classify elevation into zones, apply a correction to pH only for
forest soils, and write a function that returns a safe pH value.
Instructions:
1. Create a new script practice_5_1_conditionals.R. Add a header.
2. Elevation classification function:
Write a function elevation_zone(elev) that takes a numeric vector elev and returns a
character vector of zones using nested ifelse(). Use the breaks: <500 → "Lowland", 500–
1500 → "Hill", 1500–3000 → "Mountain", >3000 → "Alpine". Note: the function must
work on vectors of any length, so use ifelse inside.
Test it on a small vector and print the result.
3. Data quality screening with if and a loop:
Create a vector ph_values of 20 random values between 3 and 9, but insert two deliberate
errors: values 15 and -2 at random positions. Use a for loop to iterate
over ph_values (index by i). Inside the loop, use if to check if the value is outside 0–14. If
it is, print a warning message with the index and value, and set the element to NA. After
the loop, print the cleaned vector.
4. Conditional correction with ifelse and land cover:
Create a data frame sites with 10 rows:
r
[Link](11)
sites <- [Link](
site_id = 1:10,
elevation = round(rnorm(10, mean = 1200, sd = 600)),
land_cover = sample(c("Forest", "Grassland", "Wetland"), 10, replace = TRUE),
ph = round(runif(10, 5.0, 8.0), 1)
)
Use vectorised ifelse to add a column ph_corrected where:
o If land_cover is "Forest", pH is increased by 0.2 (to reflect organic acid
influence).
o Otherwise, pH is unchanged.
Print the data frame before and after.
5. Function with multiple conditions:
Write a function describe_site(elev, cover) that takes a single elevation value and a single
land cover string, and returns a character string describing the site. Use chained if/else
if/else (not ifelse, because we are dealing with a single case). The description should
combine zone and cover, e.g., "Mountain Forest". If elevation is negative, stop with an
error using stop(). Test the function with several values.
6. Apply the function to the data frame row by row (preview of apply):
Use mapply() or a for loop to apply describe_site() to each row of sites and add the result
as a new column description. Print the final data frame.
7. Reflection:
o When would you use if/else instead of ifelse()?
o Why is && preferred over & in an if condition?
o How could you use ifelse() to create a binary raster mask from a continuous
NDVI raster?
Deliverable:
The script practice_5_1_conditionals.R with all functions, commented code, and printed results.
.2 Loops: for(), while(), and Their Vectorized Alternatives
In Chapter 2 you learned that R’s vectorised operations can process entire arrays without
explicit iteration. This raises a question: if vectorisation is so powerful, why does R have loops
at all? The answer is that loops are necessary when each iteration depends on the result of the
previous one, when you must process files one by one, or when the operation cannot be
expressed as a simple element-wise function. This section introduces the two looping constructs
—for and while—explains their syntax and semantics, and, crucially, teaches you when not to
use them. We will learn to recognise situations that call for a loop and those that call for a
vectorised alternative, and we will apply loops to realistic geospatial tasks: iterating over a
directory of satellite images, applying a moving-window filter where vectorisation is
impractical, and implementing a cellular automaton. By the end, you will use loops judiciously—
not as a first resort, but as a precise tool for problems that genuinely require sequential
processing.
5.2.1 The Philosophy of Iteration in R
R is an interpreted language. When you write a for loop, R must interpret the loop body at each
iteration, calling R functions and dispatching methods anew. This overhead can be substantial
when the loop runs hundreds of thousands of times. By contrast, a vectorised function like x +
y dispatches the entire computation to compiled C or Fortran code in a single call. The rule of
thumb in the R community is: “Keep your loops in C.” That is, whenever a built-in vectorised
function or a package like terra provides the operation you need, use it instead of a loop.
However, loops are not evil. They are the correct tool when:
1. Iterations are not independent: The result of step *i* depends on step i-1. For example,
a cumulative sum that resets after a threshold, or a spatial simulation where each cell’s
state depends on its neighbors’ states at the previous time step.
2. You are processing external files or resources: Reading 100 shapefiles in a directory
requires a loop (or lapply, which is a functional wrapper over a loop).
3. The number of iterations is small (a few dozen) and the loop body is complex. The
performance difference is negligible, and a loop can be more readable than a deeply
nested apply construct.
4. You need explicit control flow within the iteration: breaking out early with break,
skipping iterations with next, or handling errors gracefully.
Thus, a skilled R programmer writes loops deliberately, not habitually. This section builds that
discernment.
5.2.2 The for Loop: Iterating Over a Sequence
The for loop iterates over each element of a vector (or list), executing a block of code for each
element. The syntax is:
r
for (variable in sequence) {
# body of the loop
}
variable takes on each value in sequence in turn.
sequence is any vector (atomic or list). The loop runs length(sequence) times.
The curly braces are optional for single-statement bodies but strongly recommended.
Example: Printing sensor readings
r
sensor_readings <- c(23.5, 24.1, 22.8, 25.0, 23.9)
for (temp in sensor_readings) {
cat("Temperature:", temp, "°C\n")
}
Here temp is a local variable that takes the value of each element. The loop itself does not return
a value; it is executed for its side effects (printing, writing files, modifying objects in the
enclosing environment).
Example: Accumulating results
To collect results from a loop, you typically pre-allocate a vector or list and fill it:
r
n <- length(sensor_readings)
result <- numeric(n) # pre-allocate
for (i in 1:n) {
result[i] <- sensor_readings[i] * 9/5 + 32 # convert to Fahrenheit
}
result
Pre-allocation is essential for performance. Growing a vector with c() inside a loop (e.g., result
<- c(result, new_value)) causes R to copy the entire vector at each iteration, leading to quadratic
slowdown. Always create the full container beforehand.
5.2.3 Iterating Over Indices vs. Over Elements
In the example above, we iterated over the index i rather than over the elements themselves. Both
approaches are valid, but they serve different purposes:
Iterate over elements (for (x in vec)) when you only need the element’s value and not its
position.
Iterate over indices (for (i in seq_along(vec))) when you need the position—for
example, to assign results, to access parallel vectors, or to condition on the index.
seq_along(x) is the preferred way to generate indices: it returns 1:length(x), and safely returns an
empty integer vector when x is length 0 (unlike 1:length(x), which would produce 1:0 → 1 0).
Always use seq_along() or seq_len() for loop indices.
r
# Using seq_along for safe index iteration
x <- c(10, 20, 30)
for (i in seq_along(x)) {
cat("Element", i, "is", x[i], "\n")
}
5.2.4 The while Loop: Iterating Until a Condition Is Met
A while loop repeats a block of code as long as a condition remains TRUE. Its syntax is:
r
while (condition) {
# body
}
The condition is evaluated before each iteration. If it is FALSE at the start, the body is never
executed.
while loops are used when the number of iterations is not known in advance—for example,
processing a stream of data until a sentinel value is reached, or running an iterative algorithm
until convergence.
Example: Finding the first negative value
r
values <- c(3, 7, 2, -1, 5, 8)
i <- 1
while (i <= length(values) && values[i] >= 0) {
i <- i + 1
}
if (i <= length(values)) {
cat("First negative value at index", i, ":", values[i], "\n")
} else {
cat("No negative value found\n")
}
Warning: A while loop must have a condition that eventually becomes FALSE; otherwise it will
run forever (an infinite loop). Always ensure the loop body modifies a variable that affects the
condition.
In geospatial analysis, while loops appear in iterative algorithms like spatial interpolation
(kriging with iterative variogram fitting), cellular automata (run until stable), and numerical
optimisation.
5.2.5 Controlling Loops: break and next
Two keywords modify the flow inside a loop:
break immediately exits the innermost loop, regardless of the condition.
next skips the rest of the current iteration and proceeds to the next element (in for) or
re-evaluates the condition (in while).
r
# Break on first missing value
x <- c(1.2, 3.4, NA, 5.6, 7.8)
for (val in x) {
if ([Link](val)) {
cat("Missing value encountered. Stopping.\n")
break
}
cat("Processing:", val, "\n")
}
r
# Skip zeros (next)
y <- c(0, 2, 0, 4, 0, 6)
for (val in y) {
if (val == 0) next
cat("Non-zero value:", val, "\n")
}
These are useful for error handling within loops that process many files or features: skip over
corrupted files (next) or stop the analysis entirely if a critical error occurs (break).
5.2.6 The Vectorised Alternative: When a Loop Is Unnecessary
Before writing a loop, ask: can this be done with a vectorised operation or an apply function?
Here are common loop patterns and their vectorised equivalents:
Loop pattern Vectorised alternative
for (i in 1:n) result[i] <- f(x[i]) result <- f(x) if f is vectorised
for (i in 1:n) result[i] <- x[i] + y[i] result <- x + y
for (i in 1:n) if (cond[i]) result[i] <- a else result[i] <- b result <- ifelse(cond, a, b)
Loop pattern Vectorised alternative
Accumulating a sum cumsum(x), sum(x)
Computing row means of a matrix rowMeans(m)
Applying a function to each element of a list lapply(lst, f) (Section 5.4)
Let’s see a concrete geospatial example. Suppose you have two vectors: elevation (a transect of
DEM values) and threshold. You want to flag all positions where elevation exceeds the threshold.
A loop would be:
r
n <- length(elevation)
flag <- logical(n)
for (i in 1:n) {
flag[i] <- elevation[i] > threshold[i]
}
The vectorised version is simply:
r
flag <- elevation > threshold
This is not only shorter but dramatically faster. Always prefer the vectorised form when it
exists.
5.2.7 When You Must Loop: Geospatial Examples
Despite the power of vectorisation, some geospatial tasks inherently require loops:
Processing a folder of shapefiles: You have 300 Landsat scene footprints as shapefiles.
You must read each, compute its area, and store the result. A loop over [Link]() is the
natural solution.
Cellular automata for wildfire spread: The state of a pixel at time t+1 depends on the
states of its neighbors at time t. You must iterate over time steps, and within each step,
over pixels. While you can vectorise the neighbor computation using matrices, the time
loop is unavoidable.
Iterative algorithms: The kriging system must be solved iteratively when the variogram
parameters are refined. A while loop tests for convergence.
Sequential sampling: You are adding sampling points one by one, each new point
location depending on the previous ones to maximize spatial coverage. This is a
sequential decision process.
In each case, the loop is part of the algorithm’s logic, not a substitute for a missing vectorised
function.
5.2.8 Pre-allocation and Performance: The Cardinal Rules
If you must use a loop in R, follow these rules to avoid catastrophic slowness:
1. Always pre-allocate the output object. Use vector("list", n) for lists, numeric(n) for
numeric vectors, matrix(NA, nrow, ncol) for matrices. Never grow an object
with c(), rbind(), or cbind() inside a loop.
2. Move invariant computations outside the loop. If you need a constant value, compute
it once before the loop starts.
3. Use local variables. Accessing an object in the global environment is slightly slower
than accessing a local variable. Inside a function, loops are faster.
4. If performance is critical and the loop cannot be vectorised, consider writing the
core in C++ with the Rcpp package. This is an advanced topic, but it underlies the
speed of sf and terra.
Example of bad vs. good pre-allocation:
r
# Bad: growing a vector
result <- c()
for (i in 1:10000) {
result <- c(result, sqrt(i))
}
# Good: pre-allocated
result <- numeric(10000)
for (i in 1:10000) {
result[i] <- sqrt(i)
}
The first version takes quadratically longer as the vector grows; the second version is linear and
fast.
5.2.9 Technical Demonstration: Loops in Geospatial Mini-Tasks
We will simulate three small geospatial scenarios that illustrate for, while, and the vectorised
alternative.
r
# ---------- 1. for loop: process a directory of "scenes" ----------
# Simulate scene file names
scene_files <- paste0("scene_", sprintf("%03d", 1:10), ".tif")
scene_areas <- numeric(length(scene_files))
for (i in seq_along(scene_files)) {
# In reality, we would read the raster and compute area.
# Here we simulate with random numbers.
scene_areas[i] <- runif(1, min = 100, max = 500) # km^2
cat("Processed:", scene_files[i], "| Area:", round(scene_areas[i], 1), "km^2\n")
}
mean_area <- mean(scene_areas)
cat("Mean area:", round(mean_area, 1), "km^2\n")
# ---------- 2. while loop: find first cloud-free scene ----------
cloud_cover <- c(80, 45, 12, 5, 95, 30, 8) # percent cloud
i <- 1
while (i <= length(cloud_cover) && cloud_cover[i] > 10) {
cat("Scene", i, "is too cloudy (", cloud_cover[i], "%)\n")
i <- i + 1
}
if (i <= length(cloud_cover)) {
cat("First usable scene is", i, "with cloud cover", cloud_cover[i], "%\n")
} else {
cat("No usable scene found.\n")
}
# ---------- 3. Vectorised alternative to a loop ----------
# Suppose we have two bands and we want NDVI for each pixel
red <- runif(100, 0.05, 0.20)
nir <- runif(100, 0.25, 0.60)
# Loop version (slow, verbose)
ndvi_loop <- numeric(100)
for (j in 1:100) {
ndvi_loop[j] <- (nir[j] - red[j]) / (nir[j] + red[j])
}
# Vectorised version (fast, concise)
ndvi_vec <- (nir - red) / (nir + red)
# Prove they are identical
[Link](ndvi_loop, ndvi_vec) # TRUE
# ---------- 4. A case where a loop is necessary: cumulative sum with reset ----------
# Elevation gains along a trail; reset cumulative gain when elevation drops.
elev_trail <- c(100, 120, 130, 110, 140, 160, 150, 180)
cum_gain <- numeric(length(elev_trail))
gain <- 0
for (k in 2:length(elev_trail)) {
diff <- elev_trail[k] - elev_trail[k-1]
if (diff > 0) {
gain <- gain + diff
} else {
gain <- 0 # reset on descent
}
cum_gain[k] <- gain
}
cum_gain # 0 20 30 0 30 50 0 30
# This resetting behaviour is not easily vectorised.
Run these examples and observe the printed outputs. Notice that the loop for NDVI is identical
to the vectorised version but longer; the cumulative gain loop, however, cannot be trivially
vectorised because each iteration depends on the previous cumulative value.
Hard Practice 5.2 – “Loops for File Processing and Iterative Algorithms”
Objective:
Write for and while loops to solve problems that require sequential logic or iteration over
external objects. Practise pre-allocation, break/next, and the decision between vectorised and
loop-based approaches.
Scenario:
You are developing a quality-control pipeline for a satellite image archive and a simple cellular
automaton for a wildfire spread simulation. The tasks require explicit loops.
Instructions:
1. Create a new script practice_5_2_loops.R. Add a header.
2. Part A: Loop over a list of image metadata.
Create a list scene_list of 20 elements, each a list with components scene_id (character)
and cloud_pct (numeric, random between 0 and 100). Use [Link](42).
r
scene_list <- lapply(1:20, function(i) list(
scene_id = paste0("LC08_", sprintf("%03d", i)),
cloud_pct = runif(1, 0, 100)
))
o Write a for loop that iterates over the scenes. If cloud_pct < 10, print "Scene <id>
is clear". If cloud_pct > 80, print "Scene <id> is too cloudy,
stopping." and break out of the loop. Use next to skip printing for intermediate
cloud amounts.
o Collect the IDs of all clear scenes (cloud_pct < 10) into a character
vector clear_scenes. Use pre-allocation with a vector of length 20 and then trim to
the actual count afterwards.
3. Part B: While loop for iterative buffer expansion.
Imagine you are expanding a search radius from a point until a target area is covered.
r
target_area <- 10000 # km^2
current_area <- 0
radius <- 0
Write a while loop that increments radius by 1 km each iteration, computes the circle area (pi *
radius^2), and updates current_area. Stop when current_area >= target_area. Print the final radius
needed. (This simulates a simple spatial search.)
4. Part C: Cellular automaton – simple fire spread (preview of raster logic).
Simulate a 1-row transect of 50 forest cells. A cell can be 0 (unburned) or 1 (burning).
Fire spreads from a burning cell to adjacent unburned cells in the next time step, but with
a 30% probability of extinguishing (becoming 2, burned out). This is an iterative process
over time steps.
o Initialize a vector forest of length 50: all 0, then set forest[25] <- 1 (middle cell on
fire).
o Write a for loop over t from 1 to 50 (time steps). Within each time step, create a
new vector next_forest (copy of forest). For each cell i from 2 to 49, apply rules:
If forest[i] == 1 (burning): with probability 0.3, it becomes 2 (burned out)
in next_forest[i]. Else it remains 1.
If forest[i] == 0 (unburned) and at least one neighbor (forest[i-
1] or forest[i+1]) is 1: with probability 0.7, it ignites (next_forest[i] <- 1).
o Use a loop over cells within the time loop (you can use a for loop inside the
time for loop). After each time step, assign forest <- next_forest. Stop early if no
cells are burning (sum of 1s is 0) using break.
o Print the time step and the number of burning cells at each step.
o After the loop, print the final state of the transect (as a string of 0,1,2 characters
concatenated, or simply the vector).
5. Part D: Recognising when to vectorise.
The fire spread rule you implemented used a nested loop over cells, which is slow for
large grids. In Chapter 15 we will see how terra::focal() performs such neighborhood
operations efficiently. In a comment, reflect on why a nested loop over 10,000 cells for
500 time steps would be unacceptably slow in R, and what alternative approach you
might seek. (No code needed.)
6. Reflection:
o What are two scenarios in geospatial analysis where a for loop is more
appropriate than a vectorised operation?
o Why is pre-allocation critical for loop performance?
o How does break enhance the robustness of a file-processing loop?
Deliverable:
The script practice_5_2_loops.R with all loops, printed output, and reflection comments.
5.3 Writing Reusable Functions: Arguments, Return Values, Scoping
In Section 5.1 you learned to make decisions; in Section 5.2 you learned to repeat actions. The
next logical step is to encapsulate a set of decisions and actions into a single, named, reusable
unit: a function. Functions are the soul of R programming. A well-written function takes inputs,
performs a specific task, and returns a result. It hides complexity, enforces consistency, and
makes your code testable and shareable. In geospatial analysis, functions are the natural way to
encode operations like coordinate transformation, spectral index computation, terrain
classification, or file-reading pipelines. Instead of copying and pasting a block of code every
time you need to compute NDVI, you write an ndvi() function once and call it a hundred times.
This section teaches the anatomy of an R function: how to define it, how to specify arguments
with sensible defaults, how to return one or many values, and how R’s lexical
scoping determines which variables a function can see. Each concept is illustrated with
geospatial examples, and the Hard Practice challenges you to build a small library of reusable
spatial functions. By the end, you will have crossed the threshold from script-writer to
programmer.
5.3.1 The Function as a Reusable Workflow: A Conceptual Shift
So far, your R scripts have been linear sequences of commands. This works for a single analysis,
but it rapidly leads to duplication. If you have computed the Normalized Difference Vegetation
Index (NDVI) for one Landsat scene, you will likely compute it for another. If you have cleaned
a set of GPS coordinates, you will clean another. Copy-and-paste is the enemy of reproducibility:
you may forget to update a variable name, or you may apply a correction inconsistently.
A function solves this by abstracting the common pattern. The pattern is: take certain inputs
(arguments), perform operations on them, and produce an output (return value). Once defined, a
function is a black box that you can call repeatedly, confident that it behaves identically each
time. When you improve the function—say, by adding error checking—every call site benefits
automatically.
In the geospatial realm, think of functions as the computational equivalent of GIS tools. A
“Buffer” tool takes a layer and a distance and returns a new layer. You do not re-invent buffering
for each project; you invoke the tool. In R, you can write your own tools. The sf package is,
under the hood, a large collection of functions. The same principle applies at the scale of your
own analysis: extract the repetitive logic, give it a name, and reuse it.
5.3.2 Defining a Function: function() and the Body
The syntax for defining a function is:
r
function_name <- function(argument1, argument2, ...) {
# body of the function
# last expression is the return value
}
function_name: a valid R name. Choose a descriptive verb or noun
phrase: compute_ndvi, reproject_points, safe_log.
function(...): the keyword function followed by a parenthesised list of formal arguments,
which act as local variable names for the inputs.
The body is a block of R code enclosed in curly braces {}. It can contain any R
expressions, conditional statements, loops, and calls to other functions.
The return value is the value of the last expression evaluated in the body. You can also
use return() explicitly.
Minimal example: converting hectares to square metres
r
ha_to_sqm <- function(hectares) {
hectares * 10000
}
ha_to_sqm(2.5) # 25000
The function receives the argument 2.5 bound to the name hectares, multiplies it by 10,000, and
the result is automatically returned.
A function with multiple lines and an explicit return
r
circle_area <- function(radius) {
if (radius < 0) {
stop("Radius must be non-negative")
}
area <- pi * radius^2
return(area)
}
circle_area(5) # 78.53982
circle_area(-1) # Error
Here stop() halts execution with an error message. The return() is not strictly necessary—
area alone would work—but it clarifies the intent.
5.3.3 Function Arguments: Position, Name, Defaults, and ...
R functions can have arguments with default values, making them optional. The syntax
is argument = default_value in the function definition.
Default arguments:
r
greet <- function(name, greeting = "Hello") {
paste(greeting, name, sep = ", ")
}
greet("Lin") # "Hello, Lin"
greet("Lin", greeting = "Hi") # "Hi, Lin"
When a default is provided, the caller may omit the argument. This is extremely useful in
geospatial functions where a common projection or a standard threshold can be assumed but
overridden when necessary.
Calling conventions: Arguments can be supplied by position or by name. Named arguments
can appear in any order. Partial matching of argument names is allowed (but discouraged for
code clarity).
r
# A function with many arguments
classify_slope <- function(angle, low = 5, moderate = 15, high = 30) {
ifelse(angle < low, "Flat",
ifelse(angle < moderate, "Gentle",
ifelse(angle < high, "Moderate", "Steep")))
}
# All by position
classify_slope(12, 5, 15, 30) # "Gentle"
# Mixing position and name
classify_slope(12, high = 25) # "Gentle" (uses default low=5, moderate=15, overrides high)
The ... (ellipsis) argument: A function can accept an arbitrary number of additional arguments
via .... These are passed through to other functions inside the body. This is common in plotting
functions and in spatial functions that forward arguments to underlying libraries.
r
# A wrapper for plotting a point on a map
plot_point <- function(lon, lat, ...) {
plot(lon, lat, ...)
}
plot_point(116.4, 39.9, pch = 19, col = "red", main = "Beijing")
Here pch, col, and main are not formal arguments of plot_point; they are captured by ... and
passed to plot(). This pattern is used extensively in the sf and terra plotting methods.
5.3.4 Return Values: return() and the Last Expression
A function always returns exactly one object. That object can be an atomic vector, a list, a data
frame, or even NULL. The return value is:
The value of the last expression evaluated, or
The value supplied to return(), which exits the function immediately.
Multiple return paths:
r
safe_divide <- function(x, y) {
if (y == 0) {
warning("Division by zero, returning NA")
return(NA)
}
x/y
}
safe_divide(10, 2) # 5
safe_divide(10, 0) # NA (with warning)
Returning multiple values: Since a function can return only one object, to return multiple
pieces of data, bundle them into a list (or a data frame, vector, etc.).
r
ndvi_stats <- function(red, nir) {
ndvi <- (nir - red) / (nir + red)
list(ndvi = ndvi, mean = mean(ndvi, [Link] = TRUE), sd = sd(ndvi, [Link] = TRUE))
}
result <- ndvi_stats(c(0.1, 0.2), c(0.3, 0.5))
result$mean # 0.55
result$sd # 0.2121
This pattern is very common in geospatial analysis: a function might compute a raster and its
summary statistics, returning them together.
Invisible return: If the result is not intended for immediate printing, use invisible(x). This is
useful for functions that are called for their side effects (e.g., writing a file), but that also return a
value for programmatic use. The sf plotting functions, for example, return the object invisibly so
you can chain operations.
5.3.5 Lexical Scoping and Environments: Where Does a Function Look for Variables?
When a function is called, R creates a new execution environment (a frame) in which the
function’s local variables and arguments live. The function’s code is evaluated in that
environment. If a variable is referenced that is not found locally, R looks up to the environment
in which the function was defined (the enclosing environment), not the environment from which
it was called. This is lexical scoping (also called static scoping).
Why does this matter? It allows functions to capture values from their definition context—the
basis for closures. For basic programming, the key implications are:
1. A function can access global variables (those in the global environment), but modifying a
global variable requires the <<- operator, which is strongly discouraged. Functions should
be self-contained: they should take everything they need as arguments and return a result,
avoiding side effects on the global workspace.
2. If a function defines a variable x, that x is local and does not affect any x in the global
environment.
3. If a function references a variable that is neither an argument nor defined locally, R will
search up the chain of enclosing environments until it finds it (or throws an error).
Demonstration of local scope:
r
x <- 10
f <- function() {
x <- 5 # local x, does not touch global x
x
}
f() #5
print(x) # 10 (global x unchanged)
Accessing a global variable (not recommended):
r
offset <- 2
add_offset <- function(val) {
val + offset # offset found in the global environment
}
add_offset(10) # 12
This works, but if offset is changed elsewhere, add_offset behaves unpredictably. Better to
pass offset as an argument.
Lexical scoping in nested functions:
r
make_adder <- function(increment) {
function(x) {
x + increment # increment is captured from make_adder's environment
}
}
add_ten <- make_adder(10)
add_ten(5) # 15
This is an advanced pattern, but it underlies many R features. For geospatial scripting, you rarely
need to write closures, but understanding scoping helps debug why a function cannot see a
variable.
5.3.6 Functions in Geospatial Programming: Examples
Let us see how functions embody common spatial operations.
1. Coordinate transformation (scalar version):
r
lonlat_to_webmercator <- function(lon, lat) {
x <- lon * 20037508.34 / 180
y <- log(tan((90 + lat) * pi / 360)) / (pi / 180)
y <- y * 20037508.34 / 180
c(x, y)
}
lonlat_to_webmercator(116.4, 39.9) # returns c(x, y) in metres
2. NDVI with safety checks:
r
safe_ndvi <- function(red, nir) {
if (any(nir + red == 0, [Link] = TRUE)) {
warning("Zero denominator; replacing with NA")
}
ndvi <- (nir - red) / (nir + red)
ifelse([Link](ndvi), NA, ndvi) # replace NaN with NA
}
3. Classify land cover from NDVI:
r
classify_from_ndvi <- function(ndvi, water_thresh = 0, barren_thresh = 0.2, veg_thresh = 0.5) {
ifelse(ndvi < water_thresh, "Water",
ifelse(ndvi < barren_thresh, "Barren",
ifelse(ndvi < veg_thresh, "Grassland", "Forest")))
}
These functions are small, testable, and reusable. You can combine
them: classify_from_ndvi(safe_ndvi(red, nir)).
5.3.7 Technical Demonstration: Building and Testing Functions
We will write several functions in a script and test them interactively.
r
# ---------- 1. Simple area conversion ----------
sqkm_to_ha <- function(sqkm) {
sqkm * 100
}
sqkm_to_ha(c(1.5, 2.3, 0.8))
# ---------- 2. Function with default argument ----------
buffer_area <- function(radius, unit = "m") {
area <- pi * radius^2
if (unit == "km") {
area <- area / 1e6
}
area
}
buffer_area(100) # metres^2
buffer_area(0.1, "km") # km^2
# ---------- 3. Function returning a list ----------
site_summary <- function(lon, lat, elev) {
centroid <- c(mean(lon), mean(lat))
list(
n_sites = length(lon),
centroid_lonlat = centroid,
max_elev = max(elev),
min_elev = min(elev)
)
}
summ <- site_summary(c(116, 117, 118), c(39, 40, 41), c(1500, 2200, 1800))
summ
# ---------- 4. Function with validation ----------
safe_clip <- function(x, lower, upper) {
if (lower > upper) stop("lower must be <= upper")
x[x < lower] <- lower
x[x > upper] <- upper
x
}
safe_clip(c(1, 5, 10, -2), 0, 8) # 1 5 8 0
# ---------- 5. Function with ... pass-through ----------
plot_spectrum <- function(wavelength, reflectance, ...) {
plot(wavelength, reflectance, type = "l", ...)
}
# plot_spectrum(1:10, runif(10), col = "darkgreen", xlab = "Band", ylab = "Reflectance")
# (uncomment to see plot; not run here)
# ---------- 6. Scoping demonstration ----------
global_val <- 100
scoping_demo <- function(x) {
y <- 10 # local
x + y + global_val # global_val found in global env
}
scoping_demo(5) # 115
# y is not available outside
# print(y) # error
Run these and examine how each function behaves. Experiment by changing arguments and
observing the output.
Hard Practice 5.3 – “Building a Reusable Spatial Function Library”
Objective:
Write a collection of functions that perform common geospatial operations on vectors (without
the sf package). Encapsulate logic, provide default arguments, return structured outputs, and use
validation. Learn to think of functions as building blocks for larger workflows.
Scenario:
You are developing a lightweight library for field data processing. You need functions for:
Converting decimal degrees to radians and back.
Computing the Euclidean distance between two points on a plane.
Computing the spherical (great-circle) distance on Earth.
Classifying slope into stability classes.
Applying a simple atmospheric correction to a single band reflectance (subtract a path
radiance).
These functions will later be used in a larger script; each must be self-contained,
documented, and tested.
Instructions:
1. Create a new script practice_5_3_functions.R. Add a header.
2. Function 1: deg_to_rad(x)
Takes a numeric vector of degrees and returns radians. Implement as x * pi / 180. Include
a check: if any absolute value exceeds 360, issue a warning.
3. Function 2: rad_to_deg(x)
Converts radians to degrees.
4. Function 3: euclidean_distance(x1, y1, x2, y2)
Returns the Euclidean distance between two points on a plane. Arguments are single
numeric values (scalar). Use sqrt((x2-x1)^2 + (y2-y1)^2). Test with coordinates (0,0) and
(3,4); should return 5.
5. Function 4: haversine_distance(lon1, lat1, lon2, lat2, R = 6371)
Computes the great-circle distance in kilometres using the Haversine formula. Inputs in
decimal degrees, converted to radians internally. Formula:
a=sin2(Δlat/2)+cos(lat1)cos(lat2)sin2(Δlon/2)a=sin2(Δlat/2)+cos(lat1)cos(lat2)sin2(Δlon
/2)
d=R×2×atan2(a,1−a)d=R×2×atan2(a,1−a)
Validate that latitudes are between –90 and 90, longitudes between –180 and 180; stop
with an error if not.
Test with Beijing (39.9, 116.4) and Shanghai (31.2, 121.5); approximate ground distance
~1060 km.
6. Function 5: classify_slope(angle, breaks = c(5, 15, 30, 45))
Classifies slope angles in degrees into
categories: "Flat", "Gentle", "Moderate", "Steep", "Very Steep" using the breaks (0–5, 5–
15, 15–30, 30–45, >45). Use cut() with [Link] = TRUE and appropriate labels.
The breaks can be customised via the default argument. Test with classify_slope(c(2, 12,
25, 40, 50)).
7. Function 6: atmospheric_correction(band, path_radiance = 0.05)
Subtracts path_radiance from the band, then sets any resulting negative values to 0.
Returns the corrected band. Test with c(0.12, 0.03, 0.20, 0.08) and default path radiance.
8. Test and document: After defining each function, write a few lines of test code that call
the function with sample inputs and print the results. Use stopifnot() for simple assertions
where appropriate (e.g., stopifnot(abs(euclidean_distance(0,0,3,4) - 5) < 0.001)).
9. Combine functions: Write a short demo that uses haversine_distance() to compute the
distance between five cities stored in a data frame (use mapply or a loop) and classify the
slopes of a set of terrain measurements using classify_slope(). Print the results.
10. Reflection:
o Why is it better to pass path_radiance as an argument rather than hard-coding it
inside atmospheric_correction?
o How does lexical scoping protect you from accidentally overwriting global
variables inside a function?
o What are the advantages of returning a list from a function instead of just printing
results to the console?
Deliverable:
The script practice_5_3_functions.R containing all function definitions, test calls, the combined
demo, and reflection comments.
5.4 The apply() Family and Functional Programming Basics
In Section 5.2 you learned to write loops. In Section 5.3 you learned to encapsulate logic in
functions. The apply() family of functions—lapply, sapply, vapply, tapply, and mapply—unites
these two ideas. They allow you to apply a function over the elements of a list or vector without
writing an explicit loop. This is functional programming: the iteration is implicit, the code is
more concise, and the intention—apply this function to each element—is stated directly. For the
geospatial scientist, these functions are the key to processing collections of shapefiles,
summarising groups of field measurements, applying a custom spectral transformation to every
band in a list, and much more. In this section, we examine each member of the family, its
purpose, its return type, and when to use it. We ground every concept in spatial data tasks, and
the Hard Practice challenges you to replace loops with functional constructs in a realistic
multi-scene image processing pipeline. By the end, you will reach for lapply where you once
wrote for, and your code will be clearer and less error-prone.
5.4.1 The Functional Paradigm in R
Functional programming is a style in which computation is achieved by applying functions to
data, often without explicit assignment and looping. R is not a pure functional language—it
allows side effects and mutable state—but it supports a functional style elegantly. The core idea
is: instead of telling R how to iterate step by step, you tell it what function to apply to each
element of a collection.
The apply() family are the workhorses of this style. They differ from the apply() on arrays
(Section 3.4) in that they operate on lists or vectors, not on array margins. The key functions are:
lapply(X, FUN, ...) — returns a list.
sapply(X, FUN, ..., simplify = TRUE) — attempts to simplify the result to a vector or
matrix.
vapply(X, FUN, [Link], ...) — returns a vector or matrix of a pre-specified type
and length; safer and faster.
tapply(X, INDEX, FUN, ...) — applies a function to subsets of a vector defined by a
grouping factor.
mapply(FUN, ...) — a multivariate version of sapply that iterates over multiple arguments
in parallel.
These functions accept other arguments via ..., which are passed through to FUN. For
example, lapply(x, mean, [Link] = TRUE) passes [Link] = TRUE to each mean call.
5.4.2 lapply(): Apply a Function to a List, Returning a List
lapply() is the most fundamental. It takes a list (or atomic vector, which is coerced to a list) and
returns a list of the same length, where each element is the result of applying FUN to the
corresponding element of the input.
r
lapply(X, FUN, ...)
lapply always returns a list, regardless of the input or output. This predictability is its strength.
Example: Computing the mean of each numeric vector in a list of bands
r
bands <- list(
blue = runif(100, 0.05, 0.15),
green = runif(100, 0.10, 0.25),
red = runif(100, 0.05, 0.20),
nir = runif(100, 0.30, 0.60)
)
band_means <- lapply(bands, mean)
band_means # list of 4, each a single numeric value
Processing a list of files: If files is a character vector of file paths, lapply(files, [Link]) returns
a list of data frames, one per file. This is the standard R pattern for batch-reading spatial data.
Accessing results: The output is a list, so you use $ or [[ ]] to extract individual
results: band_means$nir.
5.4.3 sapply(): Simplify the Result When Possible
sapply() is a variant of lapply() that attempts to simplify the result into a vector or matrix if
possible. This is convenient for interactive work but can be unpredictable in scripts because the
return type depends on the output of FUN.
r
sapply(X, FUN, ..., simplify = TRUE)
If FUN returns a single number for each element, sapply returns a named vector:
r
band_means_vec <- sapply(bands, mean)
band_means_vec # named numeric vector: blue, green, red, nir
If FUN returns vectors of the same length, sapply returns a matrix (each element a column):
r
range_stats <- sapply(bands, range) # 2-row matrix: min and max per band
range_stats
Caution: If FUN returns vectors of varying lengths, sapply falls back to a list and your code may
break downstream. For scripts, vapply() is safer.
5.4.4 vapply(): Safe and Explicit Simplification
vapply() is like sapply() but requires you to specify the expected return type and
length via [Link]. This makes it faster (no run-time type checking) and safer (an error is
raised if any result does not match the template).
r
vapply(X, FUN, [Link], ...)
[Link]: a sample of the expected return value, e.g., numeric(1) for a single
number, character(3) for a three-element character vector.
Example: Getting the mean reflectance per band, guaranteeing a numeric scalar return
r
vapply(bands, mean, numeric(1))
# blue green red nir
# ... (numeric vector)
If any band had a mean computation that returned a non-numeric or a length != 1, vapply would
stop with an error. This early failure is invaluable for debugging large workflows.
Example: Extracting the first five values of each band as a matrix (5 rows × 4 columns)
r
first_five <- vapply(bands, function(x) x[1:5], numeric(5))
first_five # matrix 5x4
Use vapply whenever you want the predictability of a known output type, especially in functions
that form part of a larger pipeline.
5.4.5 tapply(): Apply a Function Over Groups
tapply() applies a function to subsets of a vector, where the subsets are defined by one or
more factors (the INDEX). This is the base R equivalent of a grouped summary or a pivot table.
r
tapply(X, INDEX, FUN, ..., simplify = TRUE)
X: an atomic vector.
INDEX: a factor or list of factors of the same length as X.
FUN: a function applied to each group’s values.
Example: Mean elevation by land cover class
r
elev <- c(1500, 800, 1200, 2100, 950, 1800)
cover <- factor(c("Forest", "Grassland", "Forest", "Mountain", "Grassland", "Mountain"))
tapply(elev, cover, mean)
# Forest Grassland Mountain
# 1350 875 1950
If INDEX is a list of multiple factors, tapply performs multi-way grouped summaries.
Geospatial use: tapply is ideal for computing zonal statistics on an attribute table: mean
population per county, sum of area per land use type, maximum contamination per geological
unit. When vectorised grouped operations are needed before dplyr, tapply is the tool.
5.4.6 mapply() and Map(): Multi-argument Apply
mapply() is a multivariate version of sapply that applies a function to the first elements of each
argument, then the second elements, and so on.
r
mapply(FUN, ..., MoreArgs = NULL, SIMPLIFY = TRUE)
Example: Computing NDVI for parallel vectors of red and NIR
r
red <- c(0.1, 0.2, 0.15)
nir <- c(0.35, 0.55, 0.40)
ndvi_func <- function(r, n) (n - r) / (n + r)
ndvi_vals <- mapply(ndvi_func, red, nir)
ndvi_vals # 0.5556 0.4667 0.4545
If the arguments are of different lengths, they are recycled. MoreArgs is a list of constant
arguments passed unchanged to each call.
Map() is a wrapper around mapply with SIMPLIFY = FALSE, always returning a list. It is useful
when you want the safety of a list output.
r
Map(ndvi_func, red, nir) # list
Geospatial use: mapply is perfect for applying a function to multiple parallel vectors, such as
computing distance between pairs of coordinates, applying a per-sensor calibration gain to a list
of band vectors, or creating a set of file paths from a vector of dates and a template string.
5.4.7 The apply() Family vs. Explicit Loops
When should you use an apply function instead of a for loop? The apply family:
Expresses intent more clearly: lapply(files, st_read) says “read each file” in one line.
Avoids pre-allocation boilerplate: The result list is constructed automatically.
Is often slightly faster because the iteration is performed in C code, though the
difference is modest.
Encourages side-effect-free functions, as the output is captured rather than assigned to a
global variable.
However, if the function you are applying has side effects (e.g., writing plots to files, printing
progress), or if you need complex control flow (break, next), a for loop may be clearer. The rule
of thumb: if you are accumulating results, use lapply/vapply; if you are performing actions (side
effects), a loop or purrr::walk (tidyverse) may be better. But base R functional tools are sufficient
for most geospatial batch tasks.
5.4.8 Geospatial Context: Functional Workflows in Practice
1. Batch reading of shapefiles:
shp_list <- lapply([Link](pattern = "\\.shp$"), st_read)
This creates a list of sf objects.
2. Computing a vegetation index per scene:
r
compute_ndvi <- function(scene) {
(scene$nir - scene$red) / (scene$nir + scene$red)
}
ndvi_layers <- lapply(scene_list, compute_ndvi)
3. Grouped statistics on spatial samples:
tapply(samples$soil_carbon, samples$land_cover, median, [Link] = TRUE)
4. Applying a custom spectral transformation:
r
transform_band <- function(x, gain, offset) gain * x + offset
corrected <- mapply(transform_band, band_list, gains, offsets, SIMPLIFY = FALSE)
These patterns replace dozens of lines of loop code and make the analysis transparent.
5.4.9 Technical Demonstration: The Apply Family in Action
r
# ---------- 1. lapply: list of band statistics ----------
[Link](123)
bands <- list(
blue = runif(100, 0.05, 0.15),
green = runif(100, 0.10, 0.25),
red = runif(100, 0.05, 0.20),
nir = runif(100, 0.30, 0.60)
)
# Mean of each band
lapply(bands, mean)
# ---------- 2. sapply for vector output ----------
sapply(bands, mean) # named numeric vector
sapply(bands, range) # 2x4 matrix: min and max
# ---------- 3. vapply for safety ----------
vapply(bands, mean, numeric(1)) # safe version
# vapply(bands, range, numeric(2)) would also work
# ---------- 4. tapply: grouped means ----------
soil <- [Link](
ph = runif(30, 5, 8),
texture = sample(c("Clay","Silt","Sand"), 30, replace = TRUE)
)
tapply(soil$ph, soil$texture, mean)
# ---------- 5. mapply: compute NDVI from parallel bands ----------
red <- bands$red; nir <- bands$nir
ndvi <- mapply(function(r, n) (n - r) / (n + r), red, nir)
head(ndvi)
# ---------- 6. mapply with constant arguments ----------
# Subtract a different path radiance per band
path_radiance <- c(blue = 0.02, green = 0.03, red = 0.04, nir = 0.06)
corrected <- mapply(function(band, offset) band - offset, bands, path_radiance, SIMPLIFY =
FALSE)
# corrected is now a list of corrected bands
Run this code to see each function’s output type. Notice the consistency: lapply returns a list
even for single numbers; sapply simplifies; vapply enforces type.
Hard Practice 5.4 – “Functional Batch Processing of Multi-Scene Satellite Data”
Objective:
Replace explicit loops with lapply, sapply, vapply, tapply, and mapply in a realistic batch
processing workflow. Practise thinking functionally: define a function, then map it over a
collection.
Scenario:
You are responsible for processing four Landsat scenes, each represented as a list containing
band matrices (blue, green, red, nir) and a metadata list with acquisition date and scene ID. Your
tasks: compute NDVI for each scene, compute per-scene mean NDVI, extract all acquisition
dates, and compute a multi-scene average NDVI.
Instructions:
1. Create a new script practice_5_4_apply_family.R. Add a header.
2. Simulate scene data:
Write a helper function make_scene(id, date) that returns a list:
o scene_id: the id argument.
o date: [Link](date).
o bands: a list of four 50×50 matrices (use matrix(runif(2500, min, max),
nrow=50) with appropriate reflectance ranges: blue 0.05–0.15, green 0.10–0.25,
red 0.05–0.20, nir 0.30–0.60).
Create a list scenes of four scenes using Map(make_scene, id = paste0("LC08_",
1:4), date = c("2024-04-10", "2024-05-12", "2024-06-15", "2024-07-20")).
Use [Link](42) before generating random values.
3. Compute NDVI per scene:
Write a function compute_ndvi(scene) that extracts the red and nir matrices from the
scene, computes (nir - red) / (nir + red), and returns the NDVI matrix. Use Map()? No,
use lapply(scenes, compute_ndvi) and store as ndvi_list.
4. Compute mean NDVI per scene:
Use sapply(ndvi_list, mean, [Link] = TRUE) to get a named vector of mean NDVI values
per scene. Print it.
5. Extract acquisition dates:
Use sapply(scenes, function(x) x$date) to extract a vector of dates. Or vapply(scenes,
function(x) x$date, [Link](NA)). Print the dates.
6. Compute a multi-scene average NDVI matrix:
You have a list of NDVI matrices of equal dimensions. To compute the element-wise
mean across scenes, you can combine them into an array and use apply, or you can
use Reduce("+", ndvi_list) / length(ndvi_list). This is an elegant functional approach.
Use Reduce to sum all NDVI matrices and divide by the number of scenes. Assign
to mean_ndvi. Print the first 5x5 block.
7. Grouped statistics (simulated land cover):
Create a character vector land_cover of length 4 for the scenes: c("Forest", "Agriculture",
"Forest", "Urban"). Use tapply on the vector of per-scene mean NDVI (from step 4)
with land_cover as index, to compute the mean NDVI per land cover type. Print the
result.
8. Safe maximum NDVI using vapply:
Use vapply(ndvi_list, max, numeric(1), [Link] = TRUE) to compute the maximum NDVI
per scene safely. Print.
9. Reflection:
o How does lapply improve readability compared to a for loop for this batch
processing?
o Why might vapply be preferred over sapply inside a function?
o Explain how Reduce enabled the element-wise mean across scenes without an
explicit loop.
Deliverable:
The script practice_5_4_apply_family.R with all code, comments, and printed results.
5.5 Debugging and Error Handling
A script that runs without error on the first attempt is a rare exception in scientific computing.
Far more common is the script that halts with an obscure message, or worse, completes silently
and produces incorrect results. The ability to debug—to find, understand, and fix errors—and
to handle errors gracefully—to anticipate failures and respond appropriately—is what
distinguishes a reliable analyst from a frustrated one. This section introduces R’s error, warning,
and message system; the tools for tracing and inspecting code execution
(traceback, browser, debug); functions that catch and manage errors (try, tryCatch); and the
practice of defensive programming with stop, warning, and assertions. We apply every concept
to geospatial situations: a projection that fails, a raster that is missing a band, a function that
receives coordinates outside the valid range. By the end, you will not fear error messages; you
will read them, interrogate them, and write code that fails safely and informatively.
5.5.1 The Inevitability of Errors in Scientific Code
Errors are not signs of incompetence; they are a normal part of programming. Even experienced
developers spend a substantial fraction of their time debugging. In scientific analysis, the sources
of error multiply: heterogeneous data sources with inconsistent formats, missing values,
coordinate reference system mismatches, algorithmic assumptions that fail at the edges of the
study area. A robust scientist does not avoid errors entirely but constructs a workflow
that detects them early, reports them informatively, and recovers or stops safely.
R’s error system has three levels of severity:
Errors (stop): halt execution. They are fatal to the current evaluation and propagate up
the call stack unless caught.
Warnings (warning): indicate that something is potentially wrong but execution
continues. Warnings are collected and printed at the end of the top-level execution.
Messages (message): informative diagnostics that are printed immediately and do not
indicate a problem.
Understanding these levels and how to generate, suppress, and capture them is the foundation of
robust programming.
5.5.2 Generating Errors, Warnings, and Messages
You can intentionally signal problems using stop(), warning(), and message().
r
# Stop execution with an error
check_coordinate <- function(lon, lat) {
if (any(lon < -180 | lon > 180)) {
stop("Longitude must be between -180 and 180 degrees.")
}
if (any(lat < -90 | lat > 90)) {
stop("Latitude must be between -90 and 90 degrees.")
}
TRUE
}
check_coordinate(200, 45) # Error: Longitude must be between -180 and 180 degrees.
stop() takes a character string, concatenates its arguments, and signals an error of
class "simpleError". You can also specify a custom class to allow selective catching.
Warnings:
r
check_ndvi <- function(ndvi) {
if (any(ndvi < -1 | ndvi > 1, [Link] = TRUE)) {
warning("Some NDVI values are outside the valid range [-1, 1].")
}
ndvi
}
check_ndvi(c(0.5, 1.2, -0.8)) # warning printed, result returned
Warnings are stored and displayed at the end of the session, unless options(warn = 2) turns them
into errors, or warn = 0 (default) displays them immediately? Actually, they are collected and
printed at the top level after the expression completes, unless warn = 1 which prints them
immediately. For debugging, options(warn = 2) is extremely useful because it halts on the first
warning, giving you the exact location.
Messages:
r
read_scene <- function(path) {
message("Reading scene from ", path, "...")
# (actual reading code)
}
Messages are printed immediately and cannot be suppressed by suppressWarnings(); they
require suppressMessages().
5.5.3 Catching Errors with try() and tryCatch()
Sometimes an error should not halt the entire script. For example, when processing 1000 satellite
scenes, you do not want a single corrupted file to abort the entire batch. try() evaluates an
expression and returns the error object if it fails, rather than stopping.
r
result <- try(log(-1)) # returns a "try-error" object
class(result) # "try-error"
You can wrap it in a conditional:
r
safe_log <- function(x) {
res <- try(log(x), silent = TRUE)
if (inherits(res, "try-error")) {
return(NA)
} else {
return(res)
}
}
safe_log(-1) # NA
For more sophisticated handling, tryCatch() provides fine-grained control. It defines handler
functions for different conditions:
r
robust_read <- function(path) {
tryCatch(
{
# attempt to read a file
data <- [Link](path)
return(data)
},
error = function(e) {
message("Could not read ", path, ": ", e$message)
return(NULL)
},
warning = function(w) {
message("Warning while reading ", path, ": ", w$message)
invokeRestart("muffleWarning")
}
)
}
tryCatch maps conditions (error, warning, message) to handler functions. The error handler can
return a safe default, log the failure, or even signal a different condition. withCallingHandlers() is
a related function that handles conditions without interrupting the execution flow, but tryCatch is
simpler for most needs.
In geospatial scripting, tryCatch is essential for file I/O: reading shapefiles, downloading data
from APIs, or calling computational geometry functions that may fail on degenerate geometries.
You wrap the risky operation, and on error, you either skip the file, assign a missing value, or
attempt a fallback method.
5.5.4 Debugging Tools: traceback(), browser(), debug(), and debugonce()
When an error occurs, R prints a call stack showing the sequence of function calls leading to the
error. You can access this stack with traceback():
r
f <- function(x) g(x)
g <- function(x) h(x)
h <- function(x) log(x)
f(-1)
# Error in log(x) : non-numeric argument to mathematical function (or NaNs produced)
traceback()
# 3: h(x)
# 2: g(x)
# 1: f(-1)
traceback() shows the active calls. For complex spatial operations, this is invaluable: it tells you
exactly which function issued the error, and through what sequence of calls.
Interactive debugging with browser(): You can insert browser() inside a function to pause
execution and enter an interactive debugging environment at that point. You can then examine
local variables, step through code line by line, and test expressions.
r
my_function <- function(x, y) {
z <- x + y
browser() # pause here
result <- sqrt(z)
return(result)
}
my_function(4, 5)
# Browse[1]>
Inside the browser, you have access to all local variables. The commands:
n or Enter: execute the next statement.
c: continue execution until the next breakpoint or exit.
Q: quit the browser and abort.
where: print the call stack.
ls(): list objects in the current environment.
For non-interactive debugging (e.g., in a script run via source), browser() will also pause
execution, making it useful for debugging scripts.
debug() and debugonce(): These functions flag a function so that browser() is called every time
(or once) the function is executed.
r
debug(compute_ndvi) # now compute_ndvi enters browser on every call
# after fixing:
undebug(compute_ndvi)
debugonce(compute_ndvi) # enters browser only on the next call
These are ideal for inspecting function behavior without modifying the source code. In geospatial
analysis, if st_transform is giving unexpected results, you could debug(st_transform) and step
through its internals—but more commonly you debug your own wrapper functions.
5.5.5 Defensive Programming: Assertions and Input Validation
Defensive programming means writing code that assumes the worst and checks everything. The
simplest tool is stopifnot(), which evaluates a series of conditions and stops with an error if any
is FALSE:
r
compute_ndvi <- function(red, nir) {
stopifnot([Link](red), [Link](nir),
length(red) == length(nir))
(nir - red) / (nir + red)
}
stopifnot provides no custom error message, just the condition that failed. For more informative
errors, write explicit if (...) stop(...) statements.
Validating spatial inputs: Always check that:
Coordinate vectors have the same length.
CRS arguments are valid (e.g., sf::st_crs() returns without error).
Raster dimensions match before band math.
File paths exist ([Link]()).
Data frames contain expected columns ("geometry" %in% names(df) or inherits(df,
"sf")).
Assertion packages like assertthat or checkmate provide richer validation, but base
R’s stop and stopifnot suffice for most academic scripts.
5.5.6 Geospatial Debugging Scenarios
Imagine a common spatial analysis chain:
1. Read a shapefile → file missing or corrupted → tryCatch with a fallback to a different
path.
2. Reproject to a common CRS → invalid CRS string → stop with a helpful message
listing the available codes.
3. Compute area → st_area() on a geometry with NA values → warning, handled
by suppressWarnings? Better to filter NAs first.
4. Join to census data → key mismatch → stopifnot(all(sf_df$id %in% census$id)).
5. Plot → empty geometry causes error → check any(st_is_empty(geom)) and remove.
Debugging spatial code often involves inspecting intermediate objects: printing their classes,
dimensions, summary, and first few rows. Inserting str() and print() inside functions (or browser)
is a common practice.
5.5.7 Technical Demonstration: Debugging a Misbehaving Function
We will write a function with deliberate flaws, then diagnose and fix them using the tools
introduced.
r
# ---------- 1. A flawed function ----------
compute_slope <- function(elev_matrix, cellsize) {
# Calculate slope using simple finite differences
# Missing: check that elev_matrix has at least 3 rows and columns
# Missing: handle NAs
nr <- nrow(elev_matrix)
nc <- ncol(elev_matrix)
slope <- matrix(NA, nr, nc)
for (i in 2:(nr-1)) {
for (j in 2:(nc-1)) {
dx <- (elev_matrix[i, j+1] - elev_matrix[i, j-1]) / (2 * cellsize)
dy <- (elev_matrix[i+1, j] - elev_matrix[i-1, j]) / (2 * cellsize)
slope[i, j] <- atan(sqrt(dx^2 + dy^2)) * 180 / pi
}
}
return(slope)
}
# Test on a small matrix
dem <- matrix(rnorm(25, mean=1000, sd=50), nrow=5)
# Intentional error: cellsize argument missing
# slope_map <- compute_slope(dem) # would throw error due to missing argument
# Let's call with cellsize=30
slope_map <- compute_slope(dem, 30)
# No error, but edge rows/cols are NA. That's expected.
Now introduce a bug: cellsize is passed as 0, leading to division by zero and Inf values.
r
slope_map2 <- compute_slope(dem, 0)
# slope_map2 contains Inf values.
# Debug with browser:
compute_slope_debug <- function(elev_matrix, cellsize) {
browser()
# (same code)
}
# Then call it and step through.
For a real debugging session, we would use traceback() after an error, debug() to step into a
function, and tryCatch to handle file reading errors.
Hard Practice 5.5 – “Robustify a Geospatial Processing Pipeline”
Objective:
Take a set of functions that perform basic spatial operations and add error handling, input
validation, and defensive checks. Use stopifnot, tryCatch, and browser to make the pipeline
resilient to common failures. Simulate a batch processing task with some corrupted inputs.
Scenario:
You have written a function process_scene(path) that reads a CSV file containing point
observations (x, y, value), converts them to a simple spatial format (a data frame with x,y),
computes the mean value, and returns it. You will run this function over a list of file paths, some
of which are invalid or contain malformed data. You must make process_scene robust: it should
validate inputs, catch file-reading errors, warn on suspicious values, and return a safe default
(NA) if processing fails. Then you will apply it with lapply and produce a summary of successes
and failures.
Instructions:
1. Create a new script practice_5_5_debugging.R. Add a header.
2. Write a helper function make_scene_data that creates a temporary CSV file simulating
a scene:
o Generate 20 random points: x = runif(20, 0, 100), y = runif(20, 0, 100), value =
rnorm(20, mean=50, sd=10).
o Write to tempfile() and return the path.
o Create a second helper that writes a corrupt file: a CSV with missing columns, or
a non-CSV text file.
3. Write the core function process_scene(path) with the following features:
o Input validation: Check that path is a character string and that the file exists. If
not, stop with an informative error.
o Reading: Use tryCatch to read the CSV with [Link]. On error, return a list
with status = "error", message = error_message, and mean = NA.
o Validation: After reading, check that the data frame has exactly the
columns x, y, value. If not, stop with an error (which will be caught by tryCatch if
you wrap further, but for internal validation you can just stop). Since the reading
is already inside tryCatch, any stop inside will trigger the error handler. So you
can use stopifnot(all(c("x","y","value") %in% names(data))).
o Data quality check: Check if any values are NA or infinite, and issue a warning.
Replace such values with NA and compute mean with [Link] = TRUE.
(Use warning()).
o Return: a list with status = "success", mean = mean(data$value, [Link] = TRUE),
and n = nrow(data).
4. Create a list of file paths: Include three valid scenes (using the helper), one non-existent
file, and one corrupt file (wrong columns). You can construct a character vector manually.
5. Batch process with lapply: Apply process_scene to each path. The result is a list of
statuses. Use sapply to extract a vector of mean values (where status is success, else NA).
Print a summary: total scenes, number successful, number failed.
6. Use debugonce: Before running the batch, set debugonce(process_scene) to interactively
step through one call. (You might need to run the batch line by line rather than sourcing
the whole script, or run process_scene manually first.)
7. Test with stopifnot: Add a final assertion that the number of successful means matches
the number of valid files (should be 3).
8. Reflection:
o How does tryCatch prevent a single bad file from crashing the whole batch?
o Why are assertions like stopifnot important inside functions?
o When would you use browser() versus debug() for debugging a spatial function?
Deliverable:
The script practice_5_5_debugging.R with all functions, the batch processing, and reflection
comments.
Chapter 5 Review Problems
Congratulations on completing Chapter 5. You have learned to make decisions
with if and ifelse, to repeat actions with for and while, to encapsulate logic in functions, to
apply functions over lists and groups with the apply family, and to debug and harden your code.
The following problems test your ability to combine these skills in geospatially inspired
scenarios. Solve each problem in a clearly commented R script. Use base R only; loops,
conditionals, functions, and the apply family are your tools. Avoid tidyverse packages until
Chapter 6.
Easy Problems (1–5)
1. Conditional Message
Write a function check_elevation(elev) that takes a single numeric value (elevation in metres).
If elev is negative, print "Below sea level".
If elev is between 0 and 3000, print "Low to medium elevation".
If elev is above 3000, print "High elevation".
Test the function with values -10, 1500, and 4500.
2. Loop Over a Vector
Create a numeric vector areas_ha <- c(1.2, 3.5, 0.8, 12.0, 2.1, 0.5) representing parcel areas in
hectares.
Write a for loop that iterates over the indices of areas_ha and prints "Parcel <i>: <area>
ha" for each element.
Use seq_along() for safe indexing.
3. Vectorised ifelse Classification
A vector of slope angles (in degrees):
slope <- c(2, 8, 15, 22, 35, 42, 3, 18, 28, 48)
Use nested ifelse() to classify each slope into "Flat" (<5°), "Gentle" (5–
15°), "Moderate" (15–30°), "Steep" (30–45°), "Very Steep" (>45°).
Print the resulting character vector.
4. Simple Function with Default Argument
Write a function buffer_area(radius, unit = "m") that computes the area of a circle (pi *
radius^2).
If unit = "km", convert the area to square kilometres by dividing by 1,000,000.
Test with buffer_area(100) and buffer_area(0.1, "km").
5. lapply for Summary
Create a list measurements with three components: ph, temperature, moisture, each a numeric
vector of 10 random values (use runif).
Use lapply() to compute the mean of each component.
Print the resulting list.
Medium Problems (6–10)
6. Function to Compute Area and Perimeter of a Circle
Write a function circle_stats(radius) that returns a list containing area and circumference.
Validate that radius is a non-negative numeric scalar. If not, stop with an error.
Test with circle_stats(5) (should give area ~78.54, circumference ~31.42).
Test with circle_stats(-1) to see the error.
7. while Loop for Cumulative Sum Threshold
Given a vector of daily precipitation (mm):
rain <- c(2, 0, 0, 5, 12, 0, 8, 3, 0, 1, 7, 0)
Use a while loop to find the first day (index) where the cumulative rainfall exceeds 20
mm.
Print the day index and the cumulative sum at that point.
If the threshold is never reached, print "Threshold not reached".
8. Functional NDVI Computation with mapply
You have two numeric vectors red and nir of length 50, created with runif(50, 0.05,
0.20) and runif(50, 0.30, 0.60) respectively (use [Link](100)).
Define a function calc_ndvi(r, n) that returns (n - r) / (n + r) and replaces any NaN (from
0/0) with NA.
Use mapply() to apply calc_ndvi to the two vectors and store the result as ndvi.
Use [Link](ndvi) to find the index of the maximum NDVI and print the index and
value.
9. tapply for Grouped Statistics
Create a data frame samples with 40 rows:
r
[Link](42)
samples <- [Link](
site = rep(c("A", "B", "C", "D"), each = 10),
ph = runif(40, 5.5, 8.5),
organic_carbon = runif(40, 1.0, 4.0)
)
Convert site to a factor.
Use tapply() to compute the mean ph per site.
Use tapply() to compute the standard deviation of organic_carbon per site.
Print both results.
10. Debugging with tryCatch
Write a function safe_read_csv(path) that attempts to read a CSV file with [Link](),
using tryCatch.
On error, return NULL and print a message: "Failed to read <path>".
On success, return the data frame.
Test it by creating a temporary CSV file (use [Link]([Link](x=1:3, y=4:6), tmp,
[Link] = FALSE)) and reading it, then test with a non-existent file.
Challenging Problems (11–15)
11. Function with Multiple Conditions: Terrain Classification from DEM
Write a function terrain_class(elev, slope, aspect) that classifies a location based on three
numeric inputs (all scalars):
Elevation: "High" if >2000 m, else "Low".
Slope: "Steep" if >20°, else "Gentle".
Aspect: "North" if between 315–360 or 0–45°, "South" if 135–225°, else "East/West".
Return a single character string combining them, e.g., "High-Steep-North".
Validate all inputs (elev ≥ 0, slope 0–90, aspect 0–360) with stopifnot.
Test with several values.
12. Batch File Processing with lapply and Error Handling
Simulate a batch process of 10 “scenes”.
Create a list scene_paths of 10 file paths: 8 valid ones (created with a helper that writes a
temporary CSV containing random NDVI values) and 2 invalid ones (non-existent or
wrong extension).
Write a function process_scene(path) that reads a CSV (using tryCatch), computes mean
NDVI, and returns a list with scene = basename(path), mean_ndvi, and status.
Use lapply to process all scenes.
After processing, use sapply to extract the mean NDVI for successful scenes only, and
print the number of failures.
Use vapply to safely extract status messages as a character vector.
13. Nested Loops and Conditional: Spatial Autocorrelation Simulation
Simulate a 1-row transect of 30 forest cells (vector of length 30).
Initialize all cells as 0 (unburned), then set cell 15 to 1 (burning).
Write a for loop for 20 time steps. Within each time step, create a new
vector next_state (copy of current) and use a nested for loop over cells 2 to 29.
o If current cell is 1 (burning): with 20% probability it becomes 2 (burned out)
in next_state.
o If current cell is 0 and any neighbor is 1: with 60% probability it
becomes 1 (ignites).
After the time loop, compute the total number of burned cells (state 2) and the number of
remaining burning cells (state 1). Print the final state as a string of 0/1/2 characters
concatenated.
Challenge extension: Can you think of a way to vectorise the neighbor check instead of
using a nested loop? (Write your answer in comments; no code required.)
14. Closure: A Function Factory for Distance Measures
Write a function make_distance_function(method = "euclidean") that returns a function.
If method = "euclidean", the returned function should compute Euclidean distance
between two points: function(x1, y1, x2, y2) sqrt((x2-x1)^2 + (y2-y1)^2).
If method = "manhattan", the returned function should compute Manhattan distance: |x2-
x1| + |y2-y1|.
If method is anything else, stop with an error.
Use the factory to create dist_eucl and dist_manh, then test both on the points (0,0) and
(3,4).
Explain (in comments) how the returned function remembers the method parameter—this
is lexical scoping in action.
15. Robust Geospatial Pipeline with Full Error Handling
Design a small pipeline that processes a list of hypothetical “GPS log” files.
Create a helper function write_gps_file(path, n = 20) that writes a temporary CSV with
columns time (sequence of POSIXct timestamps), lon, lat, and alt (elevation,
some NA values allowed). Write 4 valid files and 1 corrupt file (e.g., missing alt column
or containing a character in lon).
Write process_gps(path) that:
1. Reads the CSV with tryCatch; returns NULL on failure.
2. Validates that required columns exist; if not, stops with an informative message
(caught by tryCatch if nested? Actually, you'll call the validation inside
the tryCatch success block, and any stop will be caught. Or you can use a separate
internal tryCatch for validation).
3. Computes total distance travelled (sum of Euclidean distances between
consecutive points, treating coordinates as planar for simplicity), mean altitude,
and total time span. Handle NA altitude by ignoring.
4. Returns a list with file, total_dist, mean_alt, time_span, and n_points.
Use lapply to process all files. Extract results with sapply. Print a summary table (a data
frame of results for successful files) and a vector of failed files.
Use debugonce to step through the processing of one valid file to verify the function
works.
6.1 Pipes (%>% and |>) and Readable Workflows
In base R, a multi-step data analysis often reads from the inside out: mean([Link](filter(dataset,
condition)$column)). The reader must parse parentheses from the innermost function outward,
mentally holding intermediate results. This is cognitively burdensome and error-prone. The
tidyverse introduces a radical syntactic improvement: the pipe operator. A pipe takes the result
of the left-hand side and passes it as the first argument of the right-hand side. With pipes, the
same analysis becomes dataset %>% filter(condition) %>% pull(column) %>% [Link]() %>%
mean(), reading left to right, top to bottom, exactly as a workflow diagram. This section explains
the theory of piping as function composition, introduces both the magrittr pipe %>% and the
base R pipe |>, demonstrates how pipes transform the readability of geospatial data
manipulation, and establishes the piping style that will be used throughout the remainder of this
book. By the end, you will read and write piped code fluently, and you will understand why the
pipe is the syntactic glue of the tidyverse.
6.1.1 The Problem of Nested Function Calls
Consider a typical geospatial data task: you have a data frame of field samples, and you want to
select the samples from forest sites, extract the pH column, remove missing values, and compute
the mean. In base R, this might be written as:
r
mean([Link](samples$ph[samples$land_cover == "Forest"]))
This single line already requires careful reading. The order of operations is:
1. Subset the ph column where land_cover is "Forest".
2. Remove NAs with [Link]().
3. Compute the mean.
But the code states the operations in the reverse order: mean is outermost, then [Link], then the
subset. For a newcomer, this inside-out reading contradicts the natural chronological order of
data processing steps.
When the analysis involves three, five, or ten steps, nested function calls become unmanageable.
The alternative—storing every intermediate result in a variable—clutters the workspace with
temporary objects:
r
forest_ph <- samples$ph[samples$land_cover == "Forest"]
forest_ph_clean <- [Link](forest_ph)
result <- mean(forest_ph_clean)
This is readable but verbose, and the temporary names (forest_ph, forest_ph_clean) must be
invented for every single step, adding mental overhead and the risk of name collisions.
The pipe operator solves both problems. It allows you to write the operations in the order they
are performed, without naming every intermediate result.
6.1.2 The Pipe as Function Composition
Mathematically, if you have functions ff, gg, and hh, applying them sequentially to an input xx is
written as h(g(f(x)))h(g(f(x))). This is function composition read right-to-left. The pipe %>
% (from the magrittr package, re-exported by dplyr and the tidyverse) allows you to write ,
which reads left-to-right as “take xx, then apply ff, then apply gg, then apply hh”.
R 4.1.0 introduced a native pipe |> with similar semantics. Both operators are used throughout
the R community, and we will primarily use %>% in this book because it is still widely seen in
geospatial packages and documentation, but we will show both and note the differences.
The pipe works by implicitly passing the left-hand side as the first argument of the right-hand
side’s function. If the left-hand side is x, then x %>% f(y) is equivalent to f(x, y). This simple
rule creates a chain of data transformations.
6.1.3 The Magrittr Pipe %>%
The %>% operator is loaded with the tidyverse or specifically with dplyr or magrittr. To use it in
a script, you must load a package that exports it.
r
library(dplyr)
# Basic usage
c(1, 2, 3, 4) %>% mean() # equivalent to mean(c(1,2,3,4))
Passing to a function with additional arguments: The left-hand side becomes the first
argument, and subsequent arguments are written normally.
r
c(1, 2, 3, NA, 5) %>% mean([Link] = TRUE)
Passing to a function that does not take the data as the first argument: Use the dot . as a
placeholder for the left-hand side.
r
c("Beijing", "Shanghai", "Guangzhou") %>% paste("is a city", sep = " ")
# Error: paste expects the strings as the first argument(s), but we passed a character vector as the
first argument. Actually, paste is vectorised, and c("Beijing","Shanghai","Guangzhou") %>%
paste("is a city") works because paste(x, ...) takes multiple arguments. Let's test: paste(c("a","b"),
"city") works fine. So the pipe works without dot.
# But for functions where the data argument is not first, you can use dot:
c(1,2,3) %>% lm(c(4,5,6) ~ .) # dot becomes the right-hand side of the formula
The dot . is especially useful for passing the piped data to a specific argument position, or for
calling non-tidy functions that do not follow the “data first” convention.
Nesting pipes: You can chain an arbitrary number of operations.
r
samples %>%
filter(land_cover == "Forest") %>%
pull(ph) %>%
[Link]() %>%
mean()
Each line receives the result of the previous line, transforms it, and passes it forward. The code
reads like a recipe.
6.1.4 The Base R Pipe |>
Since R 4.1.0, a native pipe |> is built into base R without requiring any package. Its behaviour is
similar but stricter:
The left-hand side is passed as the first unnamed argument.
The placeholder _ can be used for named arguments, but only once per call and only with
named arguments.
There is no equivalent of the magrittr dot . for arbitrary placement; the _ placeholder
works only with named arguments and can only appear once.
r
# Base R pipe
c(1, 2, 3, 4) |> mean()
c(1, 2, 3, NA, 5) |> mean([Link] = TRUE)
# Using placeholder
c(1, 2, 3) |> lm(c(4,5,6) ~ _)
The base pipe is slightly less flexible than %>%, but it is part of the language and does not add a
package dependency. In this book, we will use %>% for consistency with the broader tidyverse
ecosystem, especially because many spatial packages (like sf and terra) have methods designed
for %>%. We will note when |> is a suitable alternative.
6.1.5 Piping in Geospatial Workflows: A Glimpse
Pipes align perfectly with the typical GIS workflow: read → filter → transform → summarise
→ visualise. Consider a spatial analysis using sf:
r
st_read("[Link]") %>%
filter(population > 100000) %>%
st_transform(4326) %>%
st_buffer(dist = 5000) %>%
st_area() %>%
sum()
This single expression reads a shapefile, selects large cities, reprojects, buffers them, computes
buffer areas, and sums them. Without pipes, you would need nested calls or many intermediate
variables. With pipes, the code is a transparent narrative of the spatial analysis.
Even before we dive into sf, you will use pipes on data frames to prepare attribute tables for
mapping. The hard practice in this section will use pipes to clean a messy dataset, establishing
the pattern for everything that follows.
6.1.6 Technical Demonstration: Piping vs. Nested Code
We will rewrite a base R data cleaning task using pipes.
r
# Load dplyr for pipes and basic functions
library(dplyr)
# A sample data frame of field observations
[Link](42)
samples <- [Link](
site = rep(c("A","B","C"), each = 5),
ph = runif(15, 5, 8),
organic_carbon = runif(15, 1, 4),
stringsAsFactors = FALSE
)
# Introduce some NA
samples$ph[3] <- NA
samples$organic_carbon[7] <- NA
# ---------- Base R nested approach ----------
# Task: for site B, compute mean organic carbon ignoring NAs
mean([Link](samples$organic_carbon[samples$site == "B"]))
# ---------- Piped approach ----------
samples %>%
filter(site == "B") %>%
pull(organic_carbon) %>%
[Link]() %>%
mean()
The piped version is longer but infinitely clearer. Each step is isolated on its own line. To add an
extra step—say, rounding the mean to two decimal places—you simply append %>% round(2) to
the chain. No restructuring of parentheses is required.
6.1.7 Pipes and the Tidyverse Grammar
The tidyverse is a collection of packages that share a design philosophy: data is always a data
frame (or tibble), functions use consistent naming (filter, select, mutate, summarise, arrange),
and the pipe chains them together. We will explore these verbs in Sections 6.2–6.5. The pipe is
the syntactic thread that binds them.
A typical tidyverse pipeline looks like:
r
data %>%
verb1(...) %>%
verb2(...) %>%
verb3(...)
Each verb takes a data frame as its first argument and returns a data frame, enabling seamless
chaining. This is called the “data-frame-in, data-frame-out” principle.
For geospatial data, sf objects are data frames, so they integrate directly into this pipeline. You
can filter() an sf object by attribute, select() columns (including the geometry
column), mutate() new attributes, and then pipe directly into spatial functions
like st_buffer() or st_intersection(). This unification is one of the greatest strengths of the modern
R spatial ecosystem.
Hard Practice 6.1 – “Converting a Base R Script to a Pipeline”
Objective:
Take a base R data cleaning script that uses intermediate variables and nested functions, and
rewrite it as a single piped chain using %>%. Develop the reflex to think in pipelines.
Scenario:
You are given a base R script that processes a data frame of air quality measurements from
multiple monitoring stations. The script:
1. Filters rows where the station is "Downtown".
2. Selects the columns date and pm25.
3. Removes rows with missing pm25 values.
4. Sorts the data by date.
5. Computes the mean pm25 for the remaining records.
6. Prints the result.
The base R script is written with intermediate variables.
Instructions:
1. Create a new script practice_6_1_pipes.R. Add a header.
2. Simulate the data:
r
[Link](123)
air <- [Link](
station = sample(c("Downtown", "Airport", "Hilltop"), 50, replace = TRUE),
date = sample(seq([Link]("2024-01-01"), [Link]("2024-12-31"), by = "day"), 50),
pm25 = runif(50, 10, 200)
)
# Introduce some NAs
air$pm25[sample(1:50, 5)] <- NA
3. Rewrite the base R code as a pipe chain. The base R code (for reference) is:
r
air_downtown <- air[air$station == "Downtown", ]
air_selected <- air_downtown[, c("date", "pm25")]
air_clean <- air_selected[, ]
air_sorted <- air_clean[order(air_clean$date), ]
result <- mean(air_sorted$pm25)
print(result)
Your piped version must use filter(), select(), [Link]() (or filter()), arrange(), pull(),
and mean(). Chain them with %>%.
4. Verify that both versions give the same result. Use [Link]() if desired.
5. Add a new step: After sorting, add a step that creates a new column pm25_log =
log(pm25) (use mutate; we will formally learn mutate in Section 6.2, but you can try
it: mutate(pm25_log = log(pm25))). Adjust the final mean to be computed on the log
scale. Print the result.
6. Reflection:
o How many intermediate variables did you eliminate by using pipes?
o In what order did you read the piped chain compared to the nested base R
version?
o Why is the pipe particularly valuable for geospatial analysis, where workflows
often involve many sequential operations?
Deliverable:
The script practice_6_1_pipes.R with both the base R code (commented for reference) and the
piped version, plus the extension and reflection.
6.2 dplyr Verbs: filter(), select(), mutate(), summarise(), arrange()
The pipe operator gave us a left-to-right syntax. The dplyr package gives us the verbs—five or
six functions that together cover the vast majority of data manipulation tasks on tabular data.
Each verb does one thing, does it well, and is designed to work seamlessly with the pipe. In this
section we study the five core verbs: filter() to choose rows by condition, select() to choose
columns by name, mutate() to create or transform columns, summarise() to collapse many rows
into a single summary, and arrange() to reorder rows. For every verb we present the theoretical
semantics, the syntax, the interaction with grouped data (a preview of Section 6.3), and the
geospatial analogue: each verb directly corresponds to an operation you would perform on a
GIS attribute table. The demonstrations build a realistic soil-sample dataset, and the Hard
Practice asks you to chain all five verbs into a single pipeline that prepares data for mapping.
By the end, you will have a new primary language for working with data frames—one that scales
perfectly to the sf spatial data frames that await us in Part III.
6.2.1 The Five Verbs: A Conceptual Overview
The dplyr package (Wickham et al., 2023) is part of the tidyverse and provides a grammar of
data manipulation. Its core functions are:
Verb Action GIS Analogue
filter() Pick rows by logical condition Select features by attribute
select() Pick columns by name or property Show/hide fields in the attribute table
mutate() Create new columns or modify existing ones Add a calculated field
summarise() Reduce many rows to a single summary row Aggregate statistics for a group
arrange() Reorder rows Sort records ascending/descending
All verbs take a data frame as the first argument and return a data frame. This allows them
to be chained with %>%. Unless the operation explicitly drops columns (as select() can), all
columns are preserved through the chain. Grouped variants—using group_by()—modify the
behaviour of summarise() and mutate(), which we will explore in Section 6.3.
6.2.2 filter(): Selecting Rows by Condition
filter() retains only the rows that satisfy one or more logical conditions. Multiple conditions are
combined with & (and) automatically when separated by commas; use | for explicit OR.
r
filter(.data, condition1, condition2, ...)
Theoretical note: filter() uses non-standard evaluation (NSE). You can write column names
directly, without quoting them or prefixing with data$. This makes the code compact but requires
care when using column names stored in variables (use .data[[var]] or !!sym(var) in advanced
contexts).
Example:
r
library(dplyr)
# Sample dataset
[Link](42)
samples <- [Link](
site = rep(c("A","B","C"), each = 5),
elevation_m = sample(200:1500, 15),
ph = runif(15, 5.0, 8.0),
organic_carbon = runif(15, 1.0, 4.0)
)
# Single condition
samples %>% filter(elevation_m > 1000)
# Multiple conditions (implicit AND)
samples %>% filter(elevation_m > 1000, ph < 6.5)
# OR condition
samples %>% filter(ph < 5.5 | organic_carbon > 3.0)
# Removing NA values
samples %>% filter()
Geospatial analogue: In a GIS, this is the “Select by Attribute” dialog. In R, you
write st_read("[Link]") %>% filter(soil_type == "Clay") to obtain a new spatial layer
containing only clay soils. The operation is non-destructive: the original data frame is
unchanged; the result is a new data frame.
6.2.3 select(): Selecting Columns by Name
select() retains only the named columns. You can drop columns with -, select ranges with :, and
use helper functions like starts_with(), ends_with(), contains(), matches(), everything(),
and last_col().
r
select(.data, column1, column2, ...)
Examples:
r
# Keep specific columns
samples %>% select(site, ph)
# Drop columns
samples %>% select(-organic_carbon)
# Select a range
samples %>% select(site:elevation_m)
# Rename while selecting
samples %>% select(site_id = site, everything())
Helper functions:
r
# Select all columns starting with "elev"
samples %>% select(starts_with("elev"))
# Select all columns containing "carbon"
samples %>% select(contains("carbon"))
# Move a column to the front and keep the rest
samples %>% select(ph, everything())
Geospatial analogue: This is the GIS attribute table’s “Hide Field” or “Visible Columns”
setting. In sf, the geometry column is sticky—it is never lost unless you explicitly drop it or
convert to a plain tibble with st_drop_geometry(). select() on an sf object preserves the geometry,
so you can safely reduce the attribute table while keeping the spatial layer intact.
6.2.4 mutate(): Creating and Transforming Columns
mutate() adds new columns to the end of the data frame, or modifies existing ones if you assign
to an existing name. It computes expressions sequentially, meaning you can refer to a column
you just created in the same mutate() call.
r
mutate(.data, new_column = expression, ...)
Examples:
r
# Compute a new variable
samples %>% mutate(elevation_ft = elevation_m * 3.28084)
# Compute multiple new columns in one call
samples %>% mutate(
elevation_ft = elevation_m * 3.28084,
log_carbon = log(organic_carbon)
)
# Use a newly created column immediately
samples %>% mutate(
ph_shifted = ph - mean(ph, [Link] = TRUE),
ph_z = ph_shifted / sd(ph, [Link] = TRUE)
)
# Replace an existing column
samples %>% mutate(ph = ph + 0.2)
Geospatial analogue: mutate() is the GIS “Add Field” and “Calculate Field” tool. You can
compute population density from population and area columns, classify slope into categories, or
convert units. Because mutate() works on sf objects, you can compute new attributes and the
geometry column is carried forward untouched.
A special variant, transmute(), behaves like mutate() but drops all other columns except the
newly created ones. It is useful for extracting derived variables.
6.2.5 summarise(): Aggregating Many Rows into One
summarise() (or summarize()) collapses a data frame into a single row (or one row per group,
when used with group_by()). It is the primary tool for computing summary statistics.
r
summarise(.data, summary_name = expression, ...)
Examples:
r
samples %>% summarise(
mean_ph = mean(ph, [Link] = TRUE),
sd_ph = sd(ph, [Link] = TRUE),
n = n()
)
n() is a special function that returns the number of rows in the current group (or the whole data
frame if ungrouped). n_distinct(x) counts distinct values.
Useful summary
functions: mean(), median(), sd(), min(), max(), first(), last(), n(), n_distinct(), sum([Link](x)).
Geospatial analogue: summarise() is the GIS “Summary Statistics” tool. Combined
with group_by(), it becomes “Zonal Statistics as Table”—you can compute mean elevation per
land-cover class, total population per district, or maximum contamination per geological unit.
6.2.6 arrange(): Sorting Rows
arrange() orders the rows of a data frame by the values of one or more columns. By default,
sorting is ascending; use desc() for descending.
r
arrange(.data, column1, column2, ...)
Examples:
r
# Ascending order by elevation
samples %>% arrange(elevation_m)
# Descending order
samples %>% arrange(desc(ph))
# Multiple keys: first by site, then by elevation within site
samples %>% arrange(site, desc(elevation_m))
Missing values (NA) are always placed at the end, regardless of sort order.
Geospatial analogue: arrange() is the “Sort” operation on an attribute table. It is commonly used
to present the top-10 cities by population, to order samples by date, or to rank parcels by area.
6.2.7 Combining the Verbs: A Geospatial Pipeline
The power of dplyr emerges when these five verbs are chained with %>%. Consider a complete
attribute-table analysis, expressed as a single narrative:
r
samples %>%
filter() %>% # 1. Remove missing pH
mutate(ph_category = ifelse(ph < 6.0, "Acidic", "Neutral/Alkaline")) %>% # 2. Classify
group_by(site, ph_category) %>% # 3. Group (Section 6.3 preview)
summarise( # 4. Summarise per group
mean_carbon = mean(organic_carbon, [Link] = TRUE),
n = n()
) %>%
arrange(site, desc(n)) # 5. Sort
This pipeline is entirely self-documenting. A reader unfamiliar with the dataset can follow the
logic step by step. This is the coding style we will use throughout the remainder of the book for
all tabular data manipulation, including spatial attribute tables.
6.2.8 Technical Demonstration: The Verbs on a Soil Survey Dataset
We will create a small soil survey dataset and apply each verb individually, then chain them.
r
library(dplyr)
[Link](2024)
soil <- [Link](
sample_id = 1:30,
site = rep(c("Hill", "Valley", "Slope"), each = 10),
depth_cm = runif(30, 0, 30),
ph = runif(30, 5.0, 8.5),
organic_carbon = runif(30, 1.0, 5.0),
sand_pct = runif(30, 20, 80)
)
# Add a few NAs
soil$ph[c(3, 15)] <- NA
soil$organic_carbon[c(7, 22)] <- NA
# ---------- 1. filter ----------
soil %>% filter(site == "Hill")
soil %>% filter(ph > 6.5, depth_cm > 10)
# ---------- 2. select ----------
soil %>% select(sample_id, site, ph)
soil %>% select(-sand_pct)
soil %>% select(starts_with("org"))
# ---------- 3. mutate ----------
soil %>% mutate(
depth_category = ifelse(depth_cm < 10, "Shallow", "Deep"),
carbon_log = log(organic_carbon)
)
# ---------- 4. summarise (whole table) ----------
soil %>% summarise(
mean_ph = mean(ph, [Link] = TRUE),
mean_oc = mean(organic_carbon, [Link] = TRUE),
n_total = n(),
n_missing_ph = sum([Link](ph))
)
# ---------- 5. arrange ----------
soil %>% arrange(ph)
soil %>% arrange(desc(organic_carbon), site)
# ---------- 6. Complete pipeline ----------
soil %>%
filter(, ) %>%
mutate(
depth_class = ifelse(depth_cm > 15, "Deep", "Shallow"),
oc_class = ifelse(organic_carbon > 3, "High", "Low")
) %>%
select(site, depth_class, oc_class, ph) %>%
arrange(site, desc(ph)) %>%
head(10)
Observe how filter() eliminates rows, mutate() adds columns, select() trims columns,
and arrange() reorders the remaining rows. The pipeline is linear and transparent.
Hard Practice 6.2 – “Attribute Table Preparation with the Five Verbs”
Objective:
Take a messy dataset of simulated field observations and, using only the five dplyr verbs and
pipes, clean, transform, summarize, and sort it into a publication-ready table. No base-R
subsetting is allowed; the entire task must be a single piped chain.
Scenario:
You are given a data frame field_data containing 200 observations from a vegetation survey
across four sites. Variables include site, plot_id, date (character), height_cm, dbh_cm (diameter
at breast height), species, and crown_area_m2. The dataset contains missing values, errors
(negative heights), and inconsistent species codes. You will produce a clean summary table of
mean height and mean crown area per site, for the most common species only, sorted by mean
height.
Instructions:
1. Create a new script practice_6_2_dplyr_verbs.R. Add a header.
2. Generate the data:
r
[Link](999)
field_data <- [Link](
site = sample(c("North", "South", "East", "West"), 200, replace = TRUE),
plot_id = sample(1:20, 200, replace = TRUE),
date = sample(seq([Link]("2024-06-01"), [Link]("2024-08-31"), by="day"), 200, replace =
TRUE),
height_cm = round(rnorm(200, mean = 150, sd = 60)),
dbh_cm = round(runif(200, 1, 30), 1),
species = sample(c("Pine", "Oak", "Birch", "Spruce", "Maple"), 200, replace = TRUE, prob =
c(0.3,0.25,0.2,0.15,0.1)),
crown_area_m2 = round(runif(200, 0.5, 20), 1)
)
# Introduce errors
field_data$height_cm[sample(1:200, 5)] <- -5 # negative height
field_data$dbh_cm[sample(1:200, 10)] <- NA # missing dbh
field_data$crown_area_m2[sample(1:200, 3)] <- 0 # zero crown area
# Messy species: some have extra spaces
field_data$species[field_data$species == "Pine"] <- " Pine "
field_data$species[field_data$species == "Oak"] <- "oak"
3. In a single piped chain, applied to field_data, perform the following steps in order:
o Clean species: Use mutate() with trimws() (base R function to trim whitespace)
and tolower()? No, we want consistent case, like "Pine", "Oak", etc.
Use tools::toTitleCase() or just manual ifelse? For simplicity, mutate(species =
case_when(...)) is not yet covered. Use mutate(species = trimws(species)) and
then mutate(species = recode(species, "oak" = "Oak", " Pine " handled after
trimws)). Actually, after trimws, " Pine " becomes "Pine". So just mutate(species
= trimws(species)) fixes one; then mutate(species = ifelse(species == "oak",
"Oak", species))? But species is character. We'll do it in multiple mutate steps,
which is fine within the chain. Better: use mutate(species = recode(species, "oak"
= "Oak")) after trimming. However recode is in dplyr and is a tidyverse function.
That's acceptable. If we want to stick to base, we can do multiple mutate calls.
We'll allow recode as it's part of the tidyverse.
o Filter out errors: Remove rows where height_cm is negative, crown_area_m2 is
0, or dbh_cm is NA. Use filter().
o Create derived columns: Use mutate() to add height_m = height_cm /
100 and basal_area_cm2 = pi * (dbh_cm / 2)^2 (assuming circular stem cross-
section).
o Filter to common species: Find species with more than 20 observations. Harder:
we need to compute counts per species, filter by count, and then keep only those
rows. We can do this by grouping and filtering within the pipeline
using group_by(species) %>% filter(n() > 20) %>% ungroup(). That's a preview
of grouped operations, but it's natural. We'll allow it as it's still dplyr.
Alternatively, compute the common species separately. But the problem says
"only the five core verbs". Grouped filter with n() is an extension of filter, still
within the verb. We'll use it.
o Summarise by site and species: Group by site and species (group_by(site,
species)), then summarise() to compute mean_height_m = mean(height_m, [Link]
= TRUE), mean_crown = mean(crown_area_m2, [Link] = TRUE), and count =
n().
o Arrange: Sort by site (ascending), then mean_height_m (descending).
o Select output columns: Keep
only site, species, mean_height_m, mean_crown, count.
4. Print the final result.
5. Reflection:
o How does mutate() differ from summarise() in terms of the number of rows in the
output?
o Why is filter() placed early in the pipeline?
o How would this pipeline change if the data were an sf object with a geometry
column? (Would you need to treat the geometry specially?)
Deliverable:
The script practice_6_2_dplyr_verbs.R containing the data generation and the complete pipeline,
with comments.
6.3 Grouped Operations and Window Functions
In Section 6.2 you learned to filter, select, mutate, summarise, and arrange a whole data frame.
But spatial data is inherently grouped: samples belong to sites, pixels belong to land-cover
classes, administrative units contain many features. The dplyr function group_by() transforms a
flat table into a grouped table, so that subsequent summarise() calls produce one row per group,
and mutate() can compute group-wise statistics without collapsing the table. Together
with window functions—functions that return a value for each row but take the group into
account, like rank(), lag(), lead(), and cumsum()—grouped operations make dplyr a complete
engine for the zonal statistics, per-pixel normalisation, and temporal change detection that lie at
the heart of geospatial analysis. This section systematically covers group_by(),
grouped summarise(), grouped mutate(), and a selection of essential window functions, always
with a geospatial lens. By the end, you will think of your data in groups as naturally as you think
of map layers.
6.3.1 The Concept of a Grouped Data Frame
A data frame in R is fundamentally a list of equal-length vectors. group_by() adds a grouping
attribute—a set of columns that define the groups—without altering the data values or row
order. The class becomes "grouped_df" (and still "[Link]"), and all subsequent dplyr verbs
respect the grouping:
summarise() peels off one layer of grouping and returns one row per group (or per
combination of grouping variables).
mutate() evaluates expressions within each group, so
that mean(x) inside mutate after group_by(g) returns the group mean for each row, not
the global mean.
filter() can use n() to keep groups with a minimum number of rows, or with conditions
that depend on group summaries.
arrange() sorts within groups if requested, but by default it sorts the whole table.
The grouping persists until you call ungroup(). A grouped data frame is the tidyverse’s
abstraction for “split-apply-combine”: split the data by group, apply a function, and combine the
results. Unlike base R’s tapply and aggregate, grouped dplyr keeps everything inside a single
piped chain.
6.3.2 Creating Groups with group_by()
group_by(.data, column1, column2, ...) returns a data frame grouped by the unique combinations
of the specified columns. Grouping variables are usually factors, character vectors, or integers;
avoid grouping by continuous numeric vectors with many distinct values.
r
library(dplyr)
# Ungrouped data
soil <- [Link](
site = rep(c("Hill", "Valley"), each = 6),
depth = rep(c("Shallow", "Deep"), times = 6),
ph = runif(12, 5.5, 8.0)
)
# Group by site
soil_by_site <- soil %>% group_by(site)
soil_by_site
# A tibble: 12 × 3
# Groups: site [2]
# site depth ph
# <chr> <chr> <dbl>
# 1 Hill Shallow 6.12
# ...
The printed output shows Groups: site [2], telling you the grouping structure. You can group by
multiple columns:
r
soil %>% group_by(site, depth)
To add or remove grouping variables, use group_by() again, possibly with .add = TRUE to add to
existing groups, or use ungroup() to remove all grouping.
6.3.3 Grouped summarise(): One Row per Group
The primary use of grouping is to produce summary statistics per group. With summarise(), each
group becomes a single row.
r
soil %>%
group_by(site) %>%
summarise(
mean_ph = mean(ph, [Link] = TRUE),
sd_ph = sd(ph, [Link] = TRUE),
n = n()
)
The result is an ungrouped data frame with one row per site, unless the original grouping had
multiple variables, in which case the last grouping variable is peeled off. You can control this
with the .groups argument: "drop" (ungroup), "keep" (keep all groups), "drop_last" (default, drop
the innermost group).
Geospatial application: Zonal statistics. Given an sf data frame of sampling points with a
column land_cover, you can compute per-class mean elevation:
r
points %>%
group_by(land_cover) %>%
summarise(mean_elev = mean(elevation, [Link] = TRUE))
If points is an sf object, the geometry column is silently dropped unless you
use sf::st_union() or sf::st_combine() inside summarise() to aggregate the geometries. We will
cover this explicitly in the spatial chapters; for now, note that summarise() on an sf object
without spatial aggregation returns a non-spatial tibble. To keep the geometry, you must
aggregate it: summarise(geometry = st_union(geometry), mean_elev = mean(elevation)). We will
learn this in Chapter 12.
6.3.4 Grouped mutate(): Group-wise Calculations per Row
While summarise() collapses groups to one row, mutate() preserves all rows but computes
expressions within each group. This is essential for centering, scaling, ranking, and computing
proportions within groups.
r
soil %>%
group_by(site) %>%
mutate(
site_mean_ph = mean(ph, [Link] = TRUE),
ph_centered = ph - site_mean_ph
)
Every row now carries the site-mean pH and the deviation of its own pH from that mean. The
group-wise mean is repeated across all rows of the group, thanks to R’s recycling rules.
Geospatial application: Normalise NDVI within each land-cover class:
r
pixels %>%
group_by(land_cover) %>%
mutate(ndvi_z = (ndvi - mean(ndvi, [Link] = TRUE)) / sd(ndvi, [Link] = TRUE))
This computes the z-score of NDVI relative to the class mean and standard deviation,
highlighting anomalies within each class.
Another common use: computing the percentage of total area contributed by each parcel within a
county, or the rank of each city by population within its province.
6.3.5 Grouped filter(): Keeping Groups That Satisfy a Condition
filter() can be used after group_by() to retain only groups that meet a criterion, often using n().
r
# Keep only sites with more than 5 observations
soil %>%
group_by(site) %>%
filter(n() > 5) %>%
ungroup()
You can also filter rows within groups using group-level statistics:
r
# Keep rows where pH is above the site median
soil %>%
group_by(site) %>%
filter(ph > median(ph, [Link] = TRUE))
Geospatial application: In a dataset of field plots nested within watersheds, you might retain
only watersheds with at least 10 plots, or keep only the plot with the highest elevation in each
watershed.
6.3.6 Window Functions: Ranking, Offset, and Cumulative Operations
Window functions return a vector of the same length as the input, computed with respect to the
current group’s rows. They are typically used inside mutate(). Key families:
Ranking functions:
row_number() — row number within the group (1, 2, 3, …).
min_rank(x) — rank of x with ties (1, 1, 3, 4, …).
dense_rank(x) — rank with no gaps (1, 1, 2, 3, …).
percent_rank(x) — rank scaled to [0, 1].
ntile(x, n) — bin into n quantiles.
Offset functions:
lag(x, n = 1, default = NA) — value of x from the previous row.
lead(x, n = 1, default = NA) — value of x from the next row.
These are used for computing temporal differences (e.g., NDVI change from previous month) or
spatial lags.
Cumulative functions (vectorised but group-aware):
cumsum(x), cummean(x), cummin(x), cummax(x).
These are base R functions, but inside mutate() with grouping they restart within each group.
Examples:
r
# Within each site, rank plots by elevation
soil %>%
group_by(site) %>%
mutate(elev_rank = min_rank(ph))
# Compute lagged difference in pH (assuming rows are ordered by depth)
soil %>%
group_by(site) %>%
arrange(depth) %>%
mutate(ph_change = ph - lag(ph))
# Cumulative sum of carbon within each site
soil %>%
group_by(site) %>%
mutate(cum_carbon = cumsum(organic_carbon))
Geospatial application: In a time-series of satellite observations per pixel, you can group by
pixel ID and compute the year-to-year change in NDVI using lag(ndvi). In a dataset of parcel
sales, you can rank parcels by price within each neighbourhood. In a trajectory dataset, you can
compute cumulative distance travelled from sequential GPS fixes.
6.3.7 The ungroup() Function and Group Persistence
Grouping persists until you explicitly ungroup(). If you forget to ungroup, subsequent operations
may produce unexpected results—especially summarise(), which will continue to summarise at
the grouping level. As a general rule, ungroup after you have finished per-group operations,
unless the grouping is needed further down the pipeline.
r
result <- soil %>%
group_by(site) %>%
mutate(site_mean = mean(ph)) %>%
ungroup() %>%
mutate(overall_mean = mean(ph))
Without ungroup(), the final mutate would compute overall_mean within each site, which would
be identical to site_mean—clearly not intended.
6.3.8 Technical Demonstration: Grouped Analysis of a Vegetation Survey
We will work with a simulated vegetation dataset, apply grouping, summarise, group-wise
mutate, and window functions.
r
library(dplyr)
[Link](2025)
veg <- [Link](
site = rep(c("A","B","C"), each = 10),
transect = rep(1:5, times = 6),
species = sample(c("Oak","Pine","Birch"), 30, replace = TRUE),
height_m = round(rnorm(30, mean = 12, sd = 4), 1),
crown_area = round(runif(30, 2, 15), 1)
)
# Introduce some missing values
veg$height_m[c(3, 15, 24)] <- NA
# ---------- 1. Basic group summarise ----------
veg %>%
group_by(site) %>%
summarise(
mean_height = mean(height_m, [Link] = TRUE),
n_trees = n(),
n_missing = sum([Link](height_m))
)
# ---------- 2. Group-wise mutate: centre height within site ----------
veg %>%
group_by(site) %>%
mutate(
site_mean_ht = mean(height_m, [Link] = TRUE),
height_cent = height_m - site_mean_ht
) %>%
ungroup()
# ---------- 3. Rank within group ----------
veg %>%
group_by(site) %>%
mutate(rank_by_height = min_rank(desc(height_m))) %>%
arrange(site, rank_by_height)
# ---------- 4. Filter groups by size ----------
veg %>%
group_by(site) %>%
filter(n() >= 8) %>% # keep only sites with at least 8 observations
ungroup()
# ---------- 5. Lagged difference (assuming transects ordered) ----------
veg %>%
group_by(site) %>%
arrange(transect) %>%
mutate(ht_change = height_m - lag(height_m))
# ---------- 6. Cumulative sum within site ----------
veg %>%
group_by(site) %>%
mutate(cumul_crown = cumsum(coalesce(crown_area, 0))) # coalesce replaces NA with 0 for
cumsum
# ---------- 7. Multiple groups: site and species ----------
veg %>%
filter() %>%
group_by(site, species) %>%
summarise(
mean_height = mean(height_m),
count = n(),
.groups = "drop"
)
Run these examples and observe the output dimensions. The grouped summarise returns fewer
rows; grouped mutate returns the same number of rows.
Hard Practice 6.3 – “Zonal Statistics and Per-Group Normalisation for a Field Survey”
Objective:
Apply grouped operations and window functions to a realistic multi-site field survey dataset.
Perform zonal statistics, within-group normalisation, and identify extreme values per group using
ranks.
Scenario:
You have a dataset of 150 soil samples from five sites. Each sample has a measured pH, organic
carbon content, and soil moisture. You need to:
Compute per-site summary statistics.
Within each site, normalise pH to z-scores (z-score = (value – site mean) / site sd).
Rank samples by organic carbon within each site.
Identify the top-ranked sample in each site.
Compute the per-site cumulative sum of soil moisture after ordering by depth (simulate
depth).
Instructions:
1. Create a new script practice_6_3_grouped.R. Add a header.
2. Generate the dataset:
r
[Link](101)
sites <- c("Alpha", "Beta", "Gamma", "Delta", "Epsilon")
n <- 150
soil_survey <- [Link](
site = sample(sites, n, replace = TRUE),
depth_cm = round(runif(n, 0, 30), 1),
ph = round(rnorm(n, mean = 6.5, sd = 0.8), 1),
organic_carbon = round(runif(n, 1.5, 5.5), 2),
moisture = round(runif(n, 10, 40), 1)
)
# Introduce NAs
soil_survey$ph[sample(1:n, 8)] <- NA
soil_survey$organic_carbon[sample(1:n, 5)] <- NA
3. Pipeline 1: Per-site summary.
Create a summary table with, for each site: number of samples, mean pH (ignoring NAs),
mean organic carbon, mean moisture. Use group_by %>% summarise. Print the result.
4. Pipeline 2: Within-site normalisation.
For each site, compute ph_z = (ph - mean(ph, [Link]=TRUE)) / sd(ph, [Link]=TRUE) and
similarly for oc_z (organic carbon z-score). Use group_by %>% mutate. Print the first 20
rows. Ensure you ungroup() at the end.
5. Pipeline 3: Rank by organic carbon.
Within each site, create a column oc_rank that ranks organic carbon in descending order
(highest = 1). Use min_rank(desc(organic_carbon)) inside mutate. Print the top 3 ranks
per site? Use filter(oc_rank <= 3) and arrange(site, oc_rank) to display. Print the result.
6. Pipeline 4: Cumulative moisture along depth.
For each site, arrange by depth_cm (ascending), and compute cumul_moisture =
cumsum(moisture). Print the first 15 rows showing site, depth, moisture, and cumulative
moisture.
7. Pipeline 5: Flag extreme pH values.
Within each site, create a logical column ph_extreme that is TRUE if the pH z-score
(from pipeline 2) is greater than 2 or less than –2. Use mutate. Count how many extreme
values there are per site (use summarise after grouping by site and ph_extreme? Actually,
count per site: group_by(site) %>% summarise(n_extreme = sum(ph_extreme, [Link] =
TRUE))).
8. Reflection:
o What is the difference between summarise() and mutate() when used
after group_by()?
o Why is it important to ungroup() after grouped mutate() if you are going to do
further global calculations?
o How would you compute the percentage of total samples contributed by each site?
(Use mutate after ungrouping to compute n() / nrow(.) or similar).
Deliverable:
The script practice_6_3_grouped.R with all five pipelines, comments, and printed outputs.
6.4 Joining Tables: left_join(), inner_join(), Binding Rows/Columns
In Section 4.3 you learned base R's merge() function. The dplyr package provides a family of
join functions that are faster, more predictable, and integrated into the pipe chain. Spatial data
analysis constantly requires joining: matching field observations to site metadata, linking census
data to administrative boundaries, appending new survey seasons to an existing database. This
section covers the four mutating joins (left_join, right_join, inner_join, full_join), the two
filtering joins (semi_join, anti_join), and the binding operations (bind_rows, bind_cols). Every
concept is explained with the relational algebra behind it, and every example is drawn from
geospatial attribute-table management. By the end, you will be able to combine data from
multiple sources with precision, verify that joins have performed as expected, and diagnose
common join failures—skills that are essential before you attempt spatial joins (Chapter 14).
6.4.1 The Relational Model and the Need for Joins
A relational database stores information in multiple tables linked by keys. This principle applies
equally to the tabular data of a GIS project. You might have:
A shapefile of monitoring stations ([Link]) with columns station_id, geometry,
and elevation.
A CSV file of monthly water quality readings with columns station_id, date, ph, nitrate.
A lookup table of station types with columns station_id and type_name.
These tables share the key column station_id. To analyse whether water quality varies with
elevation, you must join the readings to the station attributes. To label stations with their type
names, you must join the lookup table.
dplyr's joins follow the SQL standard. They take two data frames, x and y, and a set of columns
to match on (by). The four mutating joins combine columns from both tables:
inner_join(x, y, by): keep only rows with matches in both tables.
left_join(x, y, by): keep all rows from x; add columns from y where there is a match,
otherwise NA.
right_join(x, y, by): keep all rows from y; x rows with no match get NA.
full_join(x, y, by): keep all rows from both tables, filling unmatched values with NA.
The filtering joins keep only rows from x that have (or do not have) a match in y, without
adding columns:
semi_join(x, y, by): keep rows in x that have a match in y.
anti_join(x, y, by): keep rows in x that do not have a match in y.
The binding operations stack or paste data frames together without a key:
bind_rows(x, y): stack data frames vertically (row binding).
bind_cols(x, y): paste data frames side by side (column binding), rarely used because no
key is checked.
6.4.2 Specifying Join Keys with by
The by argument tells *_join() which columns to match on. It can be:
A character vector of common column names: by = c("station_id", "date") for a
composite key.
A named character vector: by = c("id_x" = "id_y") when the key columns have
different names in the two tables.
Omitted: the join uses all columns common to both tables. This is convenient but
dangerous; it is safer to specify by explicitly.
When key columns have different names, use the c("name_in_x" = "name_in_y") syntax:
r
left_join(stations, readings, by = c("station_id" = "st_id"))
The output column will use the name from x (i.e., station_id).
6.4.3 The Four Mutating Joins
We will work with two small data frames that mimic a GIS scenario:
r
library(dplyr)
# Table of monitoring stations
stations <- [Link](
station_id = c("S01", "S02", "S03", "S04"),
river = c("Yangtze", "Yellow", "Pearl", "Mekong"),
elevation_m = c(450, 1200, 80, 600),
stringsAsFactors = FALSE
)
# Table of measurements (multiple per station, some stations missing)
measurements <- [Link](
station_id = c("S01", "S01", "S02", "S03", "S03", "S05"),
date = c("2024-01", "2024-02", "2024-01", "2024-01", "2024-03", "2024-01"),
ph = c(7.1, 7.0, 8.2, 6.8, 6.9, 7.5),
stringsAsFactors = FALSE
)
Note that station S04 has no measurements, S05 has measurements but no station record,
and S01 has two measurements.
inner_join(): Intersection
r
inner_join(measurements, stations, by = "station_id")
The result contains rows only for stations present in both tables (S01, S02, S03). Station S04 (no
measurements) and S05 (no station) are excluded. Each measurement row gets the
station's river and elevation_m appended.
Geospatial analogue: Selecting only field samples that fall inside a known study area, dropping
any sample without a matching polygon or any polygon without samples.
left_join(): Retain All Observations
r
left_join(measurements, stations, by = "station_id")
All rows from measurements are kept. Station S05 appears with river = NA and elevation_m =
NA because it has no match in stations. This is the most common join in scientific analysis: "give
me all the primary observations, and append whatever contextual information you can."
Geospatial analogue: Attaching county-level demographic data to each sampled point based on
the county the point falls within. Points outside all counties would get NA demographics.
right_join(): Retain All Context
r
right_join(measurements, stations, by = "station_id")
All stations are kept. Station S04, which has no measurements, appears once with ph = NA.
Station S05, which has no station, is dropped. This is equivalent to a left join with the arguments
swapped.
Geospatial analogue: Starting with a list of all administrative districts, and pulling in any
available survey data for each.
full_join(): Union
r
full_join(measurements, stations, by = "station_id")
All rows from both tables are kept. Station S04 appears with ph = NA; measurement S05 appears
with river = NA. This join loses no information.
Geospatial analogue: A complete inventory: all samples and all sites, preserving everything even
if unmatched.
6.4.4 The Two Filtering Joins
Filtering joins do not add columns; they only affect which rows of x are kept.
semi_join(): Keep Rows with a Match
r
semi_join(stations, measurements, by = "station_id")
Returns only stations that have at least one measurement (S01, S02, S03). No columns
from measurements are added. This is useful for subsetting a spatial layer to only those features
that have associated data.
Geospatial analogue: From a layer of all protected areas, keep only those that contain at least
one field plot.
anti_join(): Keep Rows Without a Match
r
anti_join(stations, measurements, by = "station_id")
Returns stations with no measurements (only S04). This is the essential tool for quality control:
find features missing data, identify unsampled polygons, or detect orphan records.
Geospatial analogue: Find all monitoring stations that produced no data in a given year, so you
can investigate the malfunction.
6.4.5 Duplicate Keys and Many-to-Many Joins
In the examples above, measurements has duplicate station_id values (one-to-many
join). dplyr handles this correctly: each measurement row is duplicated with the matching station
information. This is the expected behavior for joining attribute tables.
If both tables have duplicate keys, the join produces a Cartesian product within each key
group: every row from x with a given key is paired with every row from y with that key. This is
rarely desired and will produce a warning:
r
# Both tables have duplicate keys
x <- [Link](id = c(1,1,2), val = c(10,20,30))
y <- [Link](id = c(1,1,2), attr = c("A","B","C"))
inner_join(x, y, by = "id") # Warning: Detected an unexpected many-to-many relationship
Always inspect your keys with count(x, key) and count(y, key) to check for duplicates before
joining.
6.4.6 Binding Rows and Columns
Sometimes you need to combine data frames without a key—for example, appending this year's
survey data to last year's.
bind_rows(x, y)
Stacks data frames vertically. Columns are matched by name. Columns present in one data frame
but not the other are filled with NA.
r
survey_2023 <- [Link](site = c("A","B"), ph = c(6.5, 7.0))
survey_2024 <- [Link](site = c("C","D"), ph = c(6.8, 7.2), moisture = c(15, 20))
combined <- bind_rows(survey_2023, survey_2024)
combined
# site ph moisture
# 1 A 6.5 NA
#2 B 7.0 NA
#3 C 6.8 15
#4 D 7.2 20
bind_rows() is the tidyverse replacement for rbind() and is more forgiving of column
mismatches.
bind_cols(x, y)
Pastes data frames side by side. No key is checked; rows are matched by position. This
is dangerous unless you are absolutely certain the rows correspond. It is almost always safer to
use a join with a key. We will rarely use bind_cols().
6.4.7 Joining and sf Objects
An sf object is a data frame. You can use *_join() directly on it, provided the geometry column is
preserved on the correct side. The geometry column is just a list-column that dplyr treats like any
other column.
Critical rule for spatial joins: If you want the result to remain spatial (still an sf object),
the first argument (x) must be the sf object. left_join(sf_object, csv_data, by = "id") returns
an sf object; left_join(csv_data, sf_object, by = "id") returns a plain tibble because the first
argument is not spatial. We will practice this explicitly in Chapter 12.
For non-spatial attribute joins, this rule does not apply; you are joining ordinary data frames, and
the result is a data frame.
6.4.8 Technical Demonstration: Joining a Field Campaign
We will create three tables representing a typical field campaign and perform a sequence of joins
to build an analysis-ready dataset.
r
library(dplyr)
# Sites table (spatial context)
sites <- [Link](
site_id = c("A01", "A02", "A03", "A04"),
watershed = c("Upper", "Upper", "Lower", "Lower"),
protection = c(TRUE, FALSE, TRUE, FALSE),
stringsAsFactors = FALSE
)
# Samples table (measurements)
samples <- [Link](
sample_id = 1:10,
site_id = c("A01","A01","A02","A02","A03","A03","A03","A04","A04","A04"),
date = rep(c("2024-06","2024-07"), [Link] = 10),
ph = runif(10, 5.5, 8.0),
stringsAsFactors = FALSE
)
# Lab results table (separate file)
lab <- [Link](
sample_id = c(1,2,4,5,7,8,9,10),
organic_carbon = round(runif(8, 1.0, 4.0), 2),
stringsAsFactors = FALSE
)
# ---------- 1. Left join: attach site info to samples ----------
samples_with_sites <- samples %>%
left_join(sites, by = "site_id")
print(samples_with_sites)
# ---------- 2. Left join: attach lab results to samples ----------
full_data <- samples_with_sites %>%
left_join(lab, by = "sample_id")
print(full_data)
# Note: sample_id 3 and 6 have no lab result (NA organic_carbon)
# ---------- 3. Anti join: find samples missing lab data ----------
missing_lab <- samples %>%
anti_join(lab, by = "sample_id")
print(missing_lab) # sample_id 3 and 6
# ---------- 4. Semi join: find sites that have at least one complete record ----------
sites_with_full_data <- sites %>%
semi_join(full_data %>% filter(), by = "site_id")
print(sites_with_full_data)
# ---------- 5. Full join: combine sites with a new set of sites ----------
new_sites <- [Link](site_id = c("A04", "A05", "A06"),
watershed = c("Lower", "Middle", "Middle"),
protection = c(FALSE, TRUE, TRUE),
stringsAsFactors = FALSE)
all_sites <- sites %>%
full_join(new_sites, by = "site_id")
# Note: site_id is the same in both, but watershed and protection from y will have .x and .y
suffixes if there are conflicts with overlapping columns not in 'by'.
# Actually, with full_join, columns with the same name but not in 'by' get renamed with .x and .y
suffixes.
# Better: specify by and use suffix = c("", "_new") to control renaming, or just combine the data
differently.
print(all_sites)
Observe how each join transforms the data. The anti-join isolates quality problems; the left join
builds the master analysis table.
Hard Practice 6.4 – “Integrating Multi-Source Attribute Data with Joins”
Objective:
Use dplyr's join functions to integrate three separate tables—sites, field measurements, and
laboratory results—into a single analysis-ready data frame. Diagnose missing data using
anti-joins, and produce summary statistics that reflect data completeness.
Scenario:
You are compiling data for a soil carbon study. You receive three separate files:
1. sites: site metadata (site ID, watershed, elevation).
2. field: field measurements (sample ID, site ID, date, pH, moisture). Not all sites were
sampled.
3. lab: laboratory results (sample ID, organic_carbon, nitrogen). Some samples were lost in
transit and are missing from lab.
You must produce a single table that contains every field sample with its site information and lab
results, identify which sites have no field data and which samples have no lab data, and compute
mean organic carbon by watershed (ignoring missing values).
Instructions:
1. Create a new script practice_6_4_joins.R. Add a header.
2. Generate the three tables:
r
[Link](77)
sites <- [Link](
site_id = paste0("S", sprintf("%02d", 1:10)),
watershed = sample(c("North", "Central", "South"), 10, replace = TRUE),
elevation_m = round(rnorm(10, mean = 1200, sd = 400)),
stringsAsFactors = FALSE
)
field <- [Link](
sample_id = 1:25,
site_id = sample(sites$site_id, 25, replace = TRUE),
date = sample(seq([Link]("2024-05-01"), [Link]("2024-09-30"), by="day"), 25),
ph = round(runif(25, 5.5, 8.5), 1),
moisture_pct = round(runif(25, 10, 45), 1),
stringsAsFactors = FALSE
)
lab <- [Link](
sample_id = sort(sample(1:25, 20)), # 5 samples lost
organic_carbon = round(runif(20, 1.2, 4.8), 2),
nitrogen = round(runif(20, 0.1, 0.5), 2),
stringsAsFactors = FALSE
)
3. Join field to sites: Use left_join(field, sites, by = "site_id") and assign
to field_with_sites. Print the first 10 rows.
4. Join lab results: Use left_join(field_with_sites, lab, by = "sample_id") and assign
to master. Print a summary of missing values: master %>%
summarise(across(everything(), ~sum([Link](.)))).
5. Identify unsampled sites: Use anti_join(sites, field, by = "site_id") to find sites that have
no field samples. Print their IDs and watersheds.
6. Identify samples missing lab data: Use anti_join(field, lab, by = "sample_id") to find
samples with no lab results. Print their sample IDs and dates.
7. Compute mean organic carbon by watershed: Starting from master, filter out rows
with missing organic_carbon, group by watershed, and summarise to compute mean_oc =
mean(organic_carbon) and n = n(). Print the result.
8. Full join challenge: Create a second field table field_2025 with similar structure (invent
a few rows, using site IDs from the same set). Use bind_rows() to
stack field and field_2025. Then left-join the combined field data to sites. Print the
number of rows in the combined field table.
9. Reflection:
o Why is left_join the workhorse for building analysis-ready tables?
o What problem does anti_join solve that filter alone cannot?
o If you were joining a CSV to an sf spatial data frame, which table should be the
left side (x) to preserve the spatial class?
Deliverable:
The script practice_6_4_joins.R with all joins, printed results, and reflection comments.
6.5 tidyr: Pivoting, Nesting, and Rectangling Messy Field Data
Data from field surveys, environmental monitoring, and public repositories is rarely in the tidy
format that dplyr and ggplot2 expect. Variables may be spread across column headers (e.g., one
column per month), a single column may contain multiple pieces of information
(e.g., "SiteA_2024-06"), or repeated measurements may be stored in separate rows that need to
be combined. The tidyr package (Wickham & Girlich, 2023) provides a set of functions
to reshape messy data into tidy form and to rectangle deeply nested lists into data frames. This
section covers the core tidyr tools: pivot_longer() and pivot_wider() for changing data
layout, separate() and unite() for splitting and merging columns, nest() and unnest() for
working with list-columns (essential for spatial objects), and fill() plus replace_na() for
cleaning missing values. Every concept is anchored in geospatial fieldwork: a spreadsheet of
soil moisture with one column per depth, a GPS log with coordinates stored in a single text
string, a list of sampling points that needs to be expanded into a full data frame. By the end, you
will be able to take the most stubbornly untidy field data and transform it into the clean,
long-format tables that feed directly into dplyr pipelines and, later, into sf and terra analyses.
6.5.1 Tidy Data: The Ideal Form for Analysis
In a tidy data frame (Wickham, 2014):
1. Each variable forms a column.
2. Each observation forms a row.
3. Each type of observational unit forms a table.
This structure is optimal for vectorised operations, grouped summaries, and plotting. Most GIS
attribute tables are already close to tidy: each row is a feature, each column is a property. But
field data often arrives in a wide format (repeated measures in columns) or a messy format
(multiple values in one cell, missing values coded as -999, header rows). tidyr is the tool for
transforming these into tidy data.
Geospatial examples of messiness:
A spreadsheet where columns are pH_0cm, pH_10cm, pH_30cm (wide format; the depth
is a variable, not a column name).
A column coords containing "116.4074,39.9042" (two variables in one column).
A dataset where each site is a separate sheet in an Excel file (separate tables needing
combination).
A list of raster layers that you want to combine into a single analysis cube.
tidyr addresses these and more.
6.5.2 pivot_longer(): From Wide to Long
When variables are spread across column names, use pivot_longer() to gather them into a
single key column and a single value column.
r
pivot_longer(data, cols, names_to = "name", values_to = "value")
cols: columns to pivot (use - to exclude, : for ranges, or tidyselect helpers).
names_to: name of the new column that will store the old column names.
values_to: name of the new column that will store the cell values.
Example: Soil pH at multiple depths
r
library(tidyr)
library(dplyr)
# Wide format: one row per site, columns are depths
soil_wide <- [Link](
site = c("A", "B", "C"),
pH_0cm = c(6.5, 6.8, 7.1),
pH_10cm = c(6.7, 7.0, 7.3),
pH_30cm = c(7.0, 7.4, 7.6)
)
soil_wide
# site pH_0cm pH_10cm pH_30cm
# 1 A 6.5 6.7 7.0
#2 B 6.8 7.0 7.4
#3 C 7.1 7.3 7.6
# Pivot to long: each row is a site-depth combination
soil_long <- soil_wide %>%
pivot_longer(
cols = starts_with("pH"),
names_to = "depth",
values_to = "pH"
)
soil_long
# site depth pH
# <chr> <chr> <dbl>
#1A pH_0cm 6.5
#2A pH_10cm 6.7
#3A pH_30cm 7.0
#4B pH_0cm 6.8
# ...
The depth column now contains the old column names. To clean it, you can use names_prefix to
strip "pH_", and names_transform to convert to numeric:
r
soil_long <- soil_wide %>%
pivot_longer(
cols = starts_with("pH"),
names_to = "depth_cm",
names_prefix = "pH_",
names_transform = [Link],
values_to = "pH"
)
soil_long
# site depth_cm pH
# <chr> <dbl> <dbl>
#1A 0 6.5
#2A 10 6.7
#3A 30 7.0
# ...
Now depth_cm is numeric, ready for plotting or modelling.
Geospatial analogue: A Landsat time series may arrive with one column per acquisition date
(e.g., NDVI_20240101, NDVI_20240117). pivot_longer gathers these into
columns date and NDVI, producing the long format needed for temporal analysis.
6.5.3 pivot_wider(): From Long to Wide
The inverse of pivot_longer is pivot_wider(). It spreads a key-value pair into multiple columns.
This is useful for creating summary tables, confusion matrices, or any presentation-ready format.
r
pivot_wider(data, names_from = "key_column", values_from = "value_column")
Example: Restoring the wide soil table
r
soil_long %>%
pivot_wider(
names_from = depth_cm,
values_from = pH,
names_prefix = "pH_"
)
# site pH_0 pH_10 pH_30
# <chr> <dbl> <dbl> <dbl>
#1A 6.5 6.7 7.0
#2B 6.8 7.0 7.4
#3C 7.1 7.3 7.6
Geospatial application: You have a long table of zonal statistics: region, year, mean_ndvi.
Pivoting to wide with names_from = year gives a table of region × year, suitable for a heatmap or
a CSV report.
6.5.4 separate() and unite(): Splitting and Merging Columns
separate(): One Column into Many
When a single column contains multiple pieces of information separated by a
delimiter, separate() splits it into new columns.
r
separate(data, col, into, sep = "[^[:alnum:]]+")
col: the column to split.
into: a character vector of new column names.
sep: a separator (regex) or a position index.
Example: Site-Date codes
r
samples <- [Link](
sample_id = c("A01_2024-06", "A02_2024-07", "B01_2024-06")
)
samples %>%
separate(sample_id, into = c("site", "date"), sep = "_")
# site date
# 1 A01 2024-06
# 2 A02 2024-07
# 3 B01 2024-06
You can also split by character position (sep = 3 to split after the third character).
Geospatial application: A column coords = "116.4074,39.9042" can be separated
into lon and lat with sep = ",", then converted to numeric.
unite(): Many Columns into One
The reverse of separate() is unite(), which combines multiple columns into one.
r
unite(data, col, ..., sep = "_")
Example: Create a unique ID from site and date
r
samples_sep <- samples %>%
separate(sample_id, into = c("site", "date"), sep = "_")
samples_sep %>%
unite(sample_id, site, date, sep = "_")
6.5.5 nest() and unnest(): List-Columns for Grouped Data
nest() takes a grouped data frame and collapses the non-grouping columns into a list-column,
where each row contains a nested data frame. This is the fundamental structure that sf uses for
geometry: the geometry column is a list-column of spatial objects.
Understanding nest/unnest prepares you for advanced spatial manipulations.
nest(): Collapse Groups into Nested Data Frames
r
soil_long %>%
group_by(site) %>%
nest()
# site data
# <chr> <list>
#1A <tibble [3 × 2]>
#2B <tibble [3 × 2]>
#3C <tibble [3 × 2]>
The data column is a list of data frames, one per site. You can apply functions to each nested data
frame with purrr::map() or base lapply().
unnest(): Expand List-Columns Back to Rows
r
nested_soil %>%
unnest(data)
# Reverses the nesting.
Geospatial relevance: In sf, the geometry column is a list-column of simple feature geometries.
You can nest() attribute data and keep the geometry as a separate column, then unnest() to
restore. More importantly, when you perform a spatial join and get multiple matches, the result
may be nested, and unnest() expands them into multiple rows. We will use unnest() in Chapter
14.
6.5.6 fill() and replace_na(): Handling Missing Values in Tidy Data
fill(): Fill Missing Values from Above or Below
When a column has missing values that should be carried forward (e.g., a site name entered once
for a group of rows), fill() fills NAs with the last non-missing value.
r
df <- [Link](
site = c("A", NA, NA, "B", NA),
value = 1:5
)
df %>% fill(site)
# site value
#1 A 1
#2 A 2
#3 A 3
#4 B 4
#5 B 5
replace_na(): Replace NA with a Specified Value
r
df %>% replace_na(list(site = "Unknown", value = 0))
This is more explicit than base R’s x[[Link](x)] <- value and works naturally in pipes.
Geospatial application: A GPS log may record the transect ID only at the start of each
transect; fill() carries it forward. In a raster attribute table, replace_na() can set missing
land-cover codes to a default class.
6.5.7 Complete Tidy Workflow: From Messy Spreadsheet to Tidy Data
Consider a typical field spreadsheet:
text
Site pH_0cm pH_10cm pH_30cm Texture Lon_Lat
A 6.5 6.7 7.0 Sand 116.4,39.9
B 6.8 7.0 7.4 Clay 116.5,39.8
Steps to tidy:
1. pivot_longer pH columns to depth and pH.
2. separate Lon_Lat into lon and lat, converting to numeric.
3. separate pH column names? Already done via names_prefix.
4. Result: one row per measurement, all variables atomic.
r
clean <- raw %>%
pivot_longer(
cols = starts_with("pH"),
names_to = "depth_cm",
names_prefix = "pH_",
names_transform = [Link],
values_to = "pH"
) %>%
separate(Lon_Lat, into = c("lon", "lat"), sep = ",", convert = TRUE)
This is the foundation upon which all later analysis is built. In the spatial chapters, you will
add st_as_sf() to make it a spatial object.
6.5.8 Technical Demonstration: Rectangling a Messy Monitoring Dataset
We will create a deliberately messy dataset and clean it using tidyr.
r
library(tidyr)
library(dplyr)
# Messy data: wide, mixed information, missing values, and a text coordinate column
messy <- [Link](
transect = c("Transect 1", "Transect 2"),
date = c("2024-06-15", "2024-07-22"),
NO2_0m = c(12.3, 14.1),
NO2_10m = c(10.5, 11.8),
NO2_30m = c(8.9, 9.4),
coord = c("116.4074,39.9042", "116.4100,39.9100"),
notes = c("Windy", "Calm; sensor #2 replaced"),
stringsAsFactors = FALSE
)
# ---------- 1. Pivot longer: NO2 concentrations ----------
long <- messy %>%
pivot_longer(
cols = starts_with("NO2"),
names_to = "height",
names_prefix = "NO2_",
names_transform = function(x) gsub("m", "", x) %>% [Link](),
values_to = "NO2"
)
# height is now numeric: 0, 10, 30
# ---------- 2. Separate coordinates ----------
long <- long %>%
separate(coord, into = c("lon", "lat"), sep = ",", convert = TRUE)
# lon, lat are numeric
# ---------- 3. Fill any missing transect info (none here, but demonstrate) ----------
# long$transect[2] <- NA
# long <- long %>% fill(transect)
# ---------- 4. Replace NA (simulate) ----------
# long$NO2[1] <- NA
# long <- long %>% replace_na(list(NO2 = 0))
# ---------- 5. Nest by transect (preview of spatial grouping) ----------
nested <- long %>%
group_by(transect) %>%
nest()
nested
# Each transect now has a nested tibble of its measurements
# ---------- 6. Unnest ----------
nested %>% unnest(data)
This demonstration shows the complete rectangling workflow. The tidy output is ready
for ggplot2 or for conversion to sf with st_as_sf(coords = c("lon", "lat")).
Hard Practice 6.5 – “Tidying a Multi-Variable Field Campaign Dataset”
Objective:
Take an extremely messy simulated field dataset, apply pivot_longer, separate, unite, fill,
and nest to transform it into a tidy format suitable for analysis and mapping. Practice the
full tidyr workflow.
Scenario:
You receive a CSV (simulated in R) from a water quality survey. The dataset has:
One row per site.
Columns DO_0m, DO_5m, DO_10m (dissolved oxygen at depths).
Columns Temp_0m, Temp_5m, Temp_10m.
A column samplers containing names of two samplers separated
by & (e.g., "Alice&Bob").
A column sitedate combining site and date with underscore (e.g., "LakeA_2024-08-01").
Missing values coded as -999.
Some cells in the depth columns are blank (future: we'll set a few NAs).
You must produce a tidy data frame with
columns: site, date, sampler1, sampler2, depth_m, DO, Temp. Then nest by site.
Instructions:
1. Create a new script practice_6_5_tidyr.R. Add a header.
2. Generate the messy dataset:
r
messy <- [Link](
sitedate = c("LakeA_2024-08-01", "RiverB_2024-08-02", "LakeC_2024-08-01"),
DO_0m = c(8.1, 7.8, 8.5),
DO_5m = c(6.2, -999, 7.0),
DO_10m = c(4.5, 5.8, 5.2),
Temp_0m = c(22.1, 21.5, 23.0),
Temp_5m = c(18.4, -999, 19.2),
Temp_10m = c(14.9, 16.1, 15.5),
samplers = c("Alice&Bob", "Charlie&Dana", "Alice&Charlie"),
stringsAsFactors = FALSE
)
3. Separate sitedate into site and date using separate().
Convert date to Date (using mutate after).
4. Separate samplers into sampler1 and sampler2 using separate() with sep = "&".
5. Pivot longer the DO and Temp columns simultaneously. This
requires pivot_longer with names_sep or names_to with multiple values?
Actually, pivot_longer can handle multiple variables in the column names
using names_sep if the pattern is consistent. Here columns are like DO_0m, Temp_0m.
The pattern is variable_depth. We can use pivot_longer(cols = -c(site, date, sampler1,
sampler2), names_to = c("measure", "depth_m"), names_sep = "_"). Then pivot_wider to
spread DO and Temp as separate columns? Or keep them as a single measure column?
The task asks for columns depth_m, DO, Temp. So we need to pivot longer to
get DO and Temp in a single column? No, the request is a data frame with
columns site, date, sampler1, sampler2, depth_m, DO, Temp. That means we need to
keep DO and Temp as separate variables. We can use pivot_longer with names_to =
c(".value", "depth_m"), names_sep = "_". The special .value indicates that part of the
column name is the variable to be stored as a separate column. Let's use that.
r
long <- messy %>%
separate(sitedate, into = c("site","date"), sep="_") %>%
separate(samplers, into = c("sampler1","sampler2"), sep="&") %>%
pivot_longer(
cols = -c(site, date, sampler1, sampler2),
names_to = c(".value", "depth_m"),
names_sep = "_"
)
This splits DO_0m into DO (value) and depth_m ("0m"), and similarly for Temp. Then we can
convert depth_m to numeric (strip "m").
6. Clean depth_m: Use mutate(depth_m = [Link](gsub("m", "", depth_m))).
7. Replace missing value codes: Replace any DO or Temp equal to -999
with NA using mutate(across(c(DO, Temp), ~na_if(., -999))). na_if is a dplyr function
that replaces a value with NA.
8. Fill: Suppose site was entered only in the first row? Not in this data, but we can
demonstrate if needed; skip.
9. Nest by site: After tidying, nest the measurements (columns depth_m, DO, Temp) for
each site using group_by(site) %>% nest(data = c(date, sampler1, sampler2, depth_m,
DO, Temp))? The problem says "Then nest by site", so probably nest all other columns
except site.
10. Print the final tidy data frame (unnested) and the nested version.
11. Reflection:
o How did pivot_longer with .value and names_sep solve the double-variable
column names?
o Why is tidy data (long format) better for plotting and modelling than wide
format?
o How does nesting relate to the way sf stores geometry?
Deliverable:
The script practice_6_5_tidyr.R with all steps, comments, and printed outputs.
Chapter 6 Review Problems
Congratulations on completing Chapter 6. You have learned to pipe data through dplyr verbs,
group and summarise, join tables, and reshape messy field data with tidyr. These problems are
designed to test your ability to combine all these tools into clean, reproducible pipelines for
realistic geospatial attribute-table tasks. Solve each problem in a clearly commented R script.
Load dplyr and tidyr (or the whole tidyverse) at the start of each script. Avoid base-R
subsetting when a dplyr verb is more natural; the goal is fluent tidyverse thinking.
Easy Problems (1–5)
1. Simple Pipe and Filter
The vector ndvi <- c(0.45, 0.62, 0.38, -0.10, 0.71, 0.55, 0.29) represents NDVI at seven field
plots.
Convert ndvi to a tibble with column ndvi using tibble(ndvi = ndvi).
Pipe the tibble into filter() to keep only rows where ndvi is between 0 and 1.
Pipe the result into summarise() to compute mean_ndvi and sd_ndvi.
Print the final summary.
2. Arrange and Select
The built-in data frame iris is available. Convert it to a tibble.
Pipe into arrange() to sort by [Link] descending.
Then pipe into select() to keep only Species and [Link].
Print the first 10 rows of the result.
3. Mutate and Grouped Summary
Create a tibble plots with 20 rows:
r
[Link](1)
plots <- tibble(
site = rep(c("A","B"), each = 10),
tree_count = sample(10:50, 20, replace = TRUE),
area_ha = runif(20, 0.5, 2)
)
Use mutate() to add a column density = tree_count / area_ha.
Group by site, then summarise() to get mean_density and sd_density per site.
Print the result.
4. Pivot Longer
A tibble soil contains soil moisture at three depths:
r
soil <- tibble(
site = c("P1", "P2", "P3"),
moisture_5cm = c(0.32, 0.28, 0.35),
moisture_15cm = c(0.30, 0.26, 0.33),
moisture_30cm = c(0.28, 0.24, 0.31)
)
Use pivot_longer() to create columns depth (containing the old column names)
and moisture.
Use names_prefix to strip "moisture_" and names_transform to convert depth to numeric.
Print the long-format tibble.
5. Join Two Tables
Two tibbles:
r
stations <- tibble(station_id = c("S01","S02","S03"), river = c("Yangtze","Yellow","Pearl"))
readings <- tibble(station_id = c("S01","S01","S02"), date = c("2024-01","2024-02","2024-01"),
ph = c(7.1,7.0,8.2))
Perform a left_join() of readings with stations by station_id.
Print the result.
Medium Problems (6–10)
6. Pipeline Combining Filter, Mutate, Group, Summarise
Using the plots tibble from Problem 3 (re-generate it with the same seed):
Pipe into filter() to keep only plots with area_ha > 1.
Add a column size_class = if_else(tree_count > 30, "High", "Low").
Group by site and size_class, then summarise to get mean_density and n.
Ungroup and arrange by site then desc(mean_density).
Print the final table.
7. Multi-Table Join and Missing Data Check
Create three tibbles:
r
[Link](2)
sites <- tibble(site_id = paste0("S", 1:8), region = sample(c("North","South"), 8, replace =
TRUE))
samples <- tibble(sample_id = 1:30, site_id = sample(sites$site_id, 30, replace = TRUE), ph =
runif(30, 5, 8))
lab <- tibble(sample_id = 1:25, organic_carbon = runif(25, 1, 4)) # some samples lost
Left-join samples to sites by site_id, then left-join the result to lab by sample_id. Assign
to full_data.
Use anti_join() to find which samples (from samples) are missing from lab. Print
their sample_ids.
From full_data, compute the mean organic_carbon for each region, ignoring NA. Print
the result.
8. Reshape a Wide Climate Table
A tibble of monthly temperature for two cities:
r
temp_wide <- tibble(
city = c("Beijing", "Shanghai"),
Jan = c(-3, 4), Feb = c(0, 5), Mar = c(8, 10), Apr = c(16, 16),
May = c(22, 21), Jun = c(27, 25), Jul = c(28, 29), Aug = c(27, 28),
Sep = c(22, 24), Oct = c(15, 18), Nov = c(5, 12), Dec = c(-1, 6)
)
Pivot to long format with columns city, month, temperature.
Convert month to an ordered factor with levels [Link] (the built-in constant).
Group by city and summarise to find the month with the maximum temperature.
Print the result. (Hint: use slice_max(temperature, n = 1) after grouping.)
9. Fill and Replace NA
A tibble representing a field transect where the transect name is recorded only at the start of each
transect, and missing values are coded as -99:
r
transect <- tibble(
transect_name = c("T1", NA, NA, "T2", NA, NA),
distance_m = c(0, 50, 100, 0, 50, 100),
conductivity = c(120, 115, -99, 130, -99, 125)
)
Use fill() to carry the transect name forward.
Use na_if() to replace -99 with NA, then pipe into replace_na() to set conductivity NA
values to the overall mean conductivity (compute it first, ignoring the -99s).
Print the cleaned tibble.
10. Grouped Window Functions
Create a tibble forest with 30 rows:
r
[Link](3)
forest <- tibble(
stand = rep(c("A","B","C"), each = 10),
tree_id = 1:30,
dbh_cm = round(rnorm(30, mean = 25, sd = 8), 1)
)
Group by stand, then use mutate() to add columns:
o dbh_rank = min_rank(desc(dbh_cm)) (rank trees by diameter within each stand,
largest = 1).
o stand_mean_dbh = mean(dbh_cm) (the stand-level mean).
o dbh_diff = dbh_cm - stand_mean_dbh (deviation from stand mean).
Ungroup, then filter to keep only the top-2 ranked trees by diameter in each stand.
Print the result, showing stand, tree_id, dbh_cm, dbh_rank.
Challenging Problems (11–15)
11. Full Tidy Workflow for Water Quality Data
You receive a messy dataset (simulate it):
r
[Link](4)
water_messy <- tibble(
site_date = c("L001_2024-05-10", "L002_2024-05-10", "L001_2024-06-15"),
NO3_mgL_surface = c(2.1, 1.8, 2.4),
NO3_mgL_bottom = c(3.5, 3.0, 4.1),
PO4_mgL_surface = c(0.05, 0.03, 0.06),
PO4_mgL_bottom = c(0.08, 0.07, 0.10),
analyst = c("J. Smith", "K. Lee & P. Chen", "J. Smith")
)
Separate site_date into site and date, converting date to Date.
Separate analyst into analyst1 and analyst2 (filling missing second analyst with NA).
Pivot longer to create
columns nutrient (values NO3, PO4), layer (values surface, bottom), and concentration.
Use names_sep = "_" and .value trick: first pivot longer with names_to = c("nutrient",
".value", "layer"), names_sep = "_", but careful: we have NO3_mgL_surface -> three
parts. Actually, we need to split into nutrient, unit?, and layer. The unit mgL is constant;
we can keep it or drop. For simplicity, pivot longer
with names_pattern or names_sep after renaming? I'd suggest: rename columns to
remove _mgL first: rename_with(~ gsub("_mgL", "", .)). Then pivot longer
with names_sep = "_" and .value. This yields NO3 and PO4 as separate columns?
Actually, if we have NO3_surface, NO3_bottom, PO4_surface, PO4_bottom,
then pivot_longer(cols = -c(site, date, analyst1, analyst2), names_to = c("nutrient",
"layer"), names_sep = "_") and then pivot_wider(names_from = nutrient, values_from =
concentration)? That's messy. Better: after removing _mgL, pivot longer with names_to =
c("nutrient", "layer"), names_sep = "_" and then pivot_wider(names_from = nutrient,
values_from = concentration)? Wait, we want a tidy table
with site, date, analyst1, analyst2, layer, NO3, PO4. So we can pivot all nutrient columns
at once, generating nutrient and concentration, then spread nutrient into columns. But the
request is to create columns nutrient, layer, concentration? The problem statement says:
create columns nutrient, layer, and concentration. So we need a long format with nutrient
type, layer, and value. That is easier: after cleaning names, pivot longer with names_to =
c("nutrient", "layer"), names_sep = "_", values_to = "concentration". That directly yields
the long format. I'll go with that. So steps: remove _mgL from names, separate site/date,
separate analyst, then pivot longer on nutrient columns. That's a good challenge.
After tidying, group by site and nutrient and summarise the mean concentration across all
dates and layers. Print the result.
12. Complex Join and Nesting
Simulate a multi-year monitoring program:
r
[Link](5)
sites <- tibble(site_id = paste0("S", 1:5), lat = runif(5, 30, 40), lon = runif(5, 110, 120))
samples_2023 <- tibble(site_id = sample(sites$site_id, 20, replace = TRUE), year = 2023, ph =
runif(20, 6, 8))
samples_2024 <- tibble(site_id = sample(sites$site_id, 25, replace = TRUE), year = 2024, ph =
runif(25, 6, 8))
Combine samples_2023 and samples_2024 using bind_rows().
Join the combined samples to sites using left_join().
Group by site_id and year, and nest the measurements (pH) into a list-column data.
Then compute the mean pH for each site-year combination by mutating the nested
column: data = map(data, ~ summarise(.x, mean_ph = mean(ph))) (you'll
need purrr::map or lapply). Unnest the result to obtain a tibble of site_id, year, mean_ph.
Print the final summary.
13. Pivot Wider for a Correlation Matrix
The iris dataset is a tibble. Create a long summary: group by Species, then summarise across all
numeric columns to get the mean. Then pivot the result to a wide format where each row is a
measurement ([Link], [Link], etc.) and each column is a Species.
Start with iris %>% group_by(Species) %>% summarise(across(where([Link]),
mean)).
Pivot longer to get measure and mean_value.
Pivot wider to get Species as columns, measure as rows.
Print the resulting matrix-like table.
14. Rectangling a Deeply Messy Field Sheet
A field sheet has been entered as:
r
messy_field <- tibble(
plot = c("Plot1", "Plot2", "Plot3"),
date = c("2024-08-01", "2024-08-01", "2024-08-02"),
meas = c("pH:6.5; moisture:22", "pH:7.0; moisture:18", "pH:6.8; moisture:20")
)
The meas column contains key-value pairs separated by ; and :.
Use separate_rows() to split meas by ; into multiple rows.
Then separate() the resulting column into parameter and value using :.
Convert value to numeric.
Finally, pivot wider to have columns pH and moisture.
Print the final tidy tibble.
15. Complete Data-Cleaning Pipeline with Integrity Checks
Simulate a large messy dataset:
r
[Link](10)
n <- 200
raw <- tibble(
id = 1:n,
site = sample(c("North","South","East"), n, replace = TRUE),
date = sample(seq([Link]("2024-01-01"), [Link]("2024-12-31"), by="day"), n, replace =
TRUE),
var1 = rnorm(n, 10, 5),
var2 = rnorm(n, 20, 8)
)
# Make messy:
raw$var1[sample(1:n, 10)] <- -999 # missing code
raw$var2[sample(1:n, 15)] <- NA
raw$site[raw$site == "East"] <- " east " # whitespace and case
# Duplicate some rows
raw <- bind_rows(raw, raw[sample(1:n, 5), ])
Your task is to write a single, long pipeline that:
1. Trims whitespace from site and converts to title case (use str_to_title from stringr,
or tools::toTitleCase).
2. Replaces -999 in var1 with NA.
3. Removes any rows where both var1 and var2 are NA.
4. Removes duplicate rows based on all columns (use distinct()).
5. Groups by site and adds a column n_per_site = n() (using mutate after grouping,
not summarise).
6. Creates a new column var1_z which is the z-score of var1 within each site ((var1 -
mean(var1, [Link]=TRUE)) / sd(var1, [Link]=TRUE)).
7. Ungroups, then selects only id, site, date, var1, var1_z, var2.
8. Arranges by site then date.
Print the first 20 rows.
Also, at the end, use anti_join() to check if there are any ids that were completely
removed (i.e., list the original IDs that are not present in the cleaned dataset). Print those
IDs.
Chapter 7: Data Import/Export – Feeding the Geospatial Pipeline
No analysis begins in a vacuum. Every geospatial project starts with data—a CSV file of field
measurements, an Excel spreadsheet of sensor metadata, a shapefile downloaded from a
government portal, a satellite image retrieved from a cloud API. And every project ends with
output: a cleaned dataset saved for a collaborator, a raster written to a GeoTIFF, a statistical
model stored for future use. This chapter teaches you to move data between R and the outside
world with confidence and efficiency. We begin with the readr and readxl packages for
importing and exporting tabular data (CSV, Excel) into tidyverse-ready tibbles. We then
introduce the [Link] package for handling large text files that would choke readr. Next, we
explore programmatic access to web APIs and open-data portals—a critical skill for modern
spatial data science, where data is often served over HTTP rather than downloaded manually.
Finally, we cover base R’s I/O functions and R-native binary formats (.RData, .rds) for saving
and restoring R objects. Throughout, the emphasis is on reproducibility, encoding, data types,
and the early detection of import errors that can silently corrupt a geospatial analysis. By the
end, you will be able to design a fully scripted, automated data-ingestion pipeline that reads
from multiple sources, validates the input, and writes clean, documented output to disk.
7.1 Reading and Writing Tabular Data (CSV, Excel) with readr and readxl
The most common data interchange format in environmental science is the plain-text table: CSV
(comma-separated values) and its relatives (TSV, semicolon-delimited, fixed-width).
The readr package (Wickham, Hester, & Bryan, 2023) provides functions that are faster, more
predictable, and more informative than base R’s [Link](). For Excel files, the readxl package
(Wickham & Bryan, 2023) reads both .xls and .xlsx without external dependencies. This section
covers the theory of text encoding and parsing, the core readr functions, the importance of
column type specification, and the basics of writing data back to disk.
7.1.1 The Theory of Textual Data Exchange
A CSV file is a stream of bytes on disk. For those bytes to become an R data frame, several
layers of interpretation must occur:
1. Encoding: The bytes must be decoded into characters according to a character encoding
(UTF-8, Latin-1, Windows-1252, etc.). Mismatched encoding is the single most common
source of garbled text in imported data.
2. Delimiter parsing: The character stream is split into fields by a delimiter (, ;, \t, |).
Quoted fields may contain the delimiter character; the parser must respect quoting rules.
3. Type inference: Each field is a string. R must decide whether it represents a number, a
date, a logical value, or a character. This inference can go wrong—for example, a column
of ZIP codes with leading zeros may be interpreted as numeric and the zeros stripped.
4. Missing value recognition: Certain strings (e.g., "NA", "", "NULL", "-999") must be
recognised as missing.
Base R’s [Link]() performs these steps with many historical defaults that are no longer optimal:
it converts character columns to factors (before R 4.0), it uses "." as decimal separator, and its
type guessing is less sophisticated. The readr package provides a modern alternative with
sensible defaults: it returns a tibble, never converts strings to factors, uses "." as decimal
separator and "," as thousands separator, and reports column types and parsing problems
explicitly.
7.1.2 Core readr Functions
read_csv() — comma-delimited.
read_csv2() — semicolon-delimited (common in European locales).
read_tsv() — tab-delimited.
read_delim() — arbitrary delimiter, specified by delim = "|", etc.
read_fwf() — fixed-width files.
read_log() — web server log files.
All share the same argument structure. The most important arguments are:
file: path to the file, or a URL.
col_names: TRUE (default, first row is header), FALSE (auto-name as X1, X2, …), or a
character vector of names.
col_types: a cols() specification that controls column types. If omitted, readr prints the
guessed types and any parsing failures.
na: a character vector of strings to interpret as NA (default c("", "NA")).
locale: controls encoding, decimal mark, date format, etc.
skip, n_max: for reading only part of a large file.
Example: Reading a simple CSV
r
library(readr)
# Create a temporary CSV for demonstration
write_lines(
"site,date,ph,temperature
A,2024-07-01,6.5,23.1
B,2024-07-01,6.8,24.0
C,2024-07-02,7.1,22.5",
file = "temp_sites.csv"
)
sites <- read_csv("temp_sites.csv")
sites
# A tibble: 3 × 4
# site date ph temperature
# <chr> <chr> <dbl> <dbl>
#1A 2024-07-01 6.5 23.1
#2B 2024-07-01 6.8 24
#3C 2024-07-02 7.1 22.5
Notice that date remains character. To parse it directly as a Date, we can use col_types:
r
sites <- read_csv(
"temp_sites.csv",
col_types = cols(
site = col_character(),
date = col_date(format = "%Y-%m-%d"),
ph = col_double(),
temperature = col_double()
)
)
sites
# date is now a Date.
col_date() accepts a format string; col_datetime() handles date-times. Other column type
specifiers: col_double(), col_integer(), col_logical(), col_factor(levels), col_skip() (skip a
column), col_guess() (default).
If you omit col_types, readr guesses based on the first 1000 rows. The guessed types are printed.
If guessing fails (e.g., a column contains mostly numbers but one text entry at row 5000), you
can set guess_max higher or specify col_types explicitly.
Parsing failures: When a cell cannot be converted to the expected type, readr replaces it
with NA and issues a warning. You can collect all failures with problems(). This is a critical
quality-control step: always run problems(sites) after reading a large, unfamiliar file.
7.1.3 Writing Data with readr
The counterpart functions are:
write_csv(x, file) — writes a CSV with UTF-8 encoding and no row names.
write_tsv(x, file) — tab-delimited.
write_excel_csv(x, file) — CSV with a byte order mark for Excel compatibility.
write_delim(x, file, delim = "|").
All are vectorised for writing to a single file; for writing to multiple files, use purrr::walk().
r
write_csv(sites, "cleaned_sites.csv")
Tibbles and data frames are written with column names as header. The default na argument
writes NA as an empty field; you can set na = "NA" to write the literal string "NA" for missing
values.
7.1.4 Reading Excel Files with readxl
The readxl package provides read_xlsx() and read_xls() for Excel files, and excel_sheets() to list
sheet names. The critical arguments mirror readr: col_names, col_types, na, skip, n_max.
r
library(readxl)
# Suppose we have "field_data.xlsx"
# field <- read_xlsx("field_data.xlsx", sheet = "Sheet1", col_types = c("text", "date", "numeric"))
readxl does not write Excel files; for that, use the writexl package or the openxlsx package. But
for geospatial data exchange, CSV is preferred because it is human-readable,
version-control-friendly, and platform-independent. Use Excel import only when you must, and
always write final outputs to plain-text formats.
7.1.5 Geospatial Context: Feeding the Attribute Table
Every vector GIS layer has an attribute table. When you cannot read a shapefile directly, you
may receive a CSV of points with latitude and longitude columns. You will read that CSV
with read_csv(), verify column types, and then convert it to an sf object with st_as_sf(coords =
c("lon", "lat"), crs = 4326). The success of that spatial conversion depends entirely on the
correctness of the data import: if lon was parsed as character because of a stray
space, st_as_sf will fail with an obscure error. The discipline of explicit column types, encoding
checks, and problems() is therefore not optional; it is the foundation of reliable geospatial
scripting.
7.1.6 Technical Demonstration
r
library(readr)
library(readxl)
# ---------- 1. Reading a CSV with explicit column types ----------
sample_csv <- "sample_data.csv"
write_lines(
"site,date,value
A,2024-07-01,12.3
B,2024-07-01,14.1
C,2024-07-02,9.8
D,2024-07-02,NA
E,2024-07-03,11.0",
sample_csv
)
df <- read_csv(sample_csv, col_types = cols(
site = col_character(),
date = col_date(format = "%Y-%m-%d"),
value = col_double()
))
df
problems(df) # should be empty
# ---------- 2. Reading an Excel sheet (simulated) ----------
# Create a temporary Excel file for demonstration using writexl (if installed)
if (requireNamespace("writexl", quietly = TRUE)) {
writexl::write_xlsx(df, "[Link]")
df_xl <- read_xlsx("[Link]", col_types = c("text", "date", "numeric"))
print(df_xl)
}
7.2 Working with Large Text Files: [Link] Essentials
readr is fast enough for most environmental datasets. But when files grow to gigabytes—a
common occurrence with gridded climate data exported as CSV, or a national property database
—[Link]’s fread() function offers unparalleled speed and memory efficiency.
The [Link] package (Dowle & Srinivasan, 2023) provides a high-performance alternative
to [Link] with a concise syntax. This section is not a full [Link] tutorial; it focuses on
the fread()/fwrite() functions and enough of the [Link] syntax to perform essential geospatial
data operations on large tables.
7.2.1 Why fread() Is Faster
fread() is written in C and uses several optimizations:
It memory-maps the file rather than reading it line by line.
It guesses the delimiter and column types from the first few lines without reading the
whole file.
It parses numbers and dates using highly optimised routines.
It can read directly from compressed files (.gz, .bz2, .zip with a single file).
It can read directly from URLs and shell commands.
For a 1 GB CSV on a modern machine, fread() can be ten to one hundred times faster
than [Link]() and several times faster than read_csv(). The trade-off is that fread() returns
a [Link] (which is a [Link] with extra methods) rather than a tibble, but [Link] is fully
compatible with dplyr verbs (though [Link] has its own powerful syntax).
7.2.2 Using fread() and fwrite()
r
library([Link])
# Basic usage
dt <- fread("large_dataset.csv")
fread() auto-detects the separator, header, and column types. It prints a brief report of the number
of rows, columns, and the time taken.
Key arguments:
sep: override auto-detection, e.g., sep = "\t".
nrows: read only the first n rows.
select: a character vector of columns to keep (read only those).
drop: columns to drop.
colClasses: a character vector of column types, similar to col_types.
[Link]: strings to interpret as NA.
skip: number of lines to skip.
stringsAsFactors: FALSE by default in [Link].
verbose: TRUE to see the parsing details.
Writing: fwrite(dt, file) is the counterpart; it is similarly fast and handles large data gracefully.
Converting between [Link] and tibble: as_tibble(dt) and [Link](tb).
7.2.3 Basic [Link] Syntax for Geospatial Work
While we will continue using dplyr for most tasks, certain operations on large data are more
naturally expressed in [Link]’s concise syntax:
Filtering rows: dt[ph > 6.0]
Selecting columns: dt[, .(site, ph)] (the .() is an alias for list())
Creating new columns: dt[, ph_z := (ph - mean(ph)) / sd(ph)] — note the := operator,
which modifies the [Link] by reference (in place, without copying). This is extremely
efficient for adding columns to large tables.
Grouped summaries: dt[, .(mean_ph = mean(ph)), by = site]
The by-reference modification (:=) is one of [Link]’s greatest strengths for large geospatial
datasets: you can add a derived column to a 50-million-row table without duplicating the data.
However, it also means you must be careful not to accidentally modify your original data; when
working interactively, make a copy with copy(dt) before using :=.
Example: Processing a large soil database
r
dt <- fread("[Link]")
# Filter to topsoil, compute log(organic_carbon), and summarise by land use
dt[depth_cm == 0, .(log_oc = log(organic_carbon)), by = land_use]
# Add a column by reference
dt[, oc_per_ha := organic_carbon * bulk_density * depth_cm * 10]
We will not go deeper into [Link] syntax in this chapter—the dplyr grammar remains our
primary language—but having fread() and fwrite() in your toolbox is essential for large-scale
geospatial data engineering.
7.2.4 Geospatial Context: Big Point Clouds and Climate Tables
A LiDAR point cloud exported as CSV can have billions of rows. A global climate dataset with
daily temperature for 0.1° grid cells over 50 years has millions of rows. Neither can be handled
efficiently by [Link]() or even read_csv(). fread() allows you to read these, select only the
columns you need, filter rows by a key, and aggregate—all without loading the entire file into
memory at once if you use chunking (though fread() can memory-map, R still needs RAM for
the full object). For truly out-of-memory data, you would use [Link], arrow, or a database;
but fread() handles the “large but fits in RAM” category that forms the majority of field-station
and regional-scale data.
7.3 Importing Data from Web APIs and Open-Data Portals
An increasing amount of geospatial data is available only through web services: REST APIs,
OGC WFS/WCS services, cloud storage buckets, and open-data portals. Manual download is not
reproducible and does not scale. This section introduces programmatic data access in R:
downloading files with [Link]() and httr, reading directly from URLs, and accessing
common geospatial APIs (with a focus on the pattern rather than exhaustive coverage of every
API). We will also touch on the rnaturalearth package for natural-earth vector data, which you
have already used.
7.3.1 The Anatomy of a Web API Request
An API (Application Programming Interface) is a set of rules for requesting data from a server. A
RESTful API uses HTTP methods:
GET: retrieve data. Example: [Link]
POST: submit data (e.g., a spatial query).
An R script can send GET requests and receive data as CSV, JSON, XML, or binary.
The httr package (Wickham, 2023) provides GET(), POST(), and helpers for authentication and
headers.
Simple file download: [Link](url, destfile, mode = "wb") is sufficient for a static file
URL.
r
[Link](
"[Link]
destfile = "data/[Link]",
mode = "wb" # binary mode, essential on Windows to avoid corruption
)
rain <- read_csv("data/[Link]")
Reading directly from a URL: read_csv() and fread() can accept a URL as the file argument,
provided the URL returns raw CSV data.
r
# If the URL directly serves a CSV
data <- read_csv("[Link]
However, many APIs return JSON. The jsonlite package (Ooms, 2023) parses JSON into R lists,
which you can then convert to data frames.
r
library(httr)
library(jsonlite)
# GET request with query parameters
response <- GET("[Link]
content <- fromJSON(rawToChar(response$content))
The pattern is: GET → check http_status() → parse with jsonlite::fromJSON() → extract the
relevant list element → convert to tibble.
7.3.2 OGC Services: WFS and WCS
The Open Geospatial Consortium (OGC) defines standards for serving vector (Web Feature
Service, WFS) and raster (Web Coverage Service, WCS) data. In R, the httr package can
formulate WFS requests (e.g., GetFeature with a bounding box filter), but specialized packages
like ows4R (for WFS/WCS) and rstac (for STAC, SpatioTemporal Asset Catalogs) provide
higher-level interfaces. We will cover these in the spatial chapters; here we establish the principle
that any data accessible via a web service can be retrieved and loaded into R without ever
downloading a file manually.
7.3.3 The rnaturalearth Package: A Model of Open Data Access
You have used rnaturalearth::ne_countries(). This package programmatically downloads Natural
Earth vector data (a renowned open-source map dataset) and returns it as an sf object or
a sp object. It exemplifies the ideal: a single function call that goes to the web, retrieves the
requested layer, and loads it into memory.
r
library(rnaturalearth)
world <- ne_countries(scale = "medium", returnclass = "sf")
Behind the scenes, rnaturalearth uses [Link]() and a known URL pattern. You could
replicate this logic for any dataset that is consistently structured on a server.
7.3.4 Reproducibility and Caching
A script that downloads data from the internet is not reproducible if the server is down or the
dataset is updated. Best practices:
Cache the downloaded file: Check if the file already exists locally before downloading.
Record the URL and access date in comments.
Pin versions: Use a specific versioned URL if the provider supports it.
Use RDS (saveRDS/readRDS) to save a local copy of the parsed data for future use, so
you are not repeatedly downloading.
r
cache_file <- "data/[Link]"
if () {
world <- ne_countries(scale = "medium", returnclass = "sf")
saveRDS(world, cache_file)
} else {
world <- readRDS(cache_file)
}
This pattern ensures long-term reproducibility.
7.4 Base R I/O and Native Binary Formats (.RData, .rds)
While readr and [Link] excel at tabular data, R also has its own native formats for saving R
objects. These are essential for checkpointing the state of a long analysis, sharing intermediate
results, or preserving complex objects (lists, models, spatial features) that cannot be easily
serialised as CSV.
7.4.1 saveRDS() and readRDS(): Single-Object Serialization
saveRDS(object, file) writes a single R object to a binary file with
the .rds extension. readRDS(file) restores it. The object can be of any class: a vector, a data
frame, a list, an sf object, a fitted model, a terra SpatRaster.
r
# Save a complex object
model <- lm(ph ~ elevation, data = soil_data)
saveRDS(model, "output/ph_model.rds")
# Restore
model <- readRDS("output/ph_model.rds")
The .rds format is cross-platform (binary identical across Windows, macOS, Linux). It is the
preferred format for intermediate objects.
Geospatial use: You have performed a long spatial join that took 30 minutes. Save the
resulting sf object with saveRDS(result, "processed/joined_sites.rds"). In subsequent scripts, read
it and continue analysis without re-running the join.
7.4.2 save() and load(): Multiple Objects and Workspace Images
save(object1, object2, file = "[Link]") writes multiple named objects to a single
file. load(file) restores them into the global environment. This is the format that RStudio offers to
save on exit—the .RData file.
We strongly advise against using .RData for reproducible work. Loading an .RData file
silently overwrites existing objects and hides the provenance of the data. A script that begins
with load("[Link]") is opaque; you cannot know what variables it creates.
Use saveRDS/readRDS instead, assigning the result explicitly: my_data <- readRDS("[Link]").
7.4.3 Base R [Link]() and [Link]() Family
Base R provides [Link](), [Link](), [Link](), and their writing counterparts. We
recommend readr for new code because of its speed, consistent behaviour, and better error
reporting. However, you will encounter base I/O functions in legacy code and in many package
examples. For completeness:
r
# Base R CSV reading (not recommended for new code)
df <- [Link]("[Link]", stringsAsFactors = FALSE, [Link] = c("", "NA", "-999"))
Be aware that [Link]() converts characters to factors by default (in R < 4.0), uses "." as decimal
separator, and is slower. The stringsAsFactors = FALSE argument is essential.
Hard Practice 7 – “Automated Download and Parsing of a Public GIS Dataset”
Objective:
Build a fully automated pipeline that downloads a publicly available GIS dataset from the
internet, reads it into R, cleans and transforms the data, saves a local cached copy in .rds format,
and exports a tidy CSV for sharing. This practice integrates readr, fread, web download, and
native serialisation.
Scenario:
You need to analyse global land cover data. The EarthEnv project provides a CSV of land-cover
statistics by country (this is a hypothetical scenario; the real EarthEnv provides raster data). You
will simulate the download with a known URL to a small dataset, but the workflow should work
for any URL. You will:
1. Check if the file exists locally (cache).
2. If not, download it.
3. Read it using read_csv() or fread().
4. Clean column names, handle missing values, convert types.
5. Compute summary statistics per continent.
6. Save the cleaned data as .rds.
7. Write the summary to a CSV.
Instructions:
1. Create a new script practice_chapter7_import.R. Add a header.
2. Simulate a download URL: Use the
URL [Link]
2020/2020-01-21/spotify_songs.csv as a substitute for a real GIS dataset. This is a freely
available CSV of Spotify song features; we will treat it as if it were environmental data.
3. Define a caching function cached_download(url, destfile) that:
o If destfile does not exist, downloads from url using [Link](mode = "wb").
o Returns the path to the local file.
o Prints a message indicating whether the file was downloaded or already cached.
4. Download the dataset using your function, storing the result in data/raw_spotify.csv.
5. Read the file with read_csv() (or fread()). Use col_types to specify
that track_id, track_name, track_artist, and playlist_genre are character; all numeric
columns should be read as double; track_album_release_date as date (format "%Y-%m-
%d"). Check for parsing problems with problems().
6. Clean:
o Rename playlist_genre to genre.
o Select only the
columns: track_id, track_name, genre, danceability, energy, loudness, valence, te
mpo, duration_ms.
o Replace missing genre (if any) with "Unknown".
o Filter out rows with duration_ms less than 60000 (songs shorter than 1 minute;
we'll pretend these are invalid samples).
7. Compute grouped summary: Group by genre, and summarise:
o mean_energy = mean(energy, [Link] = TRUE)
o mean_valence = mean(valence, [Link] = TRUE)
o n = n()
Arrange by descending n.
8. Save the cleaned data to data/cleaned_spotify.rds using saveRDS().
9. Write the summary to output/genre_summary.csv using write_csv().
10. Verify: In a separate section (not within the pipeline), read back the .rds file and the CSV,
and confirm they match the original objects.
11. Reflection:
o Why is caching important for reproducibility?
o What are the advantages of saveRDS over write_csv for intermediate R objects?
o When would you use fread() instead of read_csv()?
Deliverable:
The script practice_chapter7_import.R with full pipeline, comments, and verification section.
Chapter 7 Review Problems
These problems test your ability to import, export, and manage data from various sources using
the tools of Chapter 7. Solve each in a clearly commented script. Load the required packages
(readr, readxl, [Link], httr, jsonlite) as needed. Use tidyverse style where appropriate.
Easy Problems (1–5)
1. Read a CSV with Column Type Specification
Create a temporary CSV file with the following content:
text
id,name,score,date
1,Alice,92.5,2024-06-15
2,Bob,88.3,2024-06-16
3,Charlie,95.0,2024-06-17
Read it with read_csv(), specifying that id is integer, name is character, score is double,
and date is date. Print the resulting tibble and its str().
2. Write a Tibble to CSV and Read It Back
Using the tibble from Problem 1, write it to a CSV file with write_csv(). Then read that file back
with read_csv(). Verify that the two tibbles are identical (use [Link]()).
3. Read an Excel Sheet
Create a small Excel file (use writexl::write_xlsx if available, or skip with a comment if not)
with two sheets, each containing a simple table. Read the second sheet using read_xlsx(sheet =
2). Print the result.
4. Download a File from a URL
Use [Link]() to download the CSV
at [Link]
spotify_songs.csv to a temporary file. Read it with read_csv() and print the number of rows and
columns.
5. Save and Load an RDS
Create a list results <- list(model = "linear", coefficients = c(1.2, -0.5, 0.3), fitted = TRUE). Save
it with saveRDS() to a temporary file, then load it back with readRDS(). Print the loaded list.
Medium Problems (6–10)
6. Read a Messy CSV with NA Strings
Create a CSV with missing value codes:
text
site,pH,moisture
A,6.5,22
B,-999,18
C,7.1,-999
D,NA,25
Read it with read_csv() using the na argument to treat "-999" and "NA" as missing. Print the
cleaned tibble. Compute the mean pH and moisture ignoring NAs.
7. Large File Simulation with fread()
Generate a large data frame of 100,000 rows and 5 columns of random numbers. Write it to a
CSV using fwrite(). Then read it back with fread(). Compare the time taken (use [Link]())
and report the speed. Print the first few rows.
8. API JSON Parsing
Use httr::GET() to fetch data from [Link] (a public GitHub API).
Parse the JSON response with jsonlite::fromJSON(). Extract and print
the login and public_repos fields. (This is a non-spatial API, but the pattern is identical.)
9. Cache a Download
Write a function cached_read_csv(url, cache_path) that checks if cache_path exists; if not,
downloads from url and saves to cache_path; then reads and returns the CSV from the cached
file. Test it with the Spotify songs URL from Problem 4. Run it twice and note that the second
time does not download.
10. Base R vs. readr Comparison
Create a small CSV with a column of integers that include a leading zero (e.g., "001", "002").
Read it with [Link]() and with read_csv(). Compare how the integer column is treated. Explain
the difference in a comment.
Challenging Problems (11–15)
11. Multi-File Ingestion Pipeline
Simulate a directory with three CSV files representing monthly monitoring data (same columns).
Write a script that:
Lists all .csv files in the directory using [Link](pattern = "\\.csv$").
Reads each file with read_csv() into a list of tibbles using lapply().
Combines them with bind_rows().
Writes the combined dataset to a single CSV.
Saves the combined data also as an .rds file.
Print the number of rows in the combined dataset.
12. Handling Encoding Problems
Create a character vector containing non-ASCII characters (e.g., "München", "São Paulo", "北京").
Write it to a CSV file with write_csv() (which uses UTF-8 by default). Read it back
with read_csv(). Now, intentionally read it with [Link](..., fileEncoding = "UTF-8") and
compare. Then try [Link](..., fileEncoding = "latin1") and observe the garbled output.
Comment on why encoding matters for geospatial place names.
13. Streaming API Response (Simulated)
Simulate a paginated API: create a function get_page(page) that returns a tibble of 20 rows of
random data with a page column. Write a loop that calls get_page(1), get_page(2), …, until an
empty page is returned (simulate that get_page(5) returns an empty tibble). Accumulate all pages
with bind_rows(). Print the total number of rows collected. This mimics fetching a large dataset
from a paginated spatial data API.
14. Read a Fixed-Width File
The World Meteorological Organization’s station data is sometimes distributed as fixed-width
files. Simulate a fixed-width file:
text
001ALICE 2024 36.5
002BOB 2024 34.2
003CHARLIE2024 35.9
with columns: id (3 chars), name (8 chars), year (4 chars), temperature (4 chars).
Use read_fwf() with fwf_positions() (or fwf_widths()) to parse it. Convert year to integer
and temperature to numeric. Print the tibble.
15. End-to-End Geospatial Data Ingestion
Simulate the download and processing of a CSV of point coordinates and attributes:
Create a CSV on disk with columns id, lon, lat, value, date, and 100 rows of random data.
Write a pipeline that reads the CSV, filters out any rows with missing coordinates,
converts date to Date, and saves it as an RDS.
Then load the RDS and, using base R only (no sf), compute the mean longitude and
latitude, and write the summary to a CSV file.
Finally, read the summary CSV back and print it.
Chapter 8: The Grammar of Graphics with ggplot2 – From Scatterplots to Cartography
A map is a plot of spatial data. A scatterplot of GDP against life expectancy is a plot of tabular
data. A histogram of slope angles is a plot of a single variable. All these visual displays share a
common underlying structure: data are mapped to visual properties (position, colour, size,
shape) through geometric objects (points, lines, bars, polygons). The ggplot2 package
(Wickham, 2016) implements this structure as a formal grammar of graphics, allowing you to
build plots layer by layer, from data to polished figure. This chapter teaches that grammar from
the ground up, using geospatially inspired examples throughout. We begin with the fundamental
mapping of variables to aesthetics, then progress through geometric layers, faceting for small
multiples, themes and colour scales for cartographic polish, statistical transformations, and
finally the construction of publication-ready figures. Every concept is linked directly to
map-making with ggplot2 and its spatial extensions (ggspatial, sf plotting). By the end, you will
be able to produce publication-quality scatterplots, histograms, time-series lines, and—crucially
—thematic maps that are ready for a journal or a report.
8.1 Mapping Data to Aesthetics and Geometries
8.1.1 The Grammar: Data, Aesthetics, Geometries
Wilkinson’s Grammar of Graphics (2005) defines a plot as a mapping from data to aesthetic
attributes of geometric objects. In ggplot2, this is expressed as:
r
ggplot(data = <DATA>) +
<GEOM_FUNCTION>(mapping = aes(<AESTHETICS>))
Data: a data frame (or tibble, or sf object). Every column is a variable.
Aesthetics (aes()): mappings from variable names to visual
properties: x, y, colour, fill, size, shape, alpha (transparency), linetype, group.
Geometric object (geom_*()): the visual representation: geom_point() for
points, geom_line() for lines, geom_bar() for bars, geom_histogram() for
histograms, geom_sf() for spatial features.
The beauty of the grammar is that the same aesthetic mapping can be combined with different
geometries to produce profoundly different views of the same data. A scatterplot of x = lon, y =
lat becomes a map when we use geom_sf() instead of geom_point(). The underlying logic is
identical.
8.1.2 Your First ggplot: A Scatterplot
r
library(ggplot2)
# Simple data frame of field samples
samples <- [Link](
lon = c(116.40, 116.45, 116.50, 116.42, 116.48),
lat = c(39.90, 39.92, 39.88, 39.91, 39.93),
ph = c(6.5, 7.0, 6.8, 5.9, 7.2)
)
# Base plot with data and aesthetic mapping
ggplot(data = samples, mapping = aes(x = lon, y = lat)) +
geom_point()
This creates a scatterplot of the five points in geographical space. ggplot() initialises the plot
object; geom_point() adds a point layer. The aesthetics x and y are taken from the
columns lon and lat.
Adding colour and size:
r
ggplot(samples, aes(x = lon, y = lat, colour = ph, size = ph)) +
geom_point()
Now each point’s colour and size represent the pH value. The mapping is declared in aes(),
and ggplot2 automatically scales the colour to a continuous gradient and the size to a continuous
range.
8.1.3 Global vs. Local Aesthetic Mappings
Aesthetics defined in ggplot() are global: they apply to all subsequent layers. Aesthetics defined
inside a specific geom_*() are local to that layer.
r
ggplot(samples, aes(x = lon, y = lat)) + # global x, y
geom_point(aes(colour = ph), size = 3) + # local colour
geom_point(shape = 1, size = 4, colour = "red") # fixed aesthetics, not mapped
Fixed aesthetics are set outside aes(). Mapped aesthetics are set inside aes(). Confusing the two
is a common beginner error.
8.1.4 The ggplot() Function and the + Operator
ggplot() returns a ggplot object. Layers, scales, themes, and coordinate systems are added with +.
The object is only rendered when printed (or explicitly plot()ed). This allows incremental
construction and saving of plots to variables.
r
p <- ggplot(samples, aes(x = lon, y = lat))
p <- p + geom_point(aes(colour = ph))
p # prints the plot
You can add a geom_sf() layer to plot spatial vector data directly from an sf object. We will
explore this in Section 8.2.
8.1.5 Geospatial Context: Coordinates Are Aesthetics
In a map, x = longitude, y = latitude (or easting and northing). ggplot2 treats these like any other
continuous variable. For a proper map with a fixed aspect ratio and coordinate system, we
use coord_sf() (for sf objects) or coord_fixed() (for raw lon/lat). But the fundamental principle
holds: a map is a scatterplot with a spatial coordinate system. This unification is the key insight
of the grammar of graphics applied to cartography.
8.2 Layering: Points, Lines, Polygons, and Text
A plot is built up from layers. Each layer is a geometric object with its own data, aesthetic
mapping, and optional statistical transformation. This section explores the major geoms relevant
to geospatial science.
8.2.1 Points: geom_point()
Points are used for field samples, cities, earthquake epicentres, and any discrete location. Control
aesthetics: shape, size, colour, alpha.
r
ggplot(samples, aes(x = lon, y = lat)) +
geom_point(aes(colour = ph), size = 4, alpha = 0.7)
shape can be a number (0-25) or a character (e.g., "+"). alpha ranges from 0 (transparent) to 1
(opaque); useful for overplotting.
8.2.2 Lines: geom_line(), geom_path()
Lines connect observations in order. geom_line() connects by x order; geom_path() connects by
row order. Use geom_path() for GPS tracks, and geom_line() for time series or profiles.
r
# Simple transect: elevation along a line
transect <- [Link](
distance = 1:10,
elevation = c(100, 120, 115, 140, 160, 155, 180, 200, 190, 210)
)
ggplot(transect, aes(x = distance, y = elevation)) +
geom_line(colour = "darkgreen", linewidth = 1) +
geom_point()
For paths with a group aesthetic (multiple transects), set group in aes().
8.2.3 Polygons and Filled Areas: geom_polygon(), geom_rect(), geom_ribbon()
geom_polygon() draws filled polygons, specified by vertices and a group aesthetic that identifies
each polygon. This is the basis for plotting administrative boundaries, land parcels, or convex
hulls. The fill aesthetic controls interior colour.
r
# A simple square
square <- [Link](
x = c(0, 1, 1, 0),
y = c(0, 0, 1, 1),
group = 1
)
ggplot(square, aes(x = x, y = y)) +
geom_polygon(fill = "lightblue", colour = "black")
geom_ribbon() and geom_area() are specialised for plotting confidence bands and stacked areas.
8.2.4 Text and Labels: geom_text(), geom_label()
Add text to a plot with geom_text(aes(label = variable)). geom_label() adds a background
rectangle. Avoid overplotting by using check_overlap = TRUE or the ggrepel package.
r
ggplot(samples, aes(x = lon, y = lat, label = round(ph, 1))) +
geom_point() +
geom_text(nudge_y = 0.01)
8.2.5 Spatial Layers with geom_sf()
The sf package provides geom_sf() for plotting spatial vector data directly. It automatically uses
the geometry column and applies a suitable coordinate system. This is the primary mapping
geometry.
r
library(sf)
world <- rnaturalearth::ne_countries(scale = "small", returnclass = "sf")
ggplot(data = world) +
geom_sf(fill = "lightyellow", colour = "grey50") +
theme_minimal()
geom_sf() respects the aes() mappings for fill, colour, size, etc. For example, a choropleth map
of population:
r
ggplot(world) +
geom_sf(aes(fill = pop_est), colour = "white", linewidth = 0.1) +
scale_fill_viridis_c() +
theme_minimal()
Layering with geom_sf() works exactly like any other geom: you can add geom_sf() layers for
different datasets, e.g., country boundaries and city points.
8.3 Faceting, Themes, and Custom Color Scales
8.3.1 Faceting: Small Multiples
facet_wrap(~ variable) creates a separate panel for each level of a factor, arranged in a
rectangular grid. facet_grid(rows ~ cols) creates a grid of panels by two variables.
r
# Facet by land cover type
ggplot(soil_data, aes(x = elevation, y = ph)) +
geom_point() +
facet_wrap(~ land_cover)
Faceting is the ggplot2 equivalent of making multiple maps for different regions or time periods.
Combined with geom_sf(), facet_wrap(~ year) creates a time series of maps.
8.3.2 Themes: Controlling Non-Data Appearance
ggplot2 includes several built-in themes
(theme_bw(), theme_minimal(), theme_classic(), theme_void()) that adjust background, grid
lines, fonts, and legend position. For publication, theme_bw() or theme_minimal() with custom
adjustments via theme() is standard.
r
p + theme_bw(base_size = 12) +
theme(
[Link] = "bottom",
[Link] = element_blank(),
[Link] = element_text(face = "bold", hjust = 0.5)
)
The theme() function gives access to every visual element: axis text, legend key, panel border,
margin. Mastering theme() is essential for publication-quality cartography.
8.3.3 Colour Scales: Viridis and Brewer
Colour is the most expressive aesthetic. ggplot2 provides scale functions for every aesthetic:
Continuous fill/colour: scale_fill_viridis_c(), scale_colour_viridis_c() — perceptually
uniform, colourblind-safe.
Discrete fill/colour: scale_fill_brewer(palette = "Set1"), scale_fill_viridis_d().
Custom manual: scale_fill_manual(values = c("Forest" = "darkgreen", "Urban" =
"grey")).
For maps, scale_fill_viridis_c() is the default recommendation for continuous rasters and
choropleths. For categorical land cover, use a palette from RColorBrewer or viridis.
r
ggplot(world) +
geom_sf(aes(fill = pop_est)) +
scale_fill_viridis_c(option = "magma", trans = "log10") +
theme_void()
8.4 Statistical Transformations and Position Adjustments
Many geoms perform statistical transformations automatically. For
example, geom_histogram() bins the data and plots counts; geom_density() computes a kernel
density estimate; geom_smooth() fits a LOESS or linear model. Understanding these
transformations is key to avoiding misinterpretation.
8.4.1 Built-in Stats
stat_bin() (called by geom_histogram) — divides x into bins, counts per bin.
stat_density() — kernel density.
stat_ecdf() — empirical cumulative distribution.
stat_smooth() — smooth line with confidence band.
stat_summary() — summarise y at unique x.
All can be called directly or through
their geom_* counterparts. geom_histogram() is stat_bin() with bars.
r
ggplot(soil, aes(x = ph)) +
geom_histogram(bins = 20, fill = "steelblue") +
stat_density(aes(y = after_stat(count) * 0.5), colour = "red") # overlaid density
8.4.2 Position Adjustments
In bar charts and histograms, position controls how overlapping objects are
arranged: "stack", "dodge", "fill", "identity". position = "dodge" places bars
side-by-side; "fill" shows proportions.
r
ggplot(soil, aes(x = land_cover, fill = soil_type)) +
geom_bar(position = "dodge")
For points, position_jitter() adds random noise to avoid overplotting.
8.4.3 Statistical Transformations in Spatial Context
stat_sf_coordinates() (from sf) extracts centroid coordinates for labelling or
plotting. geom_sf() itself does no statistical transformation; it plots raw geometries. However,
when you combine geom_sf() with stat_density_2d() on the coordinate columns, you can create a
heatmap of point density—a standard spatial pattern analysis.
8.5 Building Publication-Ready Plots
A publication-ready plot is more than a correct graphical representation; it is self-contained,
labelled, annotated, and exported at the correct resolution and size.
8.5.1 Titles, Labels, and Annotations
Use labs() to set titles, axis labels, and legend titles.
r
p + labs(
title = "Soil pH across the study area",
subtitle = "Field campaign 2024",
x = "Longitude", y = "Latitude",
fill = "pH"
)
Annotations with annotate("text", x, y, label) or annotate("rect") add custom elements. For
spatial data, ggspatial::annotation_scale() and annotation_north_arrow() add scale bars and north
arrows.
8.5.2 Coordinate Systems: coord_fixed() and coord_sf()
coord_fixed(ratio = 1) ensures that one unit on x equals one unit on y, essential for maps.
For sf objects, coord_sf() automatically handles the coordinate reference system and aspect
ratio. coord_sf(crs = 4326) can reproject on the fly.
r
ggplot(world) +
geom_sf() +
coord_sf(crs = "+proj=robin") # Robinson projection
8.5.3 Saving Plots
ggsave("[Link]", plot = p, width = 6, height = 4, dpi = 300) saves the last displayed plot
(or a specified one). PDF is vector-based and preferred for publication; PNG for rasterised
output. Always specify width and height.
r
ggsave("figures/soil_ph_map.pdf", width = 8, height = 6, dpi = 600)
Hard Practice 8 – “Recreating a Scientific Figure from a Geomorphology Paper”
Objective:
Use ggplot2 to reproduce a multi-panel figure that includes a scatterplot, a histogram, and a
small map, all with consistent styling. This integrates layering, faceting, themes, and custom
colour scales.
Scenario:
You are given a dataset geomorph with
columns: slope_deg, curvature, elevation_m, landform (factor: Ridge, Slope, Valley), lon, lat (for
a small area). Create a 2×2 panel figure:
1. Top-left: scatterplot of slope_deg vs curvature, coloured by landform.
2. Top-right: histogram of slope_deg, filled by landform (dodge).
3. Bottom-left: map of the sampling points with geom_point() (or geom_sf() if you convert
to sf), coloured by elevation_m.
4. Bottom-right: boxplot of curvature by landform.
Use a unified theme, a single colour scale (e.g., viridis), and add a figure caption
using labs(). Save the final figure as a PDF.
Instructions:
1. Generate the synthetic dataset with [Link](42), n <-
200 points, slope_deg from rnorm(200, 15, 8), curvature from runif(200, -1,
1), elevation_m from runif(200, 1000, 2000), landform sampled from
c("Ridge","Slope","Valley"), and coordinates randomly within a 0.1° box.
2. Convert to sf object for the map panel (use st_as_sf(coords = c("lon","lat"), crs = 4326)).
3. Create the four plots individually using ggplot2 and assign them to variables.
4. Combine them into a single figure using the patchwork package (p1 + p2 + p3 + p4 +
plot_layout(ncol = 2)). (Install if needed; base R grid combine is possible but patchwork
is simpler.)
5. Apply a unified theme_bw() with 10-point base font, no minor grid lines, and legend at
bottom.
6. Use scale_colour_viridis_d() for discrete and scale_fill_viridis_c() for continuous as
appropriate.
7. Add a title "Geomorphological Survey 2024" and subtitles to individual panels
using labs().
8. Save with ggsave("geomorph_figure.pdf", width = 10, height = 8).
Deliverable:
A script practice_chapter8_ggplot.R that generates the data and the final figure.
Chapter 8 Review Problems
These problems test your ggplot2 skills on both tabular and spatial data. Use ggplot2 and sf as
needed. Aim for polished, publication-style output.
Easy Problems (1–5)
1. Basic Scatterplot
Create a data frame df <- [Link](x = 1:10, y = (1:10)^2). Plot y vs x with points and a
smooth line (geom_smooth(method = "lm")). Add axis labels and a title.
2. Histogram
Using df <- [Link](value = rnorm(1000)), create a histogram of value with 30 bins, filled in
steelblue. Add a vertical dashed red line at the mean.
3. Bar Chart
Create a frequency table of land cover: land_cover <- sample(c("Forest","Urban","Water"), 100,
replace = TRUE). Make a bar chart with geom_bar() and a light green fill.
4. Simple Map
Load world <- rnaturalearth::ne_countries(scale = "small", returnclass = "sf"). Plot all countries
with geom_sf(), fill colour "lightgoldenrod", border "grey60". Use theme_void().
5. Custom Theme
Take the histogram from Problem 2 and apply theme_bw() with customisations: remove panel
grid, set axis title size to 14, legend position to "none".
Medium Problems (6–10)
6. Multi-Layer Map
Using the world dataset, create a map that:
Fills countries by population (pop_est) on a log10 colour scale (viridis).
Adds borders in white, linewidth 0.2.
Adds a title, and places the legend at the bottom.
Uses a Robinson projection (coord_sf(crs = "+proj=robin")).
7. Faceted Scatterplot
Using the built-in iris dataset, create a scatterplot of [Link] vs [Link], coloured
by Species. Facet by Species using facet_wrap(). Add a linear smooth to each panel.
8. Boxplot with Overlaid Points
Generate soil pH data for three land-use types (20 each). Create a boxplot
of ph by land_use with geom_boxplot([Link] = NA) and overlaid jittered points
(geom_jitter(width = 0.1, alpha = 0.5)). Colour points by land_use.
9. Time Series Line
Create monthly temperature data for two years: date <- seq([Link]("2023-01-01"),
[Link]("2024-12-31"), by="month"), temp <- 15 + 10*sin(2*pi*(1:24)/12) + rnorm(24).
Plot temp against date as a line with points, coloured by year (derived from format(date,"%Y")).
Use scale_x_date().
10. Map with Points
Generate 50 random points within China's bounding box (lon ~73–135, lat ~18–54). Create
an sf object. Load the world map, filter to China. Plot China in grey, overplot the points in red.
Add a north arrow and scale bar using ggspatial.
Challenging Problems (11–15)
11. Choropleth with Custom Breaks
Load world, filter out Antarctica. Create a choropleth of GDP per capita (gdp_md_est / pop_est),
using custom breaks: 0, 1e3, 1e4, 1e5, Inf, with labels "<1k", "1k-10k", "10k-100k", ">100k".
Use scale_fill_manual() with a hand-picked colour palette. Add a title and clean theme.
12. Density Ridges
Generate 1000 random values from three normal distributions with different means. Use
the ggridges package to create a ridge plot (geom_density_ridges()). Map the distribution group
to fill and use a viridis palette.
13. Animated Time Series Map
This requires the gganimate package. Create a small data frame of 5 cities with coordinates, and
for each year 2010:2020, simulate a population value. Use geom_sf for country boundaries,
and geom_point for cities, with point size = population, faceted? No, animate over year
using transition_time(year). Render a GIF. (Install gganimate and gifski.)
14. Custom Stat for Spatial Autocorrelation
Not a plot, but an extension: Write a function that, given a vector of values and a spatial weights
matrix, computes local Moran's I and adds it as a column. Then create a map of local Moran's I
using ggplot2. (This combines spatial statistics and plotting.)
15. Reproduce a Journal Map
Find a published map in a geomorphology or remote sensing paper (open access). Attempt to
reproduce it as closely as possible using ggplot2 and sf, using publicly available data. Document
the data source, the original figure, and your reproduction in a short R Markdown document.
This open-ended problem exercises research and plotting skills.
Chapter 9: Descriptive and Inferential Statistics for Spatial Data
A map displays where things are. Statistics tells us whether the patterns we see are real or the
product of chance. The heights of a digital elevation model, the pH values of soil samples, the
NDVI of a satellite scene—all are variables whose distributions, central tendencies, and
relationships we must describe before we can model, interpolate, or classify. This chapter builds
the statistical foundation for spatial analysis. We begin with descriptive statistics—measures of
centre, spread, and shape—and learn to compute them efficiently on vectors, matrices, and
grouped data. We then introduce probability distributions, the mathematical models from which
random samples are drawn, and we learn to generate synthetic spatial data with rnorm, runif,
and friends. With distributions understood, we turn to inferential statistics: confidence intervals,
the t-test and Wilcoxon test for comparing means, and the chi-squared test for categorical
associations. Finally, we cover bootstrapping and resampling methods—the modern,
computational approach to inference that frees us from strict parametric assumptions and is
especially valuable in spatial contexts where independence cannot be taken for granted. Every
concept is illustrated with geospatial data: comparing slope angles between two watersheds,
testing whether landslide densities differ by geology, constructing a bootstrap confidence
interval for mean contaminant concentration. By the end, you will be equipped to explore,
describe, and draw statistically valid conclusions from your spatial datasets, laying the
groundwork for the spatial statistics of Part IV.
9.1 Measures of Central Tendency, Dispersion, and Shape
Before we can ask “is the difference between these two landscapes statistically significant?” we
must be able to answer “what is the typical value, how spread out are the data, and what shape is
the distribution?”. This section formalises the three families of descriptive statistics, always with
a view toward their interpretation in geospatial contexts.
9.1.1 Central Tendency: Mean, Median, Mode
The mean (arithmetic average) is the most common measure of central tendency. For a numeric
vector x,
xˉ=1n∑i=1nxixˉ=n1i=1∑nxi
In R, mean(x, [Link] = TRUE). The mean is sensitive to outliers: a single extreme elevation (e.g.,
a mountain peak in a valley transect) can shift it substantially. In such cases, the median is more
robust.
The median is the value below which 50% of observations lie. median(x, [Link] = TRUE). It is
unaffected by extremes and is the preferred centre for skewed distributions—common in
contaminant concentrations, precipitation, and slope angles.
The mode is the most frequently occurring value. R does not have a built-in mode() function for
this purpose (base R’s mode() returns the storage type). For a factor or character vector,
use names([Link](table(x))). For continuous data, the mode is rarely used; it finds use with
categorical land-cover classes.
Geospatial usage: Mean elevation of a catchment, median soil carbon, modal land-cover class.
9.1.2 Dispersion: Variance, Standard Deviation, IQR, Range
Variance measures the average squared deviation from the mean:
s2=1n−1∑i=1n(xi−xˉ)2s2=n−11i=1∑n(xi−xˉ)2
var(x, [Link] = TRUE). The units of variance are the square of the data units (e.g., m²), making
interpretation difficult.
Standard deviation is the square root of variance, and has the same units as the data:
s=s2s=s2
sd(x, [Link] = TRUE). It is the most commonly reported measure of spread.
Interquartile Range (IQR) is the difference between the 75th and 25th percentiles: IQR(x,
[Link] = TRUE). It is robust to outliers. The five-number summary (summary(x)) provides
minimum, Q1, median, Q3, maximum.
Range: range(x) returns c(min, max); diff(range(x)) gives the spread. Highly sensitive to
outliers.
Geospatial usage: Standard deviation of slope angles indicates terrain roughness. IQR of NDVI
characterises spatial variability of vegetation.
9.1.3 Shape: Skewness and Kurtosis
Skewness measures asymmetry of the distribution. Positive skew: tail to the right (e.g.,
precipitation, where most days are dry but a few are torrential). Negative skew: tail to the left
(rare in environmental data). Base R does not include skewness(); use moments::skewness() or
compute it:
g1=1n∑(xi−xˉ)3(1n∑(xi−xˉ)2)3/2g1=(n1∑(xi−xˉ)2)3/2n1∑(xi−xˉ)3
Kurtosis measures the “tailedness” of a distribution relative to the normal. High kurtosis means
heavy tails (more extreme values). moments::kurtosis().
Geospatial usage: Skewness of elevation distributions can indicate dominant geomorphic
processes. Heavy-tailed kurtosis in rainfall data signals flood risk.
9.1.4 Technical Demonstration
r
# Simulate elevation data for two catchments
[Link](1)
catchment_A <- rnorm(100, mean = 500, sd = 150)
catchment_B <- c(rnorm(90, mean = 500, sd = 150), rnorm(10, mean = 900, sd = 50)) #
contaminated with peaks
# Central tendency
mean(catchment_A) ; median(catchment_A)
mean(catchment_B) ; median(catchment_B) # mean shifted by peaks, median stable
# Dispersion
sd(catchment_A) ; IQR(catchment_A)
sd(catchment_B) ; IQR(catchment_B)
# Shape
library(moments)
skewness(catchment_A) ; kurtosis(catchment_A)
skewness(catchment_B) ; kurtosis(catchment_B)
Observe that catchment_B has higher skewness and kurtosis, reflecting the outlying peaks.
9.2 Probability Distributions and Random Number Generation
Probability distributions are mathematical functions that describe the likelihood of different
outcomes. In R, every distribution is represented by four functions: d (density/probability
mass), p (cumulative distribution), q (quantile), and r (random generation). Understanding these
allows you to simulate spatial data, assess statistical models, and compute probabilities.
9.2.1 The Normal Distribution: rnorm, dnorm, pnorm, qnorm
The normal (Gaussian) distribution N(μ,σ2)N(μ,σ2) is the foundation of classical statistics. Many
environmental variables approximate normality after transformation.
rnorm(n, mean = 0, sd = 1) — generate n random values.
dnorm(x, mean, sd) — density at x (height of the bell curve).
pnorm(q, mean, sd) — probability P(X≤q)P(X≤q).
qnorm(p, mean, sd) — quantile: the value x such that P(X≤x)=pP(X≤x)=p.
Example: What is the probability that a randomly selected annual precipitation value from a
station with mean 800 mm and sd 150 mm is below 600 mm?
r
pnorm(600, mean = 800, sd = 150) # ~0.091 (9.1% chance)
9.2.2 The Uniform Distribution: runif
All values in a range are equally likely. Used for simulating coordinates, random sampling, and
Monte Carlo integration.
r
runif(100, min = 0, max = 1) # 100 random values in [0,1]
9.2.3 Other Distributions Important for Spatial Data
Log-normal: rlnorm(n, meanlog, sdlog). Models particle size, contaminant
concentrations, and precipitation amounts (positive, right-skewed).
Exponential: rexp(n, rate). Models inter-arrival times of earthquakes, distance to the
nearest tree.
Poisson: rpois(n, lambda). Models counts (e.g., number of species per plot, number of
landslides per cell).
Binomial: rbinom(n, size, prob). Models number of successes in size trials (e.g., number
of contaminated wells out of size total).
Gamma: rgamma(n, shape, rate). Flexible right-skewed distribution for precipitation and
waiting times.
9.2.4 Setting the Seed for Reproducibility
[Link](integer) initialises the random number generator so that subsequent calls to r* functions
produce identical results across sessions. This is mandatory for reproducible research.
9.2.5 Geospatial Simulation Examples
1. Simulating a DEM: elev <- rnorm(n_cells, mean = 800, sd = 200).
2. Simulating species occurrence: presence <- rbinom(n_sites, 1, prob = plogis(2 + 0.5 *
elevation)).
3. Simulating earthquake epicentres: lon <- runif(n, 120, 130); lat <- runif(n, 30, 40).
9.2.6 Technical Demonstration
r
[Link](42)
# Simulate 1000 soil pH values from a normal distribution
ph <- rnorm(1000, mean = 6.5, sd = 0.5)
# Plot histogram with theoretical density
hist(ph, probability = TRUE, breaks = 30, main = "Soil pH")
curve(dnorm(x, mean = 6.5, sd = 0.5), add = TRUE, col = "red", lwd = 2)
# Probability of pH < 5.5 (acid sulfate soil indicator)
pnorm(5.5, mean = 6.5, sd = 0.5) # ~0.0228
# Quantile: pH value that 95% of soils exceed? i.e., 5th percentile
qnorm(0.05, mean = 6.5, sd = 0.5) # ~5.68
9.3 Confidence Intervals and Hypothesis Testing (t-test, Wilcoxon)
Descriptive statistics summarise the sample. Inferential statistics generalise from the sample to
the population. The two pillars are confidence intervals (an interval estimate of a population
parameter) and hypothesis tests (a decision about whether an observed effect is real or due to
chance).
9.3.1 Confidence Intervals for the Mean
For a sample of size nn from a normal distribution with unknown variance, the 100(1−α)
%100(1−α)% confidence interval for the population mean is:
xˉ±tα/2,n−1snxˉ±tα/2,n−1ns
In R: [Link](x, [Link] = 0.95)$[Link].
Interpretation: If we were to repeat the sampling many times, 95% of such intervals would
contain the true population mean. It is not a 95% probability that the true mean lies in this
particular interval (that is a Bayesian credible interval).
Geospatial example: 30 soil samples from a field have mean organic carbon 2.4% with sd 0.8%.
The 95% CI is:
r
[Link](soil$organic_carbon, [Link] = 0.95)$[Link]
We are 95% confident that the true mean organic carbon of the field lies between these bounds.
9.3.2 One-Sample t-test
Tests whether the population mean equals a hypothesised value μ0μ0.
r
[Link](x, mu = mu_0)
Example: Is the mean elevation of a catchment significantly different from 1000 m?
r
[Link](elevation, mu = 1000)
The p-value is the probability of observing a sample mean as extreme as ours, or more extreme,
if the null hypothesis (true mean = 1000) were true. A small p-value (typically < 0.05) leads to
rejection of the null.
9.3.3 Two-Sample t-test
Compares the means of two independent groups. Assumes normal distributions with equal
variance (Welch’s version relaxes equal variance).
r
[Link](x, y, [Link] = FALSE) # Welch's test (default)
Geospatial example: Compare mean slope angle between north-facing and south-facing slopes.
r
north_slope <- slope[aspect_class == "North"]
south_slope <- slope[aspect_class == "South"]
[Link](north_slope, south_slope)
9.3.4 Wilcoxon Rank-Sum (Mann-Whitney U) Test
When data are non-normal or ordinal, the Wilcoxon test is a non-parametric alternative to the
t-test. It tests whether the medians of two groups differ, without assuming normality.
r
[Link](x, y)
Geospatial example: Compare contaminant concentrations at two sites where data are highly
skewed.
r
[Link](site1_conc, site2_conc)
9.3.5 Assumptions and Diagnostics
The t-test assumes:
1. Independent observations (often violated in spatial data—more on this in Part IV).
2. Approximate normality. Check with qqnorm(x); qqline(x) and [Link](x) (but
Shapiro-Wilk is sensitive to large n).
3. For classical t-test, equal variances. Check with [Link]() or leveneTest().
Spatial data frequently exhibit spatial autocorrelation, violating independence. The classical t-test
applied to contiguous spatial data yields p-values that are too optimistic. We address this in
Chapter 17 with spatial regression and spatial randomisation.
9.3.6 Technical Demonstration
r
[Link](10)
# Simulate pH from two management zones
zone_A <- rnorm(30, mean = 6.5, sd = 0.3)
zone_B <- rnorm(30, mean = 6.8, sd = 0.4)
# Boxplot
boxplot(zone_A, zone_B, names = c("A","B"), ylab = "pH")
# t-test
[Link](zone_A, zone_B)
# Wilcoxon test
[Link](zone_A, zone_B)
# Confidence interval for mean difference
[Link](zone_A, zone_B)$[Link]
9.4 Correlation and Chi-squared Tests
9.4.1 Pearson Correlation Coefficient
Measures the linear relationship between two continuous variables XX and YY:
r=∑(xi−xˉ)(yi−yˉ)∑(xi−xˉ)2∑(yi−yˉ)2r=∑(xi−xˉ)2∑(yi−yˉ)2∑(xi−xˉ)(yi−yˉ)
cor(x, y, method = "pearson"). r ranges from –1 (perfect negative) to +1 (perfect
positive). [Link](x, y) provides a confidence interval and a test of whether the population
correlation differs from zero.
Geospatial example: Correlation between elevation and soil organic carbon.
r
[Link](soil$elevation, soil$organic_carbon)
Caution: Correlation does not imply causation, and spatial autocorrelation inflates the effective
sample size, making p-values unreliable. Always examine the scatterplot (plot(x, y)).
9.4.2 Spearman Rank Correlation
Non-parametric correlation based on ranks. cor(x, y, method = "spearman"). It detects monotonic
(not necessarily linear) relationships and is robust to outliers.
r
[Link](soil$elevation, soil$organic_carbon, method = "spearman")
Use Spearman when data are skewed or when the relationship is monotonic but not linear.
9.4.3 Chi-squared Test of Independence
Tests whether two categorical variables are associated. Compares observed frequencies in a
contingency table to expected frequencies under independence.
r
[Link](table(land_cover, geology))
Geospatial example: Is land-cover type associated with underlying geology? A significant
chi-squared test suggests an association (e.g., forests preferentially occurring on sandstone).
Warning: The chi-squared test assumes independent observations. In spatial data, nearby points
may be similar, inflating the chi-squared statistic. Spatial randomisation tests (Section 9.5) can
address this.
9.4.4 Technical Demonstration
r
[Link](5)
n <- 100
elev <- rnorm(n, 500, 150)
oc <- 3 + 0.002 * elev + rnorm(n, 0, 0.5) # weak linear relationship
# Pearson correlation
[Link](elev, oc)
# Spearman
[Link](elev, oc, method = "spearman")
# Chi-squared: geology vs. land cover
geology <- sample(c("Sandstone","Limestone","Granite"), n, replace = TRUE)
cover <- ifelse(geology == "Sandstone", sample(c("Forest","Grass"), n, replace=TRUE,
prob=c(0.7,0.3)),
ifelse(geology == "Limestone", sample(c("Forest","Grass"), n, replace=TRUE,
prob=c(0.5,0.5)),
sample(c("Forest","Grass"), n, replace=TRUE, prob=c(0.3,0.7))))
[Link](table(cover, geology))
9.5 Bootstrapping and Resampling Methods
Classical inferential methods rely on distributional assumptions (normality, independence).
Bootstrapping replaces these assumptions with computation: it uses the observed sample to
simulate the sampling distribution of any statistic. This is especially useful in spatial statistics,
where standard errors from classical models are often wrong due to autocorrelation.
9.5.1 The Bootstrap Principle
The population is unknown. The sample is the best estimate of the population. The bootstrap
treats the sample as a pseudo-population and draws many resamples (with replacement) from it,
each of the same size as the original sample. For each resample, the statistic of interest is
computed. The distribution of these bootstrap statistics approximates the true sampling
distribution.
Algorithm:
1. Draw a bootstrap sample x_boot of size n by sampling x with replacement.
2. Compute the statistic θbootθboot on x_boot.
3. Repeat B times (B ≥ 1000).
4. The standard deviation of the bootstrap statistics is the bootstrap standard error.
The α/2α/2 and 1−α/21−α/2 percentiles form a (1−α)(1−α) confidence interval.
9.5.2 Bootstrap in R with boot and Manual
The boot package (Canty & Ripley) provides boot(data, statistic, R, ...). However, manual
implementation clarifies the logic:
r
# Bootstrap confidence interval for the mean
x <- rnorm(50, mean = 100, sd = 15)
B <- 2000
boot_means <- numeric(B)
for (b in 1:B) {
boot_sample <- sample(x, size = length(x), replace = TRUE)
boot_means[b] <- mean(boot_sample)
}
# 95% CI
ci <- quantile(boot_means, c(0.025, 0.975))
9.5.3 Bootstrap for Spatial Data
Spatial data are autocorrelated, so a simple bootstrap that resamples individual points does not
preserve the spatial structure. For spatial bootstrapping, we can resample blocks (spatial blocks)
or use a model-based bootstrap (resample residuals from a spatial model). This is an advanced
topic that we will revisit in Chapter 17. For now, understand that the bootstrap can be applied
whenever a parametric standard error formula is unavailable or suspect.
9.5.4 Permutation Test
A related resampling method is the permutation (randomisation) test. To test whether two groups
differ, we repeatedly randomly shuffle the group labels, recompute the test statistic each time,
and compare the observed statistic to the distribution under random assignment. This yields a
p-value that does not rely on normality or independence.
r
# Permutation test for difference in means
obs_diff <- mean(x) - mean(y)
combined <- c(x, y)
n_x <- length(x)
perm_diffs <- replicate(10000, {
idx <- sample(length(combined), n_x)
mean(combined[idx]) - mean(combined[-idx])
})
p_value <- mean(abs(perm_diffs) >= abs(obs_diff))
This is the computational foundation of the Mantel test in spatial ecology.
9.5.5 Geospatial Applications
Bootstrap confidence interval for the area of a classified land-cover type (propagating
classification uncertainty).
Bootstrap standard errors of a kriging prediction (accounting for variogram parameter
uncertainty).
Permutation test for spatial clustering of disease cases.
9.5.6 Technical Demonstration
r
[Link](123)
# Field measurement of infiltration rate (mm/hr)
infilt <- c(rexp(30, rate = 1/20), rexp(5, rate = 1/100)) # some high values
# Bootstrap mean and CI
B <- 5000
boot_means <- replicate(B, mean(sample(infilt, replace = TRUE)))
hist(boot_means, main = "Bootstrap Distribution of Mean Infiltration", breaks = 40)
ci <- quantile(boot_means, c(0.025, 0.975))
abline(v = ci, col = "red", lwd = 2)
# Permutation test comparing two groups
group1 <- rnorm(15, mean = 30, sd = 5)
group2 <- rnorm(15, mean = 35, sd = 5)
obs_diff <- mean(group1) - mean(group2)
all <- c(group1, group2)
perm_diffs <- replicate(9999, {
idx <- sample(length(all), length(group1))
mean(all[idx]) - mean(all[-idx])
})
p_perm <- (sum(abs(perm_diffs) >= abs(obs_diff)) + 1) / (9999 + 1)
p_perm
Hard Practice 9 – “Statistical Comparison of Two Watersheds”
Objective:
Perform a complete comparative statistical analysis of two simulated watersheds. Compute
descriptive statistics, test for normality, perform t-test and Wilcoxon test, compute bootstrap
confidence intervals, and interpret the results in a geomorphic context.
Scenario:
You have measurements of slope angle, elevation, and soil depth from 100 random points in each
of two watersheds, A and B. Watershed A is tectonically active; B is a stable shield. You
hypothesise that slopes are steeper and more variable in A, and elevations are higher. You will
test these hypotheses.
Instructions:
1. Simulate the datasets:
r
[Link](202)
n <- 100
slope_A <- rnorm(n, mean = 22, sd = 8)
slope_B <- rnorm(n, mean = 12, sd = 5)
elev_A <- rnorm(n, mean = 1800, sd = 300)
elev_B <- rnorm(n, mean = 800, sd = 150)
soil_A <- rlnorm(n, meanlog = 3.2, sdlog = 0.5)
soil_B <- rlnorm(n, meanlog = 3.5, sdlog = 0.4)
2. Compute descriptive statistics (mean, median, sd, IQR, skewness) for slope and elevation
in each watershed. Present in a table.
3. Create side-by-side boxplots and histograms for slope, elevation, and soil depth.
4. Test slope for normality using [Link]() and QQ plots. Based on results, decide
between t-test and Wilcoxon.
5. Perform two-sample tests comparing slope between A and B, elevation between A and B,
and soil depth between A and B. Report the test statistic, p-value, and conclusion.
6. For the mean difference in slope, compute a 95% confidence interval using both the t -test
formula and the bootstrap (2000 resamples). Compare them.
7. Perform a permutation test (9999 permutations) for the difference in mean elevation.
Report the permutation p-value alongside the t-test p-value.
8. Write a short paragraph interpreting the results: which differences are statistically
significant? Are the assumptions of the t-test met? How would spatial autocorrelation (if
points were clustered) affect the conclusions?
Deliverable:
A script practice_chapter9_statistics.R with all computations, plots, and interpretive comments.
Chapter 9 Review Problems
Solve each problem in a clearly commented script. Use base R statistical functions and
the moments package where needed.
Easy Problems (1–5)
1. Mean and Median
Generate x <- c(12, 15, 18, 22, 25, 30, 200). Compute the mean and median. Which is larger?
Why? Remove the outlier 200 and recompute. Comment.
2. Normal Probabilities
If annual rainfall is normally distributed with mean 850 mm and sd 120 mm, what is the
probability that a given year receives more than 1000 mm? Use pnorm().
3. Generate Random Data
Use [Link](1) and runif() to create 50 random longitudes between 100 and 120, and 50 random
latitudes between 30 and 45. Plot them as a scatterplot.
4. One-Sample t-test
Generate 30 random values from rnorm(30, mean = 10, sd = 2). Test whether the population
mean is 9.5 using [Link](). Report the p-value.
5. Confidence Interval
Using the data from Problem 4, compute the 95% confidence interval for the mean. Interpret it in
one sentence.
Medium Problems (6–10)
6. Two-Sample t-test and Diagnostics
Generate two groups: x <- rnorm(30, mean=15, sd=3), y <- rnorm(30, mean=18, sd=4). Check
normality with QQ plots. Test equality of variances with [Link](). Perform appropriate t-test.
Report results.
7. Correlation
Generate elev <- runif(50, 500, 2000) and ph <- 8 - 0.002 * elev + rnorm(50, 0, 0.3). Compute
Pearson and Spearman correlations. Plot the scatterplot with a smooth line.
8. Chi-Squared Test
Create a contingency table: cross-tabulate soil_type (Clay, Sand, Silt) and erosion_class (Low,
High) from a simulated dataset of 100 observations. Perform [Link](). If the expected
frequency in any cell is <5, what caution should you take? (Use [Link]() as an alternative.)
9. Bootstrap Mean
Generate 40 values from rlnorm(40, meanlog=2, sdlog=0.8). Compute the sample mean.
Bootstrap the mean (1000 resamples) and produce a histogram of bootstrap means with a 95%
CI. Compare the bootstrap CI to the normal-theory CI ([Link]).
10. Permutation Test for Difference of Medians
Generate two skewed samples: a <- rexp(25, rate=1); b <- rexp(25, rate=2). Perform a
permutation test (5000 permutations) for the difference in medians. Compare the permutation
p-value to [Link](a,b)$[Link].
Challenging Problems (11–15)
11. Spatial Simulation and Independence
Generate 100 random points in a unit square. Assign a value to each point: value <- 0.5 * x + 0.3
* y + rnorm(100, 0, 0.1). Test the correlation between x and value using [Link]. Now, create a
second dataset where the noise is spatially autocorrelated: generate errors from a spatial
Gaussian process (use gstat::gstat to simulate if possible, or approximate by smoothing random
noise). Test again. Explain why the p-value in the autocorrelated case is inflated.
12. Bootstrap for a Spatial Statistic
Given a vector of 50 polygon areas (hectares) from a land-cover map, compute the bootstrap CI
for the coefficient of variation (sd / mean). The COV is a measure of landscape fragmentation.
Compare the bootstrap CI to a CI computed using the delta method (approximate normal of
COV).
13. Power Analysis with Simulation
Use simulation to estimate the statistical power of a two-sample t-test for detecting a difference
of 0.3 standard deviations between two groups of size n = 20, 50, 100. For each n, simulate 1000
datasets under the alternative hypothesis (true difference = 0.3 sd), apply the t-test, and record
the proportion of p < 0.05. Plot power vs n.
14. Multiple Testing Correction
Simulate a raster with 1000 pixels, each with a p-value from a test comparing two time periods.
Under the null, p-values are uniformly distributed. Apply the Bonferroni correction and the
Benjamini-Hochberg (FDR) correction (use [Link]). Count how many null pixels are
(erroneously) declared significant under each correction. Explain why spatial multiple testing is a
problem in neuroimaging and remote sensing change detection.
15. Bootstrapping a Spatial Regression Model
Fit a simple linear regression lm(y ~ x, data). Perform a residual bootstrap: resample the
residuals, add them to the fitted values, refit the model, and store the coefficients. Repeat 1000
times. Compute bootstrap standard errors and CIs for the intercept and slope. Compare to the
classical summary(lm())$coefficients. This is the foundation of model-based inference when
errors are non-normal or autocorrelated.
Chapter 10: Linear Modeling and Regression Diagnostics
A map shows where; statistics describes what; a model explains why and predicts where else.
Linear regression is the simplest and most widely used tool for relating a response variable to
one or more predictors. In geoinformatics, we use regression to model soil organic carbon as a
function of elevation and slope, to predict landslide susceptibility from terrain and geology, to
calibrate satellite reflectance against field measurements, and to estimate the relationship
between population density and night-time lights. This chapter builds the theory and practice of
linear models from the ground up: fitting with lm(), interpreting coefficients and p-values,
checking assumptions with diagnostic plots, extending to polynomial and interaction terms,
selecting among competing models, and finally introducing generalized linear models for binary
outcomes like presence/absence of a species or landslide occurrence. Every concept is illustrated
with geospatial data, and the Hard Practice asks you to model soil organic carbon from
topographic predictors—a classic digital soil mapping task. By the end, you will be able to
formulate, fit, diagnose, validate, and interpret linear models, and you will be prepared for the
spatial regression models (SAR, SEM, GWR) that explicitly account for spatial autocorrelation
in Chapter 19.
10.1 Simple and Multiple Linear Regression with lm()
10.1.1 The Linear Model: Theory and Assumptions
The simple linear regression model relates a response variable YY to a single predictor XX:
Yi=β0+β1Xi+εi,εi∼N(0,σ2) [Link]=β0+β1Xi+εi,εi∼N(0,σ2) i.i.d.
β0β0 is the intercept: the expected value of YY when X=0X=0.
β1β1 is the slope: the expected change in YY for a one-unit increase in XX.
εiεi are the errors, assumed independent, normally distributed with constant
variance σ2σ2.
The model is fitted by ordinary least squares (OLS), which chooses β0,β1β0,β1 to minimise the
sum of squared residuals ∑(Yi−Y^i)2∑(Yi−Y^i)2.
The multiple linear regression extends to pp predictors:
Yi=β0+β1X1i+β2X2i+⋯+βpXpi+εiYi=β0+β1X1i+β2X2i+⋯+βpXpi+εi
Geospatial data frequently violate the independence assumption: nearby points tend to have
similar residuals. This spatial autocorrelation does not bias the coefficient estimates but does
bias their standard errors (usually downward), leading to inflated t-statistics and false positives.
We will address this in Chapter 19. For now, we treat the standard linear model as a starting
point, always checking residuals for spatial patterns.
10.1.2 Fitting a Linear Model with lm()
The workhorse function is lm(formula, data). The formula has the form response ~ predictor1 +
predictor2 + ....
r
# Simulate some soil data
[Link](42)
n <- 100
elevation <- runif(n, 500, 2000)
slope <- runif(n, 0, 30)
organic_carbon <- 5 - 0.002 * elevation + 0.05 * slope + rnorm(n, 0, 0.5)
soil <- [Link](elevation, slope, organic_carbon)
# Simple linear regression
model_simple <- lm(organic_carbon ~ elevation, data = soil)
# Multiple linear regression
model_multiple <- lm(organic_carbon ~ elevation + slope, data = soil)
10.1.3 Interpreting the Model Output
summary(model) prints a detailed report:
Coefficients table: Estimate, Std. Error, t-value, Pr(>|t|). Each coefficient tests the null
hypothesis that the true coefficient is zero.
Residual standard error: RSSn−p−1n−p−1RSS, the average deviation of observations
from the regression line.
Multiple R-squared: proportion of variance explained, R2=1−RSSTSSR2=1−TSSRSS.
Ranges 0 to 1.
Adjusted R-squared: penalises for adding predictors that do not improve fit.
F-statistic: overall test that all coefficients (except intercept) are zero.
r
summary(model_multiple)
# Coefficients:
# Estimate Std. Error t value Pr(>|t|)
# (Intercept) 5.012e+00 1.532e-01 32.72 <2e-16 ***
# elevation -2.005e-03 8.447e-05 -23.73 <2e-16 ***
# slope 5.041e-02 4.126e-03 12.22 <2e-16 ***
# ---
# Residual standard error: 0.516 on 97 degrees of freedom
# Multiple R-squared: 0.867, Adjusted R-squared: 0.864
Interpretation: each metre increase in elevation is associated with a decrease of 0.002 percentage
points in organic carbon, holding slope constant. Each degree increase in slope is associated with
a 0.05 percentage point increase, holding elevation constant.
10.1.4 Extracting Components
coef(model) — coefficients vector.
residuals(model) — residuals.
fitted(model) — fitted values.
vcov(model) — variance-covariance matrix of coefficients.
confint(model) — confidence intervals for coefficients.
predict(model, newdata) — predictions on new data, with interval =
"confidence" or "prediction".
r
# Predict organic carbon at new locations
new_sites <- [Link](elevation = c(1000, 1500), slope = c(10, 20))
predict(model_multiple, new_sites, interval = "confidence")
10.1.5 Geospatial Context: Regression as a Spatial Prediction Tool
In digital soil mapping, a regression model fitted to field samples and DEM-derived predictors
can predict soil properties at unsampled locations across a raster. Each pixel’s covariates
(elevation, slope, curvature) are plugged into the equation to produce a map of predicted organic
carbon. This is the essence of regression kriging (Chapter 20).
10.2 Model Diagnostics: Residuals, Leverage, Cook’s Distance
Fitting a model is only the first step. A responsible analyst then examines diagnostics to check
whether the model’s assumptions are plausible. The four main diagnostic plots for lm() are
obtained with plot(model, which = 1:4).
10.2.1 Residuals vs. Fitted (Which = 1)
Plots residuals ε^iε^i against fitted values Y^iY^i. Look for:
Curvature: suggests a non-linear relationship; consider adding polynomial terms or
transforming variables.
Funnel shape (heteroscedasticity): spread of residuals increases with fitted value.
Violates constant variance assumption. Log-transform the response or use weighted least
squares.
Outliers: points far from the horizontal zero line.
A smooth red line (lowess) is added; it should be approximately flat and near zero.
r
plot(model, which = 1)
10.2.2 Q-Q Plot of Residuals (Which = 2)
Plots the standardised residuals against the theoretical quantiles of a normal distribution. Points
should lie approximately on the 45° line. Deviation at the tails indicates non-normality. For large
n, mild non-normality is less critical due to the Central Limit Theorem, but gross skewness
warrants a transformation.
r
plot(model, which = 2)
10.2.3 Scale-Location Plot (Which = 3)
Plots ∣standardised residuals∣∣standardised residuals∣ against fitted values. Also used to detect
heteroscedasticity. The line should be horizontal.
r
plot(model, which = 3)
10.2.4 Residuals vs. Leverage (Which = 5)
Leverage measures how far an observation’s predictors are from the mean of predictors.
High-leverage points can exert undue influence on the regression line. Cook’s
distance combines leverage and residual size to measure influence. A contour line for Cook’s
distance = 0.5 (or 1) helps identify influential points. Points outside these contours deserve
scrutiny.
r
plot(model, which = 5)
10.2.5 Quantitative Diagnostics
rstandard(model) — standardised residuals.
rstudent(model) — studentised residuals (externally standardised, better for outlier
detection).
hatvalues(model) — leverage.
[Link](model) — Cook’s D.
A rule of thumb: observations with studentised residual > 2 or 3 may be outliers; leverage
> 2(p+1)/n2(p+1)/n is high; Cook’s D > 1 is influential. Check these against the data: is the point
a measurement error, or a genuine extreme?
10.2.6 Geospatial Diagnostics: Mapping Residuals
A uniquely spatial diagnostic is to map the residuals. Plot the residual at each sampling location
in space; look for clusters of positive or negative residuals—evidence of spatial autocorrelation,
which suggests that a spatial regression model is needed.
r
library(ggplot2)
soil$residuals <- residuals(model_multiple)
ggplot(soil, aes(x = x_coord, y = y_coord, colour = residuals)) +
geom_point(size = 3) +
scale_colour_gradient2(low = "blue", mid = "white", high = "red")
We will formalise this with Moran’s I in Chapter 17.
10.3 Polynomial and Interaction Terms
Linear models can accommodate non-linearity and interaction between predictors by extending
the formula.
10.3.1 Polynomial Terms
Adding polynomial terms (e.g., X2X2, X3X3) allows the model to capture curvature.
Use I(x^2) in the formula to protect the arithmetic from R’s formula parsing:
r
model_poly <- lm(organic_carbon ~ elevation + I(elevation^2), data = soil)
Alternatively, poly(elevation, 2, raw = TRUE) (using raw = TRUE for standard
polynomials, FALSE for orthogonal polynomials). Orthogonal polynomials are numerically
more stable and are useful when the predictors are correlated, but raw polynomials yield directly
interpretable coefficients.
Geospatial example: The relationship between elevation and species richness is often
hump-shaped (maximum at mid-elevations). A quadratic model captures this.
10.3.2 Interaction Terms
An interaction between X1X1 and X2X2 means the effect of X1X1 on YY depends on the level
of X2X2. In the formula, use X1 * X2 (which expands to X1 + X2 + X1:X2) or X1:X2 for the
interaction alone.
r
model_int <- lm(organic_carbon ~ elevation * slope, data = soil)
elevation * slope is shorthand for elevation + slope + elevation:slope.
Interpretation: the coefficient of elevation:slope is the change in the elevation effect per unit
increase in slope. If significant, the effect of elevation on organic carbon differs depending on
slope steepness.
Geospatial example: The effect of precipitation on crop yield may depend on soil type
(interaction between continuous precipitation and categorical soil type).
10.3.3 Categorical Predictors (Factors)
When a predictor is a factor (e.g., land_cover with levels Forest, Grass,
Urban), lm() automatically creates dummy variables. By default, the first level is the reference.
Coefficients for other levels represent differences from the reference.
r
soil$geology <- factor(sample(c("Granite","Basalt","Limestone"), n, replace = TRUE))
model_factor <- lm(organic_carbon ~ elevation + geology, data = soil)
summary(model_factor)
The coefficients for geologyBasalt and geologyLimestone show the mean difference in organic
carbon compared to Granite, holding elevation constant.
10.4 Model Selection and Cross-Validation
Given many potential predictors, how do we choose which to include? The goal is a model that
balances fit and complexity, and that predicts well on new data.
10.4.1 Information Criteria: AIC and BIC
Akaike Information Criterion (AIC) and Bayesian Information Criterion (BIC) are
measures of model fit penalised by the number of parameters:
AIC=−2logL+2k,BIC=−2logL+klnnAIC=−2logL+2k,BIC=−2logL+klnn
where LL is the likelihood, kk the number of parameters, and nn the sample size. Lower is better.
In R:
r
AIC(model1, model2)
BIC(model1, model2)
AIC tends to favour more complex models than BIC. Neither should be used blindly; they are
guides, not absolutes.
10.4.2 Stepwise Selection (with Caution)
step(model, direction = "both") performs stepwise selection based on AIC. It can be a useful
exploratory tool but is prone to overfitting, p-hacking, and irreproducible model choices. It is
better to select predictors based on domain knowledge and then use AIC to compare a small set
of plausible models.
r
# Stepwise selection (not recommended for final models without validation)
model_full <- lm(organic_carbon ~ ., data = soil) # all predictors
model_step <- step(model_full, direction = "both", trace = 0)
10.4.3 Cross-Validation
Cross-validation estimates the predictive performance of a model by repeatedly splitting the data
into training and validation sets. The most common is k-fold cross-validation:
1. Randomly partition data into k roughly equal-sized folds.
2. For each fold, fit the model on the other k-1 folds and predict the held-out fold.
3. Compute the prediction error (e.g., RMSE) on the held-out fold.
4. Average over folds.
In R, use caret or a manual loop:
r
library(caret)
train_control <- trainControl(method = "cv", number = 10)
model_cv <- train(organic_carbon ~ elevation + slope, data = soil,
method = "lm", trControl = train_control)
model_cv$results$RMSE
A simpler manual implementation for a linear model:
r
k <- 10
folds <- sample(rep(1:k, [Link] = n))
rmse <- numeric(k)
for (i in 1:k) {
train_data <- soil[folds != i, ]
test_data <- soil[folds == i, ]
fit <- lm(organic_carbon ~ elevation + slope, data = train_data)
pred <- predict(fit, newdata = test_data)
rmse[i] <- sqrt(mean((test_data$organic_carbon - pred)^2))
}
mean(rmse) # cross-validated RMSE
Cross-validated RMSE is an honest estimate of the model’s prediction error on new data, as
opposed to the in-sample R², which always increases with more predictors.
10.4.4 Geospatial Cross-Validation
Standard k-fold CV randomly assigns points to folds. But if points are spatially autocorrelated,
this underestimates prediction error because training and test points are nearby. Spatial
cross-validation (or block cross-validation) creates folds that are spatially contiguous blocks,
ensuring that training and test sets are separated in space.
Packages sperrorest and blockCV facilitate this. We will revisit this in Chapter 19.
10.5 Introduction to Generalized Linear Models (Logistic Regression for Landslide
Susceptibility)
Not all response variables are continuous and normally distributed. Landslides occur or do not; a
species is present or absent; a land-cover pixel is classified correctly or not. Generalized Linear
Models (GLMs) extend the linear model to non-normal response distributions via a link
function.
10.5.1 The GLM Framework
A GLM has three components:
1. Random component: YY follows a distribution from the exponential family (binomial,
Poisson, gamma, etc.).
2. Systematic component: a linear predictor η=β0+β1X1+⋯+βpXpη=β0+β1X1+⋯+βpXp.
3. Link function: g(μ)=ηg(μ)=η, where μ=E(Y)μ=E(Y).
The link function maps the expected response to the scale of the linear predictor. For logistic
regression (binomial response), the link is the logit: log(p1−p)=ηlog(1−pp)=η,
where p=P(Y=1)p=P(Y=1).
10.5.2 Logistic Regression with glm()
The syntax is glm(formula, family = binomial, data).
r
# Simulate landslide occurrence based on slope and elevation
[Link](10)
n <- 200
slope <- runif(n, 0, 45)
elevation <- runif(n, 500, 2500)
# Probability of landslide increases with slope and decreases with elevation
logit_p <- -2 + 0.1 * slope - 0.001 * elevation
p <- plogis(logit_p) # inverse logit: exp(x)/(1+exp(x))
landslide <- rbinom(n, 1, p)
landslide_data <- [Link](slope, elevation, landslide)
# Logistic regression
model_glm <- glm(landslide ~ slope + elevation, family = binomial, data = landslide_data)
summary(model_glm)
The coefficients are on the logit scale. Exponentiate them to obtain odds ratios:
r
exp(coef(model_glm))
An odds ratio > 1 for slope means that a unit increase in slope multiplies the odds of a landslide
by that factor.
Prediction: predict(model_glm, newdata, type = "response") returns predicted probabilities.
10.5.3 Model Evaluation: Confusion Matrix and ROC
For a binary classifier, choose a threshold (usually 0.5) and compare predicted class to actual
class:
r
pred_prob <- predict(model_glm, type = "response")
pred_class <- ifelse(pred_prob > 0.5, 1, 0)
table(predicted = pred_class, actual = landslide_data$landslide)
The ROC curve and AUC (Area Under the Curve) measure the model’s ability to discriminate
between the two classes regardless of threshold. Use the pROC package:
r
library(pROC)
roc_obj <- roc(landslide_data$landslide, pred_prob)
plot(roc_obj)
auc(roc_obj)
AUC ranges from 0.5 (no discrimination) to 1 (perfect discrimination).
10.5.4 Other GLM Families
Poisson: for counts (e.g., number of species per grid cell). family = poisson.
Gamma: for positive, skewed continuous data (e.g., rainfall amounts). family =
Gamma(link = "log").
Binomial with weights: for proportions (e.g., proportion of area eroded in each
catchment).
All are fitted with glm(), and diagnostics follow similar principles,
using plot() and residuals(type = "deviance").
10.5.5 Geospatial Application: Landslide Susceptibility Mapping
The logistic regression model can be applied to a grid of slope and elevation to produce a raster
of landslide probability. This is a standard technique in hazard mapping. In R, once you
have SpatRaster objects of slope and elevation (from terra), you can use predict(rasters,
model_glm, type = "response") to create the susceptibility map—a topic for Chapter 22.
Hard Practice 10 – “Modelling Soil Organic Carbon as a Function of Topographic
Variables”
Objective:
Fit, diagnose, and validate a multiple linear regression model for soil organic carbon (SOC)
using DEM-derived predictors. Perform model selection, check diagnostics, and produce a map
of predicted SOC (conceptually, as a table of predictions).
Scenario:
You have 150 field samples of SOC (%) with associated elevation, slope, aspect (converted to
northness and eastness), and curvature. You will:
1. Explore the data with scatterplots and correlations.
2. Fit a full model, then a reduced model selected by AIC.
3. Diagnose the reduced model: residual plots, normality, outliers.
4. Perform 10-fold cross-validation to estimate RMSE.
5. Predict SOC at a grid of new locations.
Instructions:
1. Simulate the dataset:
r
[Link](2024)
n <- 150
elevation <- runif(n, 100, 3000)
slope <- runif(n, 0, 40)
aspect <- runif(n, 0, 360)
northness <- cos(aspect * pi / 180)
eastness <- sin(aspect * pi / 180)
curvature <- rnorm(n, 0, 0.5)
# True relationship: SOC decreases with elevation, increases with slope and northness, curvature
effect small
SOC <- 8 - 0.003 * elevation + 0.06 * slope + 1.5 * northness + 0.3 * curvature + rnorm(n, 0,
0.8)
soil_data <- [Link](SOC, elevation, slope, northness, eastness, curvature)
2. Exploratory plots: Pairwise scatterplots (pairs()) and correlation matrix (cor()).
3. Fit full model: lm(SOC ~ ., data = soil_data). Print summary. Identify non-significant
predictors.
4. Model selection: Use step() with direction = "both" to select a reduced model (or
manually compare AIC of candidate models). Report the chosen model’s summary.
5. Diagnostics: For the reduced model, produce the four diagnostic plots. Comment on any
concerns. Check for outliers using Cook’s distance.
6. Cross-validation: Implement 10-fold CV manually (or with caret) and compute the CV
RMSE. Compare to the in-sample RMSE.
7. Prediction grid: Create a data frame grid of 400 new points spanning the range of
elevation (200 to 2800) and slope (5 to 35), with northness and eastness from a regular
sequence of aspects, and curvature zero. Predict SOC at these points and produce a
simple heatmap of predicted SOC against elevation and slope
(use ggplot2 with geom_tile() or geom_raster()).
8. Interpretation: Write a short paragraph summarising the relationship between SOC and
the topographic variables, and the model’s predictive ability.
Deliverable:
A script practice_chapter10_regression.R with all steps, plots, and commentary.
Chapter 10 Review Problems
Solve each in a clearly commented script. Use lm() and glm() as appropriate, and include
diagnostic checks.
Easy Problems (1–5)
1. Simple Linear Regression
Generate x <- 1:50 and y <- 3 + 0.5 * x + rnorm(50, 0, 2). Fit the linear model lm(y ~ x). Print
the summary and extract the slope coefficient. Plot the data with the regression line.
2. Confidence and Prediction Intervals
Using the model from Problem 1, produce 95% confidence intervals and prediction intervals
for x = 10, 25, 40. Explain in a comment the difference between them.
3. Multiple Regression
Load the built-in mtcars dataset. Fit lm(mpg ~ wt + hp, data = mtcars). Print the summary. Which
predictor is more significant? What is the adjusted R²?
4. Residual Plots
Using the model from Problem 3, produce the four diagnostic plots. Comment briefly on what
each plot suggests about the model assumptions.
5. Logistic Regression
Generate x <- runif(100, 0, 10) and y <- rbinom(100, 1, plogis(-3 + 0.8 * x)). Fit glm(y ~ x,
family = binomial). Print the coefficient and its odds ratio. Compute predicted probabilities and
create a confusion matrix with threshold 0.5.
Medium Problems (6–10)
6. Polynomial Regression
Generate x <- runif(100, 0, 10) and y <- 5 - 2 * x + 0.3 * x^2 + rnorm(100, 0, 1). Fit a linear
model and a quadratic model. Compare them with anova(model_lin, model_quad). Plot the data
with the fitted quadratic curve.
7. Interaction Term
Using the mtcars dataset, fit lm(mpg ~ wt * hp, data = mtcars). Interpret the interaction
coefficient. Create a plot showing the relationship between wt and mpg at low (50), medium
(150), and high (250) values of hp.
8. Stepwise Selection
Using the swiss dataset (socio-economic indicators of Swiss provinces), fit a full model
predicting Fertility from all other variables. Use step() with direction = "both" to select a reduced
model. Compare AIC of full and reduced models.
9. Cross-Validation
Using the mtcars dataset and lm(mpg ~ wt + hp), perform 5-fold cross-validation manually.
Compute the CV RMSE. Compare it to the in-sample RMSE from summary(model)$sigma.
10. Poisson Regression
Generate count data: x <- runif(100, 0, 5); lambda <- exp(1 + 0.5 * x); y <- rpois(100, lambda).
Fit glm(y ~ x, family = poisson). Interpret the coefficient. Check for overdispersion by
comparing residual deviance to residual degrees of freedom.
Challenging Problems (11–15)
11. Influence Analysis
Use the longley dataset (built-in, economic data). Fit lm(Employed ~ ., data = longley). Compute
Cook’s distance and identify influential years. Refit the model omitting the most influential point
and compare coefficient estimates.
12. Multicollinearity Assessment
Generate three highly correlated predictors: x1 <- rnorm(100); x2 <- x1 + rnorm(100, 0, 0.1); x3
<- x1 + rnorm(100, 0, 0.2). Generate y <- 2 + 3*x1 - x2 + 0.5*x3 + rnorm(100). Fit lm(y ~ x1 +
x2 + x3). Compute Variance Inflation Factors (VIF) using car::vif(). Explain what VIF tells you
about the standard errors of the coefficients.
13. Spatial Autocorrelation in Residuals
Simulate 100 spatial points on a grid. Generate elevation and slope as in Problem 1.
Generate SOC with the same model but with spatially autocorrelated errors: use gstat::gstat to
simulate a random field with a spherical variogram (range = 50, sill = 0.5). Fit lm(SOC ~
elevation + slope). Compute the residuals and plot them on a map. Use spdep to compute
Moran’s I of the residuals (you may need to install spdep). This previews Chapter 17.
14. Model-Based Bootstrap for Regression Coefficients
For the mtcars model lm(mpg ~ wt + hp), perform a residual bootstrap: resample the residuals,
add them to fitted values, refit, and store coefficients. Repeat 1000 times. Compute bootstrap
standard errors and confidence intervals for wt and hp. Compare to the classical OLS standard
errors. This builds on the bootstrap from Chapter 9.
15. Logistic Regression Model Validation with ROC
Generate a larger landslide dataset (n = 1000) with slope, elevation, and a binary landslide
indicator as in Section 10.5.2. Split into training (70%) and test (30%) sets. Fit the model on
training, predict probabilities on test, and produce the ROC curve with AUC. Also compute the
Brier score (mean squared error of probabilities). Compare to a null model (intercept-only)
using anova.
Chapter 11: R as a Geographic Information System – Concepts and Setup
R is not merely a statistical language that can produce maps; it is a fully programmable
Geographic Information System. To understand how that is possible, we must first understand
what a GIS fundamentally is—a set of data models, operations, and coordinate frameworks for
representing and analysing the spatial world. This chapter builds the conceptual bridge from
your knowledge of R’s data structures (vectors, data frames, lists) to the formal representation of
spatial data. We begin with the three classical ways of representing the real world: as discrete
entities with attributes, as continuous fields, and as networks. We then introduce the
international Simple Feature standard (ISO 19125) and R’s sf package, which implements it.
From there, we survey the modern R spatial ecosystem: sf for vectors, terra for rasters, stars for
spatiotemporal arrays, and the supporting libraries. Finally, we tackle coordinate reference
systems—the mathematical transformations that allow us to place spatial data on the Earth’s
surface and on flat maps—and we explore the PROJ library that powers all modern open-source
CRS handling. By the end, you will understand the architecture of spatial R, you will have
installed and verified the critical libraries, and you will be ready to begin working with vector
and raster data in the chapters that follow.
11.1 Representing the Real World: Entities, Fields, and Networks
Geography is the study of the Earth’s surface as the home of humanity. A GIS must translate this
continuous, infinitely complex surface into discrete, finite data structures that a computer can
store, query, and analyse. There are three fundamental conceptual models for doing so.
11.1.1 The Entity Model (Discrete Objects)
The entity model views the world as a collection of distinct, identifiable objects, each possessing
a location, a shape, and a set of attributes. A city, a river, a forest stand, a soil sampling point, a
county boundary—all are entities. In GIS, entities are represented as vector data: points
(0-dimensional), lines (1-dimensional), and polygons (2-dimensional).
Each entity is a row in an attribute table. The columns are its properties (name, population, area,
land-use code), and the geometry column stores its shape. This is precisely the structure of
an sf data frame: a table of features, where each feature has a geometry. The entity model is the
natural representation for cadastral parcels, administrative boundaries, infrastructure networks,
and any feature that can be individually named and enumerated.
The theoretical strength of the entity model lies in its alignment with how humans categorise the
world. We think in objects: this mountain, that lake. The operational strength is that entity
attributes can be stored in a database, queried with SQL, and joined to other tables—operations
that are straightforward because the row is the fundamental unit.
11.1.2 The Field Model (Continuous Surfaces)
Not everything is an object. Temperature varies continuously across a landscape; elevation exists
everywhere, not just at surveyed points; soil pH has a value at every location, whether we
measure it or not. The field model represents the world as a set of continuous surfaces, where
each spatial location is associated with one or more attribute values.
In GIS, fields are represented as raster data: a regular grid of cells (pixels), each storing a value.
A digital elevation model (DEM) is a raster where each cell stores the elevation at that location.
A satellite image is a raster where each cell stores reflectance in a specific spectral band. A
climate reanalysis stores temperature, pressure, and precipitation as multi-band rasters or
multidimensional arrays.
The field model is the natural representation for environmental variables that vary continuously.
It aligns with the way satellites collect data (pixels on a sensor array) and with the numerical
methods of spatial analysis (map algebra, focal operations, zonal statistics). In R,
the terra package provides the raster implementation, using the same underlying C++ library
(GDAL) as QGIS and other GIS platforms.
11.1.3 The Network Model
A network is a set of linear features (edges) connected at nodes, often with attributes describing
capacity, direction, impedance, or cost. Road networks, river systems, utility grids, and
ecological corridors are all networks. Network analysis answers questions like “what is the
shortest path from A to B?” or “which segments of the river are upstream of a pollution source?”
In GIS, networks are a specialised form of the entity model: nodes are points, edges are lines,
and the topology (which edges connect to which nodes) is either stored explicitly in the data
structure or computed on the fly from shared coordinates. In R, the sfnetworks package
integrates sf with tidygraph to provide network analysis tools, and dodgr provides efficient
routing on street networks.
11.1.4 Hybrid Approaches and the Reality of Data
Most real-world GIS projects use all three models simultaneously. A watershed analysis might
use a polygon layer of sub-catchments (entity), a DEM raster for elevation (field), and a stream
network for flow accumulation (network). The art of GIS is choosing the right model for each
component of the problem.
R’s spatial ecosystem supports all three. The sf package handles entities, terra handles fields,
and sfnetworks/dodgr handle networks. The data frame is the common denominator: sf data
frames for vectors, SpatRaster objects that can be interrogated with terra, and tidygraph objects
for networks. This unification through the data frame is one of the great strengths of the R spatial
approach.
11.2 The Simple Feature Standard and the {sf} Package
The sf package (Pebesma, 2018) is the single most important package for vector spatial data in
R. It implements the Simple Feature Access standard (ISO 19125), an international standard
that defines a common model and SQL interface for two-dimensional vector geometries.
11.2.1 The Simple Feature Model
A simple feature is a spatial object with a geometry and a set of attributes. The geometry is one
of the standard OGC types: Point, MultiPoint, LineString, MultiLineString, Polygon,
MultiPolygon, GeometryCollection. All geometries are composed of points (coordinate pairs)
and straight-line connections between them. Curved geometries are not part of the simple feature
standard (though some implementations extend it).
The critical innovation of sf is that it stores geometries in a list column inside a data frame. The
class of this column is sfc (simple feature geometry column), and each element is an sfg (simple
feature geometry). The full object is of class sf and inherits from [Link]. This means
every dplyr verb you learned in Chapter 6—
filter, select, mutate, arrange, group_by, summarise, left_join—works directly on sf objects, with
the geometry column carried along by default.
11.2.2 The Internal Structure: sfg, sfc, sf
Let us build a point from scratch to understand the internal structure.
r
library(sf)
# A single point geometry (sfg)
pt <- st_point(c(116.4074, 39.9042)) # Beijing coordinates (lon, lat)
pt
# POINT (116.4074 39.9042)
class(pt) # "XY" "POINT" "sfg"
# A geometry column (sfc) with coordinate reference system
sfc_pt <- st_sfc(pt, crs = 4326) # EPSG:4326 = WGS84 geographic
sfc_pt
class(sfc_pt) # "sfc_POINT" "sfc"
# An sf object (data frame with geometry)
sf_obj <- st_sf(name = "Beijing", geometry = sfc_pt)
sf_obj
class(sf_obj) # "sf" "[Link]"
The hierarchy is: point coordinates → sfg (geometry) → sfc (geometry column, many geometries
with a CRS) → sf (data frame, attributes + geometry column).
A polygon is more complex. It is composed of an outer ring and optional inner rings (holes).
Each ring is a matrix of coordinate pairs. A list of rings forms an sfg polygon; a list of polygons
forms a multi-polygon.
r
# A simple triangle polygon
outer_ring <- rbind(c(0,0), c(1,0), c(0,1), c(0,0)) # must close
poly <- st_polygon(list(outer_ring))
poly
# POLYGON ((0 0, 1 0, 0 1, 0 0))
This nested list structure—list of rings, each ring a matrix—is why understanding lists (Chapter
4) is essential for spatial work.
11.2.3 The Standard Geometry Types
The full set of simple feature geometry types, and their R representations:
Type R creation function Internal structure
POINT st_point(xy) numeric vector of length 2 (or 3/4 for Z/M
MULTIPOINT st_multipoint(matrix) matrix, each row a point
LINESTRING st_linestring(matrix) matrix, each row a vertex
MULTILINESTRING st_multilinestring(list) list of matrices
POLYGON st_polygon(list) list of matrices (outer ring + holes)
MULTIPOLYGON st_multipolygon(list) list of lists of matrices
GEOMETRYCOLLECTION st_geometrycollection(list) list of any sfg objects
All these are created by the st_* functions and can be combined into sfc columns with st_sfc().
11.2.4 Reading and Writing Vector Data
The workhorse function is st_read(). It calls GDAL under the hood and can read virtually any
vector format: Shapefile, GeoJSON, GeoPackage, KML, GML, PostGIS, and many more.
r
# Read a shapefile
world <- st_read("path/to/[Link]")
# Read a GeoPackage
cities <- st_read("data/[Link]")
Writing is done with st_write():
r
st_write(sf_obj, "output/[Link]", driver = "GPKG")
The layer argument specifies the layer name when writing to multi-layer formats. For Shapefiles,
each file is a layer, but GeoPackages can contain multiple layers.
st_layers() lists available layers in a data source. st_drivers() lists all available format drivers,
showing which are readable and which are writable.
11.2.5 The sf Object as a Tidy Data Frame
The geometry column in an sf object is sticky: select will not drop it unless you explicitly
use st_drop_geometry(). This design means you can manipulate attributes without accidentally
losing the spatial component.
r
# Filter cities with population > 1 million
big_cities <- cities %>% filter(population > 1e6)
# Select only the name and population columns; geometry stays
cities %>% select(name, population)
# Convert to plain data frame (drop geometry)
cities_df <- st_drop_geometry(cities)
The sticky geometry also means that summarise() on an sf object will, by default, drop the
geometry unless you aggregate it with st_union() or st_combine(). We will learn this in Chapter
12.
11.2.6 Why sf Replaced sp
Before 2016, R’s spatial vector data was handled by the sp package (Bivand, Pebesma, &
Gomez-Rubio, 2013). sp used S4 classes that were opaque to users:
a SpatialPointsDataFrame stored the coordinates in a separate slot, not in the data frame itself.
Manipulating attributes required special methods, and combining sp objects with dplyr was
impossible. The sf package resolved all these issues by embracing the data frame paradigm, and
by directly interfacing with GDAL, GEOS, and PROJ—the same libraries that QGIS and
PostGIS use. The transition is now essentially complete, and sp is maintained for legacy code
only. All new geospatial R development uses sf.
11.3 R’s Spatial Ecosystem: {sf}, {terra}, {stars}, and Beyond
R’s spatial ecosystem has undergone a renaissance since 2016. The key packages form a coherent
stack, each building on the lower-level C/C++ libraries GDAL, GEOS, and PROJ.
11.3.1 The Three Pillars
sf (Simple Features): Vector data. Classes sf (data frame with geometry), sfc (geometry
column), sfg (single geometry). Methods for reading, writing, transforming, and analysing
points, lines, and polygons. Geometry operations (buffer, intersection, union) are performed by
GEOS.
terra (Terrain Analysis): Raster data. Classes SpatRaster (single or multi-layer
raster), SpatVector (vector data, but we use sf for vectors). terra replaced the
older raster package, offering much faster performance through C++ and the ability to process
rasters larger than memory by chunking. Methods for map algebra, focal operations,
reclassification, extraction, and raster-vector overlay.
stars (Spatiotemporal Arrays): Multi-dimensional arrays for raster cubes, including an explicit
time dimension. stars is designed for satellite time series, climate reanalysis data, and model
output. It integrates with GDAL’s NetCDF and GRIB drivers.
All three packages depend on the same underlying libraries and use the same CRS machinery
(PROJ). An sf object can be coerced to a SpatVector with terra::vect(), and a SpatRaster can be
converted to a stars object with stars::st_as_stars(), ensuring interoperability.
11.3.2 Supporting Packages
spdep: Spatial dependence. Creates spatial weights matrices, computes Moran’s I, LISA
statistics, and provides spatial regression diagnostics. (Chapters 17, 19)
gstat: Geostatistics. Variogram modelling, kriging interpolation, conditional simulation.
(Chapter 20)
spatstat: Point pattern analysis. Ripley’s K, pair correlation functions, point process
models. (Chapter 18)
rnaturalearth: Open access to Natural Earth map data. Returns sf objects. Used
throughout this book.
tmap and mapview: Thematic mapping and interactive web maps. tmap provides a
grammar of graphics for cartography; mapview provides quick interactive visualisation
based on leaflet.
leaflet: The R interface to the Leaflet JavaScript library for interactive web maps.
sfnetworks and dodgr: Network analysis.
rgee: Interface to Google Earth Engine from within R, enabling planetary-scale remote
sensing analysis.
This ecosystem is actively maintained by a core group of developers (Edzer Pebesma, Roger
Bivand, Robert Hijmans, and many others) who are also active researchers. The R Spatial
community is one of the most vibrant in open-source geoinformatics.
11.3.3 Package Choice Guidelines
Vector analysis: sf is the default. It handles I/O, transformation, geometric operations,
and integration with dplyr.
Raster analysis: terra for most tasks. For multi-dimensional time series, consider stars.
Mapping: ggplot2 + geom_sf() for static maps; tmap for thematic maps with
cartographic elements; mapview or leaflet for interactivity.
Spatial statistics: spdep for autocorrelation and regression; gstat for kriging; spatstat for
point patterns.
Big raster data: terra with chunked processing; rgee for cloud-based processing on
Google Earth Engine.
11.4 Understanding Coordinate Reference Systems (CRS) and the PROJ Library
A coordinate reference system (CRS) is the mathematical framework that links coordinates (pairs
of numbers) to actual locations on the Earth’s surface. Without a CRS, a coordinate like (500000,
4300000) is meaningless. The CRS tells us whether those numbers represent degrees of
longitude and latitude, metres east and north from an origin, or pixels on a sensor.
11.4.1 Geographic and Projected CRS
A geographic CRS (or geodetic CRS) uses angular units (degrees) and references a
three-dimensional model of the Earth—a datum—which consists of a reference ellipsoid (a
mathematical shape approximating the Earth) and a set of control points that tie the ellipsoid to
the actual Earth. Examples: WGS84 (EPSG:4326), NAD83 (EPSG:4269), ETRS89
(EPSG:4258).
A projected CRS transforms the curved surface of the Earth onto a flat map using a
mathematical projection. Projections introduce distortion in area, shape, distance, or direction—
no flat map can preserve all four simultaneously. Examples: UTM zones (e.g., EPSG:32650 for
UTM Zone 50N, using the Transverse Mercator projection), Web Mercator (EPSG:3857, used by
Google Maps, OpenStreetMap), Albers Equal Area Conic (used for continental-scale thematic
mapping).
A projected CRS is always built on a geographic CRS. The WKT (Well-Known Text)
representation of a CRS includes all the parameters: ellipsoid, datum, prime meridian, angular
unit, projection method, and projection parameters (central meridian, standard parallels, false
easting/northing, scale factor).
11.4.2 EPSG Codes and Authority
The EPSG (European Petroleum Survey Group, now the IOGP’s Geomatics Committee)
maintains a registry of thousands of CRS definitions, each identified by a unique integer code.
EPSG codes are the lingua franca of spatial data: specifying crs = 4326 in sf means WGS84
geographic, and crs = 3857 means Web Mercator. R’s spatial packages accept EPSG codes as a
convenient shorthand, but under the hood they query the PROJ database to obtain the full WKT
definition.
11.4.3 The PROJ Library
PROJ (pronounced “proj-four” for its historical name, PROJ.4) is a C library that performs
coordinate transformations. It knows the formulas for hundreds of projections, the parameters of
thousands of datums, and the complex datum shifts (Helmert transformations, grid-based NTv2
files) required for high-accuracy conversion between datums.
In R, PROJ is called implicitly by sf::st_transform() and terra::project(). You never need to
interact with PROJ directly, but understanding its role helps you debug CRS problems.
The sf_extSoftVersion() function reports the PROJ version being used.
r
sf_extSoftVersion()
# GEOS GDAL proj.4
# "3.12.1" "3.8.4" "9.4.1"
The transition from PROJ.4 (legacy) to PROJ (versions 6+) brought a major improvement:
WKT2 (Well-Known Text version 2) for unambiguous CRS definitions. Older software
sometimes uses PROJ.4 strings ("+proj=utm +zone=50 +datum=WGS84 +units=m +no_defs"),
but these are now deprecated because they cannot express datum ensembles and grids.
Modern sf uses WKT2 and EPSG codes exclusively; you may encounter PROJ.4 strings in
legacy code, and sf will warn about them.
11.4.4 Working with CRS in sf and terra
st_crs(x) returns the CRS of an sf or sfc object as a crs object, which prints both the EPSG code
and the WKT.
r
st_crs(4326) # create a crs object from EPSG code
st_transform(x, crs) reprojects an sf object from its current CRS to a new one. This is the
operation that changes coordinate values: from degrees to metres, or from one projection to
another.
r
cities_utm <- cities %>% st_transform(32650) # to UTM Zone 50N
st_set_crs(x, crs) assigns a CRS to an object that lacks one, without transforming coordinates.
This is dangerous if used incorrectly (e.g., assigning a projected CRS to coordinates that are
actually in degrees). Use st_set_crs only when you know the data’s native CRS and the object is
missing the metadata.
In terra, the analogous functions are crs(x) to get the CRS, project(x, crs) to reproject, and crs(x)
<- value to set.
11.4.5 Choosing a CRS for Analysis
The choice of CRS depends on the task:
Data storage and sharing: WGS84 (EPSG:4326) is the standard for geographic data on
the web and in global datasets. It is a geographic CRS; coordinates are in decimal
degrees.
Area calculations: Use an equal-area projected CRS appropriate for the region (e.g.,
Albers Equal Area Conic for the conterminous US, EPSG:5070; or the appropriate UTM
zone for small areas). Computing area from lat/lon degrees gives incorrect results.
Distance calculations: Use a distance-preserving projection (equidistant) or, for global
analysis, compute great-circle distances directly from geographic coordinates using
spherical formulas (Haversine, Vincenty). The sf function st_distance() automatically
computes geodesic distances when the data are in geographic coordinates and
the sf_use_s2() flag is TRUE (the default).
Web mapping: Web Mercator (EPSG:3857) is the de facto standard because it is a
projected CRS that works well for tile-based maps. However, it grossly distorts area at
high latitudes.
Regional mapping: Choose a projected CRS optimised for the region (national mapping
agencies provide recommended projections).
The CRS is the single most important metadata item for any spatial dataset. An incorrect or
missing CRS will silently produce wrong results—wrong distances, wrong areas, wrong
overlays. Always check the CRS of every object you import with st_crs(), and always document
the CRS you used in your analysis.
11.4.6 Technical Demonstration: CRS Operations
r
library(sf)
library(rnaturalearth)
# Get world data (geographic CRS)
world <- ne_countries(scale = "small", returnclass = "sf")
st_crs(world) # EPSG:4326 (WGS84)
# Choose a country and reproject to a local UTM
country <- world %>% filter(admin == "India")
st_crs(country)
# Find UTM zone for central longitude
# India spans roughly 68°E to 97°E; central meridian ~82.5°E
# UTM zone = floor((82.5 + 180) / 6) + 1 = 44? Let's compute: (82.5+180)/6 = 43.75, floor=43,
+1=44.
# Northern hemisphere: EPSG:32644 (UTM Zone 44N)
india_utm <- country %>% st_transform(32644)
st_crs(india_utm) # projected CRS
# Transform back to geographic
india_geo <- india_utm %>% st_transform(4326)
# Assign CRS to data without metadata (careful!)
# Suppose we have coordinates known to be in UTM 44N
coords <- c(500000, 2500000)
pt <- st_point(coords)
sfc <- st_sfc(pt, crs = 32644) # assign correctly during creation
Hard Practice 11 – “Cataloguing All Installed CRS Definitions and Their Parameters”
Objective:
Explore the PROJ database available in your R installation. List all available CRS definitions,
extract specific parameters, and verify the CRS of a local dataset. This practice demystifies the
CRS system and builds confidence in handling projections.
Scenario:
You are setting up a geospatial laboratory and need to document the available CRS capabilities.
You will query the PROJ database from R, create a catalogue of CRS codes and their types, and
test transformations on a simple point.
Instructions:
1. Create a new script practice_chapter11_crs_catalogue.R. Add a header.
2. Query the PROJ database: Use sf::st_crs("EPSG:") with an empty string?
Actually, sf_proj_pipelines() or rgdal::make_EPSG() from the deprecated rgdal is gone.
In modern sf, you can list available EPSG codes using sf::st_crs() with
the sf_proj_search()? There is sf_proj_info(type = "crs") which lists all CRS in the PROJ
database. Use:
r
library(sf)
crs_list <- sf_proj_info(type = "crs")
head(crs_list)
This returns a data frame with columns id, name, type (e.g., "geographic 2D", "projected"),
and area. Count the number of geographic vs projected CRS installed.
3. Extract UTM zones: Filter crs_list for names containing "UTM zone". Count how many
UTM zone definitions exist for the northern and southern hemispheres.
4. Test CRS creation: Randomly select five EPSG codes from the list. For each,
use st_crs(code) to create a crs object and print its WKT (well-known text). Confirm
that st_crs() does not throw an error.
5. Transform a point through multiple CRS: Create a point in WGS84 (EPSG:4326) at
(0° longitude, 0° latitude). Transform it to Web Mercator (EPSG:3857), then to UTM
Zone 30N (EPSG:32630), then back to WGS84. Print the coordinates at each step and
verify that the round-trip returns to (0,0).
6. Check CRS of installed datasets: Load rnaturalearth::ne_countries(). Print its CRS.
Reproject it to the Robinson projection (use "+proj=robin" as a PROJ string, or find the
EPSG code if available; Robinson is EPSG:54030? Actually, Robinson is not in EPSG
officially; PROJ string works). Print the new CRS.
7. Reflection:
o Why is it dangerous to use st_set_crs() with a different CRS than the true one?
o What is the difference between a geographic CRS and a projected CRS?
o How would you choose an appropriate projection for a study area covering
Thailand? (Think about the country’s latitude range and orientation.)
Deliverable:
The script practice_chapter11_crs_catalogue.R with all queries, transformations, and reflection
comments.
Chapter 11 Review Problems
These problems test your understanding of the conceptual models of GIS, the sf package
structure, the R spatial ecosystem, and coordinate reference systems. Solve each in a clearly
commented script.
Easy Problems (1–5)
1. Create an sf Point
Create an sf object representing a single point at your home city coordinates. Include an attribute
column with the city name. Print the object and its class. Verify that it has a CRS assigned.
2. Identify the Geometry Type
Using st_point, st_linestring, and st_polygon, create one example of each geometry type.
Combine them into a single sfc column (use st_sfc with crs = 4326). Print the resulting sfc and
note its class.
3. Read and Inspect a Shapefile
Use rnaturalearth::ne_countries(scale = "small", returnclass = "sf") to load world countries.
Use st_crs(), nrow(), ncol(), and head() to describe the dataset. What columns are present? What
geometry type is it?
4. CRS Basics
Create a crs object from EPSG code 4326. Print its WKT. Create another from EPSG 3857.
Explain in a comment the difference between the two.
5. Check Package Versions
Use sf_extSoftVersion() to report the installed versions of GDAL, GEOS, and PROJ.
Use packageVersion("sf") to report the sf version.
Medium Problems (6–10)
6. Construct a Polygon from Scratch
Create an sf object representing a square of 1° × 1° centred on (0°E, 0°N). Create it by
constructing the outer ring as a matrix, then st_polygon, then st_sfc, then st_sf. Assign WGS84
CRS. Plot it. Compute its area with st_area(); what are the units? (Hint: they will be square
metres computed via geodesic calculation on the sphere.)
7. Vector-Raster Duality
Explain in a paragraph (as a comment) the difference between the entity model and the field
model. For each, give an example of a real-world phenomenon best represented by that model,
and name the R package primarily used to work with it.
8. CRS Transformation
Load the world dataset. Filter to Canada. Reproject to UTM Zone 18N (EPSG:32618). Plot the
original and reprojected geometries side-by-side (use par(mfrow = c(1,2)) in base R
or patchwork in ggplot2). Describe in a comment how the shape appears to change and why.
9. Explore the sf Object Structure
Use str() on the world dataset to examine its internal structure. Identify the geometry column.
Use $geometry to extract it; what class is it? Use [[1]] on the geometry column to extract the
geometry of the first country; what class is it? Document the hierarchy.
10. GDAL Drivers
Use st_drivers() to list all vector drivers. Filter the data frame to show only drivers that support
both reading and writing (st_drivers() %>% filter(grepl("read", write) & ... )? The columns
are name, long_name, write, copy, is_raster, is_vector). Identify three drivers that support writing
and could be used to export an sf object.
Challenging Problems (11–15)
11. Custom CRS Investigation
The UK Ordnance Survey uses the British National Grid (EPSG:27700). Use st_crs(27700) to
get the WKT. Parse the WKT (or use st_crs(27700)$proj4string for a simpler view) to identify
the projection type, the central meridian, the standard parallels, and the false easting and
northing. Explain why these parameters are needed.
12. Spherical vs. Planar Distance
Create two points: London (51.5°N, 0.1°W) and Beijing (39.9°N, 116.4°E). Compute the
distance between them using st_distance(). Note that the output is in metres (geodesic distance).
Now transform both points to Web Mercator (EPSG:3857) and compute the distance again.
Compare the two distances and explain why the Web Mercator distance is grossly incorrect.
13. Raster-Vector Interoperability
Install terra. Create a SpatRaster with 10 rows, 10 columns, extent 0–1 in both x and y, and
random values. Convert it to an sf data frame using [Link]() (to get the cell centroids as
points). What CRS does the resulting sf object have? Assign WGS84 to it if it lacks one. Then
convert the sf points back to a SpatRaster using terra::rasterize(). This demonstrates
interoperability.
14. PROJ Database Exploration
Use sf_proj_info(type = "crs") to obtain the full CRS list. Find all CRS definitions that contain
"Albers" in their name. How many are there? Filter for those covering "Canada". What EPSG
code is the standard Albers Equal Area for Canada? (Answer: EPSG:102001 or similar; check the
actual list.)
15. Conceptual Essay: Data Models in Practice
Write a short essay (as a comment block) describing how you would represent the following in a
GIS: a river network, the elevation of the surrounding landscape, and the administrative districts
through which the rivers flow. For each, identify the data model (entity, field, network), the
geometry type, the R package you would use, and the key operations you would perform. This is
a synthesis exercise linking conceptual models to R’s spatial ecosystem.
Chapter 13: Coordinate Transformations and Map Projections
In Chapter 12 you created and manipulated vector geometries. But those geometries float in an
abstract Cartesian space until they are anchored to the Earth by a coordinate reference system.
And once anchored, you will often need to move them—from the curved Earth to a flat map, from
one projection to another, from metres to degrees and back. This chapter is the definitive
treatment of coordinate transformations in R. We begin with the theory of reprojection: what
happens mathematically when st_transform() is called. We then dive into the practical art of
choosing a projection—equal-area for analysis, conformal for navigation, compromise for
global maps—and we provide a decision tree for common geospatial tasks. The third section
explores custom grids, the WKT2 string format that defines modern CRS, and the datum shifts
that make high-precision transformation possible. Every concept is demonstrated
with sf and terra code, and the Hard Practice asks you to compare area computations under
different projections, a classic exercise that reveals why projection choice matters. By the end,
you will no longer treat the CRS as a black box; you will select, apply, and defend your
projection choices with confidence.
13.1 Reprojecting Vector Data (st_transform)
Reprojection is the act of converting coordinates from one CRS to another. It is the single most
important spatial operation, because data from different sources rarely share the same CRS, and
analysis requires a common framework.
13.1.1 What Happens When You Reproject?
When you call st_transform(x, crs), sf performs a multi-step process using the PROJ library:
1. Parse the source CRS from the object’s metadata. If the object lacks a CRS (st_crs(x)
== NA), the operation stops with an error.
2. Parse the target CRS from the crs argument. This can be an EPSG integer, a WKT
string, a PROJ string, or a crs object.
3. Construct a transformation pipeline. PROJ selects the most accurate available
transformation path. This may involve:
o Converting angular units (degrees) to the geocentric Cartesian system (3D
coordinates with origin at the Earth’s centre).
o Applying a datum shift (Helmert transformation or grid-based correction) to move
between reference ellipsoids.
o Applying the map projection’s mathematical formulas (e.g., Transverse Mercator,
Albers Equal Area).
o Scaling to linear units (metres, feet).
4. Transform every coordinate pair in the geometry column. For large datasets, this is
done in compiled C code and is fast.
5. Return a new object with coordinates in the target CRS. The original object is
unchanged.
The critical point: reprojection changes coordinate values. The location on the Earth’s surface
is the same (within the accuracy of the transformation), but the numbers that describe that
location change.
13.1.2 Using st_transform()
r
library(sf)
# Create a point in WGS84
pt_geo <- st_sfc(st_point(c(116.4074, 39.9042)), crs = 4326)
# Reproject to Web Mercator
pt_merc <- st_transform(pt_geo, 3857)
pt_merc # large numbers: metres from the projection's origin
# Reproject to UTM Zone 50N
pt_utm <- st_transform(pt_geo, 32650)
pt_utm # easting ~450,000 m, northing ~4,420,000 m
st_transform() works on any sf, sfc, or sfg object, and on SpatRaster objects from terra.
For SpatRaster, the function is terra::project().
13.1.3 Transforming sf Data Frames
The vectorised nature of st_transform means you can reproject an entire sf data frame with a
single call:
r
world_merc <- st_transform(world, 3857)
All geometries are transformed, all attributes are preserved, and the CRS metadata is updated.
13.1.4 The s2 Library and Spherical Geometry
When data are in a geographic CRS (degrees), and the sf_use_s2() flag is TRUE (the default
since sf 1.0), sf uses the s2 geometry library for operations like st_distance(), st_area(),
and st_buffer(). This library performs computations on a sphere (or ellipsoid), not on a flat plane.
This is the correct way to compute global distances and areas, because treating degrees as planar
coordinates is wildly wrong.
You can check the status: sf_use_s2(). If you need to work with legacy code that assumes planar
geometry, you can turn it off with sf_use_s2(FALSE), but this is generally not recommended for
geographic coordinates. For projected coordinates, s2 is irrelevant because the coordinates are
already planar.
13.1.5 When Reprojection Is Necessary
Combining data from different sources: A shapefile from a national mapping agency in
UTM, and a GPS track in WGS84. Reproject one to match the other.
Area calculations: Must be done in an equal-area projection or with s2 on geographic
coordinates.
Distance calculations: For local distances, a suitable projected CRS; for global
distances, use s2 or a great-circle formula.
Mapping: The final map should use a projection appropriate for the region and the map’s
purpose.
13.2 Choosing the Right Projection for Distance, Area, and Direction
No single projection is perfect. Each is designed to preserve one or two properties at the expense
of others. The art of projection selection is the art of matching the projection’s strengths to the
analytical task.
13.2.1 The Four Properties
1. Conformal (preserves shape locally): Angles are preserved at every point. Mercator is
conformal. Use for navigation (rhumb lines are straight) and for large-scale mapping
where shape fidelity matters. Not for area-based analysis.
2. Equal-Area (preserves area): Areas of features are proportional to their true areas.
Albers Equal Area Conic, Lambert Azimuthal Equal Area, Mollweide. Use for density
maps, land-cover change analysis, any statistical summary by region.
3. Equidistant (preserves distance): Distances from one or two points to all other points
are true. Plate Carrée, Azimuthal Equidistant. Use for radio wave propagation, range
rings from a city.
4. Compromise: Neither strictly conformal nor equal-area, but visually pleasing for global
or continental maps. Robinson, Winkel Tripel. Use for general reference maps, wall
maps.
No flat map can be both conformal and equal-area simultaneously. This is a mathematical
theorem, not a software limitation.
13.2.2 A Decision Tree for Projection Choice
Task: Compute area of polygons.
→ Decision: Use an equal-area projection appropriate for the latitude and extent.
For the entire globe: Mollweide ("+proj=moll") or Hammer ("+proj=hammer").
For continents: Albers Equal Area Conic for mid-latitude continents; Lambert Azimuthal
Equal Area for polar regions.
For a small region (< 10° extent): UTM zone or a local equal-area projection.
Task: Compute distances between points.
→ Decision:
If the data are in geographic coordinates and the distances are large (continental/global),
keep them in WGS84. sf will compute geodesic distances via s2 (or use st_distance(...,
which = "Great Circle")).
If the distances are local (< 100 km), use a UTM zone or a local equidistant projection.
Task: Create a global thematic map.
→ Decision: Use a compromise projection. Robinson or Winkel Tripel for general audiences;
Mollweide for area-sensitive thematic data (but it distorts shape at high latitudes).
Task: Web map.
→ Decision: Web Mercator (EPSG:3857) is the de facto standard for tiled web maps. Accept the
area distortion for compatibility.
Task: Navigation.
→ Decision: Mercator, because straight lines are rhumb lines (constant bearing).
13.2.3 Common Projections in EPSG Codes
Projection EPSG Type Use Case
WGS84 4326 Geographic Global storage, GPS
Web Mercator 3857 Projected Web mapping
Projected Local analysis in East
UTM Zone 50N 32650
(conformal) Asia
Projected
UTM Zone 18N 32618 Eastern North America
(conformal)
Albers Equal Area Conic Projected Continental US
5070
(US) (equal-area) thematic mapping
Lambert Azimuthal Equal Projected European statistical
3035
Area (Europe) (equal-area) mapping
Projected
Robinson ESRI:54030 Global reference maps
(compromise)
Projected Global area-accurate
Mollweide ESRI:54009
(equal-area) maps
Not all projections have official EPSG codes; some are defined by ESRI or require PROJ
strings. sf can use PROJ strings as a fallback.
13.2.4 Checking Distortion with Tissot’s Indicatrices
A Tissot indicatrix is a small circle on the Earth. On a projected map, it becomes an ellipse,
revealing local distortion. You can generate Tissot indicatrices in R with the sf package by
creating a grid of points in geographic coordinates, buffering them with a small geodesic
distance, and then transforming the buffers to the projection under test. The shape and size of the
resulting ellipses show whether the projection is conformal (circles remain circles), equal-area
(ellipses have the same area as the original circle), or neither.
We will perform this in the Hard Practice.
13.3 Custom Grids, CRS WKT Strings, and Datum Shifts
The EPSG registry covers most common cases, but sometimes you need a custom CRS: a
modified UTM, a local mine grid, an oblique projection. This section explains how to define
custom CRS using WKT2 strings and how to understand datum transformations.
13.3.1 The Well-Known Text (WKT) Format
WKT is a human-readable text representation of a CRS, defined by the OGC standard 18-010r7.
A WKT2 string for WGS84 looks like:
text
GEOGCRS["WGS 84",
DATUM["World Geodetic System 1984",
ELLIPSOID["WGS 84",6378137,298.257223563, ...]],
PRIMEM["Greenwich",0],
CS[ellipsoidal,2],
AXIS["geodetic latitude (Lat)",north],
AXIS["geodetic longitude (Lon)",east],
...]
You can obtain the WKT of any crs object with st_crs(4326)$wkt. To create a CRS from a WKT
string, pass it to st_crs():
r
my_crs <- st_crs("GEOGCRS[\"WGS 84\", ...]") # full WKT string
13.3.2 PROJ Strings (Legacy)
Older code uses PROJ strings like "+proj=utm +zone=50 +datum=WGS84 +units=m +no_defs".
These are now deprecated by PROJ itself but still work in sf for backward compatibility. The
modern approach is to use EPSG codes or WKT2. If you must use a PROJ
string, st_crs("+proj=...") will accept it with a warning.
13.3.3 Defining a Custom CRS
Suppose you need an oblique Mercator projection centred on a specific point. You can construct
the WKT or PROJ string manually:
r
# Oblique Mercator centred on (120°E, 30°N) for a transect
custom_crs <- st_crs(
"+proj=omerc +lat_0=30 +lonc=120 +alpha=45 +gamma=0 +k_0=0.9996 +datum=WGS84
+units=m +no_defs"
)
Then use st_transform(x, custom_crs). However, for serious custom work, consult the PROJ
documentation for the exact parameter names.
13.3.4 Datum Shifts
A datum is a reference frame for measuring locations on the Earth. WGS84 is the datum used by
GPS. Other datums include NAD83 (North America), ETRS89 (Europe), GDA2020 (Australia).
They differ by up to a few metres because they reference different ellipsoids and control
networks.
When transforming between datums (e.g., NAD27 to WGS84), PROJ must apply a datum shift.
There are two types:
Helmert transformation: A 7-parameter similarity transformation (translation, rotation,
scale). Fast but approximate (1–2 m accuracy).
Grid-based correction: A file containing local offsets on a regular grid. PROJ uses
NTv2 grids for many transformations, achieving centimetre-level accuracy.
sf with recent PROJ automatically selects the best available datum shift. You can inspect the
transformation pipeline with sf_proj_pipelines(source_crs, target_crs):
r
# See available pipelines for NAD83 to WGS84
pipes <- sf_proj_pipelines(st_crs(4269), st_crs(4326))
pipes$definition[1:3]
For most environmental applications, the default pipeline is sufficient. For high-precision
surveying, you may need to install additional grid files and specify the desired pipeline.
13.3.5 Technical Demonstration: Exploring CRS Transformations
r
library(sf)
library(dplyr)
# 1. Create a regular grid of points in WGS84
lons <- seq(-180, 180, by = 30)
lats <- seq(-80, 80, by = 20)
grid_pts <- [Link](lon = lons, lat = lats)
grid_sf <- st_as_sf(grid_pts, coords = c("lon", "lat"), crs = 4326)
# 2. Transform to several projections
grid_merc <- st_transform(grid_sf, 3857)
grid_robin <- st_transform(grid_sf, "+proj=robin")
grid_moll <- st_transform(grid_sf, "+proj=moll")
# 3. Observe how coordinates change
head(st_coordinates(grid_sf))
head(st_coordinates(grid_merc)) # large metres
head(st_coordinates(grid_robin)) # projected metres
# 4. Compare distances between two points under different projections
pt1 <- st_sfc(st_point(c(0, 0)), crs = 4326)
pt2 <- st_sfc(st_point(c(10, 10)), crs = 4326)
# Geodesic distance (s2)
st_distance(pt1, pt2) # in metres
# Planar distance in Mercator (wrong)
pt1_merc <- st_transform(pt1, 3857)
pt2_merc <- st_transform(pt2, 3857)
st_distance(pt1_merc, pt2_merc) # much larger, because Mercator inflates high latitudes
Hard Practice 13 – “Comparing Area Computed Under Different Projections for a Country
Boundary”
Objective:
Compute the area of a country using multiple projections (equal-area, conformal, compromise,
geographic with s2) and compare the results. Understand why projection choice matters for
quantitative analysis.
Scenario:
You are tasked with reporting the area of Indonesia for a land-cover change study. Indonesia
straddles the equator and is widely spread in longitude. Different projections will give different
answers. You will compute the area using:
1. WGS84 with s2 (geodesic).
2. Web Mercator (EPSG:3857).
3. Mollweide (equal-area, global).
4. Albers Equal Area Conic centred on Indonesia (custom parameters: +proj=aea +lat_1=7
+lat_2=-12 +lat_0=-2 +lon_0=118 +datum=WGS84 +units=m).
5. UTM zones (pick one that covers only part of Indonesia, to show the danger of using a
single zone for a trans-equatorial country).
Instructions:
1. Load Indonesia boundary: Use rnaturalearth::ne_countries(scale = "medium",
returnclass = "sf") and filter to admin == "Indonesia".
2. Compute area in each of the five CRS choices. Use st_area(). For the UTM zone, first
find a zone that covers part of Indonesia (e.g., EPSG:32750 for UTM Zone 50S), but note
that Indonesia spans multiple UTM zones.
3. Convert all areas to square kilometres (1e6 m² per km²). Create a data
frame results with columns method and area_km2. Treat the WGS84/s2 result as the
reference truth and compute the percentage error of each method relative to it.
4. Plot the country boundaries in each projection using ggplot2 with geom_sf(). Arrange
the four projected maps in a 2×2 grid using patchwork. Under each map, label it with the
projection name and the computed area.
5. Interpretation: Write a short paragraph as a comment explaining:
o Which methods give areas closest to the s2 reference, and why.
o Why Web Mercator produces a gross overestimate for a country near the equator?
(Hint: Mercator is conformal, not equal-area; area distortion increases with
latitude. Indonesia near the equator may not be as overestimated as Greenland, but
still distorted.)
o Why a single UTM zone is inappropriate for Indonesia.
Deliverable:
A script practice_chapter13_area_comparison.R with the full analysis, the data frame of results,
the multi-panel plot, and the interpretive paragraph.
Chapter 13 Review Problems
Solve each in a clearly commented R script using sf. Pay attention to CRS specification and the
impact of projection choices.
Easy Problems (1–5)
1. Basic Reprojection
Create an sf point at (longitude=120, latitude=30) with WGS84 CRS. Reproject it to UTM Zone
51N (EPSG:32651). Print the old and new coordinates. What are the units of the new
coordinates?
2. Transform an Entire Layer
Load the world dataset from rnaturalearth. Reproject it to the Robinson projection
("+proj=robin"). Print the CRS before and after. Plot the original and reprojected maps
side-by-side.
3. Check CRS Equality
Create two crs objects: crs1 <- st_crs(4326) and crs2 <- st_crs(4326). Use == to compare them.
Try st_crs(4326) == st_crs(3857). Explain why the comparison works.
4. Extract WKT
Use st_crs(4326) to obtain a crs object. Extract its WKT string with $wkt. Print only the first 200
characters. Observe the structure: name, datum, ellipsoid, prime meridian.
5. List Available Transformations
Use sf_proj_pipelines(st_crs(4326), st_crs(32650)) to list the transformation pipelines from
WGS84 to UTM Zone 50N. How many are available? What is the accuracy of each?
Medium Problems (6–10)
6. Compare Distance in Different Projections
Create two points: (100°W, 40°N) and (105°W, 45°N) in WGS84. Compute the geodesic distance
with st_distance(). Transform both to UTM Zone 13N (EPSG:32613) and compute the planar
distance. Transform to Web Mercator and compute. Report the three distances and the percentage
difference relative to the geodesic distance.
7. Area of a Buffer
Create a point at (0°N, 0°E) in WGS84. Buffer it by 200 km. Compute the buffer area
with st_area() (which will use s2). Transform the buffer to Mollweide ("+proj=moll") and
compute the area again. Transform to Web Mercator and compute. Report the three areas. Which
is closest to the true area of a circle of 200 km radius (π × (200,000)²)?
8. Custom Albers Projection
Define an Albers Equal Area Conic projection for China, with standard parallels at 25°N and
47°N, central meridian at 105°E, and latitude of origin at 35°N. Create the PROJ string manually.
Load China from rnaturalearth and reproject to this custom CRS. Plot the result. Compute
China’s area in this projection and compare to the s2-based area.
9. Datum Shift Investigation
Load a dataset of US states (rnaturalearth::ne_states(country = "United States of America",
returnclass = "sf")). The data are in WGS84 (EPSG:4326). Transform to NAD83 (EPSG:4269)
and then back to WGS84. Do the coordinates change? Compute the maximum shift across all
vertices. (Hint: Use st_coordinates() before and after the double transform.)
10. Projection for a Transect
Define a transect line from Cape Town (18.4°E, 33.9°S) to Cairo (31.2°E, 30.0°N). Create a
custom Lambert Azimuthal Equal Area projection centred on the midpoint of the transect. Project
the line and compute its length. Compare to the geodesic length. Plot the transect in both WGS84
and the custom projection.
Challenging Problems (11–15)
11. Tissot Indicatrix Generator
Write a function tissot_indicatrices(grid, radius_km, target_crs) that:
Takes a regular grid of points in WGS84 (grid_sf).
Buffers each point by radius_km (creating small circular polygons in geodesic space).
Transforms the buffers to target_crs.
Returns the transformed polygons.
Apply it to a 20°×20° global grid with radius 500 km, and project to Mercator, Robinson,
and Mollweide. Plot each set of indicatrices on a world basemap. Annotate the plot to
explain what the shapes indicate about each projection’s distortion.
12. Area-Corrected Cartogram (Thought Experiment)
A cartogram distorts space to represent a thematic variable. While full cartogram generation is
beyond sf, you can simulate a simple one: take four neighbouring countries, assign them a
desired area proportional to population, and using an iterative algorithm (e.g., a simple
rubber-sheet transformation), adjust their coordinates to match the target areas. Implement a
basic scaling: shift each country’s centroid outward and scale its coordinates by the ratio of
sqrt(target_area / original_area). Plot the original and distorted maps. This is a programming
challenge.
13. Spherical vs. Ellipsoidal Area for a Large Polygon
st_area() with s2 computes area on the WGS84 ellipsoid. Compare this to the area computed on a
sphere of radius 6371 km. For a large, high-latitude polygon (e.g., Greenland), the difference is
noticeable. Load Greenland from rnaturalearth, compute area with s2, and then compute the
spherical area by temporarily setting sf_use_s2(TRUE) but projecting? Actually, s2 uses the
ellipsoid. To compute spherical area, you can project to an equal-area projection that uses a
sphere (e.g., "+proj=moll +R=6371000" with a spherical Earth radius). Compute the percentage
difference.
14. Transform a Raster and Compare Resampling Methods
Use terra to create a 100×100 raster of the study area extent (e.g., a country) with random values.
Project this raster from WGS84 to UTM using terra::project(). Compare the results of method =
"bilinear" and method = "near" (nearest neighbour). Plot the difference. Explain why the choice
of resampling method matters for continuous vs. categorical rasters.
15. Build a CRS from Scratch
Design a custom CRS for a hypothetical exoplanet survey. The planet has radius 5000 km
(different from Earth). Define a geographic CRS on a sphere of radius 5000 km, and a simple
Plate Carrée projection on that sphere. Write the WKT2 by hand (or by modifying a template
from st_crs(4326)$wkt, changing the ellipsoid parameters). Create a crs object from your WKT.
Transform a point on this exoplanet from geographic to projected coordinates. This is a creative
exercise that tests deep understanding of CRS components.
Chapter 13: Coordinate Transformations and Map Projections
In Chapter 12 you created and manipulated vector geometries. But those geometries float in an
abstract Cartesian space until they are anchored to the Earth by a coordinate reference system.
And once anchored, you will often need to move them—from the curved Earth to a flat map, from
one projection to another, from metres to degrees and back. This chapter is the definitive
treatment of coordinate transformations in R. We begin with the theory of reprojection: what
happens mathematically when st_transform() is called. We then dive into the practical art of
choosing a projection—equal-area for analysis, conformal for navigation, compromise for
global maps—and we provide a decision tree for common geospatial tasks. The third section
explores custom grids, the WKT2 string format that defines modern CRS, and the datum shifts
that make high-precision transformation possible. Every concept is demonstrated
with sf and terra code, and the Hard Practice asks you to compare area computations under
different projections, a classic exercise that reveals why projection choice matters. By the end,
you will no longer treat the CRS as a black box; you will select, apply, and defend your
projection choices with confidence.
13.1 Reprojecting Vector Data (st_transform)
Reprojection is the act of converting coordinates from one CRS to another. It is the single most
important spatial operation, because data from different sources rarely share the same CRS, and
analysis requires a common framework.
13.1.1 What Happens When You Reproject?
When you call st_transform(x, crs), sf performs a multi-step process using the PROJ library:
1. Parse the source CRS from the object’s metadata. If the object lacks a CRS (st_crs(x)
== NA), the operation stops with an error.
2. Parse the target CRS from the crs argument. This can be an EPSG integer, a WKT
string, a PROJ string, or a crs object.
3. Construct a transformation pipeline. PROJ selects the most accurate available
transformation path. This may involve:
o Converting angular units (degrees) to the geocentric Cartesian system (3D
coordinates with origin at the Earth’s centre).
o Applying a datum shift (Helmert transformation or grid-based correction) to move
between reference ellipsoids.
o Applying the map projection’s mathematical formulas (e.g., Transverse Mercator,
Albers Equal Area).
o Scaling to linear units (metres, feet).
4. Transform every coordinate pair in the geometry column. For large datasets, this is
done in compiled C code and is fast.
5. Return a new object with coordinates in the target CRS. The original object is
unchanged.
The critical point: reprojection changes coordinate values. The location on the Earth’s surface
is the same (within the accuracy of the transformation), but the numbers that describe that
location change.
13.1.2 Using st_transform()
r
library(sf)
# Create a point in WGS84
pt_geo <- st_sfc(st_point(c(116.4074, 39.9042)), crs = 4326)
# Reproject to Web Mercator
pt_merc <- st_transform(pt_geo, 3857)
pt_merc # large numbers: metres from the projection's origin
# Reproject to UTM Zone 50N
pt_utm <- st_transform(pt_geo, 32650)
pt_utm # easting ~450,000 m, northing ~4,420,000 m
st_transform() works on any sf, sfc, or sfg object, and on SpatRaster objects from terra.
For SpatRaster, the function is terra::project().
13.1.3 Transforming sf Data Frames
The vectorised nature of st_transform means you can reproject an entire sf data frame with a
single call:
r
world_merc <- st_transform(world, 3857)
All geometries are transformed, all attributes are preserved, and the CRS metadata is updated.
13.1.4 The s2 Library and Spherical Geometry
When data are in a geographic CRS (degrees), and the sf_use_s2() flag is TRUE (the default
since sf 1.0), sf uses the s2 geometry library for operations like st_distance(), st_area(),
and st_buffer(). This library performs computations on a sphere (or ellipsoid), not on a flat plane.
This is the correct way to compute global distances and areas, because treating degrees as planar
coordinates is wildly wrong.
You can check the status: sf_use_s2(). If you need to work with legacy code that assumes planar
geometry, you can turn it off with sf_use_s2(FALSE), but this is generally not recommended for
geographic coordinates. For projected coordinates, s2 is irrelevant because the coordinates are
already planar.
13.1.5 When Reprojection Is Necessary
Combining data from different sources: A shapefile from a national mapping agency in
UTM, and a GPS track in WGS84. Reproject one to match the other.
Area calculations: Must be done in an equal-area projection or with s2 on geographic
coordinates.
Distance calculations: For local distances, a suitable projected CRS; for global
distances, use s2 or a great-circle formula.
Mapping: The final map should use a projection appropriate for the region and the map’s
purpose.
13.2 Choosing the Right Projection for Distance, Area, and Direction
No single projection is perfect. Each is designed to preserve one or two properties at the expense
of others. The art of projection selection is the art of matching the projection’s strengths to the
analytical task.
13.2.1 The Four Properties
1. Conformal (preserves shape locally): Angles are preserved at every point. Mercator is
conformal. Use for navigation (rhumb lines are straight) and for large-scale mapping
where shape fidelity matters. Not for area-based analysis.
2. Equal-Area (preserves area): Areas of features are proportional to their true areas.
Albers Equal Area Conic, Lambert Azimuthal Equal Area, Mollweide. Use for density
maps, land-cover change analysis, any statistical summary by region.
3. Equidistant (preserves distance): Distances from one or two points to all other points
are true. Plate Carrée, Azimuthal Equidistant. Use for radio wave propagation, range
rings from a city.
4. Compromise: Neither strictly conformal nor equal-area, but visually pleasing for global
or continental maps. Robinson, Winkel Tripel. Use for general reference maps, wall
maps.
No flat map can be both conformal and equal-area simultaneously. This is a mathematical
theorem, not a software limitation.
13.2.2 A Decision Tree for Projection Choice
Task: Compute area of polygons.
→ Decision: Use an equal-area projection appropriate for the latitude and extent.
For the entire globe: Mollweide ("+proj=moll") or Hammer ("+proj=hammer").
For continents: Albers Equal Area Conic for mid-latitude continents; Lambert Azimuthal
Equal Area for polar regions.
For a small region (< 10° extent): UTM zone or a local equal-area projection.
Task: Compute distances between points.
→ Decision:
If the data are in geographic coordinates and the distances are large (continental/global),
keep them in WGS84. sf will compute geodesic distances via s2 (or use st_distance(...,
which = "Great Circle")).
If the distances are local (< 100 km), use a UTM zone or a local equidistant projection.
Task: Create a global thematic map.
→ Decision: Use a compromise projection. Robinson or Winkel Tripel for general audiences;
Mollweide for area-sensitive thematic data (but it distorts shape at high latitudes).
Task: Web map.
→ Decision: Web Mercator (EPSG:3857) is the de facto standard for tiled web maps. Accept the
area distortion for compatibility.
Task: Navigation.
→ Decision: Mercator, because straight lines are rhumb lines (constant bearing).
13.2.3 Common Projections in EPSG Codes
Projection EPSG Type Use Case
WGS84 4326 Geographic Global storage, GPS
Web Mercator 3857 Projected Web mapping
UTM Zone 50N 32650 Projected Local analysis in East
Projection EPSG Type Use Case
(conformal) Asia
Projected
UTM Zone 18N 32618 Eastern North America
(conformal)
Albers Equal Area Conic Projected Continental US
5070
(US) (equal-area) thematic mapping
Lambert Azimuthal Equal Projected European statistical
3035
Area (Europe) (equal-area) mapping
Projected
Robinson ESRI:54030 Global reference maps
(compromise)
Projected Global area-accurate
Mollweide ESRI:54009
(equal-area) maps
Not all projections have official EPSG codes; some are defined by ESRI or require PROJ
strings. sf can use PROJ strings as a fallback.
13.2.4 Checking Distortion with Tissot’s Indicatrices
A Tissot indicatrix is a small circle on the Earth. On a projected map, it becomes an ellipse,
revealing local distortion. You can generate Tissot indicatrices in R with the sf package by
creating a grid of points in geographic coordinates, buffering them with a small geodesic
distance, and then transforming the buffers to the projection under test. The shape and size of the
resulting ellipses show whether the projection is conformal (circles remain circles), equal-area
(ellipses have the same area as the original circle), or neither.
We will perform this in the Hard Practice.
13.3 Custom Grids, CRS WKT Strings, and Datum Shifts
The EPSG registry covers most common cases, but sometimes you need a custom CRS: a
modified UTM, a local mine grid, an oblique projection. This section explains how to define
custom CRS using WKT2 strings and how to understand datum transformations.
13.3.1 The Well-Known Text (WKT) Format
WKT is a human-readable text representation of a CRS, defined by the OGC standard 18-010r7.
A WKT2 string for WGS84 looks like:
text
GEOGCRS["WGS 84",
DATUM["World Geodetic System 1984",
ELLIPSOID["WGS 84",6378137,298.257223563, ...]],
PRIMEM["Greenwich",0],
CS[ellipsoidal,2],
AXIS["geodetic latitude (Lat)",north],
AXIS["geodetic longitude (Lon)",east],
...]
You can obtain the WKT of any crs object with st_crs(4326)$wkt. To create a CRS from a WKT
string, pass it to st_crs():
r
my_crs <- st_crs("GEOGCRS[\"WGS 84\", ...]") # full WKT string
13.3.2 PROJ Strings (Legacy)
Older code uses PROJ strings like "+proj=utm +zone=50 +datum=WGS84 +units=m +no_defs".
These are now deprecated by PROJ itself but still work in sf for backward compatibility. The
modern approach is to use EPSG codes or WKT2. If you must use a PROJ
string, st_crs("+proj=...") will accept it with a warning.
13.3.3 Defining a Custom CRS
Suppose you need an oblique Mercator projection centred on a specific point. You can construct
the WKT or PROJ string manually:
r
# Oblique Mercator centred on (120°E, 30°N) for a transect
custom_crs <- st_crs(
"+proj=omerc +lat_0=30 +lonc=120 +alpha=45 +gamma=0 +k_0=0.9996 +datum=WGS84
+units=m +no_defs"
)
Then use st_transform(x, custom_crs). However, for serious custom work, consult the PROJ
documentation for the exact parameter names.
13.3.4 Datum Shifts
A datum is a reference frame for measuring locations on the Earth. WGS84 is the datum used by
GPS. Other datums include NAD83 (North America), ETRS89 (Europe), GDA2020 (Australia).
They differ by up to a few metres because they reference different ellipsoids and control
networks.
When transforming between datums (e.g., NAD27 to WGS84), PROJ must apply a datum shift.
There are two types:
Helmert transformation: A 7-parameter similarity transformation (translation, rotation,
scale). Fast but approximate (1–2 m accuracy).
Grid-based correction: A file containing local offsets on a regular grid. PROJ uses
NTv2 grids for many transformations, achieving centimetre-level accuracy.
sf with recent PROJ automatically selects the best available datum shift. You can inspect the
transformation pipeline with sf_proj_pipelines(source_crs, target_crs):
r
# See available pipelines for NAD83 to WGS84
pipes <- sf_proj_pipelines(st_crs(4269), st_crs(4326))
pipes$definition[1:3]
For most environmental applications, the default pipeline is sufficient. For high-precision
surveying, you may need to install additional grid files and specify the desired pipeline.
13.3.5 Technical Demonstration: Exploring CRS Transformations
r
library(sf)
library(dplyr)
# 1. Create a regular grid of points in WGS84
lons <- seq(-180, 180, by = 30)
lats <- seq(-80, 80, by = 20)
grid_pts <- [Link](lon = lons, lat = lats)
grid_sf <- st_as_sf(grid_pts, coords = c("lon", "lat"), crs = 4326)
# 2. Transform to several projections
grid_merc <- st_transform(grid_sf, 3857)
grid_robin <- st_transform(grid_sf, "+proj=robin")
grid_moll <- st_transform(grid_sf, "+proj=moll")
# 3. Observe how coordinates change
head(st_coordinates(grid_sf))
head(st_coordinates(grid_merc)) # large metres
head(st_coordinates(grid_robin)) # projected metres
# 4. Compare distances between two points under different projections
pt1 <- st_sfc(st_point(c(0, 0)), crs = 4326)
pt2 <- st_sfc(st_point(c(10, 10)), crs = 4326)
# Geodesic distance (s2)
st_distance(pt1, pt2) # in metres
# Planar distance in Mercator (wrong)
pt1_merc <- st_transform(pt1, 3857)
pt2_merc <- st_transform(pt2, 3857)
st_distance(pt1_merc, pt2_merc) # much larger, because Mercator inflates high latitudes
Hard Practice 13 – “Comparing Area Computed Under Different Projections for a Country
Boundary”
Objective:
Compute the area of a country using multiple projections (equal-area, conformal, compromise,
geographic with s2) and compare the results. Understand why projection choice matters for
quantitative analysis.
Scenario:
You are tasked with reporting the area of Indonesia for a land-cover change study. Indonesia
straddles the equator and is widely spread in longitude. Different projections will give different
answers. You will compute the area using:
1. WGS84 with s2 (geodesic).
2. Web Mercator (EPSG:3857).
3. Mollweide (equal-area, global).
4. Albers Equal Area Conic centred on Indonesia (custom parameters: +proj=aea +lat_1=7
+lat_2=-12 +lat_0=-2 +lon_0=118 +datum=WGS84 +units=m).
5. UTM zones (pick one that covers only part of Indonesia, to show the danger of using a
single zone for a trans-equatorial country).
Instructions:
1. Load Indonesia boundary: Use rnaturalearth::ne_countries(scale = "medium",
returnclass = "sf") and filter to admin == "Indonesia".
2. Compute area in each of the five CRS choices. Use st_area(). For the UTM zone, first
find a zone that covers part of Indonesia (e.g., EPSG:32750 for UTM Zone 50S), but note
that Indonesia spans multiple UTM zones.
3. Convert all areas to square kilometres (1e6 m² per km²). Create a data
frame results with columns method and area_km2. Treat the WGS84/s2 result as the
reference truth and compute the percentage error of each method relative to it.
4. Plot the country boundaries in each projection using ggplot2 with geom_sf(). Arrange
the four projected maps in a 2×2 grid using patchwork. Under each map, label it with the
projection name and the computed area.
5. Interpretation: Write a short paragraph as a comment explaining:
o Which methods give areas closest to the s2 reference, and why.
o Why Web Mercator produces a gross overestimate for a country near the equator?
(Hint: Mercator is conformal, not equal-area; area distortion increases with
latitude. Indonesia near the equator may not be as overestimated as Greenland, but
still distorted.)
o Why a single UTM zone is inappropriate for Indonesia.
Deliverable:
A script practice_chapter13_area_comparison.R with the full analysis, the data frame of results,
the multi-panel plot, and the interpretive paragraph.
Chapter 13 Review Problems
Solve each in a clearly commented R script using sf. Pay attention to CRS specification and the
impact of projection choices.
Easy Problems (1–5)
1. Basic Reprojection
Create an sf point at (longitude=120, latitude=30) with WGS84 CRS. Reproject it to UTM Zone
51N (EPSG:32651). Print the old and new coordinates. What are the units of the new
coordinates?
2. Transform an Entire Layer
Load the world dataset from rnaturalearth. Reproject it to the Robinson projection
("+proj=robin"). Print the CRS before and after. Plot the original and reprojected maps
side-by-side.
3. Check CRS Equality
Create two crs objects: crs1 <- st_crs(4326) and crs2 <- st_crs(4326). Use == to compare them.
Try st_crs(4326) == st_crs(3857). Explain why the comparison works.
4. Extract WKT
Use st_crs(4326) to obtain a crs object. Extract its WKT string with $wkt. Print only the first 200
characters. Observe the structure: name, datum, ellipsoid, prime meridian.
5. List Available Transformations
Use sf_proj_pipelines(st_crs(4326), st_crs(32650)) to list the transformation pipelines from
WGS84 to UTM Zone 50N. How many are available? What is the accuracy of each?
Medium Problems (6–10)
6. Compare Distance in Different Projections
Create two points: (100°W, 40°N) and (105°W, 45°N) in WGS84. Compute the geodesic distance
with st_distance(). Transform both to UTM Zone 13N (EPSG:32613) and compute the planar
distance. Transform to Web Mercator and compute. Report the three distances and the percentage
difference relative to the geodesic distance.
7. Area of a Buffer
Create a point at (0°N, 0°E) in WGS84. Buffer it by 200 km. Compute the buffer area
with st_area() (which will use s2). Transform the buffer to Mollweide ("+proj=moll") and
compute the area again. Transform to Web Mercator and compute. Report the three areas. Which
is closest to the true area of a circle of 200 km radius (π × (200,000)²)?
8. Custom Albers Projection
Define an Albers Equal Area Conic projection for China, with standard parallels at 25°N and
47°N, central meridian at 105°E, and latitude of origin at 35°N. Create the PROJ string manually.
Load China from rnaturalearth and reproject to this custom CRS. Plot the result. Compute
China’s area in this projection and compare to the s2-based area.
9. Datum Shift Investigation
Load a dataset of US states (rnaturalearth::ne_states(country = "United States of America",
returnclass = "sf")). The data are in WGS84 (EPSG:4326). Transform to NAD83 (EPSG:4269)
and then back to WGS84. Do the coordinates change? Compute the maximum shift across all
vertices. (Hint: Use st_coordinates() before and after the double transform.)
10. Projection for a Transect
Define a transect line from Cape Town (18.4°E, 33.9°S) to Cairo (31.2°E, 30.0°N). Create a
custom Lambert Azimuthal Equal Area projection centred on the midpoint of the transect. Project
the line and compute its length. Compare to the geodesic length. Plot the transect in both WGS84
and the custom projection.
Challenging Problems (11–15)
11. Tissot Indicatrix Generator
Write a function tissot_indicatrices(grid, radius_km, target_crs) that:
Takes a regular grid of points in WGS84 (grid_sf).
Buffers each point by radius_km (creating small circular polygons in geodesic space).
Transforms the buffers to target_crs.
Returns the transformed polygons.
Apply it to a 20°×20° global grid with radius 500 km, and project to Mercator, Robinson,
and Mollweide. Plot each set of indicatrices on a world basemap. Annotate the plot to
explain what the shapes indicate about each projection’s distortion.
12. Area-Corrected Cartogram (Thought Experiment)
A cartogram distorts space to represent a thematic variable. While full cartogram generation is
beyond sf, you can simulate a simple one: take four neighbouring countries, assign them a
desired area proportional to population, and using an iterative algorithm (e.g., a simple
rubber-sheet transformation), adjust their coordinates to match the target areas. Implement a
basic scaling: shift each country’s centroid outward and scale its coordinates by the ratio of
sqrt(target_area / original_area). Plot the original and distorted maps. This is a programming
challenge.
13. Spherical vs. Ellipsoidal Area for a Large Polygon
st_area() with s2 computes area on the WGS84 ellipsoid. Compare this to the area computed on a
sphere of radius 6371 km. For a large, high-latitude polygon (e.g., Greenland), the difference is
noticeable. Load Greenland from rnaturalearth, compute area with s2, and then compute the
spherical area by temporarily setting sf_use_s2(TRUE) but projecting? Actually, s2 uses the
ellipsoid. To compute spherical area, you can project to an equal-area projection that uses a
sphere (e.g., "+proj=moll +R=6371000" with a spherical Earth radius). Compute the percentage
difference.
14. Transform a Raster and Compare Resampling Methods
Use terra to create a 100×100 raster of the study area extent (e.g., a country) with random values.
Project this raster from WGS84 to UTM using terra::project(). Compare the results of method =
"bilinear" and method = "near" (nearest neighbour). Plot the difference. Explain why the choice
of resampling method matters for continuous vs. categorical rasters.
15. Build a CRS from Scratch
Design a custom CRS for a hypothetical exoplanet survey. The planet has radius 5000 km
(different from Earth). Define a geographic CRS on a sphere of radius 5000 km, and a simple
Plate Carrée projection on that sphere. Write the WKT2 by hand (or by modifying a template
from st_crs(4326)$wkt, changing the ellipsoid parameters). Create a crs object from your WKT.
Transform a point on this exoplanet from geographic to projected coordinates. This is a creative
exercise that tests deep understanding of CRS components.
Chapter 15: Raster Data Foundations with terra
Vector geometries represent discrete objects—points, lines, polygons. But the Earth’s surface is
also continuous: elevation varies across a landscape, temperature changes from place to place,
a satellite measures reflectance at every pixel in its swath. These phenomena are best
represented as rasters: regular grids of cells, each storing a single value. In R,
the terra package (Hijmans, 2023) provides a high-performance, C++-backed framework for
raster data. It replaces the older raster package with faster execution, memory-safe processing,
and seamless integration with the modern spatial ecosystem. This chapter teaches you to think in
grids: to understand the raster data model, to create and import single-band and multi-band
rasters, to perform map algebra and neighbourhood operations, to resample and mosaic, and to
work with NetCDF climate data. The Hard Practice asks you to compute NDVI from Sentinel-2
bands and summarise it by land parcel—a complete satellite-data workflow. By the end, you will
be able to load, manipulate, and analyse any raster dataset that comes your way, from a local
DEM to a global climate model.
15.1 Raster Data Models and the {terra} Package
15.1.1 The Grid: A Theoretical View of Raster Data
A raster is a matrix of cells (pixels) arranged in rows and columns, covering a rectangular extent
in geographic space. Each cell stores a single value—a number, an integer code, or a missing
value flag. The raster is defined by five fundamental properties:
1. Extent: The minimum and maximum X and Y coordinates of the raster’s bounding
rectangle. In terra, accessed with ext(r).
2. Resolution: The size of each cell in the X and Y directions, typically equal for both
axes. res(r).
3. Dimensions: The number of rows (nrow) and columns (ncol). Together with resolution,
they determine the extent.
4. Coordinate Reference System (CRS): The projection or geographic coordinate
system. crs(r).
5. Origin: The coordinates of the lower-left corner of the lower-left cell (or the upper-left,
depending on convention). Together with resolution, it determines the cell grid alignment.
The raster data model is the spatial implementation of the field model (Chapter 11). It is ideal for
continuous variables (elevation, temperature, reflectance) and for categorical variables that cover
the entire space (land cover, soil type, geological unit). Because all cells share the same size and
shape, operations on rasters can be performed cell-by-cell—map algebra—or using moving
windows (focal operations).
A multi-layer raster (or raster stack/brick) contains multiple bands covering the same extent
and resolution. Each band is a separate raster layer. A Landsat scene has 7 or more bands; a
climate model output has one layer per time step. In terra, a SpatRaster object can hold any
number of layers, all sharing the same geometry.
15.1.2 The terra Package: Philosophy and Architecture
terra is the successor to the raster package (Hijmans, 2020). It is written in C++ using the GDAL,
GEOS, and PROJ libraries, and it is designed for speed, memory efficiency, and scalability. Key
design principles:
SpatRaster is a single unified class. Unlike raster, which had RasterLayer, RasterStack,
and RasterBrick, terra has a single class SpatRaster that can hold one or many layers.
Out-of-memory processing. Raster data may be too large to fit in RAM. terra can
process data in chunks from disk, writing results to temporary files, without the user
needing to manage this explicitly.
Consistent function naming. Most functions are named without a prefix
(unlike st_ in sf): rast(), crop(), mask(), project(), extract(), classify(), focal(), zonal().
Seamless integration with sf. Vectors are handled as SpatVector objects, but you can
also use sf objects directly in many terra functions that accept vector inputs
(e.g., mask(), extract()). terra also provides vect() to read vector data, but using sf for
vectors and terra for rasters is the recommended workflow.
terra is not the only raster package in R. The stars package (Pebesma, 2023) is designed for
multi-dimensional spatiotemporal arrays (raster cubes with an explicit time or band dimension).
It is better suited for model output (NetCDF with arbitrary dimensions) and for workflows that
treat the data as a data cube. For classical two-dimensional raster GIS operations, terra is simpler
and faster. We will use terra throughout this book for raster analysis, with brief pointers
to stars for advanced multi-dimensional data (Section 15.5).
15.1.3 The SpatRaster Class in Memory and on Disk
A SpatRaster can point to data that is:
In memory: All cell values are stored in RAM. Created by rast(matrix(...)) or rast(nrows
= ..., ncols = ...) with random values.
On disk: The object stores a filename and reads cell values from disk as needed. Created
by rast("[Link]"). Operations on file-based rasters may write temporary files if the result
cannot fit in memory.
Lazy: Some terra operations do not compute immediately; they create a SpatRaster that
references the source data and the operation to be performed. Computation occurs when
the values are explicitly requested (e.g., by values(), writeRaster(), or plot()). This
allows terra to optimise the sequence of operations.
The sources(r) function returns the file paths from which
a SpatRaster reads. inMemory(r) returns TRUE if all data are in RAM.
15.1.4 Geospatial Context: Rasters in Environmental Science
Environmental data is overwhelmingly raster-based:
Digital Elevation Models (DEMs): one-band rasters of elevation.
Satellite imagery: multi-band rasters of spectral reflectance.
Climate data: multi-band rasters of temperature, precipitation (time series).
Land cover: single-band categorical rasters with integer codes.
Soil maps: multi-band rasters of soil properties.
Model outputs: predicted species distributions, landslide susceptibility, flood risk.
Understanding terra is therefore not optional; it is the gateway to the majority of environmental
datasets.
15.2 Creating and Importing Single- and Multi-Layer Rasters
15.2.1 Creating a SpatRaster from Scratch
You can create a SpatRaster from a matrix, from dimensions, or from an existing object.
From a matrix:
r
library(terra)
# A 5x5 matrix of random values
m <- matrix(runif(25, 0, 100), nrow = 5, ncol = 5)
r <- rast(m)
r
# class : SpatRaster
# dimensions : 5, 5, 1 (nrow, ncol, nlyr)
# resolution : 1, 1 (x, y)
# extent : 0, 1, 0, 1 (xmin, xmax, ymin, ymax)
# coord. ref. :
The raster inherits a default extent of [0,1] and resolution of 0.2 (1/5). You can set the extent and
CRS after creation:
r
ext(r) <- c(100, 120, 30, 40) # xmin, xmax, ymin, ymax
crs(r) <- "EPSG:4326"
From dimensions:
r
r2 <- rast(nrows = 10, ncols = 20,
xmin = 500000, xmax = 510000, ymin = 4300000, ymax = 4310000,
crs = "EPSG:32650")
r2 # empty raster, no values yet
values(r2) <- runif(200, 0, 100) # assign values
Multiple layers:
r
# Create a 3-layer raster
r_multi <- rast(nrows = 5, ncols = 5, nlyrs = 3,
xmin = 0, xmax = 1, ymin = 0, ymax = 1)
values(r_multi) <- runif(75, 0, 100) # fills layer by layer
names(r_multi) <- c("Band1", "Band2", "Band3")
r_multi
15.2.2 Importing Raster Files
rast() reads virtually any raster format supported by GDAL: GeoTIFF (.tif), IMG (.img),
NetCDF (.nc), JPEG2000, and many more. The syntax is simply:
r
dem <- rast("data/[Link]")
landsat <- rast("data/landsat_scene.tif") # multi-band if the file contains multiple bands
If the file contains many bands, rast() loads all of them into a single SpatRaster. You can subset
bands after loading:
r
band4 <- landsat[[4]] # extract one band
bands_3_5 <- landsat[[3:5]] # extract several
To read only specific bands from a large file (saving memory), use rast() with the lyrs argument:
r
subset <- rast("[Link]", lyrs = c(1, 4, 5))
15.2.3 Inspecting a SpatRaster
r prints dimensions, resolution, extent, CRS, min/max values, and source.
nrow(r), ncol(r), nlyr(r), res(r), ext(r), crs(r).
hasValues(r) — TRUE if the raster has cell values.
inMemory(r) — TRUE if values are in RAM.
sources(r) — file paths.
minmax(r) — min and max values.
plot(r) — quick plot.
r
dem <- rast("data/[Link]")
nrow(dem)
res(dem)
ext(dem)
crs(dem)
plot(dem, main = "Digital Elevation Model")
15.2.4 Converting Between SpatRaster and Other R Objects
To matrix: [Link](r, wide = TRUE) returns a matrix of the first layer (for single-layer
rasters).
To data frame: [Link](r, xy = TRUE) returns a data frame with columns x, y, and
each layer’s values. This is useful for ggplot2.
To stars: stars::st_as_stars(r).
From stars: terra::rast(stars_obj).
15.2.5 Technical Demonstration
r
library(terra)
# 1. Create a synthetic DEM
dem <- rast(nrows = 100, ncols = 100,
xmin = 100, xmax = 120, ymin = 30, ymax = 40,
crs = "EPSG:4326")
# Fill with a simple elevation model: decreasing with latitude, random noise
lats <- yFromRow(dem, 1:nrow(dem))
lat_mat <- matrix(rep(lats, each = ncol(dem)), nrow = nrow(dem), byrow = TRUE)
values(dem) <- 5000 - (lat_mat - 30) * 200 + rnorm(ncell(dem), 0, 100)
plot(dem, main = "Synthetic DEM")
# 2. Import a GeoTIFF (replace with actual path)
# landcover <- rast("data/[Link]")
# plot(landcover)
# 3. Multi-layer: create a 3-band "image"
red <- rast(dem); values(red) <- runif(ncell(dem), 0.05, 0.20)
green <- rast(dem); values(green) <- runif(ncell(dem), 0.10, 0.25)
nir <- rast(dem); values(nir) <- runif(ncell(dem), 0.30, 0.60)
img <- c(red, green, nir)
names(img) <- c("Red", "Green", "NIR")
img
plot(img)
15.3 Raster Algebra and Local, Focal, Zonal, and Global Operations
Raster analysis is built on four categories of operations, distinguished by the spatial scope of the
computation.
15.3.1 Local Operations (Cell-by-Cell)
Local operations apply a function to each cell independently, using values from one or more
layers at that cell. This is the raster analogue of vectorised arithmetic on atomic vectors.
Arithmetic: r + 10, r * scale, log(r).
Band math: (nir - red) / (nir + red) computes NDVI.
Logical: r > threshold returns a binary raster.
classify(): Replace ranges of values with new values (reclassification).
app(): Apply a custom function to each cell across layers.
r
ndvi <- (img$NIR - img$Red) / (img$NIR + img$Red)
plot(ndvi, main = "NDVI")
# Reclassification
slope <- rast(nrows = 5, ncols = 5, xmin = 0, xmax = 5, ymin = 0, ymax = 5)
values(slope) <- 0:24 * 2 # 0 to 48 degrees
classes <- classify(slope, rcl = matrix(c(0, 10, 1, 10, 30, 2, 30, 50, 3), ncol = 3, byrow = TRUE))
plot(classes)
app() is the most flexible local function. It applies a function to each cell’s vector of values
across layers:
r
# For a multi-layer raster, compute the mean of all bands per cell
mean_band <- app(img, fun = mean)
15.3.2 Focal (Neighbourhood) Operations
Focal operations compute a value for each cell based on the values of that cell and its neighbours
within a moving window. This is the basis for terrain analysis (slope, aspect, curvature) and
image filtering (smoothing, edge detection).
focal(r, w = matrix(1, 3, 3), fun = mean) applies the function to a 3×3 window around each cell.
r
# 3x3 mean filter (smoothing)
smoothed <- focal(ndvi, w = 3, fun = mean, [Link] = "omit")
plot(smoothed)
# Sobel filter for edge detection (custom weight matrix)
sobel_x <- matrix(c(-1,0,1, -2,0,2, -1,0,1), nrow = 3)
edge_x <- focal(ndvi, w = sobel_x, fun = sum)
plot(edge_x)
terra::terrain() provides built-in terrain derivatives:
r
slope <- terrain(dem, v = "slope", unit = "degrees")
aspect <- terrain(dem, v = "aspect", unit = "degrees")
hillshade <- shade(slope, aspect, angle = 45, direction = 315)
plot(hillshade)
15.3.3 Zonal Operations
Zonal operations summarise raster values by zones defined by a second raster (or vector layer).
Zones are regions of cells with the same value (e.g., land-cover classes, watershed IDs).
zonal(x, z, fun = mean) computes the mean of x for each unique value in z.
r
# Create a zone raster (e.g., 3 zones)
zones <- rast(dem)
values(zones) <- sample(1:3, ncell(dem), replace = TRUE)
# Compute mean elevation per zone
zone_mean <- zonal(dem, zones, fun = mean)
zone_mean
If zones are defined by a vector layer (polygons), extract() followed by aggregate() achieves the
same:
r
zone_mean_vec <- extract(dem, polys, fun = mean, [Link] = TRUE)
15.3.4 Global Operations
Global operations reduce an entire raster (or each layer) to a single value or a small set of
statistics.
global(r, fun = "mean") — mean of all cells.
global(r, fun = "sd") — standard deviation.
global(r, fun = "quantile", probs = c(0.25, 0.75)) — percentiles.
freq(r) — frequency table of cell values.
r
global(dem, fun = "mean", [Link] = TRUE)
global(ndvi, fun = "range", [Link] = TRUE)
15.3.5 Technical Demonstration: A Terrain Analysis Pipeline
r
library(terra)
# 1. Create synthetic DEM
dem <- rast(nrows = 50, ncols = 50, xmin = 0, xmax = 10, ymin = 0, ymax = 10)
# Create a smooth elevation surface using a mathematical function
x <- seq(0, 10, [Link] = 50)
y <- seq(0, 10, [Link] = 50)
vals <- outer(x, y, function(x, y) 500 + 300 * sin(x/3) * cos(y/3) + rnorm(2500, 0, 20))
values(dem) <- vals
# 2. Terrain derivatives
slope <- terrain(dem, v = "slope", unit = "degrees")
aspect <- terrain(dem, v = "aspect", unit = "degrees")
hillshade <- shade(slope, aspect, angle = 40, direction = 270)
# 3. Focal: mean filter on slope
slope_smooth <- focal(slope, w = 5, fun = mean)
# 4. Zonal: classify slope into 3 classes and compute mean elevation per class
slope_class <- classify(slope, rcl = matrix(c(0, 10, 1, 10, 25, 2, 25, 90, 3), ncol = 3, byrow =
TRUE))
zone_mean_elev <- zonal(dem, slope_class, fun = mean)
zone_mean_elev
# 5. Plot
par(mfrow = c(2,2))
plot(dem, main = "DEM")
plot(hillshade, main = "Hillshade", col = grey(0:100/100))
plot(slope, main = "Slope (degrees)")
plot(slope_class, main = "Slope classes")
15.4 Resampling, Cropping, Masking, and Mosaicking
Raster data rarely arrives perfectly aligned with the study area. You must crop to the region of
interest, mask out clouds or water, resample to a common resolution, and mosaic adjacent tiles.
These operations are the spatial equivalent of dplyr::filter, dplyr::select, and joining.
15.4.1 Cropping: crop()
crop(r, ext) trims a raster to a specified extent (a numeric vector of length 4, a SpatExtent object,
or an sf polygon from which the extent is taken).
r
study_area_ext <- c(3, 7, 2, 8) # xmin, xmax, ymin, ymax
dem_cropped <- crop(dem, study_area_ext)
15.4.2 Masking: mask()
mask(r, mask) sets cells of r to NA where the mask raster has NA or a specified value. The mask
can also be an sf polygon—cells outside the polygon become NA.
r
# Mask by polygon
study_polygon <- st_as_sf( ... ) # an sf polygon
dem_masked <- mask(dem, study_polygon)
15.4.3 Resampling: resample() and aggregate()
resample(x, y, method = "bilinear") transforms x to match the geometry (extent, resolution,
origin) of y. The method argument specifies interpolation: "bilinear" for continuous
data, "near" for categorical.
r
# Create a target grid
target <- rast(nrows = 25, ncols = 25, xmin = 0, xmax = 10, ymin = 0, ymax = 10)
dem_coarse <- resample(dem, target, method = "bilinear")
aggregate(r, fact = 2, fun = mean) reduces resolution by a factor, applying a summary function to
the cells being combined. disagg() increases resolution by splitting cells.
r
dem_agg <- aggregate(dem, fact = 2, fun = mean) # halve resolution, average
dem_dis <- disagg(dem_agg, fact = 2, method = "bilinear") # restore, interpolate
project(r, target_crs, method = "bilinear") reprojects a raster to a new CRS, which inherently
involves resampling.
15.4.4 Mosaicking: mosaic() and merge()
When a study area is covered by multiple overlapping or adjacent raster tiles (e.g., Landsat
scenes), merge() stitches them together without overlap (taking values from the first raster where
they exist). mosaic() handles overlapping tiles by applying a function to the overlapping values
(e.g., mean, max).
r
tile1 <- rast("tile_1.tif")
tile2 <- rast("tile_2.tif")
# Merge non-overlapping tiles
merged <- merge(tile1, tile2)
# Mosaic overlapping tiles, taking the mean in overlap zones
mos <- mosaic(tile1, tile2, fun = mean)
15.4.5 Technical Demonstration: Preparing a Raster for Analysis
r
library(terra)
# 1. Create a coarse raster covering a large area
large_area <- rast(nrows = 60, ncols = 60, xmin = 0, xmax = 12, ymin = 0, ymax = 12)
values(large_area) <- runif(3600, 0, 100)
# 2. Define a study area extent
study_ext <- ext(3, 9, 2, 10)
# 3. Crop
cropped <- crop(large_area, study_ext)
# 4. Create an irregular mask (e.g., a polygon)
mask_poly <- vect("POLYGON ((4 3, 8 3, 8 9, 4 9, 4 3))", crs = crs(large_area))
masked <- mask(cropped, mask_poly)
# 5. Resample to a finer resolution
target <- rast(nrows = 70, ncols = 70, xmin = 3, xmax = 9, ymin = 2, ymax = 10)
resampled <- resample(masked, target, method = "bilinear")
# 6. Plot all steps
par(mfrow = c(2,2))
plot(large_area, main = "Original")
plot(cropped, main = "Cropped")
plot(masked, main = "Masked")
plot(resampled, main = "Resampled (finer)")
15.5 Working with NetCDF and Multidimensional Climate Data
NetCDF (Network Common Data Form) is a self-describing binary format widely used for
climate data, ocean model output, and satellite time series. A single NetCDF file can contain
multiple variables with multiple dimensions (time, depth, ensemble member). terra reads
NetCDF files as SpatRaster objects with multiple layers, while the stars package can handle them
as multi-dimensional arrays with explicit dimension labels.
15.5.1 Reading NetCDF with terra
When you read a NetCDF file with rast(), terra creates a SpatRaster where each layer
corresponds to a slice of the time dimension (or the combination of all non-spatial dimensions).
The layer names are taken from the time variable, which can be parsed with time().
r
# Read a global climate dataset (e.g., monthly temperature)
temp <- rast("data/temperature_monthly.nc")
temp
# class : SpatRaster
# dimensions : 360, 720, 12 (nrow, ncol, nlyr)
# ...
nlyr(temp) # 12 layers, one per month
# Extract time stamps
time(temp) # vector of Date or POSIXct
You can extract a subset of layers by time index, name, or date:
r
january <- temp[[1]]
summer <- temp[[6:8]]
15.5.2 Subsetting by Time and Summarising
With time information attached, you can aggregate by time period:
r
# Compute annual mean (if multiple years)
annual_mean <- tapp(temp, fun = mean)
# `tapp` applies a function over groups of layers. To use it, you need an index:
# e.g., index <- rep(1:10, each = 12) for 10 years of monthly data.
# For a single year, just apply over all layers:
annual_mean <- mean(temp)
For more complex temporal aggregation, terra::tapp() groups layers by an index vector and
applies a function to each group.
15.5.3 Using stars for Multi-Dimensional Data
stars is designed for data cubes where the dimensions are explicitly named
(e.g., x, y, time, band). It reads NetCDF as a stars object, which behaves like a tibble with an
array column. stars operations use dplyr verbs and st_apply() for dimension reduction.
r
library(stars)
temp_stars <- read_stars("data/temperature_monthly.nc")
temp_stars
# stars object with 3 dimensions: x, y, time
stars is particularly strong when you need to slice along the time dimension, filter by time, or
combine data from multiple NetCDF files with differing grids. For standard GIS raster
operations (cropping, reprojecting, map algebra), terra is simpler and faster. The two packages
are interoperable: rast() can read a stars object, and st_as_stars() can convert a SpatRaster.
15.5.4 Technical Demonstration: Monthly Temperature Anomaly
r
library(terra)
# Simulate 12 months of temperature on a small grid
r <- rast(nrows = 20, ncols = 20, nlyrs = 12,
xmin = 0, xmax = 10, ymin = 0, ymax = 10)
# Fill with a seasonal cycle + random noise
months <- 1:12
for (i in 1:12) {
values(r[[i]]) <- 15 + 10 * sin(2 * pi * (i - 4) / 12) + rnorm(400, 0, 2)
}
names(r) <- [Link]
time(r) <- [Link](paste0("2024-", 1:12, "-15"))
# 1. Compute annual mean
annual <- mean(r)
plot(annual, main = "Annual Mean Temperature")
# 2. Compute monthly anomaly (each month minus annual mean)
anomaly <- r - annual
plot(anomaly[[1]], main = paste("Anomaly for", names(r)[1]))
# 3. Global mean temperature per month
monthly_means <- global(r, fun = "mean", [Link] = TRUE)
plot(monthly_means$mean, type = "l", xlab = "Month", ylab = "Mean Temp")
# 4. Extract time series at a point
point_ts <- extract(r, [Link](x = 5, y = 5))
plot(1:12, [Link](point_ts[1, -1]), type = "l", xlab = "Month", ylab = "Temp at (5,5)")
Hard Practice 15 – “Computing NDVI from Sentinel-2 Red and NIR Bands and Zonal
Statistics per Land Parcel”
Objective:
Simulate a Sentinel-2 scene for a small agricultural area, compute NDVI, classify vegetation, and
compute mean NDVI per field parcel. This is a complete satellite-data workflow.
Scenario:
You have a 100×100 pixel Sentinel-2 image with Red (Band 4) and NIR (Band 8) bands, and a
vector layer of 20 field parcels. You will:
1. Create synthetic reflectance bands with realistic values.
2. Compute NDVI.
3. Mask out non-vegetation using an NDVI threshold.
4. Compute zonal statistics: mean NDVI per parcel.
5. Map the results.
Instructions:
1. Create the raster layers (100×100 cells, UTM Zone 32N, extent: xmin=500000,
xmax=510000, ymin=5700000, ymax=5710000, crs=32632):
r
[Link](123)
r <- rast(nrows = 100, ncols = 100,
xmin = 500000, xmax = 510000, ymin = 5700000, ymax = 5710000,
crs = "EPSG:32632")
red <- r; values(red) <- runif(10000, 0.02, 0.15)
nir <- r; values(nir) <- runif(10000, 0.25, 0.65)
# Add some spatial pattern: higher NIR in the northeast
coords <- crds(r)
nir <- nir + 0.3 * (coords[,1] - 500000) / 10000
2. Compute NDVI: ndvi <- (nir - red) / (nir + red). Plot it.
3. Classify vegetation: Create a mask vegetation <- ndvi > 0.4. Apply the mask to
NDVI: ndvi_veg <- mask(ndvi, vegetation, maskvalues = FALSE).
4. Create field parcels: Use terra::vect() or sf to create 20 random polygons representing
fields. Use st_sample on the raster extent, buffer them by 300 m, and assign a field_id.
(Use sf for vector creation, then convert to SpatVector with vect() or use directly
in extract(); terra::extract() accepts sf objects.)
5. Zonal statistics: Use extract(ndvi_veg, parcels, fun = mean, [Link] = TRUE) to compute
mean NDVI per parcel. Join the result back to the parcel geometries.
6. Map: Create a map with ggplot2 showing NDVI (as a continuous raster), parcel
boundaries, and label each parcel with its mean NDVI (using geom_sf_text()).
7. Reflection:
o Why is it important to mask non-vegetation before computing zonal statistics?
o How would the workflow change if you had a time series of NDVI (12 months)
and needed the maximum NDVI per parcel?
o What are the units of NDVI, and what values indicate healthy vegetation?
Deliverable:
A script practice_chapter15_ndvi_parcels.R with the complete workflow and map.
Chapter 15 Review Problems
Solve each in a clearly commented R script using terra. Use provided or simulated data.
Easy Problems (1–5)
1. Create and Inspect a Raster
Create a 20×30 raster of random values between 0 and 100, with extent xmin=0, xmax=30,
ymin=0, ymax=20. Print the raster. Report its dimensions, resolution, and the mean value of all
cells.
2. Raster Arithmetic
Create two rasters of the same geometry: r1 with uniform random values (0–1), r2 with uniform
random values (10–20). Compute r1 + r2, r1 * r2, and r2 / r1. Plot one of the results.
3. Crop and Mask
Create a 50×50 raster of random values. Crop it to a smaller extent (e.g., from (10,10) to
(40,40)). Then create a simple polygon (e.g., a circle) and mask the cropped raster with it. Plot
the original, cropped, and masked rasters.
4. Focal Mean
Create a 30×30 raster of random normal values (mean=0, sd=1). Apply a 5×5 focal mean filter.
Plot the original and smoothed rasters side-by-side. Observe the smoothing effect.
5. Global Statistics
Using the raster from Problem 1, compute the global mean, standard deviation, minimum, and
maximum using global(). Print the results.
Medium Problems (6–10)
6. Terrain Analysis
Create a synthetic DEM using rast(nrows=60, ncols=60, xmin=0, xmax=10, ymin=0, ymax=10).
Fill it with a smooth mathematical surface (e.g., sin(x)*cos(y)*500 + 1000). Compute slope,
aspect, and hillshade. Plot all four (DEM, slope, aspect, hillshade) in a 2×2 layout.
7. Multi-Band Image and NDVI
Create a 3-band raster representing Red, Green, and NIR bands of a 50×50 pixel area. Use
realistic reflectance ranges (Red: 0.02–0.2, NIR: 0.2–0.6). Compute NDVI. Create a binary
vegetation mask (NDVI > 0.4) and count the number of vegetation pixels.
8. Reclassification
Create a slope raster (0–90°) using terrain(). Reclassify it into 5 categories: Flat (0–5), Gentle (5–
15), Moderate (15–30), Steep (30–45), Very Steep (>45). Use classify() with a reclassification
matrix. Plot the original slope and the classified raster. Compute the area (in cell counts) of each
class using freq().
9. Zonal Statistics with Raster Zones
Using the DEM from Problem 6, create a zone raster by classifying elevation into 3
equal-interval zones. Use zonal() to compute the mean slope per elevation zone. Report the
results.
10. Aggregate and Disaggregate
Create a 100×100 raster of random values. Aggregate it by factor 4 using the mean. Then
disaggregate it back to 100×100 using bilinear interpolation. Compute the correlation between
the original and the disaggregated rasters (use values() to get vectors, then cor()). Explain why
the correlation is not 1.0.
Challenging Problems (11–15)
11. Moving Window Standard Deviation (Texture)
Create a 50×50 NDVI raster (simulate with random values, perhaps with spatial pattern
using focal on a noise field). Compute the local standard deviation of NDVI in a 5×5 window
using focal(). This is a texture measure. Plot both NDVI and texture. Identify areas of high
texture (edges, boundaries).
12. Raster Vector Overlay Accuracy
Simulate a 200×200 land cover raster with 4 classes (codes 1–4). Create 20 random points with a
“true” land cover class (derived from the raster at those points, but then perturb 10% of them to
simulate classification error). Use extract() to get the raster class at each point. Compute a
confusion matrix and overall accuracy. This is a mini accuracy assessment.
13. Multi-Temporal NDVI and Trend Analysis
Create a 20×20×36 raster cube representing monthly NDVI for 3 years. For each pixel, simulate
a seasonal cycle plus a linear trend (some pixels get a positive trend, some negative, some none).
Use app() with a custom function that fits a linear model lm(y ~ time) and returns the slope. Map
the slopes to show where vegetation is greening or browning. (This is a simplified time-series
analysis.)
14. Raster Mosaic with Blending
Create two overlapping rasters with a smooth gradient (e.g., rast_a with values increasing
eastward, rast_b with values increasing westward, overlap in the middle). Use mosaic() with fun
= mean to blend them in the overlap zone. Plot the two source rasters and the mosaic. Show a
cross-section (using a row or column extraction) to demonstrate the blending.
15. NetCDF Climate Data Extraction and Summarisation
If you have access to a NetCDF file (or simulate one using writeCDF if installed, or just simulate
a SpatRaster with multiple layers named by date), perform the following:
Read the data.
Extract the time stamps.
Compute the long-term mean for each month (i.e., the mean of all Januarys, all
Februarys, etc.).
Compute the anomaly for a specific year relative to the long-term mean.
Extract a point time series and plot it with a LOESS smooth.
This is the core workflow of climate data analysis.
chapter 17: Spatial Weights and Autocorrelation
In classical statistics, observations are assumed independent. In geography, this assumption
rarely holds. Nearby locations tend to be more similar than distant ones—a phenomenon Waldo
Tobler enshrined as the First Law of Geography: “Everything is related to everything else, but
near things are more related than distant things.” This spatial autocorrelation is not a nuisance;
it is a fundamental property of spatial processes and a source of information. This chapter
equips you to formalise, quantify, and map spatial dependence. We begin by defining neighbours
—the building block of spatial analysis—using contiguity, distance, and k-nearest neighbours.
We then construct spatial weights matrices, the mathematical objects that encode the strength of
connections between locations. With weights in hand, we compute the two classical global
statistics: Moran’s I and Geary’s C, which test whether a pattern is clustered, dispersed, or
random across the entire study area. Finally, we decompose this global signal into local
components with Local Indicators of Spatial Association (LISA) and Getis-Ord G*, which
identify hotspots, coldspots, and spatial outliers. Every concept is illustrated with geospatial
data—simulated, then real—and the Hard Practice asks you to identify deforestation hotspots
from satellite-derived change maps. By the end, you will be able to detect, measure, and map
spatial autocorrelation, and you will understand why it matters for every statistical model you fit
to spatial data.
17.1 Defining Neighbors: Contiguity, k-Nearest, Distance-Based
Before we can quantify spatial autocorrelation, we must define which pairs of locations are
“neighbours.” This seemingly simple choice is the most consequential decision in any spatial
analysis. The spdep package (Bivand, 2022) provides functions for constructing neighbour lists
from polygon geometries and point coordinates.
17.1.1 The Concept of a Neighbour
A neighbour relationship is a binary relation on a set of spatial units: for each pair (i,j)(i,j), either
they are neighbours (wij=1wij=1) or they are not (wij=0wij=0). The collection of all neighbour
relationships is stored in a neighbour list (nb object in spdep), which for each feature lists the
indices of its neighbours. From the neighbour list, a spatial weights matrix can be constructed
(Section 17.2).
The choice of neighbour definition depends on the geometry of the spatial units and the process
being modelled:
Contiguity: Units that share a boundary. Appropriate for administrative zones, land
parcels, and any polygon data where adjacency is meaningful.
Distance: Units within a specified distance of each other. Appropriate for point data
(cities, sample sites) or when you want a specific interaction radius.
k-Nearest Neighbours: The kk closest units, regardless of distance. Appropriate when
feature density varies across the study area, as it ensures each unit has the same number
of neighbours.
Graph-based: Delaunay triangulation or Gabriel graph. Appropriate for irregularly
spaced points, as it avoids long, skinny neighbour links.
17.1.2 Contiguity Neighbours for Polygons
When data are polygons, the most natural neighbour definition is based on shared
boundaries. spdep defines two types:
Rook contiguity: Polygons share a boundary of non-zero length (i.e., they touch along an
edge, not just at a corner).
Queen contiguity: Polygons share any point on their boundary (edge or vertex). Queen is
more inclusive.
The function poly2nb() computes a neighbour list from an sf polygon object (or an sp object; it
works directly with sf since spdep 1.2).
r
library(sf)
library(spdep)
# Load a dataset of North Carolina counties (from sf package)
nc <- st_read([Link]("shape/[Link]", package = "sf"), quiet = TRUE)
nc <- st_transform(nc, 32119) # NC state plane, metres
# Queen contiguity
nb_queen <- poly2nb(nc, queen = TRUE)
nb_queen
# Neighbour list object:
# Number of regions: 100
# Number of nonzero links: 490
# Percentage nonzero weights: 4.9
# Average number of links: 4.9
summary(nb_queen)
The summary shows the distribution of the number of neighbours per county. Counties with zero
neighbours are called islands (e.g., a true island, or a polygon disconnected from others). They
pose a problem for spatial analysis because they have no neighbours to condition on.
The spdep functions usually handle islands by setting them to zero weights and issuing a
warning. If you encounter islands, you can either exclude them, set them to have a fixed number
of nearest neighbours, or use a distance-based neighbour definition that includes them.
Rook contiguity:
r
nb_rook <- poly2nb(nc, queen = FALSE)
The difference between Rook and Queen is usually small for irregular administrative polygons
but can be significant for regular grids: Rook gives 4 neighbours (N, S, E, W), Queen gives 8
(including diagonals). The choice should reflect the spatial process: if diagonal interactions are
plausible (e.g., movement of animals across a grid), use Queen; if only orthogonal adjacency
matters (e.g., water flow on a regular DEM), use Rook.
17.1.3 Distance-Based Neighbours for Points and Polygons
When data are points, or when polygons are irregularly sized and contiguity is not meaningful,
distance-based neighbours are defined by a radius dd. All units within distance dd of each other
are neighbours.
spdep provides dnearneigh() for distance-based neighbours from coordinates:
r
# Extract centroid coordinates from polygons
coords <- st_centroid(nc) %>% st_coordinates()
# Distance band: neighbours within 0 to 50 km (since NC state plane is in metres)
nb_dist <- dnearneigh(coords, d1 = 0, d2 = 50000)
summary(nb_dist)
If the distance band is too small, some units will be islands. If too large, every unit is connected
to every other, which erases spatial structure. The appropriate distance should be guided by the
scale of the process under study. A variogram (Chapter 20) can help identify the range of spatial
dependence.
Adaptive distance: To ensure each unit has at least one neighbour, set d2 to the maximum
nearest-neighbour distance:
r
knn_dist <- knearneigh(coords, k = 1)
max_k1 <- max(knn_dist$dist)
nb_dist_adapt <- dnearneigh(coords, d1 = 0, d2 = max_k1)
17.1.4 k-Nearest Neighbours
knearneigh() builds a neighbour list where each unit’s neighbours are the kk closest other units.
r
k <- 5
knn <- knearneigh(coords, k = k)
nb_knn <- knn2nb(knn)
summary(nb_knn)
Each unit has exactly kk neighbours, but the relationship is not symmetric: if A is one of B’s five
nearest, B is not necessarily one of A’s five nearest. To symmetrise, use [Link](). For
spatial weights (Section 17.2), symmetry is not required but is often desired for interpretability.
k-NN is especially useful for point data with varying density. In a city centre with many points, a
fixed distance band would give each point many neighbours; in a rural area, points would be
isolated. k-NN adapts to density.
17.1.5 Graph-Based Neighbours
When points are irregularly distributed, a triangulation can create a natural neighbour
structure. spdep provides tri2nb() for Delaunay triangulation, which connects points such that no
point lies inside the circumcircle of any triangle. This avoids long, crossing edges.
r
nb_tri <- tri2nb(coords)
The Gabriel graph (gabrielneigh()) is a subset of the Delaunay triangulation: an edge exists only
if the circle with that edge as diameter contains no other points. It is more conservative,
removing edges that cross near-neighbour points.
r
nb_gab <- gabrielneigh(coords) %>% graph2nb()
These graphs are common in spatial epidemiology and ecology, where the connectivity of
habitats or the spread of disease across a landscape must respect the spatial arrangement of sites.
17.1.6 Visualising Neighbour Networks
Plotting the neighbour links helps verify that the definition captures the expected spatial
structure.
r
plot(st_geometry(nc), border = "grey")
plot(nb_queen, coords, add = TRUE, col = "blue", lwd = 0.5)
title("Queen Contiguity Neighbours")
Maps of neighbour links reveal isolated regions, overly connected hubs, or asymmetric
connections that may need correction.
17.2 Spatial Weights Matrices with {spdep}
A neighbour list records which units are neighbours. A spatial weights matrix adds how
much they influence each other. The weights matrix WW is an n×nn×n matrix where wij≠0wij
=0 if ii and jj are neighbours, and wij=0wij=0 otherwise. The diagonal wii=0wii=0 (no
self-influence).
17.2.1 Types of Spatial Weights
The simplest weights matrix is binary (also called “B” or basic): wij=1wij=1 if
neighbours, 00 otherwise. This gives equal weight to all neighbours.
Row-standardised weights divide each row by its sum, so that each row sums to 1:
wij∗=wij∑jwijwij∗=∑jwijwij
This is the most common choice for spatial regression (Chapter 19) because it makes the spatial
lag term interpretable as the average of neighbours’ values. It also stabilises the variance of the
spatial lag across units with different numbers of neighbours.
Other weighting schemes include:
Inverse distance: wij=1/dijαwij=1/dijα for some power αα (usually 1 or 2).
Common boundary length: wij=wij= length of shared border (for polygons).
Socio-economic similarity: weights based on a non-spatial variable (e.g., trade flows).
In spdep, nb2listw() converts a neighbour list to a spatial weights list object (listw), which stores
the weights in a memory-efficient sparse format.
r
# Binary weights
lw_binary <- nb2listw(nb_queen, style = "B")
# Row-standardised weights
lw_rowstd <- nb2listw(nb_queen, style = "W")
# Inverse distance weights
dists <- nbdists(nb_queen, coords)
idw_weights <- lapply(dists, function(d) 1/(d + 1)) # +1 to avoid division by zero
lw_idw <- nb2listw(nb_queen, glist = idw_weights, style = "B")
The style argument controls standardisation:
"B": basic binary (no standardisation).
"W": row-standardised (sums to 1).
"C": global standardisation (sums to nn).
"U": same as "C" but divided by nn.
"minmax": min-max normalisation.
17.2.2 Properties of Weights Matrices
A row-standardised weights matrix is row-stochastic: each row sums to 1. This is
mathematically convenient because the spatial lag operator WyWy yields a vector of local
averages.
A weights matrix is symmetric if wij=wjiwij=wji. Binary contiguity weights are symmetric;
k-nearest neighbour weights are generally not symmetric unless symmetrised. Row-standardised
contiguity weights are not symmetric because each row is divided by a different sum.
Asymmetry is acceptable for most applications.
Eigenvalues of the weights matrix play a role in spatial regression (Chapter 19). For
row-standardised weights, the maximum eigenvalue is 1.0, but for other standardisations it must
be computed. spdep provides eigenw() for this purpose.
17.2.3 Creating Weights from Distance Bands and k-NN
r
# Distance-based neighbours, row-standardised
nb_dist <- dnearneigh(coords, 0, 50000)
lw_dist <- nb2listw(nb_dist, style = "W")
# k-nearest neighbours, row-standardised (symmetrised)
knn <- knearneigh(coords, k = 5)
nb_knn <- knn2nb(knn)
nb_knn_sym <- [Link](nb_knn)
lw_knn <- nb2listw(nb_knn_sym, style = "W")
17.2.4 The listw Object Structure
A listw object contains:
style: the standardisation type.
neighbours: the neighbour list.
weights: the list of weight values corresponding to each neighbour.
[Link]: TRUE if zero-neighbour regions are allowed.
When you print a listw, it reports the number of regions, the number of non-zero links, and the
percentage of non-zero weights, along with the weights range (if summary weights are
available).
r
lw_rowstd
# Characteristics of weights list object:
# Neighbour list object:
# Number of regions: 100
# Number of nonzero links: 490
# Percentage nonzero weights: 4.9
# Average number of links: 4.9
#
# Weights style: W
# Weights constants summary:
# n nn S0 S1 S2
# W 100 10000 100 100 1000
17.3 Global Moran’s I and Geary’s C
With a spatial weights matrix in hand, we can test for spatial autocorrelation: is the observed
spatial pattern more clustered (or more dispersed) than would be expected if the values were
randomly assigned to locations?
17.3.1 The Concept of Spatial Autocorrelation
Positive spatial autocorrelation: nearby values are similar (high-high or low-low). Examples:
temperature, elevation, poverty rates, soil properties.
Negative spatial autocorrelation: nearby values are dissimilar (high-low or low-high).
Examples: competing businesses, territorial species.
Zero spatial autocorrelation: no systematic spatial pattern; the spatial arrangement is
indistinguishable from random.
Global statistics summarise the overall pattern across the study area into a single value. The two
most widely used are Moran’s I (Moran, 1950) and Geary’s C (Geary, 1954).
17.3.2 Moran’s I: Definition and Interpretation
Moran’s I is a weighted correlation between a variable and its spatial lag (the average of its
neighbours’ values). Formally, for a row-standardised weights matrix WW with wijwij and
variable xx with mean xˉxˉ:
I=n∑i∑jwij⋅∑i∑jwij(xi−xˉ)(xj−xˉ)∑i(xi−xˉ)2I=∑i∑jwijn⋅∑i(xi−xˉ)2∑i∑jwij(xi−xˉ)(xj−xˉ)
For row-standardised weights, ∑i∑jwij=n∑i∑jwij=n, so the formula simplifies to the ratio of the
cross-product of deviations weighted by WW to the total sum of squares.
Moran’s I typically ranges from approximately –1 (perfect dispersion) to +1 (perfect clustering),
with 0 indicating no spatial autocorrelation. The expected value under the null hypothesis of
spatial randomness is E[I]=−1/(n−1)E[I]=−1/(n−1), which is close to 0 for large nn.
Computing Moran’s I in R:
r
# Using the NC dataset: variable SID74 (sudden infant deaths 1974)
[Link](nc$SID74, listw = lw_rowstd, randomisation = TRUE)
The randomisation = TRUE argument uses a randomisation assumption (the observed values are
one of many possible permutations given the spatial arrangement), which does not require
normality. The output includes the Moran I statistic, its expectation, variance, and p-value.
r
# Moran I test under randomisation
#
# data: nc$SID74
# weights: lw_rowstd
#
# Moran I statistic standard deviate = 2.8, p-value = 0.0025
# alternative hypothesis: greater
# sample estimates:
# Moran I statistic Expectation Variance
# 0.219 -0.010 0.0067
Interpretation: The Moran I of 0.219 is significantly positive (p=0.0025p=0.0025), indicating that
counties tend to have similar SID rates to their neighbours—spatial clustering of the health
outcome.
The Moran scatterplot visualises this correlation: it plots the original variable xx on the x-axis
against its spatial lag WxWx on the y-axis. The slope of the regression line is the Moran’s I.
r
[Link](nc$SID74, listw = lw_rowstd,
xlab = "SID74", ylab = "Spatial Lag of SID74")
Quadrants of the Moran scatterplot identify:
High-High (HH): high values surrounded by high values.
Low-Low (LL): low values surrounded by low values.
High-Low (HL): high values surrounded by low values (spatial outliers).
Low-High (LH): low values surrounded by high values.
This decomposition is formalised in the LISA analysis (Section 17.4).
17.3.3 Geary’s C
Geary’s C is based on pairwise squared differences between neighbouring values:
C=n−12∑i∑jwij⋅∑i∑jwij(xi−xj)2∑i(xi−xˉ)2C=2∑i∑jwijn−1⋅∑i(xi−xˉ)2∑i∑jwij(xi−xj)2
Geary’s C ranges from 0 (perfect clustering, since differences are small) to 2 (perfect dispersion),
with 1 indicating no spatial autocorrelation. It is more sensitive to local differences than Moran’s
I, which is based on cross-products.
r
[Link](nc$SID74, listw = lw_rowstd, randomisation = TRUE)
A Geary’s C significantly less than 1 indicates positive spatial autocorrelation; greater than 1
indicates negative spatial autocorrelation.
17.3.4 Assumptions and Inference
Both Moran’s I and Geary’s C can be tested under two assumptions:
1. Normality: the variable is normally distributed. The test statistic is compared to a normal
distribution.
2. Randomisation: the observed values are randomly reassigned to the fixed spatial
locations. The test statistic is computed for many (default 999) random permutations, and
the p-value is the proportion of permuted statistics as extreme as or more extreme than
the observed. This is the safer, non-parametric option and is the default in [Link]().
Monte Carlo simulation is an alternative:
r
[Link](nc$SID74, listw = lw_rowstd, nsim = 9999)
This explicitly simulates the null distribution and provides an empirical p-value.
17.3.5 Choosing Between Moran’s I and Geary’s C
Moran’s I is more widely used and more intuitive (it is a correlation). Geary’s C is more sensitive
to extreme local variation, so it may detect patterns that Moran’s I misses. In practice, reporting
both can provide a richer picture, but for most geospatial applications, Moran’s I with a Moran
scatterplot is the standard.
17.4 Local Indicators of Spatial Association (LISA) and Getis-Ord G*
Global statistics tell us whether clustering exists. Local statistics tell us where. They decompose
the global measure into a value for each spatial unit, allowing the identification of hotspots,
coldspots, and spatial outliers.
17.4.1 Local Moran’s I (LISA)
Local Moran’s I for unit ii is:
Ii=(xi−xˉ)m2∑jwij(xj−xˉ)Ii=m2(xi−xˉ)j∑wij(xj−xˉ)
where m2=1n∑i(xi−xˉ)2m2=n1∑i(xi−xˉ)2. The sum of all IiIi is proportional to the global
Moran’s I.
localmoran() in spdep computes IiIi and its variance under the randomisation assumption,
providing a z-score and p-value for each unit.
r
locm <- localmoran(nc$SID74, listw = lw_rowstd)
head(locm)
# Ii [Link] [Link] [Link] Pr(z > 0)
# 1 0.132 -0.010101 0.0892 0.477 0.317
# ...
Units with significant positive IiIi (e.g., p<0.05p<0.05) are spatial clusters; units with significant
negative IiIi are spatial outliers.
Categorisation of significant units: Combine the local Moran’s I significance with the Moran
scatterplot quadrant:
High-High: significant positive IiIi, high xixi, high lag.
Low-Low: significant positive IiIi, low xixi, low lag.
High-Low: significant negative IiIi, high xixi, low lag.
Low-High: significant negative IiIi, low xixi, high lag.
localmoran() can be paired with a custom classification function, or you can use the convenience
function localmoran_perm() from spdep for Monte Carlo inference.
17.4.2 Getis-Ord G* (Gi*)
The Getis-Ord G* statistic (Getis & Ord, 1992) identifies hotspots (clusters of high values)
and coldspots (clusters of low values). Unlike local Moran’s I, which can detect both positive
and negative spatial association, G* is designed specifically for finding concentrations of high or
low values.
For a distance-band weight matrix (not row-standardised), G* at location ii is:
Gi∗=∑jwijxj−xˉ∑jwijsn∑jwij2−(∑jwij)2n−1Gi∗=sn−1n∑jwij2−(∑jwij)2∑jwijxj−xˉ∑jwij
where ss is the global standard deviation. G* is a z-score: large positive values indicate a hotspot
(the local sum is much larger than expected), large negative values indicate a coldspot.
spdep provides localG() for G* (note: it requires a distance-based neighbour list, not contiguity,
because the statistic’s distribution assumes distance-based weights).
r
# Create a distance band for all NC counties (use a bandwidth that includes at least some
neighbours)
coords <- st_centroid(nc) %>% st_coordinates()
nb_d <- dnearneigh(coords, 0, 80000) # 80 km
lw_d <- nb2listw(nb_d, style = "B") # binary weights for G*
G_star <- localG(nc$SID74, listw = lw_d)
nc$Gstar <- [Link](G_star)
Map the G* z-scores: red for hotspots (z > 1.96), blue for coldspots (z < –1.96). This is a
standard technique in epidemiology and crime analysis.
17.4.3 Interpreting Local Statistics
Local spatial statistics must be interpreted with caution:
1. Multiple testing: Testing nn hypotheses simultaneously inflates Type I error. Use a
correction (Bonferroni, or better, the false discovery rate, [Link]()).
2. The global average xˉxˉ appears in the formula. If the study area includes regions with
very different baselines, the local statistics are affected. Standardisation by the local mean
is sometimes used.
3. Edge effects: Units on the boundary have fewer neighbours, which can inflate the
variance and produce spurious significance.
4. Local indicators are descriptive, not inferential, for each location in isolation. They
are best used as exploratory tools to generate hypotheses about the spatial process, which
should then be tested with spatial regression or other models.
17.4.4 Technical Demonstration: LISA and G* on the North Carolina Dataset
r
library(sf)
library(spdep)
library(ggplot2)
# Load NC data
nc <- st_read([Link]("shape/[Link]", package = "sf"), quiet = TRUE)
nc <- st_transform(nc, 32119)
# Queen contiguity neighbours, row-standardised weights
nb_q <- poly2nb(nc, queen = TRUE)
lw_q <- nb2listw(nb_q, style = "W")
# ----- Global Moran's I -----
(moran_result <- [Link](nc$SID74, listw = lw_q, randomisation = TRUE))
[Link](nc$SID74, listw = lw_q, main = "Moran Scatterplot: SID74")
# ----- Local Moran's I -----
locm <- localmoran(nc$SID74, listw = lw_q)
nc$locI <- locm[,1]
nc$locI_p <- locm[,5]
# Categorise
nc$cluster <- "Not significant"
nc$cluster[nc$locI_p < 0.05 & nc$SID74 > mean(nc$SID74) & [Link](lw_q, nc$SID74) >
mean(nc$SID74)] <- "High-High"
nc$cluster[nc$locI_p < 0.05 & nc$SID74 < mean(nc$SID74) & [Link](lw_q, nc$SID74) <
mean(nc$SID74)] <- "Low-Low"
nc$cluster[nc$locI_p < 0.05 & nc$SID74 > mean(nc$SID74) & [Link](lw_q, nc$SID74) <
mean(nc$SID74)] <- "High-Low"
nc$cluster[nc$locI_p < 0.05 & nc$SID74 < mean(nc$SID74) & [Link](lw_q, nc$SID74) >
mean(nc$SID74)] <- "Low-High"
ggplot(nc) + geom_sf(aes(fill = cluster)) + scale_fill_manual(values = c("High-High" = "red",
"Low-Low" = "blue", "High-Low" = "orange", "Low-High" = "lightblue", "Not significant" =
"grey")) + theme_minimal() + ggtitle("LISA Clusters for SID74")
# ----- Getis-Ord G* (distance-based) -----
coords <- st_centroid(nc) %>% st_coordinates()
nb_d80 <- dnearneigh(coords, 0, 80000)
lw_d80 <- nb2listw(nb_d80, style = "B")
nc$Gstar <- [Link](localG(nc$SID74, listw = lw_d80))
ggplot(nc) + geom_sf(aes(fill = Gstar)) + scale_fill_gradient2(low = "blue", mid = "white", high
= "red", midpoint = 0) + theme_minimal() + ggtitle("Getis-Ord G*: SID74 Hotspots (80 km)")
Hard Practice 17 – “Identifying Hotspots of Deforestation Using Satellite-Derived Change
Maps”
Objective:
Apply spatial autocorrelation analysis to a simulated forest-change dataset. Define neighbours
for irregular polygons, compute global Moran’s I, and identify local clusters of high
deforestation using LISA and Getis-Ord G*.
Scenario:
You have a vector layer of 200 forest management units (polygons) in a tropical forest region.
Each unit has an attribute deforest_pct representing the percentage of forest lost between 2018
and 2024, derived from satellite land-cover change analysis. You suspect deforestation is
clustered due to road expansion and agricultural conversion. You will test this hypothesis and
map the hotspots.
Instructions:
1. Simulate the dataset:
r
[Link](2024)
library(sf)
library(spdep)
# Create a hexagonal grid as management units
grid <- st_make_grid(st_as_sfc(st_bbox(c(xmin=0, xmax=100, ymin=0, ymax=100))), cellsize =
7, what = "polygons", square = FALSE)
grid_sf <- st_sf(id = 1:length(grid), geometry = grid)
# Introduce spatial clustering in deforestation: simulate with a spatial autoregressive process
# Simplified: use a distance decay from a random "deforestation centre"
centres <- st_as_sf([Link](lon = runif(3, 20, 80), lat = runif(3, 20, 80)), coords =
c("lon","lat"), crs = st_crs(grid_sf))
grid_sf$deforest_pct <- 0
for (i in 1:3) {
d <- st_distance(grid_sf, centres[i,]) %>% [Link]()
grid_sf$deforest_pct <- grid_sf$deforest_pct + 30 * exp(-d / 15)
}
grid_sf$deforest_pct <- grid_sf$deforest_pct + rnorm(nrow(grid_sf), 0, 3)
grid_sf$deforest_pct <- pmax(grid_sf$deforest_pct, 0)
2. Define neighbours: Use Queen contiguity (poly2nb). Check for islands; if any,
set [Link] = TRUE.
3. Compute global Moran’s I for deforest_pct using row-standardised weights. Report the
statistic, its expectation, and p-value. Create a Moran scatterplot.
4. Local Moran’s I: Compute local Moran’s I and map the significant clusters (High-High,
Low-Low, High-Low, Low-High). Use a p-value threshold of 0.05, and apply a false
discovery rate correction ([Link](..., method = "fdr")).
5. Getis-Ord G*: Create a distance-band neighbour list (choose a distance so that each unit
has at least 1 neighbour). Compute G* and map the z-scores as a hotspot map.
6. Interpretation: In a comment block, answer:
o Where are the deforestation hotspots located? Do they correspond to the
simulated centres?
o What is the difference between a High-High LISA cluster and a G* hotspot?
o How would the results change if you used Rook contiguity instead of Queen?
Deliverable:
A script practice_chapter17_deforestation_hotspots.R with the simulation, analysis, maps, and
interpretation.
Chapter 17 Review Problems
Solve each in a clearly commented R script using sf and spdep. Use provided or simulated data.
Easy Problems (1–5)
1. Create a Neighbour List
Using the nc dataset (from sf), create a Queen contiguity neighbour list. Print the summary. How
many counties have exactly 3 neighbours?
2. Row-Standardised Weights
Convert the neighbour list from Problem 1 to a row-standardised listw object. Verify that for the
first county, the weights sum to 1. Extract the weights for the first county and print them.
3. Moran Scatterplot
Using nc$BIR74 (births in 1974) and the row-standardised weights, produce a Moran scatterplot.
Identify any counties that appear in the High-Low quadrant.
4. Moran’s I Test
Test nc$BIR74 for spatial autocorrelation using [Link]() with randomisation assumption.
Report the statistic, expectation, and p-value. Is there significant clustering?
5. Distance-Based Neighbours
From the nc centroid coordinates, create a distance-band neighbour list with d2 = 80000 (80 km).
Count the number of counties with zero neighbours. Use card().
Medium Problems (6–10)
6. Compare Contiguity Definitions
Create Rook and Queen contiguity neighbour lists for nc. Compute Moran’s I
for nc$SID74 under both. Compare the results. Does the choice of contiguity affect the
conclusion?
7. k-Nearest Neighbours
Using nc centroid coordinates, create a 5-nearest neighbour list. Symmetrise it
with [Link](). Convert to row-standardised weights. Compute Moran’s I for nc$SID74.
Compare the p-value to that from the Queen contiguity analysis.
8. Local Moran’s I Map
For nc$SID74, compute local Moran’s I using Queen contiguity row-standardised weights.
Create a map of the four cluster types (High-High, Low-Low, High-Low, Low-High) for counties
with p-value < 0.05. Use ggplot2.
9. Geary’s C vs. Moran’s I
Compute both Geary’s C and Moran’s I for nc$BIR74. Report both statistics and p-values. Are
they consistent? In what circumstances might they disagree?
10. Edge Effects
Select only the coastal counties of North Carolina (those bordering the Atlantic). Recompute the
Queen contiguity neighbour list and Moran’s I for SID74. Compare the p-value to the full
dataset. Discuss why edge counties might show weaker autocorrelation.
Challenging Problems (11–15)
11. Spatial Autocorrelation of Regression Residuals
Fit a linear model: lm(SID74 ~ BIR74 + NWBIR74, data = nc). Extract the residuals. Compute
Moran’s I of the residuals using Queen contiguity row-standardised weights. Is there residual
spatial autocorrelation? What does this imply for the model? (This is a lead-in to spatial
regression, Chapter 19.)
12. Monte Carlo Inference for Local Moran’s I
Use localmoran_perm() to compute local Moran’s I for nc$SID74 with 9999 permutations.
Compare the p-values to those from the analytical variance formula. Are there differences in
which counties are declared significant?
13. Simulated Spatial Autocorrelation and Power Analysis
Simulate 100 data sets of 100 points on a grid with a known autoregressive
parameter ρρ (use spdep::invIrM to generate spatial data). For each, test Moran’s I with
contiguity weights. What proportion of tests reject the null at α=0.05α=0.05 for different values
of ρρ? Plot the power curve.
14. G* Hotspot Analysis for Point Data
Simulate 500 point locations of disease cases in a city (x,y coordinates), with a higher intensity
in two “hotspot” clusters. Compute G* using a distance band (choose an appropriate radius).
Create a kernel density surface of the points and overlay the G* significant hotspots. Do they
coincide?
15. Spatio-Temporal Autocorrelation
The nc dataset has no time dimension. Simulate a 3-year panel: for each year, add random noise
to SID74. Create a spatiotemporal neighbour list by considering counties as neighbours if they
are either spatially adjacent (Queen) or the same county in the previous/next year. Compute a
global spatiotemporal Moran’s I. (Hint: use the spacetime package or construct a block-diagonal
weights matrix.)
Chapter 18: Point Pattern Analysis
A point is the simplest spatial object: a coordinate pair, a location in space. But a collection of
points—trees in a forest, earthquakes along a fault, cases of a disease in a city, crime incidents
in a neighbourhood—carries a wealth of spatial information. Are the points clustered together,
suggesting contagion or shared environmental preference? Are they regularly spaced, suggesting
competition or territoriality? Or are they distributed at random, consistent with a homogeneous
Poisson process? Point pattern analysis is the branch of spatial statistics that answers these
questions. This chapter introduces the theory and practice of analysing spatial point patterns in
R, primarily using the spatstat package (Baddeley, Rubak, & Turner, 2015), the most
comprehensive open-source toolkit for point process statistics. We begin with first-order
properties—intensity and density estimation—then move to second-order properties with
Ripley’s K, L, and pair correlation functions, which reveal the scales at which clustering or
regularity occurs. We then explore marked point patterns (where each point carries additional
information, like tree species or earthquake magnitude) and inhomogeneous processes (where
intensity varies systematically across space). Every concept is grounded in the geospatial
context: forest ecology, seismology, epidemiology, and crime mapping. The Hard Practice asks
you to analyse the spatial distribution of earthquake epicentres. By the end, you will be able to
describe, test, and model the spatial structure of point events—a skill central to ecology, public
health, and natural hazard assessment.
18.1 Density Estimation and Quadrat Analysis
The simplest question we can ask of a point pattern is: where are the points most concentrated?
This is a question about the first-order intensity λ(s)λ(s), the expected number of points per unit
area at location ss. Intensity can be constant (homogeneous) or vary across space
(inhomogeneous). Estimating intensity is the first step in any point pattern analysis.
18.1.1 The Point Pattern Object in spatstat
The spatstat package represents a point pattern as an object of class ppp. A ppp requires three
components: a vector of x-coordinates, a vector of y-coordinates, and an observation
window (class owin)—the region within which points were observed. The window is essential
because the intensity is points per unit area, and the area is defined by the window. Points outside
the window were not observed, so they cannot contribute to intensity estimates.
r
library(spatstat)
# Create a point pattern from random points in a unit square
[Link](42)
x <- runif(100, 0, 1)
y <- runif(100, 0, 1)
pp <- ppp(x, y, window = owin(c(0,1), c(0,1)))
pp
# Planar point pattern: 100 points
# window: rectangle = [0,1] x [0,1] units
The window can be any polygon, including irregular study area boundaries, imported from
shapefiles using [Link](). For geospatial data, you can convert an sf point layer and
an sf polygon boundary into a ppp using [Link]().
18.1.2 Quadrat Analysis: Dividing and Counting
The oldest method of intensity estimation is quadrat analysis: divide the study area into
equal-sized quadrats (usually squares), count the number of points in each, and compare the
observed counts to what would be expected under complete spatial randomness (CSR). Under
CSR, points follow a homogeneous Poisson process, and the count in a quadrat of
area AA follows a Poisson distribution with mean λAλA.
r
# Quadrat counts in a 5x5 grid
q <- quadratcount(pp, nx = 5, ny = 5)
q
plot(pp, main = "Quadrat Counts")
plot(q, add = TRUE, col = "red", lwd = 1.5)
To test for CSR against the alternative of clustering (variance > mean) or regularity (variance <
mean), we use the index of dispersion (variance-to-mean ratio) and the χ2χ2 goodness-of-fit
test:
r
qt <- [Link](pp, nx = 5, ny = 5)
qt
# Chi-squared test of CSR using quadrat counts
# X2 = ..., df = ..., p-value = ...
A significant p-value rejects CSR in favour of clustering (if variance > mean) or regularity (if
variance < mean). The index of dispersion is var(q)/mean(q); a value significantly greater than 1
indicates clustering.
Limitations of quadrat analysis:
The results depend on the quadrat size. Too coarse, and you lose spatial detail; too fine,
and many quadrats have zero counts, breaking the χ2χ2 approximation.
Quadrat analysis does not reveal the scale of clustering. It only tells you that the pattern
is not uniform at the chosen quadrat resolution.
For modern point pattern analysis, quadrat analysis is primarily a quick exploratory tool.
Non-parametric kernel density estimation (Section 18.1.3) and second-order methods
(Section 18.2) are preferred.
18.1.3 Kernel Density Estimation
Kernel density estimation (KDE) produces a smooth, continuous surface of
intensity λ^(s)λ^(s) by placing a kernel function (usually a Gaussian) at each point and summing
their contributions. The critical parameter is the bandwidth σσ: a small bandwidth reveals
fine-scale variation but may be noisy; a large bandwidth smooths out detail.
In spatstat, density() computes a kernel-smoothed intensity surface:
r
# Kernel density with a chosen bandwidth
den <- density(pp, sigma = 0.1)
plot(den, main = "Kernel Density (sigma = 0.1)")
# Likelihood cross-validation to select bandwidth automatically
den_bw <- [Link](pp) # optimal bandwidth by likelihood cross-validation
den_opt <- density(pp, sigma = den_bw)
plot(den_opt, main = paste("Kernel Density (sigma =", round(den_bw, 3), ")"))
Other bandwidth selection methods include [Link]() (for mean squared error of the density)
and [Link]() (Scott's rule of thumb). The choice depends on the analysis goal: cross-validation
is best for overall intensity estimation; larger bandwidths are better for identifying broad trends;
smaller bandwidths are better for hotspot detection.
Edge correction: Points near the boundary have fewer neighbours, so their intensity is
underestimated. density() applies a edge correction by default (the edge = TRUE argument
divides by the mass of the kernel inside the window). This is important for study areas with
irregular boundaries.
Adaptive bandwidth kernels (density(..., sigma = [Link])) vary the bandwidth inversely
with local point density, giving more detail in dense areas and more smoothing in sparse areas.
This is useful when point density varies greatly across the study area.
18.1.4 Intensity by Covariate
In many geospatial applications, intensity is not constant but varies with environmental
covariates. For example, the density of trees may depend on elevation; the density of disease
cases may depend on population density. In spatstat, this is modelled using rhohat():
r
# Simulate a covariate (e.g., elevation gradient)
elev <- im(matrix(seq(0, 1, [Link] = 100), nrow = 100), xcol = seq(0, 1, [Link] = 100),
yrow = seq(0, 1, [Link] = 100))
# Estimate intensity as a function of the covariate
rh <- rhohat(pp, elev)
plot(rh, main = "Intensity vs. Elevation")
This is the first step toward an inhomogeneous point process model (Section 18.3), where
intensity is modelled explicitly as a function of covariates.
18.1.5 Technical Demonstration: Density Estimation for a Simulated Species
r
library(spatstat)
# Create a clustered point pattern (simulating tree locations)
[Link](2024)
# Generate 3 cluster centres
centres <- [Link](x = runif(3, 0, 1), y = runif(3, 0, 1))
# Generate points around each centre
trees <- NULL
for (i in 1:3) {
n <- sample(20:50, 1)
x <- rnorm(n, mean = centres$x[i], sd = 0.05)
y <- rnorm(n, mean = centres$y[i], sd = 0.05)
trees <- rbind(trees, [Link](x = x, y = y))
}
# Restrict to the unit square window
inside <- trees$x >= 0 & trees$x <= 1 & trees$y >= 0 & trees$y <= 1
pp_trees <- ppp(trees$x[inside], trees$y[inside], window = owin(c(0,1), c(0,1)))
# Quadrat analysis
plot(pp_trees, main = "Tree Locations with Quadrats")
q <- quadratcount(pp_trees, nx = 5, ny = 5)
plot(q, add = TRUE, col = "red")
[Link](pp_trees, nx = 5, ny = 5)
# Kernel density
den_trees <- density(pp_trees, sigma = 0.08)
plot(den_trees, main = "Tree Density (KDE)")
# Compare with CSR (simulated)
pp_csr <- rpoispp(lambda = intensity(pp_trees), win = owin(c(0,1), c(0,1)))
plot(pp_csr, main = "CSR Simulation")
18.2 Ripley’s K, L, and Pair Correlation Functions
Intensity tells us how many points occur per unit area. It does not tell us how the points relate to
each other. Second-order statistics describe the dependence between pairs of points: are points
closer together than expected by chance (clustering)? Farther apart (regularity)? At what spatial
scales do these patterns operate? Ripley’s K function and its relatives are the standard tools.
18.2.1 Ripley’s K Function
Ripley’s K function (Ripley, 1976) counts the expected number of additional points within
distance rr of a typical point, divided by the overall intensity λλ:
K(r)=1λE[number of other points within distance r of a randomly chosen point]K(r)=λ1
E[number of other points within distance r of a randomly chosen point]
Under complete spatial randomness (CSR, homogeneous Poisson), K(r)=πr2K(r)=πr2.
If K^(r)>πr2K^(r)>πr2, points are clustered at scale rr (more neighbours than expected).
If K^(r)<πr2K^(r)<πr2, points are regularly spaced. The difference reveals the scale of pattern.
In spatstat, Kest() computes an estimate of K(r)K(r) with edge correction:
r
K <- Kest(pp_trees, correction = "border")
plot(K, main = "Ripley's K Function")
The plot shows the theoretical KCSR(r)KCSR(r) (dashed), the observed K^(r)K^(r), and an
envelope of simulations under CSR. If the observed curve lies above the envelope, clustering is
significant; below, regularity.
18.2.2 The L Function: Stabilising Variance
The K function is a cumulative measure, and its variance increases with rr, making interpretation
difficult. The L function (Besag, 1977) applies a square-root transformation to stabilise the
variance:
L(r)=K(r)πL(r)=πK(r)
Under CSR, L(r)=rL(r)=r. The transformed plot is linear, making deviations easy to see: L(r)
−r>0L(r)−r>0 indicates clustering; L(r)−r<0L(r)−r<0 indicates regularity.
r
L <- Lest(pp_trees, correction = "border")
plot(L, main = "L Function (L(r) - r)")
spatstat plots L(r)−rL(r)−r by default, with a horizontal reference line at 0.
18.2.3 The Pair Correlation Function g(r)g(r)
The pair correlation function (PCF) is the derivative of the K function. It describes the
probability of finding a pair of points separated by distance rr, relative to what would be
expected under CSR:
g(r)=K′(r)2πrg(r)=2πrK′(r)
Under CSR, g(r)=1g(r)=1. Values above 1 indicate clustering at distance rr; below 1 indicate
regularity. Unlike K, the PCF is a non-cumulative measure, so a peak at r=0.05r=0.05 tells you
specifically that pairs are common at 0.05 units, without the influence of pairs at smaller
distances.
r
pcf <- pcf(pp_trees, correction = "border")
plot(pcf, main = "Pair Correlation Function")
The PCF is particularly useful for identifying the characteristic scale of clustering (e.g., the
typical diameter of tree clumps) or the spacing of regular patterns (e.g., the distance between
individual trees in a uniform plantation).
18.2.4 Envelopes and Significance Testing
To test whether an observed pattern deviates significantly from CSR, we use simulation
envelopes. envelope() generates many (typically 99 or 999) realisations of CSR, computes the
summary function (K, L, or PCF) for each, and constructs pointwise envelopes. The observed
curve is plotted against the envelopes.
r
env_K <- envelope(pp_trees, fun = Kest, nsim = 99, correction = "border")
plot(env_K, main = "K Function with CSR Envelope")
env_L <- envelope(pp_trees, fun = Lest, nsim = 99, correction = "border")
plot(env_L, main = "L Function with CSR Envelope")
Important caveat: Pointwise envelopes are not global significance bands. If the observed curve
leaves the envelope at some distance rr, it is significant at that rr, but the overall test is not
simultaneously valid across all rr. For a global test, use [Link]() (Diggle-Cressie-Loosmore-Ford
test) or the MAD test ([Link]()), which summarise deviations across a range of rr:
r
[Link](pp_trees, Lest, nsim = 99)
18.2.5 Interpretation for Geospatial Data
Forest ecology: K(r) above the CSR envelope at small r: seedlings cluster around parent
trees. At larger r: the pattern may become regular due to competition.
Seismology: K(r) above CSR at all r: earthquakes cluster along fault lines, forming a
fractal pattern.
Epidemiology: K(r) above CSR at small r: disease cases cluster within households; at
larger r: between-household transmission.
Crime mapping: PCF peaks at specific distances may reflect the spatial scale of policing
districts or gang territories.
18.2.6 Technical Demonstration: K, L, and PCF for the Tree Data
r
library(spatstat)
# Continue with pp_trees from Section 18.1
# Ripley's K
K <- Kest(pp_trees, correction = "border")
plot(K, main = "Ripley's K: Clustered Trees")
# L function
L <- Lest(pp_trees, correction = "border")
plot(L, main = "L Function")
# Pair correlation
pcf <- pcf(pp_trees, correction = "border")
plot(pcf, main = "Pair Correlation Function")
# Envelope test for CSR
env_L <- envelope(pp_trees, fun = Lest, nsim = 99, correction = "border")
plot(env_L, main = "L Function with 99 CSR Simulations")
# Global test
[Link](pp_trees, Lest, nsim = 99)
18.3 Marked Point Patterns and Inhomogeneous Processes
Real-world point data rarely consists of anonymous points. Trees have species and diameter;
earthquakes have magnitude; crimes have type and severity. These additional attributes
are marks, and the resulting pattern is a marked point pattern. Furthermore, intensity often
varies systematically across space due to environmental heterogeneity, not just point-to-point
interaction. An inhomogeneous point process models this.
18.3.1 Marked Point Patterns
In spatstat, a marked point pattern is created by adding a vector of marks when constructing
the ppp. Marks can be continuous (numeric), categorical (factor), or multivariate (data frame).
r
# Simulate trees with species and diameter
[Link](99)
n <- 150
x <- runif(n, 0, 1)
y <- runif(n, 0, 1)
species <- sample(c("Oak", "Pine", "Birch"), n, replace = TRUE, prob = c(0.5, 0.3, 0.2))
diameter <- rlnorm(n, meanlog = 3, sdlog = 0.5)
pp_marked <- ppp(x, y, marks = [Link](species = factor(species), dbh = diameter), window
= owin(c(0,1), c(0,1)))
pp_marked
Subsetting by mark:
r
oaks <- subset(pp_marked, species == "Oak")
plot(oaks, main = "Oak Trees", cols = "darkgreen")
Mark-specific intensity:
r
den_oak <- density(subset(pp_marked, species == "Oak"), sigma = 0.1)
plot(den_oak, main = "Oak Density")
Correlation between marks: markcorr() estimates the mark correlation function, which tests
whether marks of nearby points tend to be similar (positive correlation) or dissimilar (negative
correlation). For example, are trees of the same species more likely to be near each other?
r
mc <- markcorr(pp_marked, f = function(m1, m2) { (m1$species == m2$species) + 0 })
# Not a direct markcorr call; markcorr is for continuous marks. For categorical, use:
# Split by species and compute cross-type functions.
For categorical marks, cross-type K functions (Kcross, Kdot) measure the spatial relationship
between different mark types:
r
# Cross-type K: Oak-Pine
K_cross <- Kcross(pp_marked, "Oak", "Pine")
plot(K_cross)
Mark independence test: envelope() with a random labelling null hypothesis (shuffle the marks
while keeping point locations fixed) tests whether the spatial distribution of marks is independent
of location.
r
env_random_label <- envelope(pp_marked, fun = Kcross, i = "Oak", j = "Pine", nsim = 99,
simulate = expression(rlabel(pp_marked)))
plot(env_random_label, main = "Cross-type K: Oak vs Pine")
18.3.2 Inhomogeneous Point Processes
A homogeneous Poisson process assumes constant intensity. Many real patterns are
inhomogeneous: trees are denser near rivers; crimes are denser in the city centre.
The inhomogeneous Poisson process models the intensity as a function λ(s)λ(s) of spatial
location.
In spatstat, ppm() fits a point process model. The intensity can be modelled as a log-linear
function of spatial coordinates or covariates:
r
# Fit a model where intensity depends on x and y
fit <- ppm(pp ~ x + y, data = pp)
fit
The covariates must be available as pixel images (im objects) or as functions. The formula uses
the same syntax as lm() and glm().
Inhomogeneous K function (Kinhom) adjusts the K function for spatial variation in intensity, so
that any remaining deviation from the CSR expectation reflects genuine point-to-point
interaction, not simply inhomogeneity.
r
# Estimate intensity using kernel smoothing
fit_lam <- density(pp_trees, sigma = 0.1)
K_inhom <- Kinhom(pp_trees, lambda = fit_lam)
plot(K_inhom, main = "Inhomogeneous K Function")
If Kinhom(r)Kinhom(r) still exceeds πr2πr2, there is clustering beyond that explained by
intensity variation.
18.3.3 Geospatial Applications of Marked and Inhomogeneous Processes
Ecology: Model tree locations as a function of soil moisture, slope, and light availability.
Marks: species, dbh. Test whether species segregate (separate niches) or aggregate
(facilitation).
Seismology: Point pattern of earthquake epicentres. Mark: magnitude. Inhomogeneous
intensity reflects tectonic strain rate. The b-value (Gutenberg-Richter) relates to mark
distribution.
Epidemiology: Cases of a disease, marked by age group. Inhomogeneous intensity
reflects population density. Cross-type K tests whether different age groups cluster
together.
Crime analysis: Crime locations, marked by type (burglary, assault, theft).
Inhomogeneous intensity reflects urban density. Cross-type functions test whether
different crime types co-occur.
18.3.4 Technical Demonstration: Marked and Inhomogeneous Analysis
r
library(spatstat)
# 1. Create an inhomogeneous pattern: intensity increases with x
[Link](123)
x_in <- rbeta(200, 2, 5) # beta distribution gives a gradient
y_in <- runif(200, 0, 1)
pp_in <- ppp(x_in, y_in, window = owin(c(0,1), c(0,1)))
# Plot
plot(pp_in, main = "Inhomogeneous Point Pattern")
# K function (assumes homogeneity, will show clustering)
K_hom <- Kest(pp_in)
plot(K_hom, main = "K (homogeneous assumption) - false clustering")
# Inhomogeneous K: estimate intensity first
lam <- density(pp_in, sigma = 0.15)
K_in <- Kinhom(pp_in, lambda = lam)
plot(K_in, main = "Inhomogeneous K - no residual clustering")
# 2. Marked pattern: add marks correlated with location
marks_cont <- x_in + rnorm(200, 0, 0.05) # mark depends on x
pp_marked_cont <- ppp(x_in, y_in, marks = marks_cont, window = owin(c(0,1), c(0,1)))
plot(pp_marked_cont, main = "Marked Pattern (mark proportional to x)")
# Mark correlation function
mc <- markcorr(pp_marked_cont)
plot(mc, main = "Mark Correlation Function")
# If marks are spatially correlated, mc(r) > 1 at small r.
Hard Practice 18 – “Analysing the Spatial Distribution of Earthquake Epicentres”
Objective:
Perform a complete point pattern analysis of a simulated earthquake epicentre dataset. Assess
intensity, test for clustering with K, L, and PCF, analyse the mark (magnitude), and model
inhomogeneity with a spatial covariate.
Scenario:
You are a seismologist studying a tectonic region. You have a catalogue of 500 earthquake
epicentres recorded over 20 years, with magnitudes. The region is 500 km × 500 km. The region
contains a major fault line (a straight line), and seismicity is expected to concentrate near it. You
will:
1. Estimate intensity using quadrats and kernel density.
2. Test for clustering using Ripley’s K, L, and PCF, with CSR envelopes.
3. Analyse the mark (magnitude): test whether larger earthquakes cluster spatially using
mark correlation.
4. Fit an inhomogeneous model with distance to the fault as a covariate, and compute the
inhomogeneous K.
Instructions:
1. Simulate the earthquake catalogue:
r
[Link](2024)
# Fault line: y = 250 (horizontal) with width 50 km
n_eq <- 500
# Generate epicentres near the fault
x_eq <- runif(n_eq, 0, 500)
y_eq <- 250 + rnorm(n_eq, 0, 50) # concentrate near y=250
# Restrict to [0,500] x [0,500]
inside <- y_eq >= 0 & y_eq <= 500
x_eq <- x_eq[inside]; y_eq <- y_eq[inside]
# Magnitudes: Gutenberg-Richter, exponential distribution
mag_eq <- rexp(length(x_eq), rate = 1/2) + 3 # min magnitude ~3
2. Create a ppp object: Window owin(c(0,500), c(0,500)). Marks = mag_eq.
3. Intensity estimation:
o Quadrat analysis with 10×10 km quadrats. Perform the χ2χ2 test.
o Kernel density with a bandwidth selected by [Link](). Plot the density surface.
Identify the highest-intensity region.
4. Second-order analysis:
o Compute Ripley’s K, L, and PCF with border correction.
o Generate 99 CSR envelopes for the L function. Plot with the observed L.
o Is there significant clustering? At what range of distances?
o Perform the global Diggle-Cressie-Loosmore-Ford test ([Link]()).
5. Mark analysis:
o Compute the mark correlation function for magnitude. Plot it. Does magnitude
show spatial correlation (i.e., do similar-magnitude events cluster)?
o Create two subsets: large <- subset(pp_eq, marks >= 5) and small <-
subset(pp_eq, marks < 5). Compute the cross-type K function between large and
small events. Are large events associated with small events?
6. Inhomogeneous model:
o Create a covariate image dist_fault = absolute distance in y from the fault line (y
= 250).
o Fit a Poisson point process model with ppm(pp_eq ~ dist_fault).
o Compute the inhomogeneous K function using the fitted intensity. Does the
clustering disappear after accounting for distance to the fault?
7. Map and interpretation:
o Create a publication-ready map of earthquake epicentres, kernel density, and fault
location (use ggplot2 or base plot).
o In a comment block, summarise your findings: Is seismicity clustered? At what
scale? Is the clustering explained by the fault? Do large earthquakes cluster
independently of small ones?
Deliverable:
A script practice_chapter18_earthquake_point_pattern.R with the full analysis, plots, and
interpretation.
Chapter 18 Review Problems
Solve each in a clearly commented R script using spatstat. Use simulated data as specified.
Easy Problems (1–5)
1. Create a Point Pattern
Generate 80 random points in a unit square. Create a ppp object. Print the summary. Plot the
points.
2. Quadrat Analysis
Using the point pattern from Problem 1, perform quadrat analysis with a 4×4 grid. Print the
quadrat counts. Perform the χ2χ2 test. Interpret the p-value (it should be close to uniform, hence
non-significant).
3. Kernel Density
Compute a kernel density estimate for the pattern from Problem 1 with sigma = 0.1. Plot the
density as a heatmap. Overlay the points. Where is density highest?
4. Simulate CSR and Visualise
Use rpoispp() to generate a homogeneous Poisson process with 100 points in a unit square. Plot
it. Generate another realisation and plot it side-by-side. Observe the variability of randomness.
5. K Function Basics
Compute the K function for the pattern from Problem 1. Plot it. Does the observed K lie within
the theoretical expectation? Why?
Medium Problems (6–10)
6. Clustered vs. Regular Patterns
Create three patterns of 100 points each in a unit square:
Clustered: use rMatClust() (Matérn cluster process).
Regular: use rSSI() (simple sequential inhibition).
Random: rpoispp().
Plot all three side-by-side. Compute the L function for each and plot them on the same
graph. Describe how the L function distinguishes the three types.
7. Envelope Test
For the clustered pattern from Problem 6, generate 99 CSR envelopes for the L function. Plot the
envelope with the observed L. At which distances does the pattern significantly depart from
CSR?
8. Pair Correlation Function Peaks
Create a cluster process with known cluster radius (e.g., rMatClust(radius = 0.05, mu = 10)).
Compute the PCF. Identify the peak in the PCF. Does it correspond to the cluster radius?
9. Inhomogeneous Pattern and K
Create an inhomogeneous pattern where intensity increases linearly with x (use rpoispp(lambda
= function(x, y) 200 * x)). Compute both the homogeneous K and the inhomogeneous K (with
the true intensity). Compare. Why does the homogeneous K indicate spurious clustering?
10. Marked Pattern Basics
Create a point pattern with 100 points and a continuous mark (e.g., rnorm(100, 5, 2)). Compute
the mark correlation function. Interpret the plot: is there spatial correlation of marks?
Challenging Problems (11–15)
11. Bivariate Point Pattern Analysis
Simulate two species of trees in a forest: Species A forms clusters; Species B is randomly
distributed. Create a marked point pattern with two species. Compute the cross-type K function
between A and B. Test for independence using random labelling (99 simulations). Are the two
species spatially associated or segregated?
12. Inhomogeneous Model with Covariates
Create a point pattern on a 100×100 rectangle with intensity depending on distance from a
central point (e.g., a city centre) and on a random spatial covariate (e.g., a simulated soil quality
surface). Fit a Poisson point process model with ppm() including both covariates. Interpret the
coefficients. Compute the residual K function (Kres()) to assess model fit.
13. Replicated Point Patterns and Meta-Analysis
Simulate 10 independent realisations of a clustered point process in the same window. For each,
compute the L function. Pool the estimates (average L(r) across realisations) and construct an
envelope from the variance across realisations. This is a simple meta-analysis of spatial patterns.
Compare to the envelope from a single realisation.
14. Space-Time Point Pattern
spatstat has limited space-time support, but you can simulate a space-time point pattern by
creating points with a time mark. Simulate 200 points in a unit square with a time mark (e.g.,
uniform over 10 years). Introduce temporal clustering: more events in years 5–7. Compute the
spatiotemporal K function if you have the stpp package, or simply analyse the temporal
distribution of events and the spatial distribution of early vs. late events. Use kernel density in
time.
15. Real-World Application: Crime Hotspot Analysis
Use publicly available crime data for a city (e.g., Chicago crime data from
the ggmap or spData packages, or simulate realistic data). Create a ppp object with an irregular
city boundary as the window. Perform:
Quadrat analysis and KDE to identify hotspots.
K function to assess spatial scale of clustering.
Inhomogeneous K with population density as a covariate (simulate population surface).
Map the hotspots and the inhomogeneous intensity side-by-side.
Write a short report (as a comment block) interpreting the findings for a law enforcement
audience.
Chapter 19: Spatial Regression Models
In Chapter 10 you learned to fit linear models with lm(). In Chapter 17 you learned to measure
spatial autocorrelation. Now we confront the consequence of those two chapters: when data are
spatially autocorrelated, the classical linear model fails. Its standard errors are wrong; its
p-values are misleading; its assumption of independent errors is violated. This chapter
introduces the two most widely used spatial regression models that correct for these violations:
the spatial lag model (SAR), which includes a spatially lagged dependent variable as a
predictor, and the spatial error model (SEM), which models the error term as a spatial
autoregressive process. We learn when each is appropriate, how to fit them in R using
the spatialreg package, and how to compare them using Lagrange multiplier tests, information
criteria, and residual diagnostics. The Hard Practice asks you to model house prices with
accessibility and environmental covariates, a classic application of spatial hedonic modelling.
By the end, you will be able to move beyond OLS and build regression models that respect the
First Law of Geography.
19.1 When and Why Ordinary Least Squares Fails for Spatial Data
The ordinary least squares (OLS) estimator is the best linear unbiased estimator (BLUE) under
the Gauss-Markov assumptions. One of these assumptions is that the errors εiεi are independent
and identically distributed. In spatial data, errors are rarely independent. They exhibit spatial
autocorrelation: the error at one location is correlated with errors at neighbouring locations.
19.1.1 The Consequences of Ignoring Spatial Autocorrelation
When spatial autocorrelation is present but ignored, the OLS estimates of the regression
coefficients β^β^ remain unbiased under certain conditions (if the autocorrelation is in the error
term), but their standard errors are biased downward. This leads to:
1. Inflated t-statistics: Predictors appear more significant than they truly are.
2. Narrow confidence intervals: We are overconfident in our coefficient estimates.
3. Misleading model selection: Variables that are spatially autocorrelated may be selected
as significant when they are merely reflecting the spatial structure of the response, not a
genuine causal relationship.
4. Inefficient estimates: OLS is no longer BLUE; there exists another linear unbiased
estimator with lower variance.
In the case where the spatial autocorrelation is substantive (i.e., the response variable itself is
influenced by the values of neighbouring units—a contagion, diffusion, or spillover effect), OLS
is not only inefficient but also biased and inconsistent because it omits a relevant variable (the
spatial lag).
19.1.2 Detecting the Need for Spatial Regression
The diagnostic workflow from OLS to spatial regression proceeds as follows:
1. Fit the OLS model: lm(y ~ x1 + x2, data = sf_data).
2. Extract residuals.
3. Define spatial weights (Chapter 17).
4. Compute Moran’s I of the residuals. A significant Moran’s I indicates residual spatial
autocorrelation, which violates the OLS assumptions.
5. Apply Lagrange multiplier tests to determine whether the autocorrelation is best
captured by a spatial lag model or a spatial error model.
r
library(sf)
library(spdep)
library(spatialreg)
# Load example dataset: North Carolina counties
nc <- st_read([Link]("shape/[Link]", package = "sf"), quiet = TRUE)
nc <- st_transform(nc, 32119) # NC state plane
# OLS model
ols_model <- lm(SID74 ~ BIR74 + NWBIR74, data = nc)
summary(ols_model)
# Residuals
nc$residuals <- residuals(ols_model)
# Spatial weights: Queen contiguity
nb <- poly2nb(nc, queen = TRUE)
lw <- nb2listw(nb, style = "W")
# Moran's I of residuals
[Link](nc$residuals, listw = lw, randomisation = TRUE)
# A significant Moran's I indicates spatial dependence.
If Moran’s I of the residuals is significant, OLS is inadequate and a spatial regression model
should be considered.
19.1.3 Lagrange Multiplier Tests: SAR vs. SEM
The Lagrange multiplier (LM) tests, developed by Anselin (1988), test the null hypothesis of no
spatial autocorrelation against specific alternatives:
LM-lag test: Tests for a spatially lagged dependent variable (SAR model). If the errors
are spatially independent but the true model includes a spatial lag, OLS is biased.
LM-error test: Tests for spatial autocorrelation in the error term (SEM model). If the
true model has spatially autocorrelated errors, OLS is inefficient but unbiased.
Robust LM-lag: Tests for spatial lag in the presence of local error autocorrelation.
Robust LM-error: Tests for spatial error in the presence of a local spatial lag.
spdep::[Link]() performs all four tests on an OLS model:
r
lm_tests <- [Link](ols_model, listw = lw, test = "all")
print(lm_tests)
The decision rule:
If LM-lag is significant and its robust version remains significant, choose SAR.
If LM-error is significant and its robust version remains significant, choose SEM.
If both are significant, look at the robust versions. The one with the larger test statistic (or
lower p-value) in its robust form indicates the dominant source of misspecification.
If neither is significant, OLS may be adequate (but always check residuals and consider
spatial diagnostics on theoretical grounds).
19.2 Spatial Lag Model (SAR) and Spatial Error Model (SEM)
19.2.1 The Spatial Lag Model (SAR) – Theory
The Spatial Lag Model (also called Spatial Autoregressive Model, SAR) includes a spatially
weighted average of the dependent variable as an additional predictor:
y=ρWy+Xβ+ε,ε∼N(0,σ2I)y=ρWy+Xβ+ε,ε∼N(0,σ2I)
Where:
yy is the n×1n×1 vector of the response.
ρρ (rho) is the spatial autoregressive parameter. It measures the strength of the spatial
spillover: how much yy at neighbouring locations influences yy at a given location.
WW is the n×nn×n spatial weights matrix (usually row-standardised, so WyWy is the
average of neighbours’ yy).
XX is the matrix of covariates.
ββ are the regression coefficients.
The spatial lag WyWy is endogenous (it includes yy itself on the right-hand side). This violates
the OLS assumption of exogeneity, so OLS would be inconsistent. SAR is estimated
by maximum likelihood (ML) or instrumental variables (2SLS). ML is the standard
in spatialreg.
Interpretation: The presence of ρρ means that a change in a covariate xkxk at location ii not
only affects yiyi directly (through βkβk) but also affects neighbours’ yjyj, which in turn
affect yiyi through ρWyρWy. This is the spatial multiplier effect. The total effect of a covariate
is the sum of direct and indirect effects. spatialreg provides impacts() to compute these.
When to use SAR: When there is theoretical reason to believe in spatial interaction, contagion,
diffusion, or competition. Examples: housing prices (neighbourhood prices influence a property’s
price), technology adoption (farmers adopt if neighbours adopt), crime rates (retaliatory
violence).
19.2.2 The Spatial Error Model (SEM) – Theory
The Spatial Error Model does not include a spatial lag of yy. Instead, it models the error term as
a spatial autoregressive process:
y=Xβ+u,u=λWu+ε,ε∼N(0,σ2I)y=Xβ+u,u=λWu+ε,ε∼N(0,σ2I)
Where:
λλ (lambda) is the spatial autoregressive parameter for the errors. It measures the strength
of spatial autocorrelation in the unmodelled factors.
uu is the spatially autocorrelated error term.
εε is the independent and identically distributed innovation.
OLS is unbiased for SEM but inefficient, and the standard errors are wrong. SEM is also
estimated by maximum likelihood.
Interpretation: Spatial autocorrelation in the errors means that there are spatially structured
unobserved variables. SEM corrects the standard errors and provides more efficient estimates,
but it does not add a spatial multiplier effect. The interpretation of the ββ coefficients is the same
as in OLS—they are the direct effects.
When to use SEM: When spatial autocorrelation is a nuisance—it exists in the data but is not of
substantive interest, and the goal is to obtain correct standard errors and hypothesis tests for the
covariates. Example: a model of soil carbon that includes elevation and slope, but the residuals
remain spatially autocorrelated because of an omitted variable (e.g., historic land use) that is
itself spatially structured.
19.2.3 Choosing Between SAR and SEM
The choice is guided by:
1. Theory: Is there a substantive reason for spatial interaction in yy? Yes → SAR. No, but
residuals are autocorrelated → SEM.
2. LM tests: As described in Section 19.1.3.
3. AIC/BIC: Fit both models and compare information criteria. Lower AIC/BIC indicates
better fit.
4. Interpretation goals: If you need to decompose effects into direct and indirect
(spillover) components, SAR is required. SEM provides only direct effects.
It is also possible to fit a combined model with both a spatial lag and spatial error (sacsarlm), but
such models are rarely identifiable in practice and often suffer from weak identification. The
SAR and SEM cover most applied needs.
19.3 Implementing Spatial Regression with {spatialreg}
The spatialreg package (Bivand & Piras, 2022) provides the functions lagsarlm() for SAR
and errorsarlm() for SEM. Both use maximum likelihood by default.
19.3.1 Fitting a Spatial Lag Model
r
library(spatialreg)
sar_model <- lagsarlm(SID74 ~ BIR74 + NWBIR74, data = nc, listw = lw)
summary(sar_model)
The summary includes:
Rho (ρρ): the spatial autoregressive coefficient, with a likelihood ratio test
against ρ=0ρ=0.
Coefficients: similar to OLS but correctly estimated.
Log-likelihood, AIC, BIC: for model comparison.
LR test vs. OLS: tests whether the spatial lag is significant.
Extracting impacts:
Because the SAR model includes a spatial multiplier, the ββ coefficients are not the total
marginal effects. The impacts() function computes the direct, indirect (spillover), and total effects
for each predictor:
r
impacts(sar_model, listw = lw)
# Direct effect: effect of a unit change in x_i on y_i.
# Indirect effect: effect of a unit change in all x_j (j != i) on y_i.
# Total effect: sum of direct and indirect.
19.3.2 Fitting a Spatial Error Model
r
sem_model <- errorsarlm(SID74 ~ BIR74 + NWBIR74, data = nc, listw = lw)
summary(sem_model)
The summary includes Lambda (λλ) instead of Rho. The coefficients are interpreted as direct
effects only, so no impacts() decomposition is needed.
19.3.3 Alternative Estimators
spatialreg also supports:
Spatial Durbin model: SAR plus spatially lagged covariates (lagsarlm(..., type =
"mixed")). This is a flexible model that nests both SAR and SEM.
Spatial lag by instrumental variables: stsls() for two-stage least squares, useful when
ML fails to converge or for very large datasets.
Generalised Spatial Two-Stage Least Squares (GS2SLS): gstsls() for models with both
spatial lag and additional endogenous variables.
For this chapter, we focus on the core SAR and SEM estimated by ML, which are sufficient for
most applications.
19.3.4 Working with sf and spdep Objects
spatialreg functions accept both sf data frames and Spatial*DataFrame objects (from sp).
The listw argument must be a spatial weights list. For sf objects, ensure the data are in a
projected CRS (for meaningful contiguity or distance weights). The example NC data are in US
feet; we transformed them to metres for consistency.
19.3.5 Technical Demonstration: SAR and SEM on the NC Dataset
r
library(sf)
library(spdep)
library(spatialreg)
# Load and prepare data
nc <- st_read([Link]("shape/[Link]", package = "sf"), quiet = TRUE)
nc <- st_transform(nc, 32119)
nb <- poly2nb(nc, queen = TRUE)
lw <- nb2listw(nb, style = "W")
# OLS
ols <- lm(SID74 ~ BIR74 + NWBIR74, data = nc)
summary(ols)
# LM tests
[Link](ols, listw = lw, test = "all")
# SAR
sar <- lagsarlm(SID74 ~ BIR74 + NWBIR74, data = nc, listw = lw)
summary(sar)
# Impacts
imp <- impacts(sar, listw = lw)
summary(imp)
# SEM
sem <- errorsarlm(SID74 ~ BIR74 + NWBIR74, data = nc, listw = lw)
summary(sem)
# Compare AIC
AIC(ols, sar, sem)
Observe the AIC values: the model with the lowest AIC is preferred. The LR test in the SAR and
SEM summaries tests against the OLS null. If both SAR and SEM have significant spatial
parameters, check the robust LM tests for guidance.
19.4 Model Comparison and Diagnostics
After fitting spatial regression models, we must assess their adequacy, compare them, and check
whether the spatial structure has been successfully captured.
19.4.1 Likelihood Ratio Tests
The likelihood ratio (LR) test compares a restricted model to a more general model. In spatialreg:
The LR test in lagsarlm summary tests ρ=0ρ=0 (SAR vs. OLS).
The LR test in errorsarlm summary tests λ=0λ=0 (SEM vs. OLS).
You can also manually compare nested models:
r
# Compare SAR to a model with additional covariates
sar2 <- lagsarlm(SID74 ~ BIR74 + NWBIR74 + BIR79, data = nc, listw = lw)
[Link](sar2, sar) # tests whether BIR79 is significant
19.4.2 Information Criteria: AIC and BIC
AIC and BIC penalise model complexity. Lower values indicate a better balance of fit and
parsimony. AIC(model) and BIC(model) work on sarlm objects. A difference of 2–4 is
suggestive; a difference > 10 is strong evidence.
19.4.3 Residual Spatial Autocorrelation
After fitting a spatial model, the residuals should be free of spatial autocorrelation. Compute
Moran’s I of the residuals:
r
nc$sar_residuals <- residuals(sar)
[Link](nc$sar_residuals, listw = lw, randomisation = TRUE)
# A non-significant Moran's I indicates the SAR model has captured the spatial structure.
If significant residual autocorrelation remains, the model is misspecified. Possible remedies:
Add omitted spatially structured covariates.
Use a higher-order spatial weights matrix (e.g., second-order contiguity, if interaction
occurs over longer distances).
Fit a Spatial Durbin model (includes spatially lagged XX as well as yy).
Consider a different spatial weights definition.
19.4.4 Heteroskedasticity
Spatial regression models assume constant variance of the innovations εε. Heteroskedasticity can
be diagnosed with the Breusch-Pagan test applied to the model residuals:
r
[Link](sar)
If significant, robust standard errors (heteroskedasticity-consistent) can be obtained,
though spatialreg does not directly provide them for SAR/SEM. As a workaround, one can use
the spatial heteroskedasticity and autocorrelation consistent (SHAC) estimator via
the sphet package, or transform the response.
19.4.5 Geospatial Diagnostics: Mapping Residuals and Predicted Values
Always map the OLS residuals, SAR residuals, and SEM residuals side-by-side. Visual
inspection reveals whether the spatial model has successfully whitened the residuals:
r
library(ggplot2)
nc$ols_res <- residuals(ols)
nc$sar_res <- residuals(sar)
nc$sem_res <- residuals(sem)
p1 <- ggplot(nc) + geom_sf(aes(fill = ols_res)) + scale_fill_gradient2() + ggtitle("OLS
Residuals")
p2 <- ggplot(nc) + geom_sf(aes(fill = sar_res)) + scale_fill_gradient2() + ggtitle("SAR
Residuals")
p3 <- ggplot(nc) + geom_sf(aes(fill = sem_res)) + scale_fill_gradient2() + ggtitle("SEM
Residuals")
# Combine with patchwork
library(patchwork)
p1 + p2 + p3 + plot_layout(ncol = 1)
Clusters of positive or negative residuals in the OLS map should disappear or weaken in the
SAR/SEM maps. Persistent clusters indicate an omitted covariate or an inappropriate weights
matrix.
Hard Practice 19 – “Modelling House Prices with Accessibility and Environmental
Covariates”
Objective:
Build a spatial hedonic house price model. Fit OLS, test for spatial autocorrelation, choose
between SAR and SEM, fit the chosen model, compute direct and indirect effects, and produce
diagnostic maps.
Scenario:
You have data on 500 house sales in a metropolitan area. The data include: sale price (price, in
$1000s), structural attributes (area in sq ft, age in years), neighbourhood accessibility
(dist_cbd to central business district in km, dist_park to nearest park in km), and an
environmental variable (noise level in dB). The data are provided as an sf point dataset in a
projected CRS. You suspect that house prices are spatially autocorrelated due to neighbourhood
effects (similar houses cluster, and prices influence each other through comparable sales). You
will test this and model it.
Instructions:
1. Simulate the dataset:
r
[Link](2024)
n <- 500
# Generate coordinates (UTM, km)
x <- runif(n, 0, 30)
y <- runif(n, 0, 30)
# Covariates
area <- rlnorm(n, log(1500), 0.3)
age <- rpois(n, 30)
dist_cbd <- sqrt((x-15)^2 + (y-15)^2) + rnorm(n, 0, 1)
dist_park <- runif(n, 0.1, 5)
noise <- 60 - 0.3 * dist_cbd + rnorm(n, 0, 3)
# Generate spatially autocorrelated errors using spatial autoregressive process
library(spdep)
# Create neighbour list for the points
coords <- cbind(x, y)
nb <- knn2nb(knearneigh(coords, k = 10))
lw <- nb2listw(nb, style = "W")
# Simulate spatial error: u = lambda W u + eps
# Use invIrM from spdep for a simplified SAR process on the errors
inv <- invIrM(lw, rho = 0.4) # spatial multiplier
eps <- rnorm(n, 0, 15)
u <- inv %*% eps # spatially autocorrelated error
# Price equation
price <- 200 + 0.1 * area - 0.5 * age - 3 * dist_cbd + 2 * dist_park - 1.5 * noise + u
2. Create an sf object with point geometry in a UTM CRS.
3. OLS model: lm(price ~ area + age + dist_cbd + dist_park + noise, data = sf_data).
Extract residuals.
4. Spatial weights: Create a 10-nearest neighbour list, row-standardise.
5. Moran’s I of OLS residuals. Is it significant? What does this imply?
6. LM tests: Use [Link]() to determine whether SAR or SEM is more appropriate.
Report the results.
7. Fit SAR and SEM based on LM test guidance (fit both for comparison). Report
coefficients, ρρ or λλ, AIC, and the LR test against OLS.
8. Impacts (if SAR chosen): Compute direct, indirect, and total effects for each covariate.
Interpret: which covariates have the largest spillover effects?
9. Residual diagnostics: Compute Moran’s I of the residuals for the preferred model. Has
spatial autocorrelation been removed? Map the OLS and spatial model residuals
side-by-side.
10. Interpretation: Write a paragraph summarising the findings. Does accessibility to the
CBD reduce price? Does noise matter? Is there evidence of neighbourhood spillover in
prices?
Deliverable:
A script practice_chapter19_house_price_spatial_regression.R with the simulation, model fitting,
diagnostics, and interpretation.
Chapter 19 Review Problems
Solve each in a clearly commented R script using sf, spdep, and spatialreg. Use the nc dataset
or simulated data as specified.
Easy Problems (1–5)
1. OLS and Moran’s I of Residuals
Using the nc dataset, fit lm(SID74 ~ BIR74, data = nc). Extract residuals. Using Queen
contiguity row-standardised weights, compute Moran’s I of the residuals. Is there significant
spatial autocorrelation?
2. LM Tests
For the OLS model from Problem 1, perform the four LM tests using [Link](). Report the
p-values for LM-lag, LM-error, robust LM-lag, and robust LM-error. Which spatial model is
suggested?
3. Fit a SAR Model
Fit a spatial lag model for SID74 ~ BIR74 using Queen contiguity row-standardised weights.
Print the summary. What is ρρ? Is it significant? Compare the AIC to the OLS model.
4. Fit a SEM Model
Fit a spatial error model for the same specification. Print the summary. What is λλ? Compare the
AIC to the SAR model from Problem 3.
5. Extract and Map Residuals
Extract the residuals from the SAR model in Problem 3. Add them to the nc data frame. Create a
choropleth map of the residuals using ggplot2. Do you see any remaining spatial pattern?
Medium Problems (6–10)
6. Compare OLS, SAR, and SEM AIC
For the model SID74 ~ BIR74 + NWBIR74 + BIR79, fit OLS, SAR, and SEM. Create a data
frame of AIC values. Which model performs best? Perform the LR test for the SAR model
against OLS.
7. Impacts in the SAR Model
For the SAR model from Problem 6, use impacts() to compute direct, indirect, and total effects
for BIR74 and NWBIR74. Interpret the indirect effect: what does it mean that NWBIR74 has a
significant indirect effect on SID74?
8. Spatial Durbin Model
Fit a Spatial Durbin model (SAR with spatially lagged covariates) for SID74 ~ BIR74 +
NWBIR74 using lagsarlm(..., type = "mixed"). Compare its AIC to the standard SAR. Test
whether the lagged covariates are significant using a likelihood ratio test.
9. Weight Matrix Sensitivity
Refit the SAR model from Problem 3 using three different weight definitions: Queen contiguity,
5-nearest neighbours, and distance-band (choose a band that gives a similar average number of
links). Compare the estimates of ρρ and the AIC. Does the spatial parameter change?
10. Simulate Spatial Data and Test Power
Simulate 100 datasets from a SAR process with ρ=0.5ρ=0.5 on a 10×10 grid,
using spdep::invIrM. For each, fit OLS and SAR. Record the estimate of ρρ and the p-value of
the LR test. What proportion of LR tests correctly reject the OLS null? (This is a power
analysis.)
Challenging Problems (11–15)
11. Heteroskedasticity in Spatial Regression
Fit a SAR model to the nc data. Use [Link]() to test for heteroskedasticity. If present, refit
the model using the sphet package (spreg()) with a heteroskedasticity-robust estimator. Compare
the coefficient standard errors to the ML estimates.
12. Model Specification Search
For the nc dataset and SID74 as the response, perform a specification search: start with OLS with
all available demographic covariates. Compute LM tests. Fit SAR and SEM. Then progressively
add or remove covariates, checking the significance of ρ/λρ/λ and AIC. Document the path and
justify the final model. (This is a realistic model-building exercise.)
13. Predictions from SAR
Unlike OLS, predicting from a SAR model requires the spatial weights of the new locations.
Simulate a training set of 200 points and a test set of 50 points from the same spatial process. Fit
a SAR model on the training set. To predict on the test set, you need to construct the
cross-weights between test and training points. Use [Link]() with the listw argument for
the combined weights. Compare prediction error to OLS.
14. Spatio-Temporal Regression
The nc dataset has no time dimension. Simulate a 5-year panel: for each county and year,
generate a response that depends on a county-level covariate and a year effect, plus a spatial
autoregressive term. Fit a spatial panel model using splm (if you have the splm package installed,
which is beyond spatialreg but available in R). Alternatively, fit separate SAR models for each
year and compare the stability of ρρ.
15. Real-World Application: Modelling Deforestation
Use a publicly available dataset of deforestation (e.g., from spData or a simulated landscape with
forest cover change). Build a spatial regression model predicting deforestation rate from road
density, distance to markets, population density, and protected area status. Perform the full
workflow: OLS → Moran’s I → LM tests → SAR/SEM → impacts → diagnostics. Write a short
report as a comment block.
Chapter 20: Geostatistics and Kriging
In Chapter 15 you learned to work with raster data—continuous grids of values. But how do
those grids come to be? In many environmental sciences, we measure a property at a limited
number of field locations—soil samples, groundwater wells, air quality monitors—and we need
to estimate that property across the entire study area. Geostatistics is the branch of spatial
statistics that provides the theoretical framework and practical methods for this task. Its core
insight is that the spatial correlation between sample points can be modelled and used to make
optimal, uncertainty-quantified predictions at unvisited locations. This chapter builds from the
fundamental concept of the variogram—the function that describes how spatial correlation
decays with distance—through variogram modelling and fitting, to the two workhorse
interpolation methods: ordinary kriging and universal kriging. We then compare kriging to
simpler deterministic methods (inverse distance weighting and splines) and learn when each is
appropriate. Every concept is illustrated using the gstat package (Pebesma, 2004; Gräler,
Pebesma, & Heuvelink, 2016), which integrates directly with sf and terra. The Hard Practice
asks you to interpolate soil heavy metal concentrations from point samples—a classic
contaminated land assessment task. By the end, you will be able to model spatial dependence,
produce prediction maps with associated uncertainty, and validate your interpolations using
cross-validation.
20.1 The Variogram: Understanding Spatial Dependence
Before we can predict values at new locations, we must quantify how the variable of interest
varies with distance. The variogram (or semivariogram) is the fundamental tool for this: it
describes the expected squared difference between values at two locations as a function of the
distance separating them. It is the geostatistical analogue of the spatial autocorrelation measures
in Chapter 17, but designed for continuous data and continuous space.
20.1.1 The Concept of Spatial Correlation in Geostatistics
Consider a continuous spatial variable Z(s)Z(s)—say, soil pH. At two
locations sisi and sjsj separated by distance hh, if hh is small, the values Z(si)Z(si) and Z(sj)Z(sj
) tend to be similar (positive autocorrelation). As hh increases, the similarity decays. At
sufficiently large hh, the values are essentially independent. The variogram γ(h)γ(h) captures this
decay:
γ(h)=12Var[Z(s)−Z(s+h)]γ(h)=21Var[Z(s)−Z(s+h)]
If the process is intrinsically stationary—meaning the mean is constant and the variogram
depends only on hh, not on absolute location—then the variogram completely describes the
spatial dependence. Intrinsic stationarity is a weaker assumption than second-order stationarity
(which requires constant variance and a covariance function); it underpins ordinary kriging.
The term semivariance is synonymous with γ(h)γ(h); the “semi” reflects the factor 1/21/2. In
practice, the variogram is estimated from data and then modelled with a theoretical function.
20.1.2 The Empirical Variogram
For a set of nn sample points with values z(s1),…,z(sn)z(s1),…,z(sn), the empirical
variogram (also called the sample variogram) groups pairs of points by their separation distance
into bins (lags) and computes the average semivariance within each bin:
γ^(h)=12N(h)∑(i,j):∥si−sj∥≈h[z(si)−z(sj)]2γ^(h)=2N(h)1(i,j):∥si−sj∥≈h∑[z(si)−z(sj)]2
where N(h)N(h) is the number of point pairs in the distance bin centred at hh.
In R, the gstat package computes the empirical variogram with variogram():
r
library(gstat)
library(sf)
# Example: a simulated soil dataset with spatial autocorrelation
[Link](2024)
n <- 100
coords <- [Link](x = runif(n, 0, 100), y = runif(n, 0, 100))
# Generate spatially autocorrelated values using a simple distance decay
d <- [Link](dist(coords))
sigma <- exp(-d / 30) # exponential correlation with range ~30
z <- 50 + chol(sigma) %*% rnorm(n) # correlated field
z <- [Link](z)
# Create sf object
soil_sf <- st_as_sf(coords, coords = c("x", "y"), crs = NA)
soil_sf$z <- z
# Compute the empirical variogram
v_emp <- variogram(z ~ 1, data = soil_sf)
v_emp
# np dist gamma [Link] [Link] id
# 1 12 4.75 10.2 0 0 var1
# 2 30 14.28 18.7 0 0 var1
# ...
plot(v_emp)
The formula z ~ 1 specifies a constant mean (ordinary kriging assumption). The output is a data
frame with columns:
np: number of point pairs in the bin.
dist: average separation distance of pairs in the bin.
gamma: the empirical semivariance γ^(h)γ^(h).
[Link], [Link]: the direction of the bin (for directional variograms; 0 means isotropic).
id: the variable identifier (when multiple variables are analysed).
The default number of bins and their widths are chosen automatically but can be controlled with
the width and cutoff arguments:
r
v_emp <- variogram(z ~ 1, data = soil_sf, width = 5, cutoff = 50)
cutoff is the maximum distance to consider; it should be about half the maximum extent of the
study area, because pairs at larger distances are sparse and unreliable. width is the lag separation
distance.
20.1.3 The Three Cardinal Parameters: Nugget, Sill, Range
The empirical variogram typically rises from the origin, reaches a plateau, and flattens out. Three
parameters characterise this behaviour:
Nugget (τ2τ2): the semivariance at distance zero. In theory, γ(0)=0γ(0)=0, but
measurement error and microscale variation (variation at distances smaller than the
sampling spacing) cause a discontinuity at the origin. The nugget is the variance of the
non-spatial component.
Sill (σ2+τ2σ2+τ2): the asymptotic value of the variogram at large distances. It is the total
variance of the process. The partial sill σ2σ2 is the spatially structured variance (sill
minus nugget).
Range (rr or aa): the distance beyond which the variogram reaches (or approaches) the
sill. Points separated by more than the range are essentially uncorrelated.
These parameters are estimated by fitting a theoretical variogram model (Section 20.2). They
have direct physical interpretations: the range is the distance over which spatial interpolation
borrows strength from neighbours; the nugget is the irreducible prediction uncertainty at a
sampled location; the partial sill governs the magnitude of spatial variation.
20.1.4 Directional Variograms and Anisotropy
The isotropic variogram assumes that spatial correlation depends only on distance, not on
direction. Many environmental processes are anisotropic: correlation may extend farther in the
direction of prevailing wind, along a slope, or parallel to geological strike. gstat computes
directional variograms using the alpha argument:
r
v_dir <- variogram(z ~ 1, data = soil_sf, alpha = c(0, 45, 90, 135))
plot(v_dir)
Each panel shows the variogram in a different direction. If the variograms differ (e.g., one
direction has a longer range), the process is geometric anisotropic: the range varies with
direction. This can be modelled by transforming the coordinate system (rotating and scaling)
before kriging, using vgm() with anis argument.
Zonal anisotropy (sill varies with direction) is more complex and often modelled by including a
trend (universal kriging) rather than through the variogram.
20.1.5 The Variogram Cloud
Before binning into the empirical variogram, the variogram cloud plots 12(zi−zj)221(zi−zj)2 for
every individual pair of points. It is useful for identifying outliers: a single extreme value will
create a plume of high semivariances with all other points.
r
v_cloud <- variogram(z ~ 1, data = soil_sf, cloud = TRUE)
plot(v_cloud, main = "Variogram Cloud")
If outliers are present, they can be investigated and potentially removed or Winsorised before
modelling the variogram, because they inflate the semivariance and distort the estimated
parameters.
20.1.6 Technical Demonstration: Exploring the Variogram
r
library(gstat)
library(sf)
library(ggplot2)
# Simulate 100 points with known spatial structure
[Link](42)
pts <- st_as_sf([Link](x = runif(100, 0, 100), y = runif(100, 0, 100)),
coords = c("x","y"))
# Generate values from a Gaussian random field with exponential covariance
# (using gstat's simulation function)
sim <- gstat(formula = z ~ 1, locations = ~ x + y, dummy = TRUE,
beta = 50, model = vgm(psill = 25, model = "Exp", range = 30, nugget = 5),
nmax = 100)
[Link](1)
pts$z <- predict(sim, newdata = pts, nsim = 1)$sim1
# Empirical variogram
v_emp <- variogram(z ~ 1, pts)
plot(v_emp, main = "Empirical Variogram")
# Variogram cloud
v_cloud <- variogram(z ~ 1, pts, cloud = TRUE)
ggplot(v_cloud, aes(x = dist, y = gamma)) + geom_point(alpha = 0.3) + ggtitle("Variogram
Cloud")
# Directional variogram
v_dir <- variogram(z ~ 1, pts, alpha = c(0, 90))
plot(v_dir, main = "Directional Variograms (0° vs 90°)")
20.2 Variogram Modelling and Fitting
The empirical variogram provides point estimates of semivariance at discrete lags. To use it in
kriging, we must fit a continuous, conditionally negative definite function that passes through
(or near) the empirical points. This function ensures that the kriging system has a unique, stable
solution.
20.2.1 Theoretical Variogram Models
Several authorised parametric models are available. The most common are:
1. Nugget model: Pure noise, no spatial correlation.
γ(h)=0 for h=0;γ(h)=c0 for h>0γ(h)=0 for h=0;γ(h)=c0 for h>0
Used in combination with other models as the nugget component.
2. Spherical model: Linear rise near the origin, reaches sill at the range.
γ(h)=c(3h2a−12(ha)3) for h≤a;γ(h)=c for h>aγ(h)=c(2a3h−21(ah)3) for h≤a;γ(h)=c for h>a
cc is the partial sill, aa the range. It is one of the most widely used models.
3. Exponential model: Reaches the sill asymptotically; the practical range is 3a3a.
γ(h)=c(1−exp(−h/a))γ(h)=c(1−exp(−h/a))
where aa is the scale parameter. The effective range (where 95% of sill is reached) is
approximately 3a3a.
4. Gaussian model: Smooth, parabolic near the origin. Practical range 3a3a.
γ(h)=c(1−exp(−(h/a)2))γ(h)=c(1−exp(−(h/a)2))
5. Matérn model: A flexible family with a smoothness parameter κκ. When κ=0.5κ=0.5, it
reduces to the exponential model; as κ→∞κ→∞, it approaches the Gaussian. The formula is
complex, but gstat supports it through vgm(..., model = "Mat", kappa = k).
20.2.2 Creating Variogram Models with vgm()
vgm() constructs a variogram model object. Its arguments are:
r
vgm(psill, model, range, nugget = 0, [Link], anis, kappa, ...)
psill: partial sill σ2σ2.
model: name of the model: "Nug", "Sph", "Exp", "Gau", "Mat", etc.
range: range parameter (for "Sph" it is the actual range; for "Exp" it is the scale
parameter; for "Gau" it is the scale parameter).
nugget: nugget variance.
[Link]: if specified, the new model is added to an existing model (to create nested
structures).
anis: anisotropy parameters (ratio and direction).
Example: Spherical model with nugget
r
model_sph <- vgm(psill = 20, model = "Sph", range = 30, nugget = 5)
model_sph
# model psill range
# 1 Nug 5 0
# 2 Sph 20 30
20.2.3 Fitting a Model to the Empirical Variogram
[Link]() adjusts the parameters of a starting model to minimise the weighted sum of
squared differences between the model and the empirical variogram, with weights proportional
to N(h)N(h) (the number of pairs in each bin):
r
# Starting values
v_emp <- variogram(z ~ 1, pts)
start_model <- vgm(psill = var(pts$z), model = "Sph", range = 30, nugget = 0.1 * var(pts$z))
# Fit
fit_model <- [Link](v_emp, start_model)
fit_model
plot(v_emp, fit_model, main = "Fitted Spherical Variogram")
The [Link]() function returns a variogramModel object. You can plot the empirical
variogram and the fitted model together. The weighted least-squares fit is fast but not statistically
optimal; for more rigorous fitting, use gstat::[Link]() for restricted maximum
likelihood, or the automap package’s autofitVariogram() for automatic fitting with a range of
models.
20.2.4 Choosing the Best Model
No single model is universally correct. The choice is guided by:
1. Visual inspection: Does the model capture the shape of the empirical variogram,
particularly near the origin (which most strongly influences kriging weights)?
2. Sum of squared errors (SSE): attr(fit_model, "SSErr") gives the weighted SSE.
Compare across models.
3. Cross-validation (Section 20.3): The ultimate test is predictive performance. Fit several
variogram models, perform kriging with each, and compare cross-validation RMSE and
mean error.
4. Physical knowledge: The behaviour near the origin (linear, parabolic, or flat) reflects the
smoothness of the underlying process. A spherical model with its linear origin is often
suitable for natural phenomena; a Gaussian model implies a very smooth process that
may be unrealistic for soil properties.
Automated fitting with automap:
The automap package (Hiemstra et al., 2009) provides autofitVariogram(), which tries several
models and selects the one with the best fit:
r
library(automap)
af <- autofitVariogram(z ~ 1, pts)
plot(af)
af$var_model # best fitted model
20.2.5 Technical Demonstration: Fitting and Comparing Models
r
library(gstat)
# Empirical variogram (from previous section)
v_emp <- variogram(z ~ 1, pts)
# Try several models
m_sph <- [Link](v_emp, vgm("Sph"))
m_exp <- [Link](v_emp, vgm("Exp"))
m_gau <- [Link](v_emp, vgm("Gau"))
m_mat <- [Link](v_emp, vgm("Mat"))
# Compare SSE
attr(m_sph, "SSErr")
attr(m_exp, "SSErr")
attr(m_gau, "SSErr")
attr(m_mat, "SSErr")
# Plot the best one
plot(v_emp, m_sph, main = paste("Spherical (SSE =", round(attr(m_sph,"SSErr"),2), ")"))
20.3 Ordinary Kriging and Universal Kriging with {gstat}
Kriging is a family of least-squares linear estimators that produce the best linear unbiased
predictor (BLUP) at an unvisited location, given a set of sample values and a variogram model. It
provides not only a prediction Z^(s0)Z^(s0) but also a kriging variance σK2(s0)σK2(s0), which
quantifies the uncertainty of the prediction.
20.3.1 Ordinary Kriging (OK)
Assumption: The mean μμ of the process is unknown but constant across the study area.
Kriging predictor:
Z^(s0)=∑i=1nλiZ(si)Z^(s0)=i=1∑nλiZ(si)
The weights λiλi are chosen to:
1. Minimise the prediction variance Var[Z^(s0)−Z(s0)]Var[Z^(s0)−Z(s0)].
2. Ensure unbiasedness: ∑i=1nλi=1∑i=1nλi=1 (the weights sum to 1, so that the constant
local mean is implicitly estimated).
This leads to the ordinary kriging system:
(Γ11T0)(λμ)=(γ01)(Γ1T10)(λμ)=(γ01)
where ΓΓ is the n×nn×n matrix of semivariances between sample points, γ0γ0 is the vector of
semivariances between the prediction point and the sample points, λλ is the weight vector,
and μμ is a Lagrange multiplier enforcing the sum-to-one constraint.
The kriging variance is:
σK2(s0)=γ0Tλ+μσK2(s0)=γ0Tλ+μ
Implementation in gstat:
r
# Create a prediction grid
grid <- st_as_sf([Link](x = seq(0, 100, by = 2), y = seq(0, 100, by = 2)),
coords = c("x", "y"))
# Ordinary kriging
ok <- krige(z ~ 1, locations = pts, newdata = grid, model = fit_model)
ok
# Simple feature collection with ... points and 2 fields
# [Link] [Link] geometry
#1 48.3 12.1 POINT (0 0)
# ...
# Plot predictions and kriging variance
library(ggplot2)
ggplot(ok) + geom_sf(aes(colour = [Link])) + scale_colour_viridis_c() + ggtitle("OK
Predictions")
ggplot(ok) + geom_sf(aes(colour = [Link])) + scale_colour_viridis_c() + ggtitle("OK
Variance")
The krige() function returns an sf object with columns:
[Link]: the kriging prediction Z^(s0)Z^(s0).
[Link]: the kriging variance σK2(s0)σK2(s0).
If you have multiple variables, krige() handles them simultaneously.
20.3.2 Universal Kriging (UK) – Kriging with a Trend
When the mean is not constant but varies systematically with covariates (e.g., elevation, distance
to a feature), universal kriging (also called kriging with external drift or regression kriging)
models the mean as a linear function of predictors, while the residuals are kriged.
Formulation: Z(s)=X(s)β+ε(s)Z(s)=X(s)β+ε(s), where X(s)X(s) are known covariates
and ε(s)ε(s) is a spatially autocorrelated residual with variogram γε(h)γε(h).
The UK predictor is:
Z^(s0)=X(s0)β^+ε^(s0)Z^(s0)=X(s0)β^+ε^(s0)
where β^β^ is estimated by generalised least squares (GLS) that accounts for spatial correlation,
and ε^(s0)ε^(s0) is the kriged residual.
In gstat, UK is specified by a formula that includes covariates:
r
# Simulate a covariate
pts$covariate <- pts$z * 0.3 + rnorm(100, 0, 5)
# Universal kriging
uk <- krige(z ~ covariate, locations = pts, newdata = grid, model = fit_model)
The formula z ~ covariate tells gstat to model the mean as a linear function of covariate. The
variogram model fit_model should be fitted to the residuals of the trend model, not to the raw
data. In practice, this is done iteratively or using restricted maximum likelihood (REML). A
simpler approach is to:
1. Fit the trend model with lm(z ~ covariate, data = pts).
2. Compute the residuals.
3. Fit a variogram to the residuals with variogram(residuals ~ 1, pts).
4. Use this residual variogram in krige(z ~ covariate, ...).
Alternatively, gstat can estimate the trend and variogram simultaneously with krige() using
iterative generalised least squares, but the manual approach is more transparent for learning.
Regression kriging (RK): A special case of UK where the trend is fitted by OLS, residuals are
kriged by OK, and the two are summed. It is widely used in digital soil mapping.
The gstat function krige() with a trend formula performs essentially this, though it solves the full
UK system.
20.3.3 Kriging Variance and Uncertainty
The kriging variance σK2(s0)σK2(s0) is not a direct measure of the probability that the true
value falls within an interval around the prediction. It is a measure of the estimation error
variance under the assumption that the variogram is perfectly known and the process is Gaussian.
To construct prediction intervals, one typically assumes a Gaussian distribution: the 95%
prediction interval is Z^(s0)±1.96σK2(s0)Z^(s0)±1.96σK2(s0).
Important properties of kriging variance:
It depends on the variogram and the spatial configuration of sample points, not on the
sample values (except through the variogram estimation). This is the kriging variance
map property: you can compute where you need to sample more to reduce uncertainty,
before collecting any new data.
It is zero at sample points (if the nugget is zero) and increases with distance from
samples.
It is affected by the nugget: a large nugget leads to a high minimum kriging variance
everywhere.
20.3.4 Cross-Validation
Cross-validation is the standard method for assessing kriging
performance. [Link]() performs leave-one-out cross-validation: for each sample point, it
removes that point, kriges its value using the remaining points, and computes the error.
r
cv <- [Link](z ~ 1, locations = pts, model = fit_model, nfold = nrow(pts))
head(cv)
# [Link] [Link] observed residual zscore fold
#1 49.2 10.5 51.3 -2.10 -0.65 1
# ...
# Summary statistics
mean(cv$residual) # should be near 0 (unbiasedness)
sqrt(mean(cv$residual^2)) # RMSE
mean(cv$zscore^2) # should be near 1 if the kriging variance is correct
cor(cv$observed, cv$[Link]) # correlation between observed and predicted
Key diagnostic statistics:
Mean error (ME): should be close to zero. Systematic bias indicates a problem with the
trend model or stationarity assumption.
Root mean square error (RMSE): smaller is better.
Mean squared deviation ratio (MSDR): the mean
of (residual/kriging_variance)2(residual/kriging_variance)2. Should be close to 1. If > 1,
the kriging variance underestimates uncertainty; if < 1, it overestimates.
Correlation between observed and predicted: higher is better.
20.3.5 Technical Demonstration: OK, UK, and Cross-Validation
r
library(gstat)
library(sf)
# Use the pts and grid from earlier sections
# 1. Ordinary Kriging
ok <- krige(z ~ 1, locations = pts, newdata = grid, model = fit_model)
# 2. Universal Kriging with a synthetic covariate
# Add covariate to grid (simulate as distance from centre)
grid$dist_centre <- sqrt((st_coordinates(grid)[,1]-50)^2 + (st_coordinates(grid)[,2]-50)^2)
pts$dist_centre <- sqrt((st_coordinates(pts)[,1]-50)^2 + (st_coordinates(pts)[,2]-50)^2)
# Fit trend and residual variogram
trend_lm <- lm(z ~ dist_centre, data = pts)
pts$residuals <- residuals(trend_lm)
v_res <- variogram(residuals ~ 1, pts)
m_res <- [Link](v_res, vgm("Sph"))
# Universal Kriging
uk <- krige(z ~ dist_centre, locations = pts, newdata = grid, model = m_res)
# Compare OK and UK predictions
plot(ok["[Link]"], main = "Ordinary Kriging")
plot(uk["[Link]"], main = "Universal Kriging")
# Cross-validation for OK
cv_ok <- [Link](z ~ 1, pts, model = fit_model)
mean(cv_ok$residual)
sqrt(mean(cv_ok$residual^2))
# Cross-validation for UK
cv_uk <- [Link](z ~ dist_centre, pts, model = m_res)
sqrt(mean(cv_uk$residual^2))
20.4 Comparison with Deterministic Methods (IDW, Spline)
Kriging is not the only interpolation method, and its statistical optimality comes at the cost of
modelling the variogram. Simpler deterministic methods are often used and can perform well,
especially when the sampling is dense or the spatial structure is weak.
20.4.1 Inverse Distance Weighting (IDW)
IDW estimates Z(s0)Z(s0) as a weighted average of sample values, with weights inversely
proportional to a power of the distance:
Z^(s0)=∑izidi−p∑idi−pZ^(s0)=∑idi−p∑izidi−p
where di=∥s0−si∥di=∥s0−si∥ and pp is the power parameter (typically p=2p=2). IDW is simple,
fast, and has no statistical model, but it does not provide an uncertainty estimate, and it is
sensitive to clustering of sample points (points close to each other have redundant influence).
In gstat, idw() performs IDW:
r
idw_result <- idw(z ~ 1, locations = pts, newdata = grid, idp = 2)
plot(idw_result["[Link]"], main = "IDW (p=2)")
The idp argument is the power. Higher powers give more influence to the nearest point;
as p→∞p→∞, IDW approaches the nearest-neighbour interpolator.
20.4.2 Spline Interpolation
Splines fit a smooth, flexible surface through the data points by minimising a penalised sum of
squared deviations. They are particularly suited for smooth phenomena (e.g., air pressure,
topography) and can produce visually pleasing surfaces. However, they can extrapolate poorly
and do not provide a direct uncertainty measure.
In R, several packages provide spline interpolation:
fields::Tps() (thin-plate splines, a type of radial basis function).
mgcv::gam() (generalised additive models with spatial smooths).
terra::interp() (spline or linear interpolation to a raster from points).
Using terra:
r
library(terra)
# Convert points to a matrix
xyz <- [Link](pts)[, c("x","y","z")]
r <- rast(nrows = 50, ncols = 50, xmin = 0, xmax = 100, ymin = 0, ymax = 100)
spline_r <- interp(r, xyz[,1:2], xyz[,3], method = "spline")
plot(spline_r)
Using fields::Tps:
r
library(fields)
tps_fit <- Tps(st_coordinates(pts), pts$z)
grid_coords <- st_coordinates(grid)
tps_pred <- predict(tps_fit, grid_coords)
grid$tps <- tps_pred
20.4.3 Comparison Using Cross-Validation
The most objective way to compare interpolation methods is through cross-validation metrics.
For IDW and splines, the gstat cross-validation function cannot be used directly (except for IDW,
which has [Link]? No, [Link] is for kriging; for IDW, you can manually implement
leave-one-out). A simple approach is to split the data into training and test sets, fit each method
on the training set, predict on the test set, and compute RMSE and MAE.
r
# Split into training (80%) and test (20%)
[Link](1)
test_idx <- sample(1:n, size = round(0.2 * n))
train <- pts[-test_idx, ]
test <- pts[test_idx, ]
# OK
ok_train <- krige(z ~ 1, train, test, model = fit_model)
rmse_ok <- sqrt(mean((ok_train$[Link] - test$z)^2))
# IDW
idw_train <- idw(z ~ 1, train, test, idp = 2)
rmse_idw <- sqrt(mean((idw_train$[Link] - test$z)^2))
# Compare
c(OK = rmse_ok, IDW = rmse_idw)
Typically, kriging outperforms IDW when the spatial structure is strong and the variogram is
well-estimated. IDW can be competitive for very dense sampling, but it does not adapt to
anisotropic or variable spatial correlation. Splines excel for smooth surfaces but can overfit noisy
data.
20.4.4 Technical Demonstration: IDW vs. Spline vs. Kriging
r
library(gstat)
library(terra)
# 1. IDW
idw_result <- idw(z ~ 1, pts, grid, idp = 2)
# 2. Spline (using terra)
xyz <- [Link](pts)[, c("x","y","z")]
r <- rast(nrows = 50, ncols = 50, xmin = 0, xmax = 100, ymin = 0, ymax = 100)
spline_r <- interp(r, xyz[,1:2], xyz[,3], method = "spline")
# 3. Kriging (OK)
ok_result <- krige(z ~ 1, pts, grid, model = fit_model)
# Extract predictions to data frame for comparison
pred_compare <- [Link](
OK = ok_result$[Link],
IDW = idw_result$[Link],
Spline = extract(spline_r, st_coordinates(grid))[,1] # adjust column name
)
# Scatterplot matrix
pairs(pred_compare)
Hard Practice 20 – “Interpolating Soil Heavy Metal Concentrations from Point Samples”
Objective:
Perform a complete geostatistical analysis: explore the data, compute and model the variogram,
perform ordinary kriging, produce prediction and uncertainty maps, compare to IDW and splines
using cross-validation, and write a brief interpretive report.
Scenario:
You are an environmental consultant assessing cadmium (Cd) contamination at a former
industrial site. A field survey collected 80 soil samples at irregularly spaced locations within a
200 m × 200 m area (local coordinate system, metres). Cadmium concentrations (mg/kg) were
measured. You must produce a contamination map for the entire site, quantify the uncertainty,
and recommend whether additional sampling is needed.
Instructions:
1. Simulate the dataset:
r
[Link](2024)
n <- 80
coords <- [Link](x = runif(n, 0, 200), y = runif(n, 0, 200))
# Simulate Cd with a spatial trend + hot spots
d <- [Link](dist(coords))
# Spatial component
Sigma <- exp(-d / 30) + 0.2 * diag(n)
spatial <- chol(Sigma) %*% rnorm(n)
# Trend: higher near old factory at (100,100)
trend <- 2 * exp(-0.5 * ((coords$x-100)^2 + (coords$y-100)^2) / 50^2)
Cd <- 5 + 3 * trend + 2 * spatial + rnorm(n, 0, 1)
Cd <- pmax(Cd, 0) # non-negative
site_sf <- st_as_sf([Link](coords, Cd = [Link](Cd)), coords = c("x","y"), crs = NA)
2. Exploratory analysis: Plot the sampling locations and Cd values. Create a histogram and
a spatial bubble plot.
3. Variogram: Compute the empirical variogram. Determine appropriate cutoff and width.
Plot the variogram cloud and identify any outliers. Fit spherical, exponential, and
Gaussian models. Select the best model based on SSE and visual fit. Plot the empirical
variogram with the fitted model.
4. Ordinary Kriging: Create a prediction grid of 100×100 m at 2-m resolution
(use st_make_grid or [Link]). Perform ordinary kriging. Map the predictions and the
kriging variance. Identify areas where the kriging standard deviation exceeds 3 mg/kg
(high uncertainty).
5. Cross-validation: Perform leave-one-out cross-validation for the kriging model. Report
ME, RMSE, and MSDR. Interpret each.
6. Comparison: Perform IDW (p = 2) and a thin-plate spline
(using fields::Tps or terra::interp) on the same grid. Create a data frame of predictions at
the sample points from OK, IDW, and spline using the cross-validation results (or a
separate test set). Compute RMSE for each method. Which performs best?
7. Reporting: In a comment block, write a short summary for a non-technical stakeholder:
where is Cd contamination concentrated? How certain are the predictions? Would you
recommend additional sampling, and if so, where?
Deliverable:
A script practice_chapter20_soil_cadmium_kriging.R with the full analysis, all maps, and the
report.
Chapter 20 Review Problems
Solve each in a clearly commented R script using gstat, sf, and optionally terra. Use simulated
data as specified.
Easy Problems (1–5)
1. Compute an Empirical Variogram
Simulate 50 random points in a 100×100 square with a spatially autocorrelated variable (use a
simple distance-based correlation as in Section 20.1.6). Compute and plot the empirical
variogram. What are the approximate nugget, sill, and range from visual inspection?
2. Fit a Variogram Model
Using the variogram from Problem 1, fit a spherical model. Print the fitted parameters and plot
the empirical variogram with the fitted model.
3. Ordinary Kriging
Using the fitted variogram from Problem 2, create a regular 20×20 grid and perform ordinary
kriging. Plot the predictions and the kriging variance.
4. IDW Interpolation
Using the same data and grid, perform IDW with power 2 and power 3. Plot both results. How
does changing the power affect the surface?
5. Cross-Validation of OK
Perform leave-one-out cross-validation for the ordinary kriging model from Problem 3. Report
the mean error and RMSE.
Medium Problems (6–10)
6. Directional Variogram
Simulate a point pattern with geometric anisotropy: the range is longer in the E-W direction than
N-S. Compute directional variograms at 0° and 90°. Fit separate models and compare the range
parameters.
7. Universal Kriging vs. Ordinary Kriging
Simulate data with a strong linear trend (e.g., z increases with x). Fit OK and UK (with x as
covariate). Compare the prediction maps and cross-validation RMSE. Does UK improve over
OK in this case?
8. Nested Variogram Models
Simulate data with a nugget, a short-range component (range 10), and a long-range component
(range 50). Fit a nested model using vgm(psill1, "Sph", 10, [Link] = vgm(psill2, "Sph", 50,
nugget = nug)). Plot the empirical and fitted variogram. Discuss the physical interpretation of
two ranges (e.g., microscale variation vs. regional trend).
9. Kriging Variance as a Sampling Design Tool
Using the Cd data from the Hard Practice (or simulated), compute the kriging variance map.
Identify the 10 locations with the highest kriging variance. Plot these as proposed new sampling
locations on top of the variance map. Explain how this map can guide adaptive sampling.
10. Compare Cross-Validation Metrics for Multiple Variogram Models
For a given dataset, fit spherical, exponential, Gaussian, and Matérn models. For each,
perform [Link]() and compute RMSE and MSDR. Which model gives the best combination of
low RMSE and MSDR near 1.0?
Challenging Problems (11–15)
11. Block Kriging
Ordinary kriging predicts at a point. Block kriging predicts the average value over a larger block
(e.g., a 10×10 m cell). In gstat, block kriging is performed by providing a newdata with polygons
(e.g., a grid of sf polygons) or using the block argument in krige(). Simulate data, perform point
kriging and block kriging on a 20×20 grid of 5×5 m blocks. Compare the kriging variance maps.
Why is block kriging variance lower?
12. Cokriging
Cokriging uses a secondary correlated variable to improve predictions. Simulate a primary
variable (e.g., soil carbon) and a secondary variable (e.g., remote sensing index) that is measured
at many more points. Fit a linear model of coregionalisation (using gstat::gstat() with multiple
formulas and [Link]()). Perform cokriging. Compare the kriging variance to ordinary kriging.
(Note: Cokriging is advanced but fully supported by gstat.)
13. Stochastic Simulation
Kriging produces a smooth surface that minimises local error but does not reproduce the spatial
variability (the variogram). Conditional Gaussian simulation (krige(..., nsim = ...)) generates
multiple realisations that honour the data and the variogram. Simulate 100 realisations of the Cd
data. Compute the mean and 95% prediction interval from the realisations. Compare the interval
to the kriging prediction interval.
14. Spatio-Temporal Kriging
Simulate data with a spatial and a temporal dimension (e.g., monthly temperature at 30 stations
over 12 months). Use gstat with the spacetime package or construct a spatio-temporal variogram
manually. Fit a sum-metric model. Perform spatio-temporal kriging for a future month. Map the
predictions and their variance. (This is an advanced topic but builds directly on the variogram
concepts.)
15. Real-World Soil Interpolation and Digital Soil Mapping
Use a real dataset (e.g., from the sp package: meuse dataset of heavy metals along the Meuse
river in the Netherlands, available in sp). Perform a full analysis: exploratory data analysis,
variography, ordinary kriging, universal kriging with elevation and distance to river as
covariates, and validation. Compare the digital soil mapping approach (UK) to ordinary kriging.
Produce a publication-quality map of predicted zinc concentrations and kriging variance.
(The meuse dataset is classic and highly instructive.)
Chapter 21: Remote Sensing Data in R
Satellite remote sensing is the single largest source of spatial data about our planet. Every day,
sensors on Landsat, Sentinel-2, MODIS, and many other platforms capture terabytes of imagery
covering the Earth’s surface. This chapter teaches you to access, import, preprocess, and
analyse satellite data directly within R. We begin with an overview of the major satellite imagery
providers and the open-access programmes that make their data freely available. We then learn
to import and preprocess satellite imagery: reading GeoTIFFs into terra, applying radiometric
calibration and atmospheric correction, and performing cloud masking. The third section is the
heart of spectral analysis: computing vegetation indices (NDVI, NDWI, SAVI, and custom
indices) from multispectral bands. Finally, we build a time-series stack of vegetation indices and
perform anomaly detection—a fundamental technique for monitoring drought, deforestation, and
phenological shifts. Every concept is illustrated with simulated data where real data access is
impractical, but the code is transferable to actual downloaded scenes. The Hard Practice asks
you to build a Sentinel-2 NDVI time-series stack and detect anomalies, a workflow that mirrors
real-world environmental monitoring systems. By the end, you will have the skills to process
satellite imagery from raw data to actionable information.
21.1 Satellite Imagery Providers and Access (Sentinel, Landsat, MODIS)
The modern era of Earth observation is defined by free and open data policies. Three
programmes dominate the landscape: the European Space Agency’s Sentinel-2, the joint
NASA/USGS Landsat programme, and NASA’s MODIS. Understanding their characteristics is
essential for selecting the right data for your application.
21.1.1 Landsat: The Longest Continuous Record
The Landsat programme began in 1972 and is now on its ninth satellite (Landsat 9, launched
2021). Landsat 8 and 9 carry the Operational Land Imager (OLI) and the Thermal Infrared
Sensor (TIRS).
Spatial resolution: 30 m for multispectral bands, 15 m for panchromatic (Band 8), 100
m for thermal.
Temporal resolution: 16 days per satellite; 8 days combined (Landsat 8 + 9).
Spectral bands: 11 bands. Key bands for vegetation: Red (Band 4, 0.64–0.67 µm), NIR
(Band 5, 0.85–0.88 µm), SWIR1 (Band 6, 1.57–1.65 µm).
Data access: USGS EarthExplorer ([Link]), or programmatically via
the landsat package in R (though rgee for Google Earth Engine is more practical for
large-scale queries). Scenes are provided as Level-1 (at-sensor radiance) and Level-2
(surface reflectance, atmospherically corrected).
File format: GeoTIFF, one file per band, or a bundled .[Link].
Landsat’s strength is its temporal depth. For studies of land-cover change since the 1980s,
Landsat is irreplaceable.
21.1.2 Sentinel-2: The European Workhorse
The Copernicus Sentinel-2 mission (Sentinel-2A, 2015; Sentinel-2B, 2017) provides
high-resolution multispectral imagery.
Spatial resolution: 10 m for visible and NIR (Bands 2, 3, 4, 8); 20 m for red-edge and
SWIR; 60 m for atmospheric bands.
Temporal resolution: 5 days combined (at equator).
Spectral bands: 13 bands. Key bands: Red (Band 4), NIR (Band 8), SWIR1 (Band 11).
Data access: Copernicus Open Access Hub ([Link]), or via
the sen2r package in R, or through Google Earth Engine with rgee. Sentinel-2 data are
provided as Level-1C (top-of-atmosphere reflectance) and Level-2A
(bottom-of-atmosphere surface reflectance, atmospherically corrected with Sen2Cor).
File format: JPEG 2000 (.jp2) inside a SAFE format directory. terra can read .jp2 if the
JPEG2000 driver is available.
Sentinel-2’s 10-m resolution and frequent revisit make it ideal for agricultural monitoring,
deforestation detection, and land-cover mapping at landscape scale.
21.1.3 MODIS: Global Daily Coverage
The Moderate Resolution Imaging Spectroradiometer (MODIS) on NASA’s Terra and Aqua
satellites provides daily global coverage.
Spatial resolution: 250 m (Bands 1–2), 500 m (Bands 3–7), 1 km (other bands).
Temporal resolution: 1–2 days.
Products: Pre-processed data products include surface reflectance (MOD09GA),
vegetation indices (MOD13Q1), land surface temperature, and many more. These are
distributed as gridded tiles in sinusoidal projection.
Data access: NASA Earthdata, or via the MODIStsp package in R, or through Google
Earth Engine.
File format: HDF-EOS. terra can read MODIS HDF files if the GDAL HDF4/5 drivers
are installed.
MODIS is the tool for global-scale and continental-scale monitoring. Its 250-m resolution is
coarse for local studies, but its daily frequency allows near-real-time vegetation condition
assessment and phenology analysis.
21.1.4 Programmatic Access from R
For teaching and reproducible research, downloading individual scenes manually is acceptable
but does not scale. Several R packages provide programmatic access:
rgee: An R interface to Google Earth Engine (GEE). You can query, process, and export
satellite imagery from the GEE catalogue without downloading raw files to your local
machine. Requires a Google account and initial setup. This is the most powerful approach
for large-scale analysis.
sen2r: Automates the downloading and preprocessing of Sentinel-2 data from the
Copernicus Open Access Hub.
MODIStsp: Downloads and processes MODIS time series.
getSpatialData (now maintained as getSpatialData and rsat): Unified interface to multiple
satellite archives.
In this chapter, we will work with simulated data that mimics the structure of Landsat/Sentinel-2
surface reflectance, so that you can learn the processing concepts without requiring internet
access or large file downloads. The code is directly transferable to real scenes when you
substitute rast() with the path to a real GeoTIFF.
21.1.5 Geospatial Context: Choosing the Right Sensor
Application Recommended Sensor Key Bands
Agricultural field monitoring Sentinel-2 (10 m) Red, NIR, Red-Edge
Deforestation detection (regional) Landsat (30 m) or Sentinel-2 Red, NIR, SWIR
Continental vegetation condition MODIS (250 m) Red, NIR
Lake water quality Sentinel-2 Visible, NIR
Land surface temperature Landsat TIRS, MODIS Thermal bands
Long-term land-cover change (1980s+) Landsat All
The choice always involves a trade-off between spatial resolution, temporal frequency, and
spectral coverage.
21.2 Importing and Preprocessing Satellite Data (Radiometric Calibration, Atmospheric
Correction)
Satellite imagery is rarely analysis-ready when downloaded. Raw Level-1 data require
radiometric calibration (converting digital numbers to physical units of radiance or reflectance)
and atmospheric correction (removing the effects of the atmosphere to obtain surface
reflectance). Level-2 data have these corrections applied, but cloud masking and additional
preprocessing are still needed.
21.2.1 Reading Satellite Imagery with terra
Most satellite data are distributed as GeoTIFF or JPEG2000 files. terra::rast() reads them
directly. For a multi-band scene, each band is a separate file, and you can stack them with c().
r
library(terra)
# Simulate reading a Sentinel-2 Level-2A scene
# In reality: paths <- c("[Link]", "[Link]"), but we simulate:
[Link](2024)
# Create a 100x100 raster with realistic reflectance values
blue <- rast(nrows = 100, ncols = 100, xmin = 0, xmax = 10000, ymin = 0, ymax = 10000)
red <- rast(blue)
nir <- rast(blue)
values(blue) <- runif(10000, 0.02, 0.15)
values(red) <- runif(10000, 0.02, 0.12)
values(nir) <- runif(10000, 0.20, 0.60)
# Stack into a multi-band raster
scene <- c(blue, red, nir)
names(scene) <- c("B02_Blue", "B04_Red", "B08_NIR")
scene
For a real Sentinel-2 SAFE archive, the bands are in a directory structure. You can
use [Link](pattern = ".jp2") to get all band files, read them into a list with lapply(), and stack
with rast().
21.2.2 Radiometric Calibration: DN to Reflectance
Landsat Level-1 data store digital numbers (DNs) that must be converted to top-of-atmosphere
(TOA) reflectance using gain and offset coefficients provided in the metadata (MTL file). The
formula is:
ρTOA=gain×DN+offsetρTOA=gain×DN+offset
For Landsat 8/9, the metadata
includes REFLECTANCE_MULT_BAND_x and REFLECTANCE_ADD_BAND_x. In R:
r
# Example: calibrate a single band (pseudo-code for real data)
# gain <- 2.0000e-05 # from MTL file
# offset <- -0.100000
# band_TOA <- band_DN * gain + offset
Sentinel-2 Level-1C data are already in TOA reflectance (quantised as 16-bit integers; divide by
10,000 to get 0–1 reflectance). Level-2A data are bottom-of-atmosphere (BOA) surface
reflectance, also quantised; the scaling factor is 10,000.
r
# For Sentinel-2 L2A (simulated integer storage)
red_int <- round(red * 10000)
red_boa <- red_int / 10000
The scaling ensures values are in the range [0, 1] (or occasionally slightly above 1 due to bright
surfaces or atmospheric correction artifacts).
21.2.3 Atmospheric Correction
Atmospheric correction removes the scattering and absorption effects of the atmosphere to
retrieve surface reflectance. The two most common methods for Landsat/Sentinel-2 are:
Sen2Cor: The official ESA processor for Sentinel-2 Level-1C → Level-2A. Run
externally; output is the L2A product.
LaSRC (Landsat Surface Reflectance Code): Used by USGS to produce Landsat
Level-2 products.
DOS (Dark Object Subtraction): A simple, fast method that assumes that the darkest
pixel in each band should have zero surface reflectance. The atmospheric path radiance is
estimated as the minimum value in the band (or a low percentile). DOS is implemented
in landsat and in many GIS tools. It is approximate but sufficient for many vegetation
index applications.
In R, the RStoolbox package provides radCor() for Landsat and Sentinel-2:
r
library(RStoolbox)
# radCor(scene, metaData = "[Link]", method = "dos")
Since RStoolbox may not always be available, a manual DOS correction is straightforward:
r
# Simple DOS: subtract the 1st percentile of each band
dos_offset <- global(scene, fun = function(x) quantile(x, 0.01, [Link] = TRUE))
scene_dos <- scene - dos_offset
scene_dos <- clamp(scene_dos, lower = 0, upper = 1)
For precise quantitative analysis (e.g., water quality, biomass estimation), use the official L2A
products or run Sen2Cor/LaSRC. For vegetation index monitoring, DOS is often adequate.
21.2.4 Cloud Masking
Clouds obscure the surface and must be masked. Sentinel-2 L2A includes a Scene Classification
Layer (SCL) that identifies cloud, cloud shadow, water, and other classes. Landsat Level-2
includes a QA_PIXEL band. These are single-band categorical rasters that can be used to create
a binary mask.
For Sentinel-2 SCL:
Class 8: cloud medium probability.
Class 9: cloud high probability.
Class 10: thin cirrus.
Class 11: cloud shadow.
In R:
r
# Read SCL band (simulated or real)
# scl <- rast("SCL.jp2")
# cloud_mask <- scl %in% c(8, 9, 10, 11)
# scene_masked <- mask(scene, cloud_mask, maskvalues = TRUE)
For Landsat QA_PIXEL, bitwise operations are needed to extract the cloud bit.
The RStoolbox package simplifies this.
Cloud masking is essential for time-series analysis. A single cloudy pixel can produce a spurious
NDVI drop that mimics deforestation. Always mask clouds before computing indices or trends.
21.2.5 Technical Demonstration: Preprocessing Workflow
r
library(terra)
# Simulate a scene with clouds
[Link](55)
scene <- rast(nrows = 50, ncols = 50, nlyrs = 3,
xmin = 0, xmax = 5000, ymin = 0, ymax = 5000)
values(scene) <- runif(50*50*3, 0, 1)
names(scene) <- c("Blue", "Red", "NIR")
# Add some cloud-contaminated pixels (high reflectance in all bands)
cloud_cells <- sample(1:2500, 100)
scene[cloud_cells] <- 0.9
# Create a simple cloud mask based on a threshold
# (In reality, use the SCL or QA band)
cloud_mask <- scene$Blue > 0.8
# Mask the scene
scene_masked <- mask(scene, cloud_mask, maskvalues = TRUE)
# Quick NDVI
ndvi <- (scene_masked$NIR - scene_masked$Red) / (scene_masked$NIR + scene_masked$Red)
plot(c(scene_masked, ndvi), main = c("Blue (masked)", "Red (masked)", "NIR (masked)",
"NDVI"))
21.3 Spectral Indices: NDVI, NDWI, SAVI, and Custom Indices
Spectral indices are algebraic combinations of two or more spectral bands that enhance a
particular surface property while suppressing noise from illumination, atmosphere, and soil
background. They are the workhorses of remote sensing analysis.
21.3.1 Normalized Difference Vegetation Index (NDVI)
NDVI is the most widely used vegetation index. It exploits the sharp increase in reflectance from
the red to the near-infrared that is characteristic of healthy green vegetation:
NDVI=ρNIR−ρRedρNIR+ρRedNDVI=ρNIR+ρRedρNIR−ρRed
Range: –1 to +1.
Typical values: Dense vegetation ~0.6–0.9; sparse vegetation ~0.2–0.5; bare soil ~0–0.2;
water and cloud <0.
Applications: Vegetation condition, phenology, drought monitoring, land-cover
classification.
Computing NDVI in R is simple raster algebra:
r
ndvi <- (nir - red) / (nir + red)
For time-series analysis, NDVI is computed for every date in the stack.
Cautions: NDVI saturates at high biomass (LAI > 3). It is sensitive to soil background—dark
soils have higher NDVI than light soils at the same vegetation cover. Atmospheric aerosols
reduce the contrast, lowering NDVI. Use surface reflectance data when possible.
21.3.2 Normalized Difference Water Index (NDWI)
NDWI is sensitive to liquid water content in vegetation and open water bodies. There are two
common formulations:
Gao (1996) NDWI (vegetation water content):
NDWI=ρNIR−ρSWIRρNIR+ρSWIRNDWI=ρNIR+ρSWIRρNIR−ρSWIR
McFeeters (1996) NDWI (open water):
NDWI=ρGreen−ρNIRρGreen+ρNIRNDWI=ρGreen+ρNIRρGreen−ρNIR
The Gao NDWI is used for drought stress and fuel moisture assessment. The McFeeters NDWI
delineates water bodies. In terra:
r
# Gao NDWI (vegetation water)
ndwi_gao <- (nir - swir1) / (nir + swir1)
# McFeeters NDWI (open water)
ndwi_mc <- (green - nir) / (green + nir)
21.3.3 Soil-Adjusted Vegetation Index (SAVI)
SAVI (Huete, 1988) modifies NDVI to minimise soil brightness influences:
SAVI=ρNIR−ρRedρNIR+ρRed+L×(1+L)SAVI=ρNIR+ρRed+LρNIR−ρRed×(1+L)
where LL is a soil adjustment factor. L=0.5L=0.5 is typical for intermediate vegetation
cover; L=0L=0 reduces SAVI to NDVI; L=1L=1 for very sparse vegetation.
r
L <- 0.5
savi <- (nir - red) / (nir + red + L) * (1 + L)
SAVI is preferred over NDVI in arid and semi-arid regions where soil background is visible
through the canopy.
21.3.4 Other Important Indices
Enhanced Vegetation Index (EVI): Designed to reduce atmospheric and soil
background effects, with parameters for aerosol resistance. EVI is the standard MODIS
vegetation index. Formula involves blue band: 2.5 * (NIR - Red) / (NIR + 6*Red -
7.5*Blue + 1).
Normalized Burn Ratio (NBR): Uses NIR and SWIR2 to detect burned areas. (NIR -
SWIR2) / (NIR + SWIR2). Burned areas have low NBR.
Normalized Difference Moisture Index (NDMI): Similar to Gao NDWI, (NIR -
SWIR1) / (NIR + SWIR1). Sensitive to vegetation water stress.
Green Chlorophyll Index (CIgreen): (NIR / Green) - 1. Sensitive to leaf chlorophyll
content.
Atmospherically Resistant Vegetation Index (ARVI): (NIR - (2*Red - Blue)) / (NIR +
(2*Red - Blue)). Reduces atmospheric effects.
All can be computed with band math in terra.
21.3.5 Custom Indices and Band Ratios
You can define any algebraic combination of bands. For example, a simple ratio index for burn
severity:
r
burn_severity <- swir2 / nir
Higher values indicate more severe burning. Custom indices can be calibrated for specific
regions or applications using field data.
21.3.6 Technical Demonstration: Computing Multiple Indices
r
library(terra)
# Create a simulated multispectral scene
[Link](99)
nrow <- 50; ncol <- 50
blue <- rast(nrows = nrow, ncols = ncol, vals = runif(2500, 0.03, 0.10))
green <- rast(nrows = nrow, ncols = ncol, vals = runif(2500, 0.05, 0.15))
red <- rast(nrows = nrow, ncols = ncol, vals = runif(2500, 0.02, 0.12))
nir <- rast(nrows = nrow, ncols = ncol, vals = runif(2500, 0.25, 0.55))
swir1 <- rast(nrows = nrow, ncols = ncol, vals = runif(2500, 0.10, 0.35))
# Stack
scene <- c(blue, green, red, nir, swir1)
names(scene) <- c("Blue", "Green", "Red", "NIR", "SWIR1")
# Compute indices
ndvi <- (nir - red) / (nir + red)
ndwi_gao <- (nir - swir1) / (nir + swir1)
savi <- (nir - red) / (nir + red + 0.5) * 1.5
evi <- 2.5 * (nir - red) / (nir + 6*red - 7.5*blue + 1)
# Combine into a multi-layer raster
indices <- c(ndvi, ndwi_gao, savi, evi)
names(indices) <- c("NDVI", "NDWI_Gao", "SAVI", "EVI")
plot(indices)
21.4 Time-Series Analysis of Vegetation Indices
Satellite data is inherently temporal. A single image provides a snapshot; a stack of images over
time reveals trends, cycles, and anomalies. Time-series analysis of vegetation indices is the
foundation of agricultural monitoring, drought early warning, deforestation alert systems, and
phenological studies.
21.4.1 Building a Time-Series Raster Stack
A time-series stack is a SpatRaster where each layer represents one acquisition date. In practice,
you read all scenes for a region over a given period and stack them:
r
# Pseudocode for reading 36 monthly NDVI scenes
# files <- [Link]("NDVI_monthly/", pattern = ".tif$", [Link] = TRUE)
# ndvi_ts <- rast(files)
# time(ndvi_ts) <- [Link](c("2022-01-01", "2022-02-01", ...))
For teaching, we simulate a time series of 36 months (3 years) with a seasonal cycle and a linear
trend:
r
[Link](2024)
n_cells <- 1000
n_months <- 36
ndvi_stack <- rast(nrows = 20, ncols = 50, nlyrs = n_months)
# Time index
months <- 1:n_months
# For each cell, simulate a seasonal cycle + trend + noise
# Simplified: create a base seasonal raster and add noise
season <- sin(2 * pi * months / 12)
for (i in 1:n_months) {
# Base NDVI: 0.4 + 0.2 * seasonal + 0.002 * trend + noise
values(ndvi_stack[[i]]) <- 0.4 + 0.2 * season[i] + 0.002 * i + runif(n_cells, -0.1, 0.1)
}
names(ndvi_stack) <- paste0("Month", 1:n_months)
time(ndvi_stack) <- seq([Link]("2022-01-01"), by = "month", [Link] = n_months)
ndvi_stack
plot(ndvi_stack[[1:4]], main = c("Jan 2022", "Feb 2022", "Mar 2022", "Apr 2022"))
21.4.2 Temporal Statistics: Mean, Maximum, and Anomaly
Common temporal summaries of a vegetation index stack:
Mean NDVI over the whole period: mean(ndvi_stack). This gives the average
vegetation condition, often called the “greenness” baseline.
Maximum NDVI (peak growing season): max(ndvi_stack). Used in land-cover
classification.
NDVI amplitude (range): max(ndvi_stack) - min(ndvi_stack). A measure of vegetation
seasonality.
Coefficient of variation: stdev(ndvi_stack) / mean(ndvi_stack). High CV indicates
strong seasonality or land-use change.
r
# Temporal summaries
mean_ndvi <- mean(ndvi_stack)
amp_ndvi <- max(ndvi_stack) - min(ndvi_stack)
stdev_ndvi <- stdev(ndvi_stack)
plot(c(mean_ndvi, amp_ndvi, stdev_ndvi), main = c("Mean NDVI", "Amplitude", "Standard
Deviation"))
21.4.3 Trend Analysis: Pixel-wise Linear Regression
To detect long-term changes (greening or browning), fit a linear regression at each pixel:
NDVI=β0+β1⋅t+εNDVI=β0+β1⋅t+ε
The slope β1β1 indicates the direction and magnitude of change. A positive slope indicates
greening; a negative slope indicates browning. This is done with terra::approximate() or by
writing a custom function for app():
r
# Pixel-wise linear trend
time_vec <- 1:nlyr(ndvi_stack)
# Using terra::app with a function that fits lm and returns slope
trend_fun <- function(y) {
if (all([Link](y))) return(NA)
fit <- lm(y ~ time_vec)
coef(fit)[2] # slope
}
ndvi_trend <- app(ndvi_stack, fun = trend_fun)
plot(ndvi_trend, main = "NDVI Trend (slope per month)")
Pixels with significant trends (p-value < 0.05) can be mapped by extracting the p-value from the
model. However, app() with lm on every pixel is slow for large stacks. A faster approach uses
matrix algebra:
r
# Fast linear regression slope
X <- cbind(1, time_vec)
beta_hat <- solve(t(X) %*% X, t(X) %*% t(values(ndvi_stack)))
slope <- beta_hat[2, ]
# Reshape to raster
ndvi_trend_fast <- rast(ndvi_stack[[1]])
values(ndvi_trend_fast) <- slope
plot(ndvi_trend_fast)
This computes the ordinary least squares slope for all pixels in a single matrix operation.
21.4.4 Anomaly Detection
An anomaly is a deviation from the expected seasonal value. To detect anomalies:
1. Compute the long-term mean for each month (e.g., the mean of all Januarys).
2. Compute the anomaly as the difference between the current NDVI and the long-term
mean for that month.
3. Flag months where the anomaly exceeds a threshold (e.g., ±2 standard deviations).
In terra, this is done using tapp() to compute monthly means, then subtraction:
r
# Compute monthly means (12 months)
monthly_idx <- rep(1:12, times = n_months / 12)
mean_monthly <- tapp(ndvi_stack, index = monthly_idx, fun = mean)
# Anomaly for a specific month (e.g., January 2024, layer 25)
jan_mean <- mean_monthly[[1]]
jan_2024 <- ndvi_stack[[25]]
anomaly <- jan_2024 - jan_mean
# Standardize anomaly by long-term monthly standard deviation
sd_monthly <- tapp(ndvi_stack, index = monthly_idx, fun = sd)
jan_sd <- sd_monthly[[1]]
z_anomaly <- anomaly / jan_sd
plot(c(jan_2024, jan_mean, z_anomaly), main = c("Jan 2024 NDVI", "Long-term Jan Mean",
"Z-score Anomaly"))
A z-score anomaly map highlights areas of unusually low (drought) or high (excess rain)
vegetation greenness for that month.
21.4.5 Time-Series Extraction at a Point
To examine the temporal profile at a specific location (e.g., a field plot), use extract():
r
# Extract time series at a point
point <- vect(cbind(x = 25, y = 10), crs = crs(ndvi_stack))
ts <- extract(ndvi_stack, point, ID = FALSE)
ts_vector <- [Link](ts)
plot(time(ndvi_stack), ts_vector, type = "l", xlab = "Date", ylab = "NDVI",
main = "NDVI Time Series at a Point")
This profile can be analysed for phenological metrics (start of season, peak date, end of season)
using packages like phenopix, greenbrown, or phenofit.
21.4.6 Technical Demonstration: Anomaly Detection Workflow
r
library(terra)
# Reusing ndvi_stack from above, or create a new small demo
[Link](42)
n_cells <- 500
n_years <- 3
n_layers <- n_years * 12
ndvi_ts <- rast(nrows = 20, ncols = 25, nlyrs = n_layers)
# Simulate with a strong negative anomaly in year 2, months 6–8 (drought)
for (i in 1:n_layers) {
yr <- (i-1) %/% 12 + 1
mo <- (i-1) %% 12 + 1
base <- 0.5 + 0.3 * sin(2 * pi * (mo - 4) / 12)
trend <- 0.001 * i
noise <- rnorm(n_cells, 0, 0.05)
# Drought in year 2 summer: reduce NDVI
drought <- ifelse(yr == 2 & mo %in% 6:8, -0.25, 0)
values(ndvi_ts[[i]]) <- base + trend + noise + drought
}
names(ndvi_ts) <- paste0("Y", rep(1:3, each=12), "M", rep(1:12, 3))
time(ndvi_ts) <- seq([Link]("2022-01-01"), by = "month", [Link] = n_layers)
# Monthly climatology
month_idx <- rep(1:12, n_years)
clim_mean <- tapp(ndvi_ts, month_idx, fun = mean)
clim_sd <- tapp(ndvi_ts, month_idx, fun = sd)
# Anomaly for July 2023 (layer 19, year 2 month 7)
july_mean <- clim_mean[[7]]
july_sd <- clim_sd[[7]]
july_2023 <- ndvi_ts[[19]]
z_anom <- (july_2023 - july_mean) / july_sd
# Identify drought pixels (z < -1.5)
drought_mask <- z_anom < -1.5
plot(c(july_2023, july_mean, z_anom, drought_mask),
main = c("July 2023 NDVI", "July Climatology", "Z-Anomaly", "Drought (z < -1.5)"))
Hard Practice 21 – “Building a Sentinel-2 NDVI Time-Series Stack and Anomaly
Detection”
Objective:
Simulate a 3-year monthly Sentinel-2 NDVI time series for a small agricultural region, introduce
a simulated drought, and perform a full anomaly detection workflow. Produce maps of mean
NDVI, trend, and drought-affected areas.
Scenario:
You are an agricultural monitoring analyst. You have 36 monthly NDVI images (simulated) from
January 2022 to December 2024 at 10-m resolution over a 5 km × 5 km area. The region
contains a gradient from irrigated agriculture (high NDVI) to rain-fed grassland (medium NDVI)
to bare soil (low NDVI). A drought occurred in the summer of 2023 (June–August), severely
reducing NDVI in the rain-fed areas. You will:
1. Create a simulated NDVI time-series stack with realistic spatial pattern and seasonality.
2. Compute the long-term mean NDVI and NDVI amplitude.
3. Perform a pixel-wise linear trend analysis and map the slope.
4. Detect the drought anomaly using z-scores relative to monthly climatology.
5. Extract and plot a time series at a rain-fed location.
Instructions:
1. Create the base landscape: A 50×50 grid (each pixel = 10 m → 500 m × 500 m area,
adjust dimensions to simulate 5 km). Create a raster layer base_ndvi that decreases from
east (irrigated, ~0.8) to west (bare, ~0.2) with random noise. This is the spatial pattern.
2. Add seasonality: For each of 36 months, add a sinusoidal seasonal cycle (amplitude
0.25, peak in August). Irrigated areas have lower seasonal amplitude (constant water
supply); rain-fed areas have higher amplitude.
3. Add interannual trend: A slight positive trend (0.001 per month) in the irrigated east,
zero or negative trend (–0.0005) in the west (land degradation).
4. Introduce drought: For months 18–20 (June–August 2023), reduce NDVI by 0.3 in the
rain-fed area (west of x = 2500 m) to simulate drought impact.
5. Add random noise: rnorm(n_cells, 0, 0.03) per layer.
6. Stack layers into a SpatRaster and assign time stamps.
7. Compute and map:
o Mean NDVI over the 3 years.
o NDVI amplitude (max – min).
o Pixel-wise linear trend slope (use matrix method for speed).
8. Anomaly detection:
o Compute monthly climatology (mean and SD for each of the 12 months).
o Compute the z-score anomaly for August 2023 (the peak drought month).
o Map the z-score and highlight pixels with z < –2.0.
9. Time-series extraction:
o Extract the NDVI time series at one irrigated pixel (east) and one rain-fed pixel
(west).
o Plot both time series on the same graph with a vertical line indicating the drought
period.
10. Interpretation: In a comment block, summarise where and when the drought was most
severe, and whether the long-term trends indicate recovery or continued degradation.
Deliverable:
A script practice_chapter21_ndvi_timeseries_anomaly.R with the full simulation, maps, and
interpretation.
Chapter 21 Review Problems
Solve each in a clearly commented R script using terra. Use simulated data where real data are
not available.
Easy Problems (1–5)
1. Simulate a Sentinel-2 Scene
Create a 50×50 raster with 4 bands: Blue, Green, Red, NIR, with realistic surface reflectance
ranges. Stack them. Name the bands. Print the raster. Plot a true-colour composite
using plotRGB().
2. NDVI Computation
Using the scene from Problem 1, compute NDVI. Plot the NDVI map with a green colour ramp.
What is the range of NDVI values? Are there any values outside [–1, 1]? If so, explain why and
fix them.
3. Cloud Masking
Simulate cloud contamination by setting 5% of pixels in all bands to 0.85. Create a cloud mask
by thresholding the Blue band (>0.7). Mask the scene. Plot the original and masked NDVI
side-by-side.
4. Simple Temporal Mean
Create a 6-layer NDVI stack (simulate 6 monthly NDVI rasters with a seasonal cycle). Compute
the mean NDVI over the 6 months and plot it. Extract the time series at the centre pixel and plot
it as a line graph.
5. EVI Calculation
Using the scene from Problem 1, compute EVI using the formula 2.5 * (NIR - Red) / (NIR +
6*Red - 7.5*Blue + 1). Compare EVI to NDVI for a few pixels: compute the difference
raster EVI - NDVI. Where is the difference largest?
Medium Problems (6–10)
6. Multi-Temporal Cloud-Free Composite
Simulate 12 monthly NDVI rasters. Introduce random cloud contamination (set some pixels to
NA) in each month. Create a cloud-free composite by taking the median NDVI per pixel across
all months. Compare the median composite to the mean of all months (with [Link] = TRUE).
Which is more robust to cloud contamination?
7. NDVI Anomaly for a Given Month
Using the 36-month stack from the Hard Practice (or a similar simulation), compute the monthly
climatology (12-month mean). Compute the NDVI anomaly for July 2023 (difference from the
July mean). Plot the anomaly map and interpret the spatial pattern.
8. SAVI vs. NDVI in Sparse Vegetation
Simulate a scene with varying soil brightness: create a base soil reflectance map that varies
smoothly across the scene. Add a sparse vegetation signal (NDVI ~0.2–0.3). Compute NDVI and
SAVI (L=0.5). Compare the two indices across the soil gradient: does SAVI reduce the soil
influence?
9. Burn Severity with NBR
Simulate a pre-fire and post-fire NIR and SWIR2 scene. Pre-fire: typical vegetation values.
Post-fire: reduce NIR and increase SWIR2 in a “burn scar” region (a polygon). Compute NBR
for both dates. Compute the difference dNBR = NBR_pre – NBR_post. Map the dNBR and
classify burn severity (low, moderate, high) using standard thresholds (e.g., dNBR > 0.1, >0.27,
>0.44).
10. Pixel-wise Correlation Between NDVI and a Climate Variable
Simulate a 24-month NDVI stack and a corresponding 24-month precipitation time series (as a
vector). For each pixel, compute the Pearson correlation between NDVI and precipitation. Map
the correlation. Which areas show the strongest correlation? (Use app() with a custom function or
matrix algebra.)
Challenging Problems (11–15)
11. Phenology Metrics from NDVI Time Series
Using the 36-month NDVI stack, extract the time series at 10 sample points. For each point,
smooth the time series (use loess or [Link]), then compute phenological metrics: start of
season (SOS), end of season (EOS), peak of season (POS), and growing season length. Use
the phenopix package if available, or implement a simple threshold method (e.g., SOS = first
date when NDVI exceeds 50% of amplitude).
12. Harmonic Regression for NDVI
Fit a harmonic regression model (sinusoidal with 1-year and 6-month harmonics) to each pixel of
the 36-month NDVI stack:
NDVI = β0 + β1*sin(2πt/12) + β2*cos(2πt/12) + β3*sin(4πt/12) + β4*cos(4πt/12).
Extract the amplitude and phase of the annual cycle. Map the amplitude and the phase (peak
month). This is the standard method for land-cover classification from MODIS time series.
13. MODIS NDVI Compositing and Gap-Filling
Simulate a 365-day daily NDVI time series for one pixel with gaps (NA) representing cloudy
days. Apply a temporal smoothing and gap-filling algorithm: a simple moving window
(filter() or rollapply) to fill gaps. Alternatively, use the imputeTS or zoo package for
interpolation. Plot the original and gap-filled series.
14. Land-Cover Classification from Multi-Temporal NDVI
Create a 12-month NDVI stack for a region with three known land-cover types (forest, crop,
urban) by simulating distinct seasonal NDVI profiles. Generate 300 random pixels with labels.
Use the 12 NDVI values as features to train a Random Forest classifier (randomForest package).
Predict the land cover across the entire raster using terra::predict(). Assess accuracy with a
confusion matrix.
15. Real-World Sentinel-2 Workflow
If you have internet access, download a Sentinel-2 Level-2A scene using sen2r or manually from
the Copernicus Hub for a small region of interest. Read the bands into R with terra. Perform
cloud masking using the SCL band. Compute NDVI, SAVI, and NDWI. Create a map with three
panels: NDVI, SAVI, NDWI. Write a short comment block describing the landscape based on the
indices. (If download is not possible, describe the workflow steps in pseudo-code and the
expected results
Chapter 22: Image Classification and Accuracy Assessment
Spectral indices and band combinations reveal patterns, but they do not name the patterns. To
produce a land-cover map—a thematic raster where each pixel is labelled “Forest,” “Water,”
“Urban,” or “Cropland”—we must move from continuous reflectance to discrete categories.
This is the task of image classification. In remote sensing, classification algorithms assign each
pixel to a class based on its spectral signature and, increasingly, on spatial context and auxiliary
data. This chapter covers the full classification workflow: designing training and validation
samples, applying supervised classifiers (Maximum Likelihood, Random Forest, Support Vector
Machines), exploring unsupervised clustering, and rigorously assessing accuracy with confusion
matrices, Kappa, and overall accuracy. Every concept is implemented in R using terra for raster
handling, randomForest and e1071 for machine learning, and custom functions for accuracy
metrics. The Hard Practice asks you to classify a suburban area into land-cover types and
validate the result against ground truth. By the end, you will be able to produce a scientifically
defensible land-cover map from satellite imagery—the most common product of remote sensing
analysis.
22.1 Supervised Classification: Maximum Likelihood, Random Forest, SVM
Supervised classification uses labelled training data—pixels whose class is known—to teach a
classifier how to recognise each class. The trained model is then applied to every pixel in the
image. The three most widely used algorithms in remote sensing are Maximum Likelihood
(parametric, fast, well-understood), Random Forest (non-parametric, robust, handles many
features), and Support Vector Machines (powerful for complex decision boundaries).
22.1.1 The Supervised Classification Workflow
The workflow is universal, regardless of the algorithm:
1. Define classes: Decide on a land-cover typology (e.g., Forest, Grassland, Water, Built-up,
Bare Soil).
2. Collect training samples: For each class, digitise polygons or points on the image or
from field knowledge, and extract the spectral values at those locations. The result is a
data frame where each row is a pixel and the columns are the spectral bands (and
optionally, indices), plus a class label.
3. Split samples: Reserve a portion (typically 20–30%) for independent validation. The
remainder trains the classifier.
4. Train the classifier: Fit the model to the training data.
5. Predict: Apply the model to every pixel of the image.
6. Validate: Use the reserved validation samples to compute the confusion matrix and
accuracy metrics.
7. Post-process: Apply a majority filter to remove salt-and-pepper noise (optional but
recommended).
We will execute each step in R using simulated data that mirrors a Sentinel-2 scene.
22.1.2 Preparing Training Data
Training data is a data frame with predictor columns (the bands) and a response column (the
class). In terra, extract() extracts pixel values at point or polygon locations.
r
library(terra)
[Link](2024)
# Simulate a 4-band image (Blue, Green, Red, NIR)
nrow <- 100; ncol <- 100
blue <- rast(nrows = nrow, ncols = ncol, vals = runif(10000, 0.03, 0.15))
green <- rast(nrows = nrow, ncols = ncol, vals = runif(10000, 0.05, 0.20))
red <- rast(nrows = nrow, ncols = ncol, vals = runif(10000, 0.02, 0.12))
nir <- rast(nrows = nrow, ncols = ncol, vals = runif(10000, 0.20, 0.60))
img <- c(blue, green, red, nir)
names(img) <- c("Blue", "Green", "Red", "NIR")
# Simulate training point locations for three classes
n_train <- 300
class_labels <- sample(c("Forest", "Urban", "Water"), n_train, replace = TRUE)
# Assign coordinates for each class (spatially clustered to be realistic)
train_pts <- vect([Link](
x = ifelse(class_labels == "Forest", runif(n_train, 0, 40),
ifelse(class_labels == "Urban", runif(n_train, 30, 70), runif(n_train, 60, 100))),
y = runif(n_train, 0, 100)
), geom = c("x","y"), crs = crs(img))
# Assign spectral values that reflect the class (using simple rules)
train_vals <- extract(img, train_pts, ID = FALSE)
# Modify to create class separation
train_vals$Red[train_pts$class == "Forest"] <- train_vals$Red[train_pts$class == "Forest"] *
0.7
train_vals$NIR[train_pts$class == "Forest"] <- train_vals$NIR[train_pts$class == "Forest"] *
1.6
train_vals$NIR[train_pts$class == "Water"] <- train_vals$NIR[train_pts$class == "Water"] *
0.3
train_vals$Red[train_pts$class == "Urban"] <- train_vals$Red[train_pts$class == "Urban"] *
1.3
# Combine
train_df <- cbind(class = train_pts$class, train_vals)
head(train_df)
22.1.3 Maximum Likelihood Classification (MLC)
Maximum Likelihood assumes that the spectral values for each class follow a multivariate
normal distribution. For each pixel, it computes the probability that the pixel belongs to each
class, given its spectral vector, and assigns the class with the highest probability.
The probability density for class kk with mean vector μkμk and covariance matrix ΣkΣk is:
P(x∣k)=1(2π)p/2∣Σk∣1/2exp(−12(x−μk)TΣk−1(x−μk))P(x∣k)=(2π)p/2∣Σk∣1/21exp(−21(x−μk
)TΣk−1(x−μk))
In R, Maximum Likelihood is not built into terra but is easily implemented:
r
# Fit MLC by estimating class means and covariances
classes <- unique(train_df$class)
n_bands <- 4
prior <- list()
for (cl in classes) {
sub <- subset(train_df, class == cl)
prior[[cl]] <- list(
mean = colMeans(sub[, 2:5]),
cov = cov(sub[, 2:5]),
n = nrow(sub)
)
}
# Prediction function for a single pixel
mlc_predict <- function(x, priors) {
probs <- sapply(priors, function(p) {
diff <- x - p$mean
# Gaussian log-density (ignoring constant for comparison)
-0.5 * log(det(p$cov)) - 0.5 * t(diff) %*% solve(p$cov) %*% diff
})
names(priors)[[Link](probs)]
}
# Apply to the image using app()
mlc_map <- app(img, fun = function(vals) {
apply(vals, 1, function(row) mlc_predict(row, prior))
})
# Note: app() applies to cells; for multi-band prediction, terra::predict() is easier with some
models.
# For a clean implementation, we can use terra::predict() with a model, but MLC requires custom
code.
A simpler route for MLC is to use the RStoolbox package, which
provides superClass() with method = "mlc", but it depends on raster. For full terra compatibility,
Random Forest is the preferred choice, as we show next.
22.1.4 Random Forest Classification
Random Forest (Breiman, 2001) is an ensemble of decision trees. Each tree is trained on a
bootstrap sample of the training data, and at each split, only a random subset of the predictors is
considered. The final classification is the majority vote across all trees (typically 500 or more).
Random Forest is non-parametric, handles high-dimensional data, provides variable importance
measures, and is extremely robust.
In R, the randomForest package provides the engine, and terra::predict() applies the model to
a SpatRaster.
r
library(randomForest)
# Prepare training data
train_df$class <- [Link](train_df$class)
train_data <- train_df[, c("Blue", "Green", "Red", "NIR")]
train_labels <- train_df$class
# Train Random Forest
rf_model <- randomForest(x = train_data, y = train_labels, ntree = 500, importance = TRUE)
rf_model
print(rf_model$confusion) # out-of-bag confusion matrix
# Variable importance
varImpPlot(rf_model, main = "Variable Importance")
# Predict on the image
rf_map <- predict(img, rf_model, type = "response")
rf_map
# class : SpatRaster
# categories : Forest, Urban, Water
The predict() method for randomForest objects automatically handles the raster input, returning a
categorical SpatRaster with the predicted class for each pixel.
Key parameters of Random Forest:
ntree: number of trees. More trees reduce variance but increase computation time. 500 is
a common default.
mtry: number of variables randomly sampled at each split. Default is sqrt(n_features) for
classification.
nodesize: minimum size of terminal nodes. Larger values produce smaller trees, reducing
overfitting.
Random Forest also provides an out-of-bag (OOB) error estimate, which is an internal
cross-validation: each tree is trained on a bootstrap sample (about 63% of the data), and the
remaining 37% (out-of-bag) are used as a test set for that tree. The OOB error is a useful,
unbiased estimate of classification accuracy, but it should be supplemented with an independent
validation set.
22.1.5 Support Vector Machine (SVM)
SVM (Cortes & Vapnik, 1995) finds the hyperplane that maximally separates classes in a
high-dimensional feature space. By using kernel functions (radial basis, polynomial), SVM can
model complex, non-linear decision boundaries. In remote sensing, SVM often performs well for
classes that are spectrally similar, such as different crop types.
The e1071 package provides svm():
r
library(e1071)
# Train SVM with radial basis kernel
svm_model <- svm(class ~ Blue + Green + Red + NIR, data = train_df,
kernel = "radial", cost = 10, gamma = 0.1)
svm_model
# Predict on the image
svm_map <- predict(img, svm_model, type = "response")
plot(svm_map)
Key SVM parameters:
kernel: "linear", "radial", "polynomial", "sigmoid". Radial is the most flexible for remote
sensing.
cost: penalty for misclassifications. Higher cost fits the training data more tightly, risking
overfitting.
gamma: kernel width. Small gamma = smooth decision boundary; large gamma = more
complex, potentially overfit.
SVM requires tuning. Use [Link]() to search for optimal cost and gamma via cross-validation:
r
tune_result <- [Link](class ~ ., data = train_df, gamma = 10^(-3:0), cost = 10^(-1:2))
best_model <- tune_result$[Link]
22.1.6 Algorithm Choice in Practice
Random Forest is the first choice for most land-cover mapping projects. It is robust,
requires little tuning, handles many predictors, and provides OOB error and variable
importance.
SVM is preferred when class boundaries are complex and the number of training samples
is moderate (SVM can overfit with very large training sets).
Maximum Likelihood is fast and interpretable but assumes normality and is sensitive to
outliers. It is less common in modern applied remote sensing but is still taught as the
parametric baseline.
Deep learning (convolutional neural networks) is increasingly used for very
high-resolution imagery and object-based classification, but requires substantial
computational resources and training data, and is beyond the scope of this book.
22.2 Training and Validation Sample Design
The quality of a land-cover map depends critically on the quality and design of the training and
validation samples. Poorly designed samples lead to biased maps and misleading accuracy
estimates. This section covers the principles and methods for collecting, evaluating, and splitting
samples.
22.2.1 Principles of Sample Design
1. Representativeness: Training samples must represent the full spectral variability within
each class. If "Forest" includes deciduous, coniferous, and mixed stands, the training
samples must include examples of all three.
2. Independence: Training and validation samples must be independent. Using the same
samples for both training and validation yields grossly over-optimistic accuracy
estimates. Split samples spatially (different polygons) or, better, collect validation data
from a different source (field survey, higher-resolution imagery).
3. Adequacy: A rule of thumb is at least 50–100 pixels per class for training, and 30–50 for
validation, but more is always better, especially for spectrally variable classes. The
sample size should be proportional to class variability, not just class area.
4. Spatial separation: Training and validation pixels should not be adjacent or from the
same polygon, to avoid spatial autocorrelation inflating accuracy.
22.2.2 Splitting Samples: Training vs. Validation
The simplest split is random: 70% for training, 30% for validation. But for spatial data, a spatial
split is better: assign entire polygons or buffered points to training or validation, so that no
validation pixel is within a certain distance of a training pixel.
r
# Simple random split
[Link](1)
n_all <- nrow(train_df)
train_idx <- sample(1:n_all, size = floor(0.7 * n_all))
train_set <- train_df[train_idx, ]
valid_set <- train_df[-train_idx, ]
# Check class balance
table(train_set$class)
table(valid_set$class)
For a spatial split, if you have polygon training areas, assign each polygon a random number and
split by polygon ID.
22.2.3 Class Separability Analysis
Before training a classifier, assess whether the classes are spectrally separable using:
Boxplots of spectral values by class.
Transformed divergence or Jeffries-Matusita distance (available in RStoolbox or
computed manually).
Scatterplots of band pairs, coloured by class.
r
library(ggplot2)
library(tidyr)
train_long <- train_df %>%
pivot_longer(cols = Blue:NIR, names_to = "Band", values_to = "Reflectance")
ggplot(train_long, aes(x = class, y = Reflectance, fill = class)) +
geom_boxplot() +
facet_wrap(~ Band, scales = "free_y") +
theme_minimal() +
ggtitle("Spectral Separability by Band")
If two classes overlap heavily in all bands, consider adding spectral indices (NDVI, NDWI),
texture measures, or topographic covariates to improve separability.
22.2.4 Sample Augmentation and Active Learning
If initial accuracy is poor for a class, you can add more training samples. Active learning is an
iterative process: classify, identify pixels with low prediction confidence (e.g., where the
probability of the assigned class is below a threshold), and manually label those pixels to
improve the classifier. This is an advanced technique but can dramatically improve efficiency in
large mapping projects.
22.3 Unsupervised Classification and Clustering
Unsupervised classification groups pixels into spectral clusters without any training labels. The
analyst then assigns meaning to the clusters afterward (e.g., “Cluster 1 is forest, Cluster 2 is
water”). This is useful for exploratory analysis, for detecting unknown land-cover types, or when
training data are unavailable.
22.3.1 K-Means Clustering
K-means partitions the data into kk clusters by minimising the within-cluster sum of squares.
In terra, k_means() performs k-means on a raster:
r
# Extract a sample of pixels for clustering (to reduce computation)
[Link](42)
sample_pts <- spatSample(img, size = 5000, method = "random", [Link] = TRUE)
sample_vals <- [Link](sample_pts)
# Perform k-means
km <- kmeans(sample_vals, centers = 5, nstart = 10)
km$centers # mean spectrum of each cluster
# Predict on the full image
km_map <- predict(img, km, fun = function(model, data) {
# For each pixel, find nearest cluster centre
...
})
# terra::predict with kmeans is not directly supported; use raster::predict or the `RStoolbox`
package.
# A manual approach:
km_map <- app(img, fun = function(vals) {
dists <- apply(km$centers, 1, function(cent) colSums((t(vals) - cent)^2))
apply(dists, 1, [Link])
})
After clustering, examine the cluster centres (the mean spectrum of each cluster) and assign each
cluster a class name based on its spectral characteristics.
22.3.2 ISODATA and Hierarchical Clustering
ISODATA is a more flexible variant of k-means that allows clusters to split and merge. It is
available in the RStoolbox package (unsuperClass() with method = "isodata"). Hierarchical
clustering (hclust()) groups pixels by spectral similarity and produces a dendrogram, which can
be cut at a chosen similarity level to produce a desired number of classes.
22.3.3 When to Use Unsupervised Classification
No training data available: Rapid mapping of a new region.
Exploratory analysis: Understanding the spectral structure of an image before defining
classes.
Detecting change: Clustering a difference image to identify change categories.
Pre-classification: Using cluster labels as features in a supervised classifier (a hybrid
approach).
Unsupervised classification is less accurate than supervised methods for thematic mapping, but it
is fast, objective, and requires no prior knowledge.
22.4 Accuracy Metrics: Confusion Matrix, Kappa, Overall Accuracy
A classification map is only as good as its validation. Accuracy assessment quantifies how well
the classified map matches reality, as represented by a set of independent validation samples. The
standard tools are the confusion matrix (also called error matrix), overall accuracy, producer’s
accuracy, user’s accuracy, and the Kappa coefficient.
22.4.1 The Confusion Matrix
A confusion matrix is an m×mm×m table where mm is the number of classes. Rows represent
the classified (predicted) class; columns represent the reference (true) class. The diagonal cells
are correctly classified pixels; off-diagonal cells are errors.
In R, use table() with the validation data or the caret::confusionMatrix() function:
r
# Extract predicted class at validation points
valid_pred <- extract(rf_map, valid_pts, ID = FALSE)
valid_true <- valid_pts$class
# Confusion matrix
cm <- table(Predicted = valid_pred[,1], Reference = valid_true)
cm
22.4.2 Accuracy Metrics
Overall Accuracy (OA): The proportion of correctly classified validation pixels:
OA=∑i=1mniiNOA=N∑i=1mnii
r
oa <- sum(diag(cm)) / sum(cm)
oa
Producer’s Accuracy (PA): For a given class ii, the proportion of reference pixels of class ii that
were correctly classified. PA answers: “How well did the classifier capture this class?” It is the
recall.
PAi=nii∑j=1mnjiPAi=∑j=1mnjinii
r
pa <- diag(cm) / colSums(cm)
pa
User’s Accuracy (UA): For a given class ii, the proportion of pixels classified as class ii that
were actually that class. UA answers: “If the map says this pixel is class ii, how likely is it to be
correct?” It is the precision.
UA_i = \frac{n_{ii}}{\sum_{j=1}^m n_{ij}} ``` ```r ua <- diag(cm) / rowSums(cm) ua ```
**Kappa Coefficient (\(\hat{\kappa}\)):** Measures agreement between the classified map and
reference data, adjusted for chance agreement: \[ \hat{\kappa} = \frac{p_o - p_e}{1 - p_e}
where po=OApo=OA and pe=∑i(row totali×column totali)/N2pe=∑i(row totali×column totali
)/N2.
Kappa ranges from –1 (complete disagreement) to +1 (perfect agreement), with 0 indicating
chance agreement. However, Kappa has been criticised for being overly conservative and
difficult to interpret across different landscapes (Pontius & Millones, 2011). Many remote
sensing scientists now prefer to report OA, PA, UA, and the quantity/allocation disagreement
decomposition instead of Kappa. We will compute Kappa for completeness but emphasise the
per-class metrics.
r
# Kappa
p_o <- oa
p_e <- sum(rowSums(cm) * colSums(cm)) / sum(cm)^2
kappa <- (p_o - p_e) / (1 - p_e)
kappa
22.4.3 Sampling Design for Accuracy Assessment
The validation samples used for accuracy assessment must be independent of the training
samples and should be collected using a probability sampling design. The most common
designs are:
Simple random sampling: Every pixel has an equal chance of being selected. Simple
but may under-sample small classes.
Stratified random sampling: The area is stratified by mapped class, and a fixed number
of validation points is randomly selected within each stratum. This ensures that rare
classes are adequately represented.
Systematic sampling: Points on a regular grid. Efficient but may be autocorrelated.
For a robust accuracy assessment, the sample size should be guided by the desired precision of
the accuracy estimates. A common rule of thumb is at least 50 samples per class, but for
large-area mapping, formal sample size calculation based on a binomial proportion (or
multinomial) is recommended.
22.4.4 Technical Demonstration: Full Accuracy Assessment
r
# Simulate independent validation points
[Link](999)
n_val <- 200
val_class <- sample(c("Forest", "Urban", "Water"), n_val, replace = TRUE, prob = c(0.4, 0.35,
0.25))
val_pts <- vect([Link](
x = runif(n_val, 0, 100),
y = runif(n_val, 0, 100),
class = val_class
), geom = c("x","y"), crs = crs(img))
# Extract predicted class
val_pred <- extract(rf_map, val_pts, ID = FALSE)
val_df <- [Link](Predicted = val_pred[,1], Reference = val_pts$class)
# Confusion matrix
cm <- table(val_df)
print(cm)
# Compute metrics
oa <- sum(diag(cm)) / sum(cm)
pa <- diag(cm) / colSums(cm)
ua <- diag(cm) / rowSums(cm)
# Summary table
accuracy_table <- [Link](
Class = colnames(cm),
Producer_Accuracy = round(pa, 3),
User_Accuracy = round(ua, 3)
)
print(accuracy_table)
cat("Overall Accuracy:", round(oa, 3), "\n")
Hard Practice 22 – “Land Cover Classification of a Suburban Area and Validation with
Ground Truth”
Objective:
Perform a complete supervised land-cover classification of a simulated suburban landscape using
Random Forest. Collect training data, train the model, classify the image, and perform an
independent accuracy assessment with a confusion matrix.
Scenario:
You are mapping land cover for a suburban municipality. The area contains six classes: Forest,
Grass, Urban (built-up), Road, Water, and Bare Soil. You have a simulated Sentinel-2 image
(Blue, Green, Red, NIR) at 10-m resolution over a 5 km × 5 km area. You also have a set of
field-digitised training polygons and an independent set of validation points from a
high-resolution aerial photo.
Instructions:
1. Simulate the image: Create a 500×500 pixel raster (representing 5 km × 5 km at 10 m
resolution) with 4 bands. Introduce realistic spectral values for the six classes by defining
a “class map” first, then assigning spectral values accordingly. (Hint: create
a class_map raster with values 1–6, then for each band, assign values based on the class,
with added noise.)
2. Create training polygons: Using the class_map (ground truth), randomly sample 500
training pixels (stratified by class, roughly equal per class). Extract their spectral values.
3. Train a Random Forest classifier with 500 trees. Print the OOB confusion matrix and
variable importance.
4. Classify the image: Use predict() to produce a land-cover map.
5. Independent validation: Sample 300 new, independent validation pixels from
the class_map (ensuring no overlap with training pixels). Extract the predicted class at
those locations.
6. Accuracy assessment:
o Create the confusion matrix.
o Compute Overall Accuracy, Producer’s Accuracy, User’s Accuracy, and Kappa.
o Display the results in a formatted table.
7. Post-processing: Apply a 3×3 majority filter to the classification map to reduce
salt-and-pepper noise. (terra::focal() with fun = "modal".)
8. Map the results: Create a publication-quality map of the classified land cover with a
colour legend. Use ggplot2 or terra::plot().
9. Interpretation: In a comment block, discuss which classes had the lowest User’s
Accuracy and why (e.g., spectral confusion). Suggest improvements for the next iteration
of the map.
Deliverable:
A script practice_chapter22_landcover_classification.R with the full workflow, maps, accuracy
table, and interpretation.
Chapter 22 Review Problems
Solve each in a clearly commented R script using terra, randomForest, and optionally e1071.
Use simulated data where specified.
Easy Problems (1–5)
1. Create a Training Dataset
Create a 4-band image (50×50) and a set of 100 training points with three classes (A, B, C).
Extract the spectral values at the training points. Print the first 10 rows of the training data frame.
2. Train a Random Forest Classifier
Using the training data from Problem 1, train a Random Forest classifier with 200 trees. Print the
model summary and the OOB error rate.
3. Classify the Image
Apply the trained Random Forest model from Problem 2 to the entire image using predict(). Plot
the original image (true-colour composite with plotRGB()) and the classified map side-by-side.
4. Confusion Matrix
Create a validation set of 50 points with known class labels. Extract the predicted class at these
points. Create and print the confusion matrix.
5. Overall Accuracy
Using the confusion matrix from Problem 4, compute and print the Overall Accuracy, Producer’s
Accuracy for each class, and User’s Accuracy for each class.
Medium Problems (6–10)
6. Compare Classifiers
Train three classifiers on the same training data: Maximum Likelihood (implement manually or
use a simple normal-based classifier), Random Forest, and SVM. Classify the image with each.
Compare their Overall Accuracy on the same validation set. Which performs best?
7. Variable Importance and Feature Reduction
Using the Random Forest model from Problem 2, examine the variable importance plot. If you
removed the least important band and retrained, does accuracy decrease? What if you added
NDVI as a predictor? Simulate adding NDVI as an extra band and re-classify.
8. Sample Size Sensitivity
Starting from a large training set (500 points), progressively reduce the training set size (400,
300, 200, 100, 50) and train a Random Forest for each. Record the OOB error and the overall
accuracy on a fixed validation set. Plot accuracy vs. training set size. At what sample size does
accuracy noticeably degrade?
9. Unsupervised Classification
Perform k-means clustering (k = 5) on the image from Problem 1. Plot the cluster map. Compute
the cluster centres (mean spectral values). Can you identify which cluster corresponds to which
land-cover class by inspecting the centres?
10. Post-Classification Filtering
Apply a 3×3, 5×5, and 7×7 majority filter to the classified map from Problem 3. For each,
compute the overall accuracy against the validation set. What size filter gives the best trade-off
between noise reduction and boundary preservation?
Challenging Problems (11–15)
11. Stratified Accuracy Assessment with Confidence Intervals
Design a stratified random sample of 300 validation points (stratified by the predicted class from
Random Forest). Compute the overall accuracy and its binomial 95% confidence interval.
Compute per-class user’s accuracy and their confidence intervals. Present the results in a
professional table.
12. Multi-Temporal Classification
Create a two-date image stack (e.g., summer and winter, 8 bands total). Train a Random Forest
using all 8 bands. Compare accuracy to using only the summer bands. Does multi-temporal data
improve separation of deciduous forest from evergreen?
13. Feature Engineering: Texture, NDVI, and Topography
Add texture measures (focal standard deviation of NIR in a 5×5 window) and NDVI as
additional bands. Train Random Forest on the augmented feature set. Does accuracy improve?
Which new feature contributes most to accuracy?
14. Land-Cover Change Detection
Classify the same area for two different years (simulate a change: forest to urban). Perform
post-classification comparison: create a change matrix showing the transition from year 1 classes
(columns) to year 2 classes (rows). Map the change types. Compute the area of each transition.
15. Real-World Application: Sentinal-2 Classification
If you have access to a Sentinel-2 scene (L2A), perform a full land-cover classification
workflow:
Read the bands.
Compute NDVI, NDWI, and a built-up index.
Digitise training polygons for at least five classes (use terra::draw() or a GIS and import).
Train Random Forest.
Classify the image.
Validate with independent points.
Produce a publication-quality map with legend, scale bar, north arrow, and accuracy
table.
Write a one-page report (as a comment block) describing the methods, results, and
limitations.
Chapter 23: Photogrammetry and 3D Terrain Analysis
The surface of the Earth is not flat. Elevation shapes the flow of water, the distribution of
vegetation, the suitability of land for agriculture, and the risk of natural hazards. A Digital
Elevation Model (DEM) is a raster where each cell stores the height of the terrain above a
reference datum—a continuous, digital representation of topography. From a DEM, an
extraordinary range of derivative products can be computed: slope, aspect, hillshade, curvature,
topographic wetness index, stream networks, and viewsheds. These derivatives are the
foundation of geomorphometry, the quantitative analysis of land surface shape. This chapter
teaches you to work with DEMs in R: to acquire them from open-access sources, to import and
visualise them, to compute the standard terrain derivatives, to perform viewshed and
line-of-sight analysis, and to begin working with the high-resolution 3D point clouds produced
by modern photogrammetry and LiDAR, using the lidR package. The Hard Practice asks you to
compute an erosion risk index from DEM derivatives and rainfall data—a classic spatial
multi-criteria analysis. By the end, you will be able to extract the full topographic information
content from a DEM, and you will understand how terrain shapes environmental processes.
23.1 Digital Elevation Models (DEM) and Their Sources
A DEM is the foundational dataset for terrain analysis. Understanding its characteristics, sources,
and limitations is essential before any derivative is computed.
23.1.1 DEM, DSM, and DTM: Terminology
The term DEM is often used generically, but three distinct products exist:
Digital Terrain Model (DTM): The elevation of the bare earth, with vegetation,
buildings, and other structures removed. DTMs are used for hydrological modelling,
slope stability analysis, and geomorphological mapping.
Digital Surface Model (DSM): The elevation of the topmost surface, including
vegetation canopy, buildings, and other structures. DSMs are produced by
photogrammetry (stereo satellite imagery, UAVs) and by the first return of LiDAR. They
are used for viewshed analysis, urban modelling, and canopy height estimation.
Digital Elevation Model (DEM): A generic term often used for DTM, but in some
contexts (e.g., SRTM, ALOS World 3D) it refers to a surface model that may include
some vegetation bias. Always check the product specification.
In this chapter, we work primarily with DTMs, as they represent the ground surface.
23.1.2 Global and Regional DEM Data Sources
Several global DEM products are freely available, each with different spatial resolutions, vertical
accuracies, and coverage:
Vertical
Product Resolution Accuracy Coverage Provider
(RMSE)
SRTM (Shuttle Radar 30 m (1 60°N–
~10 m (global) NASA/USGS
Topography Mission) arc-second) 56°S
ALOS World 3D 30 m ~5 m Global JAXA
Vertical
Product Resolution Accuracy Coverage Provider
(RMSE)
(AW3D30)
83°N–
ASTER GDEM v3 30 m ~10–15 m NASA/METI
83°S
Copernicus GLO-30 30 m ~3–4 m Global ESA
NASADEM 30 m ~5 m Global NASA
TanDEM-X 90 m 90 m ~1 m Global DLR
FABDEM (Forest And
30 m ~5 m Global Fathom
Buildings removed)
MERIT DEM 90 m ~5 m Global U-Tokyo
SRTM 90 m 90 m ~10 m Global CGIAR-CSI
For local studies, many countries provide higher-resolution DTMs (1–10 m) derived from
airborne LiDAR or national mapping programmes. Examples: USGS 3DEP (1 m, USA), UK
Environment Agency LiDAR (1 m, England), IGN RGE ALTI (1 m, France), Geoscience
Australia DEM (5 m). These are often accessed via web portals or APIs.
23.1.3 Importing a DEM into R
DEMs are distributed as single-band GeoTIFF files. terra::rast() reads them directly:
r
library(terra)
# Read a DEM (simulated here; replace with actual file path)
dem <- rast("data/[Link]")
dem
# class : SpatRaster
# dimensions : 3601, 3601, 1
# resolution : 30, 30
# extent : ...
# coord. ref. : EPSG:4326
# source : [Link]
# name : dem
# Quick visualisation
plot(dem, main = "Digital Elevation Model", col = [Link](100))
23.1.4 Understanding DEM Metadata and Preprocessing
Before analysis, inspect:
CRS: Ensure it is a projected CRS (metres) for slope and area calculations. If in
geographic coordinates (degrees), reproject with project().
Voids (NA values): SRTM and ASTER DEMs may contain voids (areas of no data, often
over water or steep terrain). These can be filled with terra::focal() or terra::fillNA().
Resampling: If you need to change resolution, use aggregate() (coarsen)
or project() (reproject to a new grid). Always use method = "bilinear" for continuous
elevation data; method = "near" for categorical rasters.
Units: Elevation units should be metres. Some older products use decimetres or feet;
convert as needed.
r
# Example: reproject to UTM and fill voids
dem_utm <- project(dem, "EPSG:32650", method = "bilinear")
dem_utm_filled <- focal(dem_utm, w = 5, fun = mean, [Link] = "only", [Link] = TRUE)
23.1.5 Simulating a DEM for Teaching
For reproducibility and to avoid large file downloads, we will create a synthetic DEM using
mathematical surfaces and random noise. This DEM behaves like a real landscape and allows
full exploration of terrain analysis functions.
r
[Link](2024)
dem <- rast(nrows = 200, ncols = 200,
xmin = 0, xmax = 10000, ymin = 0, ymax = 10000,
crs = "EPSG:32650") # UTM Zone 50N, metres
# Create a synthetic landscape with hills and valleys
x <- seq(0, 10000, [Link] = 200)
y <- seq(0, 10000, [Link] = 200)
vals <- outer(x, y, function(x, y) {
500 + 300 * sin(x/1500) * cos(y/2000) + 200 * sin((x+y)/3000) + rnorm(length(x)*length(y), 0,
30)
})
values(dem) <- vals
plot(dem, main = "Synthetic DEM", col = [Link](100))
This DEM has a mean elevation around 500 m, undulating terrain with hills and valleys, and
realistic noise.
23.2 Deriving Slope, Aspect, Hillshade, and Terrain Ruggedness
From a DEM, the first and second derivatives of elevation with respect to the horizontal
coordinates are the fundamental terrain descriptors.
23.2.1 Slope: The Steepness of the Terrain
Slope is the rate of change of elevation in the steepest direction. It is computed as the magnitude
of the gradient vector:
slope=arctan((∂z∂x)2+(∂z∂y)2)slope=arctan(∂x∂z)2+(∂y∂z)2
The partial derivatives are approximated from the 3×33×3 neighbourhood of each cell. Several
finite-difference algorithms exist; terra::terrain() uses the Horn (1981) method by default, which
is the standard for continuous terrain.
r
slope <- terrain(dem, v = "slope", unit = "degrees")
plot(slope, main = "Slope (degrees)", col = viridis::viridis(100))
Slope is measured in degrees (0° = flat, 90° = vertical cliff) or as a percentage (rise/run × 100).
Use unit = "radians" for radian output. Slope is the single most important terrain variable: it
controls surface runoff, soil erosion, landslide potential, and solar radiation receipt.
23.2.2 Aspect: The Direction of the Steepest Slope
Aspect is the compass direction (azimuth) that the slope faces, measured clockwise from north
(0° = north, 90° = east, 180° = south, 270° = west). Flat areas have no aspect (returned as NA or
0 depending on the implementation).
r
aspect <- terrain(dem, v = "aspect", unit = "degrees")
plot(aspect, main = "Aspect (degrees)", col = rainbow(360))
Aspect is used for solar radiation modelling, vegetation habitat analysis (north-facing vs.
south-facing slopes), and wind exposure assessment. A common transformation
is northness and eastness:
r
northness <- cos(aspect * pi / 180)
eastness <- sin(aspect * pi / 180)
These are continuous variables ranging from –1 to +1, avoiding the circular nature of aspect
(where 1° and 359° are similar).
23.2.3 Hillshade: Simulating Illumination
A hillshade (shaded relief) simulates how the terrain would appear if illuminated by a light
source at a given azimuth and elevation angle. It is not a physical property but a visualisation
tool that dramatically enhances the perception of topography.
r
hs <- shade(slope, aspect, angle = 45, direction = 315)
plot(hs, main = "Hillshade (azimuth 315°, altitude 45°)", col = grey(0:100/100))
Overlaying a semi-transparent colour raster (e.g., elevation or land cover) on a hillshade
produces a visually stunning and informative map:
r
plot(dem, col = [Link](100), alpha = 0.6, main = "Elevation with Hillshade")
plot(hs, col = grey(0:100/100), alpha = 0.4, add = TRUE, legend = FALSE)
23.2.4 Terrain Ruggedness and Other Derivatives
Terrain Ruggedness Index (TRI): The mean absolute difference between a cell and its 8
neighbours. High TRI indicates rough, dissected terrain.
r
tri <- terrain(dem, v = "TRI")
Topographic Position Index (TPI): The difference between a cell’s elevation and the mean
elevation of its neighbourhood. Positive TPI = ridge, negative TPI = valley.
r
tpi <- terrain(dem, v = "TPI")
Roughness: The standard deviation of elevation in a neighbourhood.
r
roughness <- terrain(dem, v = "roughness")
Flow direction and accumulation: These are the hydrological derivatives, computed
by terra::terrain() with v = "flowdir" or using the whitebox package for advanced hydrological
analysis. Flow accumulation is the basis for stream network extraction, but its accurate
computation requires pit filling (terra::fillSinks()) and a flow routing algorithm (D8, D-infinity).
We will focus on slope and aspect in this chapter; hydrological modelling is an advanced topic.
23.2.5 Curvature: The Shape of the Surface
Curvature describes how the slope changes across space:
Profile curvature: curvature in the direction of the steepest slope. Positive = convex
(accelerating flow), negative = concave (decelerating flow).
Plan curvature: curvature perpendicular to the slope direction. Positive = convex
(divergent flow), negative = concave (convergent flow).
Total curvature: a combination.
terra::terrain() does not directly output curvature, but you can compute it from slope and aspect
or using the spatialEco package. For simplicity, we use spatialEco::curvature():
r
# Install spatialEco if needed
# remotes::install_github("jeffreyevans/spatialEco")
library(spatialEco)
curv <- curvature(dem, type = "planform")
plot(curv, main = "Plan Curvature")
23.2.6 Technical Demonstration: Terrain Derivatives
r
library(terra)
# Use the synthetic DEM from Section 23.1
# Compute all major derivatives
slope <- terrain(dem, v = "slope", unit = "degrees")
aspect <- terrain(dem, v = "aspect", unit = "degrees")
hillshade <- shade(slope, aspect, angle = 40, direction = 315)
tri <- terrain(dem, v = "TRI")
tpi <- terrain(dem, v = "TPI")
roughness <- terrain(dem, v = "roughness")
# Plot in a multi-panel layout
par(mfrow = c(3,2))
plot(dem, main = "DEM")
plot(slope, main = "Slope (°)", col = viridis::viridis(100))
plot(aspect, main = "Aspect (°)", col = rainbow(360))
plot(hillshade, main = "Hillshade", col = grey(0:100/100))
plot(tri, main = "TRI", col = viridis::viridis(100))
plot(tpi, main = "TPI", col = [Link](100))
23.3 Viewshed Analysis and Line-of-Sight
A viewshed is the area visible from a given observer point. It is a fundamental operation in
landscape planning, military science, telecommunications, and visual impact assessment. In R,
the terra::viewshed() function computes the viewshed from a single point or from multiple
points.
23.3.1 The Viewshed Algorithm
Given an observer location OO at height hobshobs above the ground, the viewshed algorithm
determines which cells in the DEM are visible. For each cell, it traces the line of sight
from OO to the target cell, checking whether any intermediate cell’s elevation obstructs the line
of sight. The target is visible if the elevation angle from OO to the target is greater than or equal
to the maximum elevation angle from OO to any intermediate cell.
The observer height is typically set to 1.7 m (human eye height) or 10–30 m (fire lookout tower).
The target height (if not the ground) can be added for specific objects (e.g., a 20 m tall building).
23.3.2 Computing a Viewshed in terra
terra::viewshed() takes a DEM, an observer location (as a SpatVector point), and observer/target
height options:
r
# Observer location (a point near the centre of the DEM)
observer <- vect(cbind(5000, 5000), crs = "EPSG:32650")
# Compute viewshed
v <- viewshed(dem, loc = observer, observer = 1.7, target = 0)
plot(v, main = "Viewshed (green = visible, red = not visible)", col = c("red", "green"))
The output is a binary raster: 0 = not visible, 1 = visible.
Multiple observers: Provide a SpatVector with multiple points. viewshed() will compute the
combined viewshed (visible from at least one observer).
Viewshed with Earth curvature and atmospheric refraction: By
default, terra::viewshed() assumes a flat Earth. For large distances (>10 km), Earth curvature and
refraction become significant. The curvature and refraction arguments can be set (refraction
coefficient default is 0.14 for standard atmosphere, or 0 for no refraction). The algorithm corrects
for the effective Earth radius.
r
v_curv <- viewshed(dem, loc = observer, observer = 30, target = 0,
curvature = TRUE, refraction = 0.14)
23.3.3 Line-of-Sight Analysis
A line-of-sight (LOS) is the visibility along a specific path, typically between two points.
While terra does not have a built-in LOS function, you can compute it manually:
1. Create a line between the two points.
2. Sample the DEM along the line at a fine resolution.
3. For each sampled point, compute the elevation angle from the observer.
4. Check if the line of sight is blocked.
A simplified approach extracts the elevation profile and checks cumulative slope:
r
# Two points: observer and target
obs <- cbind(4000, 5000)
tar <- cbind(7000, 5000)
line <- vect(rbind(obs, tar), type = "lines", crs = crs(dem))
# Extract elevation profile
profile <- extract(dem, line, along = TRUE)[[1]]
dist <- profile[,1]
elev <- profile[,2]
# Simple visibility check: is any point along the path higher than the line from obs to target?
obs_elev <- elev[1] + 1.7
tar_elev <- elev[length(elev)]
line_slope <- (tar_elev - obs_elev) / (max(dist) - min(dist))
expected_elev <- obs_elev + line_slope * (dist - min(dist))
visible <- all(elev <= expected_elev)
plot(dist, elev, type = "l", main = "Line of Sight Profile", xlab = "Distance (m)", ylab =
"Elevation (m)")
abline(a = obs_elev, b = line_slope, col = "red", lty = 2)
text(max(dist)/2, max(elev), labels = ifelse(visible, "VISIBLE", "BLOCKED"), col =
ifelse(visible, "green", "red"), cex = 1.5)
For rigorous LOS analysis, use the viewscape or windfarmGA packages, or the GRASS GIS
module [Link].
23.3.4 Applications of Viewshed Analysis
Landscape impact assessment: What is the visual impact of a proposed wind farm or
new building?
Fire lookout placement: Where should fire towers be located to maximise visible area?
Military: What areas are observable from a given position?
Archaeology: What landscape features are visible from a prehistoric site?
Telecommunications: Where to place a cell tower for maximum coverage.
23.4 3D Point Clouds from Photogrammetry: Introduction to {lidR}
Photogrammetry and LiDAR produce point clouds: dense collections of 3D coordinates (X, Y,
Z) with additional attributes (intensity, return number, classification). The lidR package (Roussel
et al., 2020) is the premier R package for processing, analysing, and visualising airborne LiDAR
data. While a full treatment of LiDAR processing is beyond the scope of this book, this section
introduces the core concepts and demonstrates how to derive a DTM, DSM, and canopy height
model from a point cloud.
23.4.1 The LAS/LAZ Format and lidR
LiDAR data are stored in the LAS (LASer) file format (.las) or its compressed version, LAZ
(.laz). The lidR package reads these files into a LAS object, which stores the point cloud as
a [Link] with standard attributes: X, Y, Z, Intensity, ReturnNumber, NumberOfReturns,
Classification, and more.
r
library(lidR)
# Read a LAS file (simulated or real)
# las <- readLAS("data/lidar_plot.las")
# print(las)
For learning, lidR provides a small example dataset:
r
LASfile <- [Link]("extdata", "[Link]", package = "lidR")
las <- readLAS(LASfile, select = "xyz", filter = "-drop_z_below 0")
plot(las, color = "Z", bg = "white", size = 2)
23.4.2 Ground Classification and DTM Creation
LiDAR points are classified into ground, vegetation, building, water, etc. If the LAS file includes
classification (field Classification), ground points (class 2) can be used to create a DTM. If
not, lidR::classify_ground() uses a progressive morphological filter or a cloth simulation filter
(CSF) to identify ground returns.
r
# Classify ground (if not already classified)
las_ground <- classify_ground(las, algorithm = csf())
# Create a DTM
dtm <- rasterize_terrain(las_ground, res = 1, algorithm = tin())
plot(dtm, main = "DTM")
23.4.3 Canopy Height Model (CHM)
A Canopy Height Model is the height of vegetation above the ground. It is computed by
subtracting the DTM from the DSM:
1. Create a DSM from all first returns.
2. Normalise the point cloud by subtracting the DTM from each point’s Z.
3. Create a CHM from the normalised point cloud.
r
# Normalise heights
las_norm <- normalize_height(las, algorithm = tin())
# Create CHM
chm <- rasterize_canopy(las_norm, res = 1, algorithm = p2r(subcircle = 0.2))
plot(chm, main = "Canopy Height Model", col = viridis::viridis(100))
23.4.4 Individual Tree Detection and Segmentation
From a CHM, lidR can detect individual tree tops using a local maximum filter (locate_trees())
and segment tree crowns (segment_trees()):
r
ttops <- locate_trees(las_norm, lmf(ws = 5))
las_seg <- segment_trees(las_norm, algorithm = dalponte2016(chm, ttops))
plot(las_seg, color = "treeID", bg = "white")
This is the gateway to forest inventory from LiDAR: tree count, tree height, crown area, and
biomass estimation.
23.4.5 Integration with terra and sf
lidR outputs can be converted to terra rasters
(rasterize_terrain, rasterize_canopy return SpatRaster objects) or to sf point clouds. This
seamless integration means you can use all the terra and sf tools from earlier chapters on
LiDAR-derived products.
Hard Practice 23 – “Computing Erosion Risk from DEM Derivatives and Rainfall Data”
Objective:
Use the synthetic DEM to compute slope, curvature, and a topographic wetness proxy, combine
them with simulated rainfall intensity data, and produce a simple erosion risk index map using
map algebra. This is a classic GIS multi-criteria analysis (weighted overlay).
Scenario:
You are assessing soil erosion risk for a watershed. The Revised Universal Soil Loss Equation
(RUSLE) uses factors for rainfall erosivity (R), soil erodibility (K), slope length and steepness
(LS), cover management (C), and support practice (P). You will compute a simplified LS factor
from the DEM, simulate an R factor from rainfall data, assume constant K, C, and P, and
compute a relative erosion risk index.
Instructions:
1. Use the synthetic DEM from Section 23.1 (200×200, UTM, 10 km extent). Compute
slope in degrees and radians.
2. Compute the LS factor (slope length and steepness) using the formula (Moore & Burch,
1986):
LS=(flow accumulation×cellsize22.13)0.4×(sin(slope)0.0896)1.3LS=(22.13flow accumulation×c
ellsize)0.4×(0.0896sin(slope))1.3
o Compute flow accumulation: first fill sinks with terra::fillSinks(), then compute
flow direction with terra::terrain(v = "flowdir"), then flow accumulation
with terra::flowAccumulation()? Actually terra does not
have flowAccumulation directly; use whitebox or a simple approximation.
Alternatively, simulate a flow accumulation proxy: for each cell, use the log of the
contributing area approximated by the distance to the nearest ridge? To keep it
simple, we can use the tpi or just a random surface for teaching? The challenge
should be feasible. I'll use terra::flowAccumulation if it exists, but it doesn't.
Instead, we'll use a simplified approach: compute flow accumulation
with raster::flowAccumulation from the old raster package? To avoid dependency
issues, we can simulate a flow accumulation raster that looks realistic: increasing
with distance from ridges. Or we can use the whitebox package if installed? But
to keep it self-contained, I'll simulate flow accumulation as a function of the
DEM: flow_acc = terrain(dem, v = "catchment_area")?
Actually terra::terrain does not provide flow accumulation. The terra package is
focused on grid operations; hydrological routines are in raster or whitebox. I'll use
a workaround: compute a simple flow accumulation proxy by setting flowacc =
focal(dem, w=5, fun=function(x) sum(x > x[5]))? That's not hydrological. Better:
use the topmodel package? Too complex. For the hard practice, I'll simulate a
flow accumulation grid that is spatially autocorrelated and correlated with the
DEM, and then use that. That is acceptable for a teaching exercise on map
algebra. I'll generate a fake flowacc surface that is a smooth function of the DEM
plus noise.
3. Simulate rainfall data: Create a rainfall intensity raster (R factor) that varies spatially
(e.g., higher in the north of the study area). Use a smooth gradient.
4. Compute erosion risk index: E=R×LS×K×C×PE=R×LS×K×C×P.
Assume K=0.3K=0.3, C=0.5C=0.5, P=1.0P=1.0. Normalise the result to a 0–100 scale for
interpretability.
5. Classify erosion risk: Low (0–25), Moderate (25–50), High (50–75), Very High (>75).
Create a categorical map.
6. Map the results: Create a multi-panel map showing DEM, slope, LS factor, R factor,
erosion risk index, and classified risk. Use a consistent colour scheme.
7. Interpretation: In a comment block, identify which areas are at highest risk and explain
the topographic and climatic drivers. Suggest where erosion control measures should be
prioritised.
Deliverable:
A script practice_chapter23_erosion_risk.R with the complete workflow and maps.
Chapter 23 Review Problems
Solve each in a clearly commented R script using terra and optionally lidR. Use the synthetic
DEM or simulated data as specified.
Easy Problems (1–5)
1. Import and Visualise a DEM
Create a synthetic DEM (50×50, UTM, 1 km extent) with rast() and fill with random elevations
between 100 and 500 m plus a gentle slope. Plot the DEM with [Link](). Report its
dimensions, resolution, and CRS.
2. Compute Slope
Using the DEM from Problem 1, compute slope in degrees. Plot the slope map. What is the mean
slope? Where are the steepest areas located?
3. Compute Aspect and Northness
Compute aspect from the DEM. Transform to northness (cos(aspect * pi / 180)). Plot both.
Explain what northness represents and why it is useful.
4. Hillshade
Create a hillshade with azimuth 315° and altitude 45°. Plot the hillshade. Overlay the DEM with
partial transparency.
5. Viewshed from a Point
Choose an observer point near the centre of the DEM. Compute the viewshed with observer
height 1.7 m. Plot the viewshed. What percentage of the DEM is visible?
Medium Problems (6–10)
6. Terrain Ruggedness Index
Compute the TRI for the DEM. Plot TRI. Identify the three most rugged cells. Are they on steep
slopes or flat areas? Explain how TRI captures local roughness beyond slope.
7. Curvature
Compute profile curvature using the spatialEco package or manually from slope and aspect. Plot
the curvature. Where are concave areas (valleys) and convex areas (ridges)?
8. Multiple Observer Viewshed
Create three observer points (e.g., fire towers). Compute the combined viewshed. Map the
number of observers that can see each cell. Which areas are visible from all three towers?
9. Solar Radiation
Compute potential incoming solar radiation (insolation) for a given day and latitude using
the insol package or a simplified formula. Use terra::terrain() for slope and aspect; implement the
formula from Bonan (2015). Map total daily insolation (Wh/m²). Compare north-facing and
south-facing slopes.
10. Extract Elevation Profile
Draw a line across the DEM (e.g., from the southwest corner to the northeast corner). Extract the
elevation profile. Plot the profile as a line graph. Compute the maximum gradient along the
profile.
Challenging Problems (11–15)
11. Stream Network Extraction
Using the whitebox package (install if available) or a simplified D8 algorithm, extract the stream
network from the DEM. This requires: pit filling, flow direction, flow accumulation, and
thresholding. Plot the stream network over the DEM.
12. Topographic Wetness Index (TWI)
Compute TWI = ln(a / tan(β)), where a is the specific catchment area (flow accumulation per unit
contour length) and β is the slope in radians. Use the simulated flow accumulation from the Hard
Practice or implement a simplified version. Map TWI. Identify areas likely to be saturated.
13. LidR Canopy Height Model and Tree Detection
Using the example [Link] from lidR, create a CHM and detect tree tops. Map the
CHM with detected trees overlaid. Compute the mean tree height and the number of trees per
hectare.
14. Visibility and Wind Farm Planning
Simulate the placement of a wind turbine (80 m hub height, observer height 1.7 m) at five
candidate sites on the DEM. For each site, compute the viewshed. Create a map showing the
cumulative visual impact (number of turbines visible per cell). Which site has the least visual
impact on a nearby town (specify a town polygon)?
15. LiDAR-Derived DTM vs. SRTM
If you have access to a high-resolution LiDAR DTM and the corresponding SRTM tile for the
same area, compare them. Compute the difference raster (LiDAR – SRTM). Where are the
largest differences? Are they systematic (e.g., SRTM overestimates elevation in forested areas
due to canopy bias)? Write a short report as a comment block. (If data are unavailable, describe
the expected differences and the implications for flood modelling.)
Chapter 24: Integrating GIS, Remote Sensing, and Field Data – Complete Workflows
You have learned vectors, rasters, spatial statistics, remote sensing, and terrain analysis. Each
skill is powerful in isolation, but the true art of geospatial science lies in combining them into a
single, coherent analysis. A landslide susceptibility model, for example, uses a DEM and its
derivatives, a geological map (vector polygons), satellite-derived land cover, and field-verified
landslide inventories. A crop yield prediction fuses Sentinel-2 time series with field
measurements and soil maps. This chapter teaches you to design, build, and document such
integrated workflows. We begin with the principles of reproducible project organisation—
directory structure, relative paths, and the here package. We then build a multi-source data
fusion pipeline: reading, aligning, and combining vectors, rasters, and tabular field data into a
unified analysis dataset. Finally, we learn to transform an R script into an automated report
with R Markdown, producing a self-contained PDF or HTML document that includes your text,
code, figures, tables, and citations. The Hard Practice asks you to perform an end-to-end
landslide susceptibility mapping: from raw data to a validated susceptibility map and an
automated report. By the end, you will not only be able to do the analysis—you will be able to
share it, reproduce it, and defend it.
24.1 Designing a Reproducible Geospatial Project
A reproducible project is one that can be re-run by anyone, on any computer, at any time,
producing identical results. This is not an abstract ideal; it is a requirement of scientific integrity
and a practical necessity when you revisit a project six months later. The foundation of
reproducibility is organisation.
24.1.1 The Project Directory Structure
A well-organised project separates raw data, processed data, code, outputs, and documentation
into distinct folders. A standard structure for a geospatial project is:
text
my_project/
├── data/
│ ├── raw/ # Original, immutable data files
│ │ ├── [Link]
│ │ ├── [Link]
│ │ └── field_samples.csv
│ └── processed/ # Cleaned, transformed data (often .rds)
│ ├── study_area.gpkg
│ └── analysis_dataset.rds
├── scripts/ # All R scripts
│ ├── 01_import_clean.R
│ ├── 02_spatial_analysis.R
│ └── 03_modeling.R
├── output/ # Generated outputs
│ ├── figures/
│ │ └── susceptibility_map.pdf
│ └── tables/
│ └── model_summary.csv
├── report/ # R Markdown report
│ └── final_report.Rmd
├── [Link] # Project description
└── my_project.Rproj # RStudio project file
The RStudio project file (.Rproj) is the anchor. Double-clicking it opens RStudio with the
working directory set to the project root, and it restores the file history and settings. Always
create a project before starting an analysis.
24.1.2 Relative Paths and the here Package
Never use absolute paths (e.g., C:/Users/Alice/MyProject/data/[Link]). They break the moment
the project is moved to another computer or shared with a collaborator. Instead, use relative
paths from the project root.
The here package (Müller, 2020) constructs stable relative paths regardless of the current
working directory:
r
library(here)
dem <- rast(here("data", "raw", "[Link]"))
samples <- read_csv(here("data", "raw", "field_samples.csv"))
here() detects the project root by looking for an .Rproj file or a .git directory. It works on
Windows, macOS, and Linux, and it resolves the path separator automatically. Adopting here() is
the single most impactful step toward reproducibility.
24.1.3 Version Control with Git
Version control tracks changes to your code over time, allows you to revert to previous versions,
and facilitates collaboration. Git is the standard tool; GitHub, GitLab, and Bitbucket are hosting
platforms. A full Git tutorial is beyond our scope, but the minimum you should do is:
1. Initialise a Git repository in your project folder: git init.
2. Commit your scripts regularly with descriptive messages: git commit -m "Added data
import and cleaning script".
3. Push to a remote repository (e.g., GitHub) for backup and sharing.
RStudio has built-in Git integration (Git pane). Combined with here and an RStudio project, your
entire analysis—data, code, output—is versioned and portable.
24.1.4 Managing R Environments with renv
Package versions change. An analysis that works with sf 1.0 may break with sf 1.1 if a function
is deprecated. The renv package (Ushey, 2023) captures the exact package versions used in your
project and restores them on any machine:
r
# Install renv once
[Link]("renv")
# Initialise in the project
renv::init()
# After installing packages, snapshot them
renv::snapshot()
# On a new machine, restore the environment
renv::restore()
renv creates a private package library for each project, isolating it from other projects and from
system-wide R updates. For a publication or a thesis, renv guarantees that your code will run
years later.
24.1.5 The Script Order Convention
Number your scripts to indicate the order in which they should be run:
01_import_clean.R — reads raw data, cleans, saves processed data.
02_exploratory_analysis.R — produces descriptive plots and statistics.
03_modeling.R — fits models, validates, saves results.
04_mapping.R — creates final maps.
A master script, run_all.R, sources them in sequence:
r
source(here("scripts", "01_import_clean.R"))
source(here("scripts", "02_exploratory_analysis.R"))
source(here("scripts", "03_modeling.R"))
source(here("scripts", "04_mapping.R"))
This allows a single command to reproduce the entire analysis. Each script should be
self-contained: it loads the packages it needs, reads input files
from data/processed/ (or data/raw/), and writes output files to data/processed/ or output/. Avoid
scripts that depend on objects that were created interactively in the console.
24.2 Multi-Source Data Fusion: Vectors, Rasters, Tables, and Field Observations
Real-world analyses combine data from multiple sources. The technical challenge is to align
them spatially and temporally, and to merge them into a single analysis-ready data frame or
raster stack. This section presents the standard patterns for fusing vector, raster, and tabular data.
24.2.1 The Central Concept: A Common Spatial Framework
All data must be in the same coordinate reference system before they can be combined. The first
step in any fusion is:
r
target_crs <- "EPSG:32650" # choose an appropriate projected CRS for the study area
dem <- project(dem, target_crs)
geology <- st_transform(geology, target_crs)
samples_sf <- st_transform(samples_sf, target_crs)
For raster-vector fusion, also ensure that the rasters share the same extent and resolution (or that
you explicitly manage resampling). The terra functions resample(), crop(), and extend() align
rasters:
r
# Align all rasters to a common reference grid
ref_grid <- rast(extent = ext(study_area), resolution = 30, crs = target_crs)
dem_aligned <- resample(dem, ref_grid, method = "bilinear")
landcover_aligned <- resample(landcover, ref_grid, method = "near")
When combining raster and vector, the terra::rasterize() function converts vector layers to rasters
with the same grid:
r
geology_raster <- rasterize(vect(geology), ref_grid, field = "rock_type")
24.2.2 Fusing Point Samples with Rasters
A common task: you have a set of field sample points (vector) and multiple raster layers
(elevation, slope, NDVI, distance to road), and you want to build a data frame for statistical
modelling where each row is a sample point and each column is a predictor.
terra::extract() extracts raster values at point locations and returns a data frame:
r
samples_sf <- st_read(here("data", "raw", "[Link]"))
# Extract values from multiple rasters
raster_stack <- c(dem, slope, ndvi)
samples_extracted <- extract(raster_stack, vect(samples_sf), bind = TRUE)
# 'bind = TRUE' attaches the extracted values as new columns to the SpatVector
# Convert back to sf for dplyr manipulation
samples_sf <- st_as_sf(samples_extracted)
head(samples_sf)
# Now each point has columns: dem, slope, ndvi, plus original attributes
If the samples also have non-spatial attributes from a laboratory (e.g., soil organic carbon),
merge by sample ID:
r
lab_data <- read_csv(here("data", "raw", "lab_results.csv"))
samples_full <- samples_sf %>%
left_join(lab_data, by = "sample_id")
The result is a complete, analysis-ready sf data frame containing spatial coordinates,
environmental predictors extracted from rasters, and the response variable from the field.
24.2.3 Joining Vector Attributes to Raster Zones
If you have a raster of land-cover classes and a vector layer of administrative boundaries, you
might want to compute the area of each land-cover class per administrative unit.
The terra::zonal() function works with raster zones, but if zones are vectors, terra::extract() with
a summary function is used:
r
# For each polygon, compute the mean NDVI and the modal land cover
poly_stats <- extract(raster_stack, vect(polygons), fun = mean, [Link] = TRUE, bind = TRUE)
poly_stats_sf <- st_as_sf(poly_stats)
For more complex summaries (e.g., histogram of land-cover classes per polygon), you may need
to extract all pixel values and then use dplyr::group_by() and dplyr::count().
24.2.4 Temporal Fusion: Matching Field Dates to Satellite Overpasses
Field measurements have a date. Satellite indices have a date. To match each field observation to
the nearest satellite acquisition, compute the absolute time difference and select the minimum.
This is a non-spatial join combined with temporal logic, as seen in Chapter 6.
r
# field_data has columns: sample_id, date, ph
# ndvi_ts is a SpatRaster with time attribute
# Extract NDVI for each field sample at each satellite date
field_ndvi <- extract(ndvi_ts, vect(field_data), bind = TRUE, ID = FALSE)
# The result has one column per satellite layer; pivot to long format
field_ndvi_long <- field_ndvi %>%
pivot_longer(starts_with("NDVI_"), names_to = "date_str", values_to = "ndvi") %>%
mutate(sat_date = [Link](str_extract(date_str, "\\d{4}-\\d{2}-\\d{2}")))
# Compute difference and filter nearest
matched <- field_ndvi_long %>%
mutate(diff_days = abs([Link](difftime(sat_date, date, units = "days")))) %>%
group_by(sample_id) %>%
slice_min(diff_days, n = 1) %>%
ungroup()
This is a standard pattern in digital soil mapping and crop modelling.
24.2.5 Technical Demonstration: Building a Multi-Source Analysis Dataset
r
library(sf)
library(terra)
library(dplyr)
library(here)
# 1. Define paths
dem_path <- here("data", "raw", "[Link]")
geol_path <- here("data", "raw", "[Link]")
sample_path <- here("data", "raw", "[Link]")
# 2. Read data
dem <- rast(dem_path)
geology <- st_read(geol_path)
samples <- read_csv(sample_path)
# 3. Harmonise CRS
target_crs <- "EPSG:32650"
dem <- project(dem, target_crs)
geology <- st_transform(geology, target_crs)
# 4. Create derived rasters
slope <- terrain(dem, v = "slope", unit = "degrees")
tri <- terrain(dem, v = "TRI")
# 5. Convert samples to sf
samples_sf <- st_as_sf(samples, coords = c("lon", "lat"), crs = 4326)
samples_sf <- st_transform(samples_sf, target_crs)
# 6. Extract rasters at sample points
raster_stack <- c(dem, slope, tri)
names(raster_stack) <- c("elevation", "slope", "tri")
samples_extracted <- extract(raster_stack, vect(samples_sf), bind = TRUE)
samples_analysis <- st_as_sf(samples_extracted)
# 7. Join with geology (spatial join)
samples_analysis <- st_join(samples_analysis, geology["rock_type"], join = st_nearest_feature)
# 8. Save processed dataset
saveRDS(samples_analysis, here("data", "processed", "analysis_dataset.rds"))
24.3 Building an Automated Reporting Pipeline with R Markdown
An R script produces results. R Markdown produces a document. It combines Markdown (a
lightweight text formatting language) with executable R code chunks. When you render (knit)
an .Rmd file, R executes the code and inserts the results—figures, tables, values—directly into
the text. The output can be HTML, PDF, Word, or many other formats.
R Markdown is the standard tool for computational science reporting because it guarantees that
every number and every figure in your report is generated directly from the data, with no manual
cutting and pasting. If the data change, you re-render the report, and everything updates
automatically.
24.3.1 The Structure of an R Markdown Document
A minimal .Rmd file looks like this:
markdown
---
title: "Landslide Susceptibility Assessment"
author: "Your Name"
date: "2024-07-15"
output: html_document
---
## Introduction
This report presents the landslide susceptibility map for the study area.
The analysis was conducted in R using the `terra` and `sf` packages.
## Methods
The susceptibility index was computed using the following predictors:
- Slope (derived from DEM)
- Geology
- Distance to roads
- Land cover
```{r load_packages}
library(terra)
library(sf)
library(dplyr)
{r
dem <- rast("data/raw/[Link]")
# ...
Results
The mean slope in landslide areas was r mean_slope degrees.
Figure 1 shows the susceptibility map.
{r
plot(susceptibility, main = "Susceptibility")
text
The YAML header between `---` delimiters specifies metadata and output format. Text is written
in Markdown. R code chunks are enclosed in ` ```{r} ` and ` ``` `. Inline R code is enclosed in ``
`r ` ``.
When you click **Knit** in RStudio, the entire document is executed in a fresh R session,
figures are generated and embedded, and the output is rendered to HTML (or the specified
format). This fresh session is the ultimate test of reproducibility: if your report knits without
error on a clean machine, your analysis is truly reproducible.
#### 24.3.2 Code Chunk Options
Code chunks accept options that control their behaviour:
- `echo = FALSE`: hide the code, show only the output.
- `eval = FALSE`: show the code but don’t run it.
- `include = FALSE`: run the code but don’t show anything.
- `message = FALSE`, `warning = FALSE`: suppress messages and warnings.
- `[Link]`, `[Link]`: control figure size.
- `[Link]`: figure caption.
- `cache = TRUE`: cache the chunk results; re-run only if the chunk changes. Useful for long
computations.
```markdown
```{r load_data, echo = FALSE, message = FALSE}
# This code runs silently
text
#### 24.3.3 Tables and Figures
For tables, the `knitr::kable()` function produces markdown tables:
```r
knitr::kable(accuracy_table, caption = "Accuracy Assessment", digits = 3)
For ggplot2 figures, the plot is automatically embedded. You can set the figure size in the chunk
header:
markdown
```{r [Link] = 8, [Link] = 5}
ggplot(data) + geom_sf(aes(fill = risk_class))
text
For `terra` plots, use `plot()` and it will be captured automatically.
#### 24.3.4 Inline R Code
Inline R code inserts computed values directly into sentences:
```markdown
The overall accuracy was `r round(oa * 100, 1)`%.
When the report is knitted, r oa is replaced by the value of the R variable oa. If oa changes
because of updated data, the sentence updates automatically. This eliminates the most common
source of scientific reporting errors: manually transcribing numbers from the analysis to the text.
24.3.5 Citations and Bibliographies
R Markdown supports automatic citation management through BibTeX (.bib files). Add
a bibliography field to the YAML header and use [@citation_key] in the text:
yaml
---
title: "Landslide Assessment"
output: html_document
bibliography: [Link]
---
markdown
The susceptibility model was implemented using Random Forest [@breiman2001].
R Markdown will automatically format the references and insert a bibliography section. This,
combined with code-driven results, makes R Markdown the complete tool for writing a
data-driven scientific paper.
24.3.6 Parameterised Reports
A parameterised report allows you to re-run the same analysis for different inputs—e.g., a
different study area or a different year—without editing the code. Parameters are declared in the
YAML header:
yaml
params:
study_area: "watershed_A"
year: 2024
Inside the document, the parameter values are accessed as params$study_area and params$year.
When knitting, you can pass values via the Knit button or programmatically:
r
rmarkdown::render("[Link]", params = list(study_area = "watershed_B", year = 2023))
This is invaluable for producing consistent reports for multiple regions or years.
Hard Practice 24 – “End-to-End Landslide Susceptibility Mapping”
Objective:
Perform a complete geospatial analysis workflow from raw data to a validated susceptibility map
and an automated R Markdown report. Integrate a DEM and its derivatives, a geological map,
land-cover data, and a landslide inventory.
Scenario:
You are a natural hazard specialist tasked with producing a landslide susceptibility map for a
mountainous region. You have:
A DEM (30 m resolution, simulated).
A geology shapefile (three rock types).
A land-cover raster (simulated from Sentinel-2).
A landslide inventory point dataset (presence/absence of landslides).
You will compute terrain derivatives, extract predictors, fit a logistic regression model,
predict susceptibility, validate with an independent test set, and write a reproducible R
Markdown report.
Instructions:
1. Set up the project: Create an RStudio project with the directory structure described in
Section 24.1. Use here() for all paths.
2. Simulate the data:
o DEM: 100×100, UTM 50N, 30 m resolution, realistic terrain (use sin/cos plus
noise).
o Geology: three polygons representing rock types (Granite, Limestone, Schist).
o Land cover: three classes (Forest, Grass, Bare), simulated as a raster.
o Landslide inventory: 200 presence points (located preferentially on steep slopes
and certain geologies) and 200 absence points (randomly distributed). Include
a date column for splitting.
3. Preprocessing script (01_preprocess.R):
o Read all raw data.
o Compute slope, TRI, and TPI from the DEM.
o Rasterize geology and land cover.
o Extract all predictors at landslide points.
o Split into training (70%) and test (30%) sets.
o Save processed data to data/processed/.
4. Modelling script (02_model.R):
o Fit a logistic regression model (glm(landslide ~ slope + TRI + TPI + rock_type +
land_cover, family = binomial)).
o Predict susceptibility on a 100×100 grid covering the study area.
o Validate on the test set: compute predicted probabilities, create a confusion
matrix, calculate overall accuracy, and compute the ROC curve and AUC.
o Save the model and the susceptibility raster.
5. Mapping script (03_map.R):
o Create a publication-quality susceptibility map with ggplot2 or tmap.
o Overlay the training and test landslide points with different symbols.
o Add a hillshade background for visual context.
o Save the map to output/figures/.
6. R Markdown report (report/landslide_report.Rmd):
o Title, author, date, output = html_document.
o Sections: Introduction, Data, Methods, Results, Discussion, Conclusion.
o Embed the susceptibility map, the ROC curve, the confusion matrix table.
o Use inline R code to report key numbers (AUC, overall accuracy, number of
landslides, area in high susceptibility class).
o Include at least one citation.
7. Reflection: In the report Discussion, explain which predictors were most important
(based on coefficient significance), what the AUC tells you about model performance,
and the limitations of the analysis (e.g., temporal mismatch, unaccounted covariates).
Deliverable:
A complete project folder with scripts/, data/, output/, report/, and the rendered HTML report.
The report should knit from a clean R session.
Chapter 24 Review Problems
Solve each in a clearly commented R script or R Markdown document as specified.
Easy Problems (1–5)
1. Create a Project Structure
Create a directory structure for a hypothetical geospatial project called “Wetland_Monitoring”
with folders: data/raw, data/processed, scripts, output/figures, and report. Write an R script that
uses here() to construct the path to a file data/raw/water_quality.csv and prints the full path.
2. Read and Harmonise CRS
Create two simple sf objects with different CRS (one WGS84, one UTM). Write a script that
reads them, harmonises to a common projected CRS, and computes the distance between their
centroids.
3. Extract Raster Values at Points
Create a raster of random values (50×50) and 10 random points. Extract the raster values at the
points. Print the resulting data frame.
4. Write an R Markdown Document
Create a minimal .Rmd file with a YAML header (output: html_document), a section titled
“Results”, and a code chunk that loads ggplot2 and creates a scatterplot
of mtcars$wt vs mtcars$mpg. Knit it to HTML.
5. Inline R Code
In the R Markdown document from Problem 4, add a sentence: “The mean miles per gallon in
the mtcars dataset is r mean(mtcars$mpg).” Knit and verify that the number appears correctly.
Medium Problems (6–10)
6. Build a Multi-Source Analysis Dataset
Simulate: a DEM raster, a land-cover raster, a roads line layer, and a set of 50 field sample
points. Extract elevation, slope, land cover, and distance to nearest road at each sample point.
Save the resulting analysis dataset as an RDS file.
7. Temporal Matching of Field Data to Satellite Index
Simulate a monthly NDVI stack (12 layers) and a set of 10 field points each with a date. For each
field point, extract the NDVI value from the layer whose date is closest to the field date. Report
the mean absolute time difference.
8. Parameterised Report
Create an R Markdown report that accepts a parameter region (e.g., “North” or “South”). In the
report, load a dataset and filter it to the specified region, then display a summary table and a plot.
Knit the report for both regions.
9. Reproducibility Check
Write a master script run_all.R that sources three sub-scripts in order. Test it by clearing your
workspace (rm(list = ls())) and running the master script. Does it produce the expected outputs
without errors?
10. Create a Project README
Write a [Link] for a geospatial project. Include: project title, author, date, description of
the data, instructions for running the analysis (what to install, what scripts to run in order), and a
note on the expected output.
Challenging Problems (11–15)
11. Full Landslide Susceptibility Workflow
Follow the Hard Practice instructions and produce the complete project: project structure,
preprocessing script, modelling script, mapping script, and R Markdown report. Ensure that the
report knits from a clean environment.
12. Multi-Year Crop Yield Analysis
Simulate three years of Sentinel-2 NDVI time series for 50 field plots, along with yield data for
each plot and year. Build an analysis pipeline that extracts seasonal NDVI statistics (max, mean,
amplitude) for each plot and year, merges with yield data, fits a linear model yield ~ max_ndvi +
amplitude, and writes the results to an automated report comparing the three years.
13. Ensemble Model and Report
Extend the landslide susceptibility model: fit Random Forest and SVM in addition to logistic
regression. Compare their AUC on the test set. In the R Markdown report, include a table
comparing the three models and a figure showing all three ROC curves on the same plot.
14. Continuous Integration for Reproducibility
Set up a GitHub Actions workflow (or describe the steps) that automatically runs
your run_all.R script and knits your R Markdown report on a cloud server whenever you push
changes to the repository. Test that the workflow completes without error. (This requires a
GitHub account and basic familiarity with CI, but describing the setup in detail is a valid
answer.)
15. Scientific Paper Template
Create an R Markdown template for a scientific paper (output: PDF
via pdf_document or bookdown::pdf_book) that includes: title page, abstract, introduction,
methods, results, discussion, conclusions, and references. Populate it with the landslide
susceptibility analysis from Problem 11. Format figures and tables for journal submission (e.g.,
8×5 cm figures). Include cross-references to figures and tables. (Use the bookdown package for
cross-referencing.) This is the capstone of reproducible geospatial reporting.
Chapter 25: Parallel Processing and Large Raster Handling
The raster datasets of modern geospatial science can be immense: a single Sentinel-2 tile
contains over 120 million pixels; a global 30-m land-cover map contains hundreds of billions of
cells. Processing such data on a single processor core, one cell at a time, is prohibitively slow.
This chapter equips you with the techniques to handle large rasters efficiently in R—first by
using terra's built-in chunked processing to keep memory usage low, and then by harnessing
multiple processor cores through parallel computing with the future and furrr packages.
Finally, we connect R to Google Earth Engine via the rgee package, enabling planetary-scale
analysis on Google's cloud infrastructure without downloading a single image to your local
machine. Every concept is grounded in practical geospatial tasks: computing a continent-scale
forest mask, applying a custom spectral index to a time series, and performing a pixel-wise trend
analysis across decades of satellite data. By the end, you will be able to scale your R geospatial
workflows from a single study area to the entire planet.
25.1 Memory-Efficient Raster Processing with terra::app() and Blocks
R holds data in RAM. When a raster is larger than available RAM, terra does not load it all at
once. Instead, it processes the raster in chunks (blocks of rows), reading a chunk, computing,
writing the result to a temporary file, and moving to the next chunk. Understanding and
controlling this chunked processing is the key to handling large rasters without running out of
memory.
25.1.1 How terra Processes Large Rasters
When you call a terra function on a file-based SpatRaster, the package:
1. Inspects the file's block structure (how the GeoTIFF or other format is organised into
internal tiles or strips).
2. Estimates whether the result can fit in RAM.
3. If the result is too large, terra writes the output to a temporary .tif file and processes block
by block.
4. Returns a SpatRaster that points to the temporary file.
You can force chunked processing with the terraOptions() function, which allows you to set the
maximum memory that terra is allowed to use:
r
terraOptions(memfrac = 0.3) # use at most 30% of available RAM
terraOptions(memmax = 4) # use at most 4 GB
The memfrac option is a fraction of total system RAM; memmax is an absolute value in GB.
Setting these appropriately prevents terra from filling your RAM and crashing your session. For
a machine with 16 GB RAM, memmax = 8 is a safe limit.
You can also control the number of CPU cores used by terra (it uses RcppParallel internally for
some operations like project, resample, and focal):
r
terraOptions(todisk = TRUE) # force temporary files even for small results
25.1.2 Using terra::app() for Custom Chunked Functions
app() applies a custom function to a SpatRaster, either layer-wise (if fun returns a scalar) or
cell-wise across layers (if fun returns a vector per cell). It automatically processes in chunks, so
you can write a function that would not fit in memory if applied to the entire raster at once.
r
library(terra)
# A large raster (simulated on disk)
r <- rast(nrows = 10000, ncols = 10000, nlyrs = 3)
values(r) <- runif(3e8) # 300 million values, ~2.4 GB
writeRaster(r, "large_raster.tif", overwrite = TRUE)
# Apply a custom function: for each cell, compute the median of the 3 bands
# This function receives a vector of 3 values and returns a scalar
r_large <- rast("large_raster.tif")
median_band <- app(r_large, fun = median, filename = "median_band.tif", overwrite = TRUE)
The key arguments:
fun: the function to apply. It receives a vector (for single-layer) or a row of a matrix (for
multi-layer).
filename: if provided, the output is written directly to disk, avoiding RAM accumulation.
overwrite = TRUE: allows overwriting an existing file.
wopt: a list of additional write options (e.g., compression, datatype).
For multi-layer rasters, app() passes the cell values as a vector, one cell at a time. This is
equivalent to looping over every cell, but terra does it in compiled C++ code within each chunk,
making it much faster than an R loop.
Example: Computing a pixel-wise linear trend across a 100-layer time series
r
# Simulate a 100-layer time series
n_layers <- 100
ts_rast <- rast(nrows = 5000, ncols = 5000, nlyrs = n_layers)
# (In reality, you would load actual files and stack them)
# Function to compute slope of linear regression
trend_fun <- function(y) {
if (all([Link](y))) return(NA)
x <- 1:length(y)
fit <- lm(y ~ x)
coef(fit)[2]
}
# Apply, writing to disk
slope_rast <- app(ts_rast, fun = trend_fun,
filename = "trend_slope.tif", overwrite = TRUE)
Memory note: app() is efficient for functions that can be vectorised over cells. If your custom
function is slow (e.g., a complex model fitted cell-by-cell), the processing time may be
dominated by the R function call overhead. In such cases, consider parallel processing (Section
25.2) or rewriting the core computation in C++ with Rcpp.
25.1.3 Block-wise Processing with terra::writeStart() and writeValues()
For maximum control over chunked processing—for example, when you need to write a raster
that is the result of a complex, multi-step computation that cannot be expressed as a single
function—terra provides a low-level interface:
writeStart(x, filename, ...): opens a new raster for writing.
writeValues(x, values, row): writes a block of values to the specified rows.
writeStop(x): finalises and closes the file.
r
# Create a template raster
template <- rast(nrows = 1000, ncols = 1000)
out <- writeStart(template, filename = "block_output.tif", overwrite = TRUE)
# Process in blocks of 100 rows
for (start_row in seq(1, nrow(template), by = 100)) {
end_row <- min(start_row + 99, nrow(template))
n_rows <- end_row - start_row + 1
# Read a block (from a source raster, for example)
# block <- some_function(start_row, n_rows)
# For demonstration, write a constant block
block <- matrix(runif(n_rows * ncol(template)), nrow = n_rows, ncol = ncol(template))
writeValues(out, block, start_row)
}
writeStop(out)
This pattern is useful when your computation depends on spatial context beyond the local
window (e.g., a hydrological model that routes flow across the entire catchment) and you need to
orchestrate the chunking yourself.
25.1.4 Compression and Data Types
Large rasters consume large disk space. terra supports GeoTIFF compression and data type
specification:
Compression: "LZW" (lossless), "DEFLATE" (lossless, often smaller but
slower), "ZSTD" (fast, modern).
Data type: "INT2S" (16-bit signed integer, range –32,768 to 32,767), "INT4S" (32-bit
integer), "FLT4S" (32-bit float), "FLT8S" (64-bit double, default). Using a smaller data
type (e.g., "INT2S" for elevation stored in whole metres) dramatically reduces file size.
r
writeRaster(r, "[Link]",
datatype = "INT2S",
gdal = c("COMPRESS=DEFLATE", "PREDICTOR=2"),
overwrite = TRUE)
For a DEM with values 0–5000 m, "INT2S" is perfectly adequate and uses half the disk space
of "FLT4S". Always choose the smallest data type that fits your data range and precision.
25.2 Parallel Computing with {future} and {furrr}
Chunked processing uses one processor core. Modern computers have many cores (often 8–64).
Parallel computing uses multiple cores simultaneously to reduce execution time. In R,
the future package (Bengtsson, 2023) provides a unified interface to parallel backends, and
the furrr package (Vaughan & Dancho, 2022) extends the functional programming tools
from purrr to run in parallel.
25.2.1 The Philosophy of future
future separates the definition of a computation from its execution. You write an expression that
will be evaluated in the future, and you choose a plan (sequential, multisession, multicore,
cluster) that determines where and how it runs. The result is collected when you request it.
r
library(future)
# Define a future
f <- future({
# Some long computation
[Link](2)
mean(rnorm(1e7))
})
# The main R session is free to do other work
# ...
# Collect the result (blocks until done)
value <- value(f)
value
The plan() function sets the execution strategy:
plan(sequential): default, runs in the current R session.
plan(multisession, workers = 4): runs in 4 background R sessions (works on all OS).
plan(multicore, workers = 4): runs in 4 forked R processes (Unix/macOS only, faster and
shares memory).
plan(cluster, workers = ...): runs on a remote cluster.
r
plan(multisession, workers = 4)
For geospatial work, multisession is safe on all platforms; multicore is faster on macOS/Linux
but can cause issues with some packages.
25.2.2 furrr: Parallel map()
furrr::future_map() is a parallel replacement for purrr::map(). It applies a function to each
element of a list or vector, distributing the calls across the workers specified by the future plan.
r
library(furrr)
plan(multisession, workers = 8)
# List of file paths to 50 raster tiles
tile_paths <- [Link]("tiles/", pattern = ".tif$", [Link] = TRUE)
# Function to process one tile
process_tile <- function(path) {
r <- rast(path)
# Some computation: compute mean NDVI
mean_ndvi <- global(r, fun = "mean", [Link] = TRUE)$mean
return([Link](tile = basename(path), mean_ndvi = mean_ndvi))
}
# Process all tiles in parallel
results <- future_map(tile_paths, process_tile, .progress = TRUE)
all_results <- bind_rows(results)
The .progress = TRUE argument shows a progress bar. future_map returns a list, which can be
combined with bind_rows() or [Link](rbind, ...).
Important: When parallel processing raster data, each worker must load the necessary packages.
Use future_map(..., .options = furrr_options(packages = c("terra", "sf"))) to ensure packages are
available in each worker session.
25.2.3 Parallelising Pixel-wise Operations on Large Rasters
terra::app() is already internally parallelised for some operations, but custom R functions passed
to app() run sequentially. To parallelise a custom function over a raster, you can:
1. Split the raster spatially into overlapping or non-overlapping tiles.
2. Process each tile in parallel using future_map.
3. Merge the results with terra::merge().
The tiler or sperrorest packages can help with spatial tiling, or you can create tiles manually:
r
# Split a large raster into 16 tiles
r_large <- rast("large_raster.tif")
tiles <- makeTiles(r_large, c(4, 4), filename = "tiles/tile_.tif")
# Define processing function
process_tile <- function(tile_path, out_path) {
r <- rast(tile_path)
result <- app(r, fun = your_complex_function)
writeRaster(result, out_path, overwrite = TRUE)
return(out_path)
}
# Apply in parallel
tile_paths <- [Link]("tiles/", pattern = "tile_.*.tif$", [Link] = TRUE)
out_paths <- paste0("processed/", basename(tile_paths))
future_map2(tile_paths, out_paths, process_tile,
.options = furrr_options(packages = "terra"))
# Merge processed tiles
processed_tiles <- rast(out_paths)
final_raster <- merge(processed_tiles)
terra::makeTiles() creates non-overlapping tiles. If your function requires a neighbourhood (e.g.,
focal operations), you must add overlap to the tiles and trim after processing, or
use terra::focal() which handles boundaries internally.
25.2.4 Parallelising Spatial Joins and Large Vector Operations
sf functions are not natively parallelised, but you can parallelise by splitting the data. For
example, to perform a spatial join of 1 million points to 10,000 polygons:
r
# Split points into chunks
n_chunks <- 8
points_split <- split(points_sf, rep(1:n_chunks, [Link] = nrow(points_sf)))
# Parallel spatial join
plan(multisession, workers = 8)
joined_chunks <- future_map(points_split, function(chunk) {
st_join(chunk, polygons, join = st_within)
}, .options = furrr_options(packages = "sf"))
# Recombine
all_joined <- bind_rows(joined_chunks)
This pattern works for any embarrassingly parallel operation where the computation on one
chunk does not depend on the others.
25.3 Using {rgee} to Connect R with Google Earth Engine
Google Earth Engine (GEE) is a cloud-computing platform that hosts petabytes of satellite
imagery and geospatial datasets, and provides a JavaScript/Python API for processing them at
planetary scale. The rgee package (Aybar et al., 2020) brings the Earth Engine API to R, allowing
you to use R syntax to query, process, and export GEE data. All computation happens on
Google's servers; only the final results are downloaded to your R session.
25.3.1 Setting Up rgee
rgee requires a Google account, a GEE account (sign up at [Link]), and the
Earth Engine Python API installed. The setup is one-time:
r
# Install rgee
[Link]("rgee")
# Install the Python Earth Engine API (requires a conda or virtual environment)
library(rgee)
ee_install() # guided installation
# Initialise the connection
ee_Initialize(user = "your_email@[Link]", drive = TRUE)
After initialisation, you have access to the full Earth Engine data catalogue and computational
engine from within R.
25.3.2 The rgee Workflow
The workflow mirrors the Earth Engine JavaScript/Python API, but with R syntax:
1. Define a region of interest (ROI) as an [Link] or by uploading an sf object.
2. Access an image collection from the Earth Engine catalogue
(e.g., ee$ImageCollection("COPERNICUS/S2_SR")).
3. Filter the collection by date, bounds, and cloud cover.
4. Apply functions to the collection: cloud masking, index computation, temporal
reduction.
5. Export the result to Google Drive, Google Cloud Storage, or directly to your R session
as a SpatRaster or stars object.
r
library(rgee)
library(sf)
# 1. Define a region of interest (a polygon in WGS84)
roi <- st_as_sfc(st_bbox(c(xmin = -74.5, xmax = -73.5, ymin = 40.5, ymax = 41.5), crs = 4326))
roi_ee <- sf_as_ee(roi)
# 2. Access Sentinel-2 surface reflectance collection
s2 <- ee$ImageCollection("COPERNICUS/S2_SR")
# 3. Filter by date and ROI, and pre-filter cloud cover
s2_filtered <- s2$
filterDate("2023-06-01", "2023-08-31")$
filterBounds(roi_ee)$
filter(ee$Filter$lt("CLOUDY_PIXEL_PERCENTAGE", 10))
# 4. Create a cloud-free composite (median)
composite <- s2_filtered$median()
# 5. Clip to ROI
composite_clipped <- composite$clip(roi_ee)
# 6. Compute NDVI
ndvi <- composite_clipped$normalizedDifference(c("B8", "B4"))$rename("NDVI")
# 7. Export the NDVI as a raster to R
ndvi_rast <- ee_as_rast(ndvi, region = roi_ee, scale = 10)
plot(ndvi_rast, main = "Sentinel-2 NDVI Median Composite (JJA 2023)")
ee_as_rast() downloads the result as a SpatRaster (via a temporary GeoTIFF). For very large
areas, you can export to Google Drive with ee_image_to_drive() and download later, which is
asynchronous and more robust for large datasets.
25.3.3 Map-Reduce over Image Collections
The real power of Earth Engine is its ability to map a function over an entire image collection
(e.g., compute NDVI for every Sentinel-2 scene in a year), and then reduce the collection (e.g.,
compute the maximum NDVI for the year). In rgee:
r
# Define a function to compute NDVI for a single image
add_ndvi <- function(image) {
ndvi <- image$normalizedDifference(c("B8", "B4"))$rename("NDVI")
image$addBands(ndvi)
}
# Map the function over the collection
s2_with_ndvi <- s2_filtered$map(add_ndvi)
# Reduce: compute the maximum NDVI across all images (greenest pixel)
max_ndvi <- s2_with_ndvi$select("NDVI")$max()
# Export
max_ndvi_rast <- ee_as_rast(max_ndvi, region = roi_ee, scale = 10)
plot(max_ndvi_rast, main = "Maximum NDVI (JJA 2023)")
This pattern—filter, map, reduce, export—is the core of all Earth Engine analyses.
25.3.4 Common GEE Datasets for Geospatial Science
Earth Engine's catalogue includes thousands of datasets. Key ones for geospatial scientists:
Landsat: LANDSAT/LC08/C02/T1_L2 (Landsat 8, Collection 2, Level 2 surface
reflectance).
Sentinel-2: COPERNICUS/S2_SR (Surface Reflectance).
MODIS: MODIS/061/MOD13Q1 (NDVI/EVI, 250 m, 16-day).
DEM: USGS/SRTMGL1_003 (SRTM 30 m), MERIT/DEM/v1_0_3 (MERIT DEM).
Land cover: ESA/WorldCover/v200 (10 m global land
cover), MODIS/061/MCD12Q1 (500 m land cover).
Climate: IDAHO_EPSCOR/TERRACLIMATE (monthly climate, 4
km), ECMWF/ERA5/DAILY.
Population: WorldPop/GP/100m/pop (100 m population density).
Nighttime lights: NOAA/VIIRS/DNB/MONTHLY_V1/VCMSLCFG.
25.3.5 When to Use rgee vs. Local Processing
Criterion Local (terra) Cloud (rgee)
Data volume <10 GB per analysis Unlimited
Criterion Local (terra) Cloud (rgee)
Processing speed Limited by CPU cores Virtually unlimited (scales automatically)
Custom Limited to GEE's JavaScript API functions (or Py
Any R function
algorithms with rgee extension)
Internet required No Yes
Cost Free (your hardware) Free for non-commercial use
Full (versioned
Reproducibility Partial (GEE data catalogue is versioned, but API may chang
packages)
For a regional study (a single watershed, a single country), terra on a local machine is sufficient.
For a global or multi-decadal analysis, rgee is the only practical choice.
25.3.6 Technical Demonstration: Continent-Scale Forest Mask
r
library(rgee)
library(sf)
# Initialise Earth Engine
ee_Initialize(drive = TRUE)
# Define a region of interest: the Amazon basin (bounding box)
amazon_bbox <- st_bbox(c(xmin = -80, xmax = -45, ymin = -20, ymax = 5), crs = 4326)
amazon_ee <- sf_as_ee(st_as_sfc(amazon_bbox))
# Load the ESA WorldCover land cover (10 m, 2021)
worldcover <- ee$ImageCollection("ESA/WorldCover/v200")$first()
# Select the tree cover band (class 10 = Tree cover)
tree_cover <- worldcover$select("Map")$eq(10)
# Compute forest mask and export to R (at 1000 m resolution for demo)
forest_mask <- ee_as_rast(tree_cover, region = amazon_ee, scale = 1000, via = "drive")
plot(forest_mask, main = "Amazon Forest Mask (ESA WorldCover 2021)")
Hard Practice 25 – “Continent-Scale Forest Mask Computation in Chunks”
Objective:
Simulate a continent-scale land-cover dataset (or use a real global dataset) and process it in
parallel chunks. Compute a forest mask, apply a focal majority filter to reduce noise, and count
the forest area per 1° grid cell. Use terra chunking and future parallelisation.
Scenario:
You are assessing forest cover for an entire continent (simulated). The land-cover raster is 30,000
× 30,000 pixels (~900 million cells, 3.6 GB). You will:
1. Create or read the large raster (simulate a smaller one, but write the script as if it were
large).
2. Compute a binary forest mask.
3. Apply a 5×5 majority filter in parallel across tiles.
4. Compute forest area per 1° grid cell using terra::zonal() on a zone raster.
5. Export the per-cell forest area as a CSV.
Instructions:
1. Simulate the large raster (to avoid actually creating a 30k×30k file, create a 1000×1000
raster but write the script to scale):
r
[Link](2024)
landcover <- rast(nrows = 1000, ncols = 1000,
xmin = -80, xmax = -45, ymin = -20, ymax = 5,
crs = "EPSG:4326")
# Simulate land cover: 1=Forest, 2=Non-forest, 3=Water, etc.
values(landcover) <- sample(1:5, ncell(landcover), replace = TRUE, prob = c(0.4, 0.3, 0.15, 0.1,
0.05))
writeRaster(landcover, "data/large_landcover.tif", overwrite = TRUE)
2. Create a binary forest mask using terra::app() or direct algebra: forest <- landcover ==
1. Write to disk with filename = "data/forest_mask.tif".
3. Create a tiling plan: Use terra::makeTiles() to split the forest mask into, say, 4×4 tiles.
Write them to a temporary directory.
4. Define a processing function that reads a tile, applies a 3×3 majority filter (focal(..., fun
= "modal", w = 3)), and writes the filtered tile back.
5. Process tiles in parallel: Use future_map() with 4 workers. Combine the filtered tiles
with merge() or vrt(). (For the simulated size, parallelisation may not be faster due to
overhead, but the script structure should be correct for large data.)
6. Create a 1° grid of zones covering the extent of the forest mask. Use terra::rast(nrows
= ..., ncols = ...) aligned to whole degrees.
(Hint: floor(ext(forest)) and ceiling(ext(forest)) for extent, then res = 1.)
7. Compute zonal statistics: Use terra::zonal(forest, zone_grid, fun = "sum") to get the
number of forest pixels per 1° cell. Since the raster is in degrees, you must be aware that
cell area varies with latitude. For this practice, approximate area as pixel_count *
(1/1000)^2 * 111320^2 * cos(lat_rad). Compute the area per cell and export as CSV.
8. Visualise: Create a choropleth map of forest area per 1° cell.
Reflection: In comments, discuss: (a) Why is a majority filter applied after classification? (b)
What are the advantages of tiling for parallel processing? (c) How would you scale this
workflow to a truly global dataset using rgee instead of local processing?
Deliverable:
A script practice_chapter25_continent_forest.R with the full workflow and a short discussion.
Chapter 25 Review Problems
Solve each in a clearly commented R script. For parallel problems, use future and furrr where
specified.
Easy Problems (1–5)
1. Check terra Memory Settings
Use terraOptions() to print the current memory fraction and maximum memory. Set memmax =
2 and confirm the change. Then reset to default.
2. Apply a Function with app()
Create a 3-layer raster (50×50). Use app() to compute the row-wise median of the three layers for
each cell. Write the result to a temporary file.
3. Simple future
Use future to compute mean(rnorm(1e7)) asynchronously. While it runs, print "Computing...".
Then retrieve and print the result.
4. future_map on a List
Create a list of 10 numeric vectors of length 1000. Use furrr::future_map() with 2 workers to
compute the mean of each vector. Combine the results into a single vector.
5. Basic rgee Connection
(If you have a GEE account.) Initialise rgee with ee_Initialize(). Print the list of available bands
in the USGS/SRTMGL1_003 dataset. If you do not have an account, write the commands that
would do this, with comments.
Medium Problems (6–10)
6. Chunked Processing with writeStart
Create a 200×200 raster. Use writeStart(), a loop over blocks of 50 rows, and writeValues() to
create a new raster where each cell is the square of the original cell value. Compare the result to
a direct r^2 computation.
7. Parallel Tile Processing
Create a 200×200 raster. Use makeTiles() to split it into 4 tiles. Process each tile by applying a
3×3 mean filter (focal), using future_map with 2 workers. Merge the filtered tiles and compare to
a direct focal() on the whole raster.
8. Memory-Efficient NDVI Computation
Simulate a 1000×1000 2-band raster (Red and NIR) stored on disk. Use app() to compute NDVI
cell-by-cell, writing the output directly to a new file. Monitor memory usage (use gc() before and
after) to confirm it stays low.
9. Parallel Spatial Join
Create 500 random points and 20 random polygons. Split the points into 4 chunks.
Use future_map to perform a spatial join (st_join) for each chunk in parallel. Recombine and
verify the row count matches a direct st_join.
10. GEE NDVI Composite
Using rgee, define a small ROI (e.g., your hometown bounding box), access Sentinel-2 SR for
June–August 2023, filter by cloud cover < 20%, compute a median composite, and compute
NDVI. Export and plot the result. (If no GEE account, describe the steps in comments.)
Challenging Problems (11–15)
11. Pixel-wise Trend on a Large Time Series
Create a 500×500 raster with 120 layers (simulate monthly NDVI for 10 years) written to disk.
Use app() with a custom trend function (linear regression slope) to compute the trend for every
pixel, writing directly to a new file. Report the time taken.
12. Parallel Time-Series Extraction and Modeling
From the 120-layer time series in Problem 11, extract the time series at 100 random point
locations (use extract()). Use future_map to fit a harmonic regression (lm(value ~ sin(2*pi*t/12)
+ cos(2*pi*t/12))) to each point's time series in parallel. Extract the amplitude and phase.
13. Scalable Zonal Statistics with Tiles
Create a large zone raster (e.g., 2000×2000 pixels, 10 zones) and a large value raster of the same
dimensions. Use makeTiles() to split both into tiles. Write a function that computes zonal() on a
pair of tiles and returns the statistics. Apply it in parallel with future_map2. Combine the per-tile
zonal statistics into a global zonal summary (this requires careful handling of zone IDs across
tiles).
14. Batch Download and Process with GEE
Using rgee, download monthly NDVI composites for 2022 for 10 small ROIs (e.g., 10 national
parks) as a batch: define the ROIs, write a loop or future_map that for each ROI exports the
NDVI time series to Google Drive, then downloads and saves as an RDS. (If you cannot run this,
write the script with all GEE calls and explain the expected output.)
15. Full Parallel Workflow for Global Forest Change
Design a script (pseudo-code or real, if resources permit) that processes the Hansen Global
Forest Change dataset (UMD/hansen/global_forest_change_2023_v1_11 in GEE) for a large
country (e.g., Brazil). The workflow should: (a) load the tree cover and loss year bands, (b)
compute forest loss area per year for each 0.1° grid cell, (c) export the resulting table as a CSV,
and (d) create a map of forest loss hotspots. Describe the parallelisation strategy: what is done on
GEE, what is done locally, and where future is applied.
Chapter 26: Web Mapping Applications and Dashboards
An analysis that cannot be shared is an analysis that has no impact. The maps and statistics you
produce in R can be turned into interactive web applications—accessible to decision-makers,
stakeholders, and the public through a simple web browser, with no R installation required. This
final chapter teaches you to build two kinds of interactive geospatial products: interactive web
maps with the leaflet package, which wraps the world’s most popular open-source web mapping
library, and geospatial dashboards with the shiny package, which turns R code into a reactive
web application. We then cover the essentials of publishing and deploying these applications, so
your work can be shared with the world. The Hard Practice asks you to build an interactive
explorer for a city’s green infrastructure—a complete mini-application that integrates vector,
raster, and user interaction. By the end of this chapter, you will have acquired not only the
analytical skills of a geospatial scientist but also the communication tools of a modern data
professional. This is the bridge from analysis to impact.
26.1 From Static to Interactive: shiny Fundamentals
A static map answers a question. An interactive application allows the user to ask their own
questions. shiny (Chang et al., 2023) is an R package that builds web applications from R code,
without requiring knowledge of HTML, CSS, or JavaScript. It uses a reactive
programming model: when the user changes an input (a slider, a dropdown, a map click), the
server automatically recomputes the outputs (tables, plots, maps) and updates the display.
26.1.1 The Architecture of a Shiny App
A Shiny application consists of two main components, typically in a single file called app.R:
1. The user interface (UI): Defines the layout and appearance of the application—what the
user sees. Built with functions
like fluidPage(), sidebarLayout(), selectInput(), sliderInput(), leafletOutput(), plotOutput(
), tableOutput().
2. The server function: Contains the R code that runs when the app is loaded or when the
user interacts with inputs. It defines reactive expressions that depend on input values and
produce outputs.
A minimal Shiny app:
r
library(shiny)
# Define UI
ui <- fluidPage(
titlePanel("Hello Shiny"),
sidebarLayout(
sidebarPanel(
sliderInput("bins", "Number of bins:", min = 1, max = 50, value = 30)
),
mainPanel(
plotOutput("distPlot")
)
)
)
# Define server logic
server <- function(input, output) {
output$distPlot <- renderPlot({
x <- faithful$waiting
bins <- seq(min(x), max(x), [Link] = input$bins + 1)
hist(x, breaks = bins, col = "steelblue", border = "white",
main = "Histogram of waiting times", xlab = "Waiting time (min)")
})
}
# Run the application
shinyApp(ui = ui, server = server)
The UI creates a slider input named bins. The server has a reactive
context: renderPlot() re-executes whenever input$bins changes, and the plot updates instantly.
26.1.2 Reactive Expressions
A reactive expression is a function that caches its result and only recomputes when its
dependencies change. Use reactive() for intermediate calculations that are used by multiple
outputs, avoiding redundant computation.
r
server <- function(input, output) {
# Reactive expression: reads and filters data
filtered_data <- reactive({
data <- read_csv("data/[Link]")
data %>% filter(year == input$year)
})
output$map <- renderLeaflet({
leaflet(filtered_data()) %>% addTiles() %>% addMarkers()
})
output$summary <- renderTable({
filtered_data() %>% summarise(mean = mean(value), n = n())
})
}
filtered_data() is called like a function but behaves like a variable that updates only
when input$year changes.
26.1.3 Common UI Inputs and Outputs
Inputs:
selectInput("variable", "Choose a variable:", choices = c("pH", "Carbon")) — dropdown.
sliderInput("range", "Elevation range:", min = 0, max = 3000, value = c(500, 1500)) —
range slider.
dateRangeInput("dates", "Date range:", start = "2024-01-01", end = "2024-12-31") —
date picker.
numericInput("threshold", "Threshold:", value = 0.5, min = 0, max = 1, step = 0.05) —
number input.
radioButtons("method", "Classification method:", choices = c("Random Forest",
"SVM")) — radio buttons.
Outputs:
plotOutput("plot") — for ggplot2 or base graphics.
tableOutput("table") — for static tables.
leafletOutput("map") — for interactive maps (Section 26.2).
textOutput("text") — for text.
verbatimTextOutput("console") — for printed output.
26.1.4 Layout Options
sidebarLayout(): classic layout with a narrow sidebar (inputs) and a wide main panel
(outputs).
fluidRow() + column(): flexible grid system; can create multi-panel dashboards.
tabsetPanel(): tabs within a page.
navbarPage(): multi-page app with a navigation bar.
r
ui <- navbarPage("Geospatial Dashboard",
tabPanel("Map", leafletOutput("map")),
tabPanel("Statistics", plotOutput("histogram"), tableOutput("summary")),
tabPanel("About", p("This dashboard was built with R and Shiny."))
)
26.1.5 Geospatial Context: Why Shiny?
For the geospatial scientist, Shiny transforms a script that only you can run into a tool that a field
officer, a policy advisor, or a community group can use. Examples:
A landslide early warning dashboard that updates with new rainfall data and shows
at-risk areas.
An agricultural monitoring tool where a farmer can click on their field to see the NDVI
time series and yield forecast.
A conservation planning application where a user can adjust the weight given to
different biodiversity criteria and see the resulting priority map update in real time.
A participatory mapping platform where stakeholders can add points and polygons to a
shared map.
Shiny is the delivery mechanism for the analytical skills you have built throughout this book.
26.2 Building a Geospatial Dashboard with leaflet and shiny
leaflet (Cheng, Karambelkar, & Xie, 2023) is the R interface to the Leaflet JavaScript library, the
world's most popular open-source web mapping engine. A leaflet map is a web page: it supports
panning, zooming, pop-ups, and layer toggling. When embedded in a Shiny app, the map
becomes a two-way communication channel: the user can click on the map to trigger R
computations, and R can dynamically update the map with new data.
26.2.1 Creating a Basic Leaflet Map
A leaflet map is built by piping layers onto a leaflet() object:
r
library(leaflet)
library(sf)
# Create an sf object of points
cities <- [Link](
name = c("Beijing", "Shanghai", "Guangzhou"),
lon = c(116.4, 121.5, 113.3),
lat = c(39.9, 31.2, 23.1),
pop = c(21.5, 26.3, 18.7) # millions
)
cities_sf <- st_as_sf(cities, coords = c("lon", "lat"), crs = 4326)
leaflet(cities_sf) %>%
addTiles() %>%
addCircleMarkers(
radius = ~sqrt(pop) * 2,
color = "red",
fillOpacity = 0.6,
label = ~name,
popup = ~paste0("<b>", name, "</b><br>Population: ", pop, " million")
)
Key leaflet functions:
addTiles(): adds the default OpenStreetMap basemap. Alternative
basemaps: addProviderTiles("[Link]"), addProviderTiles("[Link]
").
addCircleMarkers(): for point data. Radius can be constant or data-driven.
addMarkers(): standard pin markers.
addPolygons(): for polygon data. Supports fill colour, border, highlighting, popups.
addPolylines(): for line data.
addRasterImage(): for SpatRaster or RasterLayer objects (rendered as a tile overlay).
addLegend(): adds a colour legend.
addLayersControl(): allows the user to toggle layers on and off.
addMiniMap(): inset overview map.
addScaleBar(): scale bar.
Popups and labels: label is shown on hover; popup is shown on click. Both accept HTML,
enabling rich formatting.
26.2.2 Adding Raster Data to Leaflet
leaflet can display a SpatRaster by converting it to a PNG tile overlay. The raster must be in
EPSG:4326 (geographic coordinates). terra::project() to WGS84 before adding to the map.
r
library(terra)
library(leaflet)
# Assume a SpatRaster 'ndvi' in WGS84
pal <- colorNumeric(c("brown", "yellow", "darkgreen"), values(ndvi), [Link] = "transparent")
leaflet() %>%
addTiles() %>%
addRasterImage(ndvi, colors = pal, opacity = 0.7) %>%
addLegend(pal = pal, values = values(ndvi), title = "NDVI")
For large rasters, leaflet automatically creates image tiles at appropriate zoom levels.
However, addRasterImage() can be slow for very large rasters; in such cases, pre-project and
resample the raster to a manageable resolution (e.g., 1000×1000 pixels) before adding to the
map.
26.2.3 Interactivity: Click Events and Dynamic Updates
When a leaflet map is inside a Shiny app, the map can communicate user interactions to the
server:
input$map_click: returns the coordinates of a click.
input$map_shape_click: returns the properties of a clicked polygon/marker.
input$map_bounds: returns the current map bounds (north, east, south, west).
These can trigger reactive updates to other parts of the dashboard.
r
# UI
ui <- fluidPage(
leafletOutput("map"),
verbatimTextOutput("click_info")
)
# Server
server <- function(input, output) {
output$map <- renderLeaflet({
leaflet(cities_sf) %>%
addTiles() %>%
addCircleMarkers(layerId = ~name)
})
output$click_info <- renderPrint({
input$map_marker_click
})
}
layerId in addCircleMarkers() ensures that clicking a marker returns its identity, not just
coordinates.
Dynamic map updates can be done with leafletProxy(), which modifies an existing map without
redrawing it entirely:
r
observeEvent(input$year, {
filtered <- cities_sf %>% filter(year == input$year)
leafletProxy("map") %>%
clearMarkers() %>%
addCircleMarkers(data = filtered, ...)
})
leafletProxy() is essential for smooth, flicker-free updates.
26.2.4 A Complete Geospatial Dashboard: Structure
A typical geospatial Shiny dashboard combines:
A sidebar with input controls (date range, variable selector, threshold slider).
A main panel with a leaflet map.
Below the map, a row of summary statistics (value boxes or tables).
Possibly a second tab with a time-series plot or a data table.
r
ui <- fluidPage(
titlePanel("City Green Infrastructure Explorer"),
sidebarLayout(
sidebarPanel(
selectInput("city", "Select City:", choices = c("All", "Beijing", "Shanghai", "Guangzhou")),
sliderInput("area_min", "Minimum Park Area (ha):", min = 0, max = 50, value = 5),
radioButtons("basemap", "Basemap:", choices = c("Streets", "Satellite"))
),
mainPanel(
leafletOutput("map", height = 500),
br(),
fluidRow(
column(6, tableOutput("summary")),
column(6, plotOutput("histogram"))
)
)
)
)
server <- function(input, output) {
# Reactive filtered data
parks_filtered <- reactive({
data <- parks
if (input$city != "All") {
data <- data %>% filter(city == input$city)
}
data %>% filter(area_ha >= input$area_min)
})
# Render map
output$map <- renderLeaflet({
leaflet() %>%
addTiles() %>%
setView(lng = 116.4, lat = 39.9, zoom = 10)
})
# Update map reactively
observe({
pal <- colorNumeric("Greens", parks_filtered()$area_ha)
leafletProxy("map", data = parks_filtered()) %>%
clearShapes() %>%
addPolygons(
fillColor = ~pal(area_ha),
fillOpacity = 0.6,
color = "darkgreen",
weight = 1,
label = ~paste(name, "-", round(area_ha, 1), "ha"),
highlightOptions = highlightOptions(color = "yellow", weight = 3, bringToFront = TRUE)
)
})
# Summary table
output$summary <- renderTable({
parks_filtered() %>%
st_drop_geometry() %>%
summarise(
`Number of parks` = n(),
`Total area (ha)` = sum(area_ha),
`Mean area (ha)` = round(mean(area_ha), 1)
)
})
# Histogram
output$histogram <- renderPlot({
ggplot(parks_filtered(), aes(x = area_ha)) +
geom_histogram(bins = 20, fill = "forestgreen", color = "white") +
labs(x = "Area (ha)", y = "Count") +
theme_minimal()
})
}
This dashboard allows a city planner to explore urban green spaces: filter by city and minimum
area, view the spatial distribution on an interactive map, and see summary statistics and a
histogram that update in real time.
26.3 Publishing and Deployment Strategies
A Shiny application running on your laptop is visible only to you. To share it with others, you
must deploy it to a server. Several options exist, from free cloud services to enterprise-grade
infrastructure.
26.3.1 [Link]: The Simplest Deployment
[Link] (by Posit) is a cloud platform that hosts Shiny applications. The free tier allows 5
applications with limited runtime hours per month. Deployment is done directly from RStudio:
1. Install the rsconnect package: [Link]("rsconnect").
2. Create an account at [Link] and obtain a token.
3. Configure rsconnect with your token.
4. Click Publish in RStudio (or run rsconnect::deployApp()).
r
rsconnect::setAccountInfo(name = "your_account", token = "your_token", secret =
"your_secret")
rsconnect::deployApp("path/to/app")
The application is immediately available at [Link]
Considerations for geospatial apps: Large spatial datasets should be pre-processed and stored
as .rds files. Rasters should be resampled to a manageable resolution. All package dependencies
must be declared in the app's global.R or loaded in app.R. [Link] has a maximum upload
size of 1 GB for the free tier.
26.3.2 Shiny Server and Posit Connect
For organisations requiring more control, security, or scalability:
Shiny Server (Open Source): Free, installed on your own Linux server. Supports
multiple applications but has no authentication or scaling features.
Posit Connect: Commercial product with authentication, user management, scheduled
reports, and load balancing. Suitable for enterprise deployment.
Both require setting up a Linux server with R, Shiny Server, and the necessary geospatial system
dependencies (GDAL, GEOS, PROJ, as we installed in Chapter 1). Docker containers
(e.g., rocker/shiny) simplify this process.
26.3.3 Exporting as a Standalone HTML Map with leaflet
If your application is purely a map without reactive R computations (i.e., the data is static and the
map is self-contained), you can export a leaflet map as a standalone HTML file:
r
m <- leaflet(cities_sf) %>% addTiles() %>% addCircleMarkers()
htmlwidgets::saveWidget(m, "cities_map.html", selfcontained = TRUE)
The resulting cities_map.html file can be opened in any web browser, shared via email, or
embedded in a website. This is the simplest way to share an interactive map with a non-technical
audience.
26.3.4 Embedding Shiny Apps and Leaflet Maps in Websites
Shiny apps can be embedded in a web page using an <iframe> tag pointing to the Shiny
Server or [Link] URL.
Leaflet maps (as HTML widgets) can be embedded in R Markdown documents, which
can then be published as standalone HTML pages or integrated into static site generators
(e.g., Hugo, Jekyll).
R Markdown with runtime: shiny creates an interactive document that can be deployed
on [Link] or Shiny Server.
26.3.5 The Geospatial Scientist as Communicator
The tools in this chapter are not merely technical add-ons; they represent a philosophy. A map is
an argument. An interactive dashboard is a conversation with the data. By deploying your
analysis as a web application, you invite scrutiny, enable collaboration, and multiply the impact
of your work. The same rigour you applied to variogram modelling and classification accuracy
must now be applied to user interface design: is the map intuitive? Are the controls clearly
labelled? Does the application load quickly? The best geospatial analysis is the one that is used.
Hard Practice 26 – “Building an Interactive Geospatial Explorer for a City’s Green
Infrastructure”
Objective:
Design and build a complete Shiny dashboard that integrates a leaflet map, user controls, and
reactive outputs to explore urban green infrastructure. This is the capstone project of the
textbook, synthesising skills from every chapter.
Scenario:
You are a GIS analyst for a city government. The planning department wants an interactive tool
to explore the city's green infrastructure: parks, street trees, and green roofs. The tool should
allow users to:
1. Filter parks by minimum area and by district.
2. Toggle the visibility of street trees and green roofs as separate layers.
3. Click on a park to see its name, area, and a list of tree species within it.
4. View a bar chart of park area by district.
5. Switch between a street basemap and a satellite basemap.
Instructions:
1. Simulate the data:
o Create a sf polygon layer of 10 city districts (use a regular grid or simple shapes).
o Create a sf polygon layer of 50 parks, randomly distributed, with
attributes: name, area_ha, district (matching the district layer), and a
list-column tree_species (a character vector of 2–5 species per park, simulated).
o Create a sf point layer of 500 street trees, randomly distributed, with
attribute species.
o Create a sf polygon layer of 30 green roofs, with attribute area_m2.
o Assign all layers to WGS84 (EPSG:4326) for leaflet compatibility.
2. UI design:
o Use a navbarPage with two tabs: “Map Explorer” and “Data Summary”.
o In the Map Explorer tab: a sidebar
with selectInput("district"), sliderInput("area_min"), checkboxGroupInput("layers
"), and radioButtons("basemap"). Main panel: leafletOutput("map", height = 600).
o In the Data Summary
tab: plotOutput("district_plot") and tableOutput("species_table").
3. Server logic:
o Create a reactive expression parks_filtered() that filters parks by district (if “All”
is not selected) and by minimum area.
o Render the leaflet map with renderLeaflet() and use observe() to update layers
reactively with leafletProxy(). Manage layer visibility based on the checkbox
input.
o When a park polygon is clicked (input$map_shape_click), display its details in
a verbatimTextOutput or a modal dialog (showModal()).
o For the basemap switch, use observeEvent(input$basemap) and leafletProxy() to
change the tile layer.
o In the Data Summary tab: create a bar chart of total park area by district. Create a
frequency table of the top 10 tree species across all parks.
4. Testing: Run the app locally. Verify that all filters, layer toggles, and click events work as
expected.
5. Reflection: In a comment block at the top of app.R, describe:
o The design decisions you made (e.g., why certain inputs are where they are).
o The reactive dependencies in your server logic.
o How you would extend the app for a real-world deployment (e.g., connecting to a
live database, adding user authentication, optimising for large datasets).
Deliverable:
A single app.R file (and any supporting data files) that runs the complete dashboard. Include
a [Link] with instructions for running the app.
Chapter 26 Review Problems
Solve each in a clearly commented R script or Shiny app as specified.
Easy Problems (1–5)
1. Minimal Shiny App
Create a Shiny app with a sliderInput for a sample size n and a plotOutput showing a histogram
of rnorm(n). Run it and verify that the histogram updates when the slider moves.
2. Basic Leaflet Map
Create a leaflet map of three cities of your choice. Use addCircleMarkers() with popups showing
the city name and a random population value. Add a legend (manual).
3. Leaflet with Raster
Create a small 50×50 raster of random NDVI values in WGS84. Use leaflet to display it
with addRasterImage() and a colour legend.
4. Shiny with Reactive Text
Create a Shiny app with a textInput() for a city name and an textOutput() that displays "You
selected: [city name]". Understand the reactive chain.
5. Export a Leaflet Map to HTML
Using the map from Problem 2, export it as a self-contained HTML file
with htmlwidgets::saveWidget(). Open the file in a browser. (No script required for this problem
beyond the export command.)
Medium Problems (6–10)
6. Leaflet Layer Control
Create a leaflet map with two polygon layers (e.g., parks and districts) and
use addLayersControl() to allow toggling each layer on and off. Use different colours for each
layer.
7. Reactive Map with leafletProxy()
Build a Shiny app with a selectInput() for a variable (e.g., “population” or “area”) and a leaflet
map of polygons that updates the fill colour based on the selected variable,
using leafletProxy() without redrawing the entire map.
8. Click-and-Display Dashboard
Create a Shiny app that displays a leaflet map of points (e.g., weather stations). When a point is
clicked, show a table below the map with the station’s attributes. Use input$map_marker_click.
9. Multi-Tab Dashboard
Build a Shiny app with two tabs using tabsetPanel(): “Map” (leaflet map of a study area) and
“About” (text describing the data and methods). Ensure the map renders only on the Map tab.
10. Dashboard with Filtered Data
Create a Shiny app that loads a dataset of 50 field samples (simulated) with
attributes site, date, ph, carbon. Use dateRangeInput() and selectInput() for site to filter the data.
Display the filtered data in a tableOutput() and a plotOutput() of pH over time.
Challenging Problems (11–15)
11. Full Green Infrastructure Dashboard
Follow the Hard Practice instructions and build the complete city green infrastructure explorer.
Ensure all features work: district filtering, area slider, layer toggles, park click popups, basemap
switch, summary tab with bar chart and species table.
12. Real-Time Data Update
Modify the app from Problem 11 so that it can read new park data from a CSV file that is
updated externally. Use reactiveFileReader() (from the shiny package) to monitor the file for
changes and update the map automatically. Simulate the external update by writing a small script
that modifies the CSV.
13. Advanced Popups with HTML and Plotly
In a leaflet map, when a park polygon is clicked, instead of a text popup, show a popup
containing an interactive time-series plot of NDVI (simulated for that park) using plotly. You will
need to render the plot as HTML and pass it to the popup.
14. Shiny App with User Authentication
Using the shinymanager package, add a login page to your green infrastructure dashboard.
Create two user accounts (e.g., “admin” and “viewer”) with different passwords. (Note:
authentication requires the shinymanager package; installation and implementation are part of
the challenge.)
15. Deploy to [Link]
Take the dashboard from Problem 11 (or a simplified version), create a free account
on [Link], and deploy the app. Share the URL. Write a short deployment guide (as
a [Link] file) detailing the steps you took, including any issues encountered (e.g., package
dependencies, file paths, data size limits) and how you resolved them.
Appendices
Appendix A: Installing R, RStudio, and Essential Geospatial Libraries (Step-by-Step Guide
for Windows, macOS, Linux)
This appendix provides detailed, platform-specific instructions to set up a fully functional R
geospatial environment. If you have already completed the setup in Chapter 1, this serves as a
reference for future installations.
A.1 Windows
1. Install R:
o Navigate to [Link]
o Click the link to download the latest R installer (e.g., [Link]).
o Run the installer. Accept the default options. Ensure that "Save version number in
registry" is selected (this helps RStudio detect R automatically).
o Click "Finish."
2. Install RStudio:
o Go to [Link]
o Click "Download RStudio Desktop for Windows."
o Run the downloaded executable and accept the defaults.
o Launch RStudio. It should automatically detect your R installation.
3. Install Rtools (needed for compiling packages from source):
o Go to [Link]
o Download the latest Rtools installer matching your R version.
o Run the installer, accept defaults, and ensure the checkbox to add Rtools to the
PATH is selected.
o Restart RStudio.
4. Install geospatial packages (automatic dependencies):
o In the RStudio Console, run:
r
[Link]("sf")
[Link]("terra")
o On Windows, these packages download pre-compiled binaries that include
GDAL, GEOS, and PROJ internally. No separate installation of these libraries is
required.
o Verify installation:
r
library(sf)
sf_extSoftVersion()
A.2 macOS
1. Install R:
o Go to [Link]
o Determine your Mac’s processor: Apple Silicon (M1/M2/M3) or Intel. Download
the corresponding .pkg file.
o Double-click the .pkg and follow the installer.
2. Install RStudio:
o Download from [Link]
o Open the .dmg and drag RStudio to the Applications folder.
o Launch RStudio.
3. Install Command Line Tools (if not already present):
o Open Terminal (Applications → Utilities).
o Run xcode-select --install. A dialog will appear; click "Install." This provides the
compilers needed for source packages.
4. Install system geospatial libraries (optional but recommended):
o Install Homebrew from [Link]
o In Terminal, run:
bash
brew install gdal geos proj
o While recent CRAN binaries for sf include these libraries, having them
system-wide allows compilation of development versions and ensures other tools
(e.g., QGIS) can share them.
5. Install geospatial packages:
o In RStudio Console:
r
[Link]("sf")
[Link]("terra")
o Verify:
r
library(sf)
sf_extSoftVersion()
A.3 Linux (Ubuntu 22.04/24.04)
1. Add the CRAN repository (if not using the system’s R, which may be outdated):
bash
sudo apt update
sudo apt install --no-install-recommends software-properties-common dirmngr
wget -qO- [Link] | sudo tee -a
/etc/apt/[Link].d/cran_ubuntu_key.asc
sudo add-apt-repository "deb [Link] $(lsb_release -cs)-
cran40/"
sudo apt update
2. Install R:
bash
sudo apt install r-base r-base-dev
3. Install system geospatial libraries:
bash
sudo apt install libgdal-dev libgeos-dev libproj-dev libudunits2-dev libgsl-dev
4. Install RStudio:
o Download the .deb package from [Link]
o Install with: sudo dpkg -i rstudio-*.deb
o If dependency errors appear, run sudo apt --fix-broken install.
5. Install geospatial packages:
o In RStudio Console:
r
[Link]("sf")
[Link]("terra")
o These will compile from source using the system libraries installed in step 3.
o Verify:
r
library(sf)
sf_extSoftVersion()
Appendix B: R Package Quick Reference
Package Purpose Key Functions
Vector spatial data
sf st_read, st_write, st_transform, st_buffer, st_intersects, st_join
(Simple Features)
rast, writeRaster, crop, mask, project, focal, zonal, app, extract, te
terra Raster spatial data
n
Spatiotemporal
stars read_stars, st_as_stars, st_apply
raster cubes
Package Purpose Key Functions
Data manipulation
dplyr filter, select, mutate, summarise, arrange, group_by, left_join
grammar
tidyr Data reshaping pivot_longer, pivot_wider, separate, unite, nest, unnest
Grammar of
ggplot2 ggplot, aes, geom_point, geom_sf, facet_wrap, theme
graphics
tmap Thematic mapping tm_shape, tm_polygons, tm_bubbles, tm_layout
Interactive web
leaflet leaflet, addTiles, addPolygons, addRasterImage, leafletProxy
maps
shiny Web applications fluidPage, renderPlot, renderLeaflet, reactive, observe
Spatial
spdep poly2nb, nb2listw, [Link], localmoran, [Link]
dependence
spatialreg Spatial regression lagsarlm, errorsarlm, impacts
gstat Geostatistics variogram, [Link], krige, [Link]
Point pattern
spatstat ppp, density, Kest, Lest, pcf, envelope, ppm
analysis
Random Forest
randomForest randomForest, predict
classification
Google Earth
rgee ee$ImageCollection, sf_as_ee, ee_as_rast
Engine interface
future / furrr Parallel computing plan, future_map
Package Purpose Key Functions
LiDAR point
lidR readLAS, classify_ground, rasterize_terrain, locate_trees
cloud analysis
readr / readxl Data import read_csv, write_csv, read_xlsx
Project-relative
here here
paths
knitr / rmarkdown Dynamic reports knit, render
Appendix C: Data Repositories and Open Data for Geoinformatics
Natural Earth Data: [Link]. Public domain global vector datasets at
1:10m, 1:50m, and 1:110m scales. In R: rnaturalearth::ne_countries().
USGS EarthExplorer: [Link]. Landsat, SRTM DEM, and many other
datasets.
Copernicus Open Access Hub: [Link]. Sentinel-1, Sentinel-2, Sentinel-3
data.
NASA Earthdata: [Link]. MODIS, NASADEM, ASTER GDEM, and more.
Global Forest Watch: [Link]. Hansen forest change data, tree cover, and
land use.
WorldPop: [Link]. High-resolution population density grids.
OpenStreetMap: [Link]. Crowd-sourced global map data. In
R: osmdata package.
FAO GeoNetwork: [Link]/geonetwork. Soils, land cover, climate, and agricultural data.
WorldClim: [Link]. Bioclimatic variables and historical climate data.
Google Earth Engine Data Catalog: [Link]/earth-engine/datasets.
Petabytes of satellite imagery and geospatial data.
Appendix D: Common CRS Definitions for Geoinformatics
CRS EPSG Code Type Use Case
WGS84 GPS, global storage,
4326 Geographic (2D)
(latitude/longitude) web data
GPS with ellipsoidal
WGS84 (3D) 4979 Geographic (3D)
heights
Web mapping (Google,
Web Mercator 3857 Projected
OSM, Bing)
UTM Zone 50N (China Local mapping in
32650 Projected
East) 114°E–120°E
UTM Zone 51N (China Local mapping in
32651 Projected
Northeast) 120°E–126°E
UTM Zone 44N (India, Local mapping in
32644 Projected
part of China) 72°E–78°E
Projected Continental US
Albers Equal Area (US) 5070
(Equal-Area) thematic mapping
Projected European statistical
ETRS-LAEA (Europe) 3035
(Equal-Area) mapping
Projected
Robinson ESRI:54030 Global reference maps
(Compromise)
Projected Global area-accurate
Mollweide ESRI:54009
(Equal-Area) maps
CRS EPSG Code Type Use Case
British National Grid 27700 Projected UK mapping
NAD83 (geographic) 4269 Geographic North American datum
NAD83 / UTM Zone 17N 26917 Projected Eastern North America
Appendix E: Glossary of Key Terms
Affine transformation: A linear transformation (translation, rotation, scaling) applied to
coordinates.
Anisotropy: Spatial dependence that varies with direction. Contrast with isotropy.
Band: A single layer of a multispectral raster, corresponding to a specific wavelength
range.
Coordinate Reference System (CRS): A framework for locating coordinates on the
Earth, comprising a datum, projection, and units.
Datum: A reference ellipsoid and set of control points defining the origin and orientation
of a coordinate system.
Digital Elevation Model (DEM): A raster of elevation values. Includes DTMs (bare
earth) and DSMs (surface).
EPSG code: A unique integer identifying a CRS in the EPSG registry.
GDAL: Geospatial Data Abstraction Library. Reads and writes raster and vector data.
GEOS: Geometry Engine – Open Source. Performs spatial operations.
Geoid: The equipotential surface of the Earth’s gravity field, approximating mean sea
level.
Geostatistics: The branch of statistics concerned with spatially continuous data and
interpolation (kriging).
Kriging: A geostatistical interpolation method that produces optimal, unbiased
predictions with associated variance.
LiDAR: Light Detection And Ranging. A remote sensing method that uses laser pulses to
measure distances, producing 3D point clouds.
Map algebra: Cell-by-cell arithmetic on rasters.
Moran’s I: A measure of global spatial autocorrelation.
NDVI: Normalized Difference Vegetation Index. (NIR – Red) / (NIR + Red).
OGC: Open Geospatial Consortium. An international standards body for geospatial data
and services.
PROJ: A library for coordinate reference system transformations.
Raster: A grid of cells (pixels) representing a continuous spatial variable.
Simple Feature (sf): An OGC/ISO standard for representing vector geometries with
attributes.
Spatial autocorrelation: The tendency of nearby locations to have similar values.
Spatial weights matrix: A matrix WW where wijwij quantifies the connection between
locations ii and jj.
Vector: A spatial data model representing points, lines, and polygons with attributes.
Appendix F: Solutions to Selected Hard Practices
This appendix provides complete or skeletal solutions to the Hard Practices marked with an
asterisk () in the main text. The solutions are written as fully commented R scripts. Due to space,
not all Hard Practices are included; the ones below represent the most algorithmically instructive
problems.*
F.1 Hard Practice 15 – “Computing NDVI from Sentinel-2 Red and NIR Bands and Zonal
Statistics per Land Parcel”
r
# practice_chapter15_ndvi_parcels.R
library(terra)
library(sf)
library(dplyr)
# 1. Create the raster layers
[Link](123)
r <- rast(nrows = 100, ncols = 100,
xmin = 500000, xmax = 510000, ymin = 5700000, ymax = 5710000,
crs = "EPSG:32632")
red <- r; values(red) <- runif(10000, 0.02, 0.15)
nir <- r; values(nir) <- runif(10000, 0.25, 0.65)
# Add spatial pattern to NIR
coords <- crds(r)
nir <- nir + 0.3 * (coords[,1] - 500000) / 10000
# 2. Compute NDVI
ndvi <- (nir - red) / (nir + red)
plot(ndvi, main = "NDVI")
# 3. Vegetation mask
vegetation <- ndvi > 0.4
ndvi_veg <- mask(ndvi, vegetation, maskvalues = FALSE)
# 4. Create field parcels (random)
[Link](456)
parcels_sf <- st_as_sf(st_sample(st_as_sfc(st_bbox(r)), 20))
parcels_sf <- st_buffer(parcels_sf, dist = 300)
parcels_sf$field_id <- 1:20
# 5. Zonal statistics
zonal_stats <- extract(ndvi_veg, vect(parcels_sf), fun = mean, [Link] = TRUE)
parcels_sf$mean_ndvi <- zonal_stats[,2]
# 6. Map
library(ggplot2)
ggplot() +
geom_raster(data = [Link](ndvi, xy = TRUE), aes(x = x, y = y, fill = ndvi)) +
scale_fill_viridis_c() +
geom_sf(data = parcels_sf, fill = NA, color = "white") +
geom_sf_text(data = parcels_sf, aes(label = round(mean_ndvi, 2)), color = "white", size = 3) +
ggtitle("NDVI with Parcel Mean NDVI") +
theme_minimal()
Back Matter
References
Anselin, L. (1988). Spatial Econometrics: Methods and Models. Kluwer Academic
Publishers.
Baddeley, A., Rubak, E., & Turner, R. (2015). Spatial Point Patterns: Methodology and
Applications with R. CRC Press.
Bengtsson, H. (2023). future: Unified Parallel and Distributed Processing in R. R
package version 1.33.0.
Bivand, R. (2022). spdep: Spatial Dependence: Weighting Schemes, Statistics and
Models. R package version 1.2-8.
Bivand, R., & Piras, G. (2022). spatialreg: Spatial Regression Analysis. R package
version 1.2-5.
Bivand, R., Pebesma, E., & Gómez-Rubio, V. (2013). Applied Spatial Data Analysis with
R (2nd ed.). Springer.
Breiman, L. (2001). Random forests. Machine Learning, 45(1), 5–32.
Chang, W., Cheng, J., Allaire, J. J., Sievert, C., Schloerke, B., Xie, Y., Allen, J.,
McPherson, J., Dipert, A., & Borges, B. (2023). shiny: Web Application Framework for
R. R package version 1.7.5.
Cheng, J., Karambelkar, B., & Xie, Y. (2023). leaflet: Create Interactive Web Maps with
the JavaScript ‘Leaflet’ Library. R package version 2.2.0.
Dowle, M., & Srinivasan, A. (2023). [Link]: Extension of ‘[Link]’. R package
version 1.14.8.
Gräler, B., Pebesma, E., & Heuvelink, G. (2016). Spatio-temporal interpolation using
gstat. The R Journal, 8(1), 204–218.
Hijmans, R. J. (2023). terra: Spatial Data Analysis. R package version 1.7-55.
Hijmans, R. J. (2020). raster: Geographic Data Analysis and Modeling. R package
version 3.4-13.
Moran, P. A. P. (1950). Notes on continuous stochastic phenomena. Biometrika, 37(1/2),
17–23.
Müller, K. (2020). here: A Simpler Way to Find Your Files. R package version 1.0.1.
Ooms, J. (2023). jsonlite: A Simple and Robust JSON Parser and Generator for R. R
package version 1.8.7.
Pebesma, E. (2018). Simple features for R: Standardized support for spatial vector
data. The R Journal, 10(1), 439–446.
Pebesma, E. (2004). Multivariable geostatistics in S: the gstat package. Computers &
Geosciences, 30(7), 683–691.
Pebesma, E. (2023). stars: Spatiotemporal Arrays, Raster and Vector Data Cubes. R
package version 0.6-0.
Pontius, R. G., & Millones, M. (2011). Death to Kappa: birth of quantity disagreement
and allocation disagreement for accuracy assessment. International Journal of Remote
Sensing, 32(15), 4407–4429.
Roussel, J.-R., Auty, D., Coops, N. C., Tompalski, P., Goodbody, T. R. H., Meador, A. S.,
Bourdon, J.-F., de Boissieu, F., & Achim, A. (2020). lidR: An R package for analysis of
Airborne Laser Scanning (ALS) data. Remote Sensing of Environment, 251, 112061.
Ushey, K. (2023). renv: Project Environments. R package version 1.0.0.
Vaughan, D., & Dancho, M. (2022). furrr: Apply Mapping Functions in Parallel using
Futures. R package version 0.3.1.
Wickham, H. (2016). ggplot2: Elegant Graphics for Data Analysis (2nd ed.).
Springer-Verlag.
Wickham, H. (2014). Tidy Data. Journal of Statistical Software, 59(10), 1–23.
Wickham, H., & Bryan, J. (2023). readxl: Read Excel Files. R package version 1.4.3.
Wickham, H., François, R., Henry, L., Müller, K., & Vaughan, D. (2023). dplyr: A
Grammar of Data Manipulation. R package version 1.1.2.
Wickham, H., & Girlich, M. (2023). tidyr: Tidy Messy Data. R package version 1.3.0.
Wickham, H., Hester, J., & Bryan, J. (2023). readr: Read Rectangular Text Data. R
package version 2.1.4.
Wilkinson, L. (2005). The Grammar of Graphics (2nd ed.). Springer-Verlag.
Index
Note: This is a conceptual index. In a printed textbook, page numbers would be listed.
%>% (pipe operator): Chapter 6
aes(): Chapter 8
AIC: Chapter 10, 19
Anisotropy (variogram): Chapter 20
app() (terra): Chapter 15, 25
apply(): Chapter 3, 5
Array: Chapter 3
Assignment (<-): Chapter 2
Band math: Chapter 15, 21
Bootstrapping: Chapter 9
Buffer: Chapter 12
Chi-squared test: Chapter 9
Classification (image): Chapter 22
Cloud masking: Chapter 21
Coercion (type): Chapter 2
Confusion matrix: Chapter 22
Contiguity (Rook, Queen): Chapter 17
Coordinate Reference System (CRS): Chapter 11, 13
Correlation: Chapter 9
Cross-validation (spatial): Chapter 10, 20
cut(): Chapter 4
Data frame: Chapter 4
Date/time classes: Chapter 4
DE-9IM: Chapter 14
Debugging: Chapter 5
Digital Elevation Model (DEM): Chapter 23
dplyr verbs: Chapter 6
Eigenvalues (weights matrix): Chapter 17, 19
Error handling (tryCatch): Chapter 5
factor: Chapter 4
filter(): Chapter 6
focal(): Chapter 15, 25
for loop: Chapter 5
Functions (writing): Chapter 5
future/furrr: Chapter 25
GDAL: Chapter 11, 12, 15
ggplot2: Chapter 8
glm(): Chapter 10
group_by(): Chapter 6
gstat: Chapter 20
Hillshade: Chapter 23
IDW (Inverse Distance Weighting): Chapter 20
if/else: Chapter 5
ifelse(): Chapter 2, 5
impacts() (SAR): Chapter 19
Indexing (vectors): Chapter 2
Joins (dplyr): Chapter 6
K-means clustering: Chapter 22
Kappa coefficient: Chapter 22
Kriging (ordinary, universal): Chapter 20
Lagrange Multiplier tests: Chapter 19
leaflet: Chapter 26
lidR: Chapter 23
List: Chapter 4
lm(): Chapter 10
Logical subsetting: Chapter 2
Map algebra: Chapter 15
Matrix: Chapter 3
merge(): Chapter 4
Missing values (NA): Chapter 2
MODIS: Chapter 21
Moran’s I (global, local): Chapter 17
mutate(): Chapter 6
NA: Chapter 2
NDVI: Chapter 15, 21
NetCDF: Chapter 15
OLS regression: Chapter 10, 19
Parallel computing: Chapter 25
Pipe (|>, %>%): Chapter 6
Point pattern analysis: Chapter 18
Polygon creation: Chapter 12
PROJ: Chapter 11, 13
Random Forest: Chapter 22
rast(): Chapter 15
Recycling (vector): Chapter 2
Reprojection: Chapter 13
Residuals: Chapter 10, 19
rgee: Chapter 25
Ripley’s K function: Chapter 18
sf package: Chapter 11, 12, 14
shiny: Chapter 26
Slope/Aspect: Chapter 23
Spatial autocorrelation: Chapter 17
Spatial Error Model (SEM): Chapter 19
Spatial Lag Model (SAR): Chapter 19
Spatial weights matrix: Chapter 17
spatstat: Chapter 18
Spectral indices: Chapter 21
stars package: Chapter 11, 15
SVM: Chapter 22
sweep(): Chapter 3
t-test: Chapter 9
tapply(): Chapter 5
terra: Chapter 15, 21, 22, 23, 25
Terrain analysis: Chapter 23
tidyr pivoting: Chapter 6
Time series (raster): Chapter 21
Tissot indicatrix: Chapter 13
Topological relations: Chapter 14
try()/tryCatch(): Chapter 5
UTM projection: Chapter 13
Variogram: Chapter 20
Vector (atomic): Chapter 2
Vectorisation: Chapter 2
Viewshed: Chapter 23
while loop: Chapter 5