Welton Beck Macroinvertebrate Analysis

Author

Lydia Larocque

Published

August 14, 2026

1. Setup

# load packages
library(tidyverse)
library(janitor)
library(flextable)
library(patchwork)
library(effectsize)

set.seed(42)

# create outputs folder
dir.create("outputs", showWarnings = TRUE)
dir.create("outputs/figures", showWarnings = TRUE)
dir.create("outputs/tables", showWarnings = TRUE)

2. Data preparation and verification

# import data
raw_data <- read_csv("raw_riverfly.csv")

# check data is present with no missing values
head(raw_data)
# A tibble: 6 × 12
  site  reach feature    `phylum/group` class        order    family common_name
  <chr> <chr> <chr>      <chr>          <chr>        <chr>    <chr>  <chr>      
1 CR    CR1   Tree-lined Crustacea      Malacostraca Amphipo… Gamma… freshwater…
2 CR    CR1   Tree-lined Crustacea      Malacostraca Isopoda  Asell… water slat…
3 CR    CR1   Tree-lined Mollusca       Gastropoda   Hygroph… Plano… ramshorn s…
4 CR    CR1   Tree-lined Mollusca       Gastropoda   Hygroph… Lymna… pond snail 
5 CR    CR1   Tree-lined Mollusca       Gastropoda   unident… unide… unidentifi…
6 CR    CR1   Tree-lined Annelida       Clitellata   Hirudin… Gloss… leeches    
# ℹ 4 more variables: BMWP_category <chr>, BMWP_score <dbl>, present <dbl>,
#   BMWP_contribution <dbl>
tail(raw_data)
# A tibble: 6 × 12
  site  reach feature        `phylum/group` class   order     family common_name
  <chr> <chr> <chr>          <chr>          <chr>   <chr>     <chr>  <chr>      
1 IR    IR5   Non-tree-lined Arthropoda     Insecta Hemiptera Corix… lesser wat…
2 IR    IR5   Non-tree-lined Arthropoda     Insecta Hemiptera Nepid… water scor…
3 IR    IR5   Non-tree-lined Arthropoda     Insecta Hemiptera Gerri… pond skater
4 IR    IR5   Non-tree-lined Arthropoda     Insecta Ephemero… Caeni… mayfly nym…
5 IR    IR5   Non-tree-lined Arthropoda     Insecta Ephemero… Baeti… mayfly nym…
6 IR    IR5   Non-tree-lined Arthropoda     Insecta Neuropte… Siali… alder flies
# ℹ 4 more variables: BMWP_category <chr>, BMWP_score <dbl>, present <dbl>,
#   BMWP_contribution <dbl>
str(raw_data)
spc_tbl_ [240 × 12] (S3: spec_tbl_df/tbl_df/tbl/data.frame)
 $ site             : chr [1:240] "CR" "CR" "CR" "CR" ...
 $ reach            : chr [1:240] "CR1" "CR1" "CR1" "CR1" ...
 $ feature          : chr [1:240] "Tree-lined" "Tree-lined" "Tree-lined" "Tree-lined" ...
 $ phylum/group     : chr [1:240] "Crustacea" "Crustacea" "Mollusca" "Mollusca" ...
 $ class            : chr [1:240] "Malacostraca" "Malacostraca" "Gastropoda" "Gastropoda" ...
 $ order            : chr [1:240] "Amphipoda" "Isopoda" "Hygrophila" "Hygrophila" ...
 $ family           : chr [1:240] "Gammaridae" "Asellidae" "Planorbidae" "Lymnaeidae" ...
 $ common_name      : chr [1:240] "freshwater shrimp" "water slater" "ramshorn snail" "pond snail" ...
 $ BMWP_category    : chr [1:240] "Gammaridae" "Asellidae" "Other Mollusca families" "Other Mollusca families" ...
 $ BMWP_score       : num [1:240] 6 3 3 3 3 3 3 4 5 5 ...
 $ present          : num [1:240] 1 1 1 1 0 1 0 1 0 1 ...
 $ BMWP_contribution: num [1:240] 6 3 3 0 0 3 0 4 0 5 ...
 - attr(*, "spec")=
  .. cols(
  ..   site = col_character(),
  ..   reach = col_character(),
  ..   feature = col_character(),
  ..   `phylum/group` = col_character(),
  ..   class = col_character(),
  ..   order = col_character(),
  ..   family = col_character(),
  ..   common_name = col_character(),
  ..   BMWP_category = col_character(),
  ..   BMWP_score = col_double(),
  ..   present = col_double(),
  ..   BMWP_contribution = col_double()
  .. )
 - attr(*, "problems")=<externalptr> 
nrow(raw_data)
[1] 240
colSums(is.na(raw_data))
             site             reach           feature      phylum/group 
                0                 0                 0                 0 
            class             order            family       common_name 
                0                 0                 0                 0 
    BMWP_category        BMWP_score           present BMWP_contribution 
                0                 0                 0                 0 
# verify experimental sampling structure
table(raw_data$site)

 CR  IR 
120 120 
table(raw_data$reach)

CR1 CR2 CR3 CR4 CR5 IR1 IR2 IR3 IR4 IR5 
 24  24  24  24  24  24  24  24  24  24 
table(raw_data$feature)

Non-tree-lined     Tree-lined 
           120            120 
# check BMWP variables 
summary(raw_data$BMWP_score)
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
  2.000   3.000   5.000   4.792   5.000  10.000 
summary(raw_data$BMWP_contribution)
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
  0.000   0.000   0.000   1.688   3.000  10.000 
table(raw_data$present)

  0   1 
140 100 

3. Module-level summary

# creating 10 module-level summaries
module_data <- raw_data %>% 
  group_by(site, reach, feature) %>% 
  summarise( BMWP = sum(BMWP_contribution),
             NTAXA = sum(present),
             .groups = "drop") %>% 
  mutate( ASPT = BMWP / NTAXA )
module_data
# A tibble: 10 × 6
   site  reach feature         BMWP NTAXA  ASPT
   <chr> <chr> <chr>          <dbl> <dbl> <dbl>
 1 CR    CR1   Tree-lined        36    10  3.6 
 2 CR    CR2   Tree-lined        17     5  3.4 
 3 CR    CR3   Tree-lined        54    12  4.5 
 4 CR    CR4   Tree-lined        54    12  4.5 
 5 CR    CR5   Tree-lined        38     8  4.75
 6 IR    IR1   Non-tree-lined    28     7  4   
 7 IR    IR2   Non-tree-lined    56    16  3.5 
 8 IR    IR3   Non-tree-lined    52    12  4.33
 9 IR    IR4   Non-tree-lined    35     8  4.38
10 IR    IR5   Non-tree-lined    35    10  3.5 
# exporting module-level summary table for appendix
module_table <- module_data %>%
  mutate(ASPT = round(ASPT, 2)) %>%
  select(site, reach, feature, BMWP, NTAXA, ASPT) %>%
  rename(Site = site,
    "Module" = reach,
    "Riparian condition" = feature,
    BMWP = BMWP,
    NTAXA = NTAXA,
    ASPT = ASPT)
module_table
# A tibble: 10 × 6
   Site  Module `Riparian condition`  BMWP NTAXA  ASPT
   <chr> <chr>  <chr>                <dbl> <dbl> <dbl>
 1 CR    CR1    Tree-lined              36    10  3.6 
 2 CR    CR2    Tree-lined              17     5  3.4 
 3 CR    CR3    Tree-lined              54    12  4.5 
 4 CR    CR4    Tree-lined              54    12  4.5 
 5 CR    CR5    Tree-lined              38     8  4.75
 6 IR    IR1    Non-tree-lined          28     7  4   
 7 IR    IR2    Non-tree-lined          56    16  3.5 
 8 IR    IR3    Non-tree-lined          52    12  4.33
 9 IR    IR4    Non-tree-lined          35     8  4.38
10 IR    IR5    Non-tree-lined          35    10  3.5 
# format as flextable
module_table <- module_table %>%
  flextable() %>%
  theme_booktabs() %>%
  autofit() %>%
  align(align = "center", part = "all")
module_table

Site

Module

Riparian condition

BMWP

NTAXA

ASPT

CR

CR1

Tree-lined

36

10

3.60

CR

CR2

Tree-lined

17

5

3.40

CR

CR3

Tree-lined

54

12

4.50

CR

CR4

Tree-lined

54

12

4.50

CR

CR5

Tree-lined

38

8

4.75

IR

IR1

Non-tree-lined

28

7

4.00

IR

IR2

Non-tree-lined

56

16

3.50

IR

IR3

Non-tree-lined

52

12

4.33

IR

IR4

Non-tree-lined

35

8

4.38

IR

IR5

Non-tree-lined

35

10

3.50

save_as_docx(module_table,path = "outputs/tables/A1_module_summary.docx")

4. Descriptive Statistics

# summarising the 10 module-level BMWP, NTAXA, and ASPT data into the two 
# riparian conditions
descriptive_stats <- module_data %>% 
  group_by(feature) %>% 
  summarise(n = n(),
            BMWP_mean = mean(BMWP),
            BMWP_sd = sd(BMWP),
            BMWP_median = median(BMWP),
            BMWP_min = min(BMWP),
            BMWP_max = max(BMWP),
            
            NTAXA_mean = mean(NTAXA),
            NTAXA_sd = sd(NTAXA),
            NTAXA_median = median(NTAXA),
            NTAXA_min = min(NTAXA),
            NTAXA_max = max(NTAXA),
            
            ASPT_mean = mean(ASPT),
            ASPT_sd = sd(ASPT),
            ASPT_median = median(ASPT),
            ASPT_min = min(ASPT),
            ASPT_max = max(ASPT),
            
            .groups = "drop")
view(descriptive_stats)

# creating a descriptive statistics table with mean and standard deviation of 
# each riparian condition
ds_table <- descriptive_stats %>% 
  mutate(BMWP = paste0(round(BMWP_mean, 2), " ± ",
                       round(BMWP_sd,2)),
         NTAXA = paste0(round(NTAXA_mean, 2), " ± ", 
                        round(NTAXA_sd, 2)),
        ASPT = paste0(round(ASPT_mean, 2), " ± ", 
                      round(ASPT_sd,2))) %>% 
          select(feature, n, BMWP, NTAXA, ASPT)
ds_table
# A tibble: 2 × 5
  feature            n BMWP         NTAXA       ASPT       
  <chr>          <int> <chr>        <chr>       <chr>      
1 Non-tree-lined     5 41.2 ± 12.11 10.6 ± 3.58 3.94 ± 0.43
2 Tree-lined         5 39.8 ± 15.34 9.4 ± 2.97  4.15 ± 0.61
ds_table <- ds_table %>% 
  rename("Riparian condition" = feature,
         "BMWP\n(mean ± SD)" = BMWP, 
         "NTAXA\n(mean ± SD)" = NTAXA, 
         "ASPT\n(mean ± SD)" = ASPT) %>% 
  flextable() %>% 
  theme_booktabs() %>%
  autofit() %>%
  align(align = "center", part = "all")
ds_table

Riparian condition

n

BMWP
(mean ± SD)

NTAXA
(mean ± SD)

ASPT
(mean ± SD)

Non-tree-lined

5

41.2 ± 12.11

10.6 ± 3.58

3.94 ± 0.43

Tree-lined

5

39.8 ± 15.34

9.4 ± 2.97

4.15 ± 0.61

save_as_docx(ds_table,path = "outputs/tables/1_Descriptive.docx")

# creating a jitter box plot presenting mean and standard deviation alongside 
# individual module observations 
# BMWP jittered boxplot
bmwp_ds <- ggplot( module_data, 
                   aes( x = feature, 
                        y = BMWP)) + 
  geom_boxplot(outlier.shape = NA, 
               fill = "gray95") + 
  geom_jitter( aes(color = "Individual 10m modules"),
               width = 0.1, size = 1.5 ) + 
  labs( x = NULL,
        y = "BMWP score",
        color = NULL) +
  theme_light()
bmwp_ds

# NTAXA jittered boxplot
ntaxa_ds <- ggplot( module_data, 
                   aes( x = feature, 
                        y = NTAXA)) + 
  geom_boxplot(outlier.shape = NA, 
               fill = "gray95") + 
  geom_jitter( aes(color = "Individual 10m modules"),
               width = 0.1, size = 1.5 ) + 
  labs( x = "Riparian condition",
        y = "Number of BMWP-scoring\ntaxa (NTAXA)",
        color = NULL) +
  theme_light()
ntaxa_ds

# ASPT jittered boxplot
aspt_ds <- ggplot( module_data, 
                   aes( x = feature, 
                        y = ASPT)) + 
  geom_boxplot(outlier.shape = NA, 
               fill = "gray95") + 
  geom_jitter( aes(color = "Individual 10m modules"),
               width = 0.1, size = 1.5 ) + 
  labs( x = NULL,
        y = "Average BMWP-score per\ntaxon (ASPT)",
        color = NULL) +
  theme_light()
aspt_ds

# merge 3 jitter boxplots into one line
plots_ds <- bmwp_ds + ntaxa_ds + aspt_ds +
    plot_layout(ncol = 3, guides = "collect") &
    theme(axis.title = element_text(size = 8),
          axis.text.x = element_text(size = 6),
          axis.text.y = element_text(size = 8),
          legend.position = "bottom",
          legend.text = element_text(size = 8),
          legend.margin = margin(t = -10))
plots_ds

# save to folder
ggsave("outputs/figures/1_conditions.png",
  plot = plots_ds,
  width = 18,
  height = 10,
  units = "cm",
  dpi = 300)

5. Exploratory diagnostics

# Inspecting individual module values
module_data %>% select(feature, reach, BMWP, NTAXA, ASPT)
# A tibble: 10 × 5
   feature        reach  BMWP NTAXA  ASPT
   <chr>          <chr> <dbl> <dbl> <dbl>
 1 Tree-lined     CR1      36    10  3.6 
 2 Tree-lined     CR2      17     5  3.4 
 3 Tree-lined     CR3      54    12  4.5 
 4 Tree-lined     CR4      54    12  4.5 
 5 Tree-lined     CR5      38     8  4.75
 6 Non-tree-lined IR1      28     7  4   
 7 Non-tree-lined IR2      56    16  3.5 
 8 Non-tree-lined IR3      52    12  4.33
 9 Non-tree-lined IR4      35     8  4.38
10 Non-tree-lined IR5      35    10  3.5 
# Restructuring data for diagnostics
diagnostic_data <- module_data %>% 
  select(feature, BMWP, NTAXA, ASPT) %>% 
  pivot_longer(cols = c(BMWP, NTAXA, ASPT),
               names_to = "metric",
               values_to = "value")

# Looking at visual distribution of data using Normality diagnostic QQ plots 
diagnostic_qqplot <- ggplot(diagnostic_data, aes(sample = value)) +
  stat_qq() +
  stat_qq_line() +
  facet_grid(feature ~ metric, scales = "free") +
  labs(x = "Theoretical quantiles",
       y = "Sample quantiles") +
  theme_light()
diagnostic_qqplot

# Exporting as a supporting diagnostics in the Appendix
ggsave("outputs/figures/A1_qq.png",
  plot = diagnostic_qqplot,
  width = 8,
  height = 6,
  dpi = 300)

6. Spatial analysis

# Creating a spatial module plot
# Setting the module order from downstream to upstream
module_data <- module_data %>%
  mutate(reach = factor(reach,
                        levels = c("CR1", "CR2", "CR3", "CR4", "CR5",
                                   "IR1", "IR2", "IR3", "IR4", "IR5")))
# Reordering data
spatial_data <- module_data %>%
  mutate(module_position = c(1, 2, 3, 4, 5,
                             1, 2, 3, 4, 5)) %>%
  select(feature, reach, module_position, BMWP, NTAXA, ASPT) %>%
  pivot_longer(cols = c(BMWP, NTAXA, ASPT),
               names_to = "metric",
               values_to = "value")

# Plotting spatial variation in the three macroinvertebrate metrics
spatial_plot <- ggplot(spatial_data,
                       aes(x = reach,
                           y = value,
                           group = feature)) +
  geom_line() +
  geom_point(size = 2) +
  facet_wrap(~ metric, 
             scales = "free_y", 
             ncol = 1) +
  labs(x = "Sampling module",
       y = NULL) +
  theme_light() +
    theme(axis.text.x = element_text(size = 11, margin = margin(t = 10)),
          axis.text.y = element_text(size = 11),
          strip.text = element_text(size = 11),
          axis.title = element_text(size = 11))
spatial_plot

# Exporting spatial variation figure for appendix
ggsave("outputs/figures/A2_spatial_variation.png",
       plot = spatial_plot,
       width = 7,
       height = 10,
       dpi = 300)