Back to Article
Analysis Notebook
Download Source

This notebook contains all data processing, model fitting, and figure generation code supporting the manuscript. All analyses can be reproduced from the five CSV files in data/derived/. The rendered version of the manuscript is available at the project page.

Setup

In [1]:
View Code

packages <- c(
  "DT","kableExtra","emmeans","patchwork","gratia","pROC","caret",
  "mgcv","glmmTMB","performance","tibble","tidyverse","readxl")

quiet_load <- function(pkg) {
  if (!requireNamespace(pkg, quietly = TRUE)) {
    suppressWarnings(suppressMessages(install.packages(pkg, dependencies = TRUE)))
  }
  suppressPackageStartupMessages(require(pkg, character.only = TRUE, quietly = TRUE))
  invisible(TRUE)
}

options(repos = c(CRAN = "https://cloud.r-project.org"))
invisible(lapply(packages, quiet_load))
# Downloading packages -------------------------------------------------------
- Downloading biglm 0.9-3 from CRAN ...         OK [71.5 Kb in 0.9s]
- Downloading compositions 2.0-9 from CRAN ...  OK [1.9 Mb in 0.89s]
- Downloading robustbase 0.99-7 from CRAN ...   OK [3 Mb in 0.55s]
- Downloading DEoptimR 1.2-0 from CRAN ...      OK [104.1 Kb in 0.46s]
- Downloading bayesm 3.1-7 from CRAN ...        OK [3.4 Mb in 2.3s]
- Downloading logspline 2.1.22 from CRAN ...    OK [165.9 Kb in 1.2s]
- Downloading mediation 4.5.1 from CRAN ...     OK [1.2 Mb in 0.99s]
- Downloading Hmisc 5.2-6 from CRAN ...         OK [3.7 Mb in 0.55s]
- Downloading htmlTable 2.5.0 from CRAN ...     OK [437.4 Kb in 0.45s]
- Downloading robmixglm 1.2-8 from CRAN ...     OK [729 Kb in 1.4s]
- Downloading fastGHQuad 1.0.1 from CRAN ...    OK [382.1 Kb in 0.46s]
- Downloading actuar 3.3-7 from CRAN ...        OK [1.4 Mb in 0.46s]
- Downloading expint 0.2-1 from CRAN ...        OK [149.2 Kb in 0.44s]
- Downloading rsm 2.10.6 from CRAN ...          OK [896.8 Kb in 1.9s]
Successfully downloaded 14 packages in 19 seconds.

The following package(s) will be installed:
- abind          [1.4-8]
- actuar         [3.3-7]
- bayesm         [3.1-7]
- bayesplot      [1.15.0]
- bbmle          [1.0.25.1]
- bdsmatrix      [1.3-7]
- biglm          [0.9-3]
- bridgesampling [1.2-1]
- brio           [1.1.5]
- brms           [2.23.0]
- Brobdingnag    [1.2-9]
- callr          [3.8.0]
- car            [3.1-5]
- carData        [3.0-6]
- checkmate      [2.3.4]
- coda           [0.19-4.1]
- compositions   [2.0-9]
- crayon         [1.5.3]
- DBI            [1.3.0]
- DEoptimR       [1.2-0]
- desc           [1.4.3]
- diffobj        [0.3.8]
- distributional [0.8.1]
- emmeans        [2.0.4]
- estimability   [2.0.0]
- expint         [0.2-1]
- fastGHQuad     [1.0.1]
- Formula        [1.2-6]
- ggridges       [0.5.7]
- gridExtra      [2.3.1]
- Hmisc          [5.2-6]
- htmlTable      [2.5.0]
- inline         [0.3.21]
- lmerTest       [3.2-1]
- logspline      [2.1.22]
- loo            [2.10.1]
- lpSolve        [5.6.23]
- MatrixModels   [0.5-4]
- matrixStats    [1.5.0]
- mediation      [4.5.1]
- multcomp       [1.4-31]
- multcompView   [0.1-12]
- MuMIn          [1.48.19]
- mvtnorm        [1.4-2]
- nleqslv        [3.3.7]
- ordinal        [2026.7-26]
- pkgbuild       [1.4.8]
- pkgload        [1.5.3]
- posterior      [1.7.0]
- praise         [1.0.0]
- processx       [3.9.0]
- ps             [1.9.3]
- quantreg       [6.1]
- QuickJSR       [1.10.0]
- RcppParallel   [6.2.0]
- robmixglm      [1.2-8]
- robustbase     [0.99-7]
- rprojroot      [2.1.1]
- rsm            [2.10.6]
- rstan          [2.32.7]
- rstantools     [2.7.0]
- SparseM        [1.84-2]
- StanHeaders    [2.32.10]
- tensorA        [0.36.2.1]
- testthat       [3.3.2]
- TH.data        [1.1-5]
- ucminf         [1.2.3]
- VGAM           [1.1-14]
- waldo          [0.6.2]
- xtable         [1.8-8]
These packages will be installed into "~/PhD/01_thesis_chapters/03_koura_shoreline_habitats/renv/library/windows/R-4.6/x86_64-w64-mingw32".

# Installing packages --------------------------------------------------------
- Installing estimability 2.0.0 ...             OK [linked from cache]
- Installing mvtnorm 1.4-2 ...                  OK [linked from cache]
- Installing emmeans 2.0.4 ...                  OK [linked from cache]
- Installing ggridges 0.5.7 ...                 OK [linked from cache]
- Installing abind 1.4-8 ...                    OK [linked from cache]
- Installing checkmate 2.3.4 ...                OK [linked from cache]
- Installing tensorA 0.36.2.1 ...               OK [linked from cache]
- Installing distributional 0.8.1 ...           OK [linked from cache]
- Installing matrixStats 1.5.0 ...              OK [linked from cache]
- Installing posterior 1.7.0 ...                OK [linked from cache]
- Installing bayesplot 1.15.0 ...               OK [linked from cache]
- Installing DBI 1.3.0 ...                      OK [linked from cache]
- Installing biglm 0.9-3 ...                    OK [installed binary and cached in 0.3s]
- Installing RcppParallel 6.2.0 ...             OK [linked from cache]
- Installing StanHeaders 2.32.10 ...            OK [linked from cache]
- Installing inline 0.3.21 ...                  OK [linked from cache]
- Installing gridExtra 2.3.1 ...                OK [linked from cache]
- Installing loo 2.10.1 ...                     OK [linked from cache]
- Installing ps 1.9.3 ...                       OK [linked from cache]
- Installing processx 3.9.0 ...                 OK [linked from cache]
- Installing callr 3.8.0 ...                    OK [linked from cache]
- Installing desc 1.4.3 ...                     OK [linked from cache]
- Installing pkgbuild 1.4.8 ...                 OK [linked from cache]
- Installing QuickJSR 1.10.0 ...                OK [linked from cache]
- Installing rstan 2.32.7 ...                   OK [linked from cache]
- Installing rstantools 2.7.0 ...               OK [linked from cache]
- Installing Brobdingnag 1.2-9 ...              OK [linked from cache]
- Installing coda 0.19-4.1 ...                  OK [linked from cache]
- Installing bridgesampling 1.2-1 ...           OK [linked from cache]
- Installing nleqslv 3.3.7 ...                  OK [linked from cache]
- Installing brms 2.23.0 ...                    OK [linked from cache]
- Installing carData 3.0-6 ...                  OK [linked from cache]
- Installing Formula 1.2-6 ...                  OK [linked from cache]
- Installing SparseM 1.84-2 ...                 OK [linked from cache]
- Installing MatrixModels 0.5-4 ...             OK [linked from cache]
- Installing quantreg 6.1 ...                   OK [linked from cache]
- Installing car 3.1-5 ...                      OK [linked from cache]
- Installing DEoptimR 1.2-0 ...                 OK [installed binary and cached in 0.29s]
- Installing robustbase 0.99-7 ...              OK [installed binary and cached in 0.99s]
- Installing bayesm 3.1-7 ...                   OK [installed binary and cached in 0.35s]
- Installing compositions 2.0-9 ...             OK [installed binary and cached in 0.5s]
- Installing lmerTest 3.2-1 ...                 OK [linked from cache]
- Installing logspline 2.1.22 ...               OK [installed binary and cached in 0.3s]
- Installing lpSolve 5.6.23 ...                 OK [linked from cache]
- Installing htmlTable 2.5.0 ...                OK [installed binary and cached in 0.49s]
- Installing Hmisc 5.2-6 ...                    OK [installed binary and cached in 0.59s]
- Installing mediation 4.5.1 ...                OK [installed binary and cached in 0.34s]
- Installing TH.data 1.1-5 ...                  OK [linked from cache]
- Installing multcomp 1.4-31 ...                OK [linked from cache]
- Installing multcompView 0.1-12 ...            OK [linked from cache]
- Installing MuMIn 1.48.19 ...                  OK [linked from cache]
- Installing ucminf 1.2.3 ...                   OK [linked from cache]
- Installing ordinal 2026.7-26 ...              OK [linked from cache]
- Installing fastGHQuad 1.0.1 ...               OK [installed binary and cached in 0.29s]
- Installing bdsmatrix 1.3-7 ...                OK [linked from cache]
- Installing bbmle 1.0.25.1 ...                 OK [linked from cache]
- Installing VGAM 1.1-14 ...                    OK [linked from cache]
- Installing expint 0.2-1 ...                   OK [installed binary and cached in 0.32s]
- Installing actuar 3.3-7 ...                   OK [installed binary and cached in 1.0s]
- Installing robmixglm 1.2-8 ...                OK [installed binary and cached in 0.36s]
- Installing rsm 2.10.6 ...                     OK [installed binary and cached in 0.33s]
- Installing brio 1.1.5 ...                     OK [linked from cache]
- Installing rprojroot 2.1.1 ...                OK [linked from cache]
- Installing pkgload 1.5.3 ...                  OK [linked from cache]
- Installing praise 1.0.0 ...                   OK [linked from cache]
- Installing crayon 1.5.3 ...                   OK [linked from cache]
- Installing diffobj 0.3.8 ...                  OK [linked from cache]
- Installing waldo 0.6.2 ...                    OK [linked from cache]
- Installing testthat 3.3.2 ...                 OK [linked from cache]
- Installing xtable 1.8-8 ...                   OK [linked from cache]
Successfully installed 70 packages in 7.5 seconds.
The following package(s) will be installed:
- arrow         [25.0.0]
- askpass       [1.2.1]
- assertthat    [0.2.1]
- bit           [4.6.0]
- bit64         [4.8.2]
- blob          [1.3.0]
- cellranger    [1.1.0]
- clipr         [0.8.1]
- conflicted    [1.2.0]
- covr          [3.6.5]
- curl          [7.1.0]
- dbplyr        [2.6.0]
- dtplyr        [1.3.3]
- feather       [0.4.0]
- forcats       [1.0.1]
- gargle        [1.6.1]
- googledrive   [2.1.2]
- googlesheets4 [1.1.2]
- haven         [2.5.5]
- hms           [1.1.4]
- httr          [1.4.8]
- ids           [1.0.1]
- mockr         [0.2.2]
- openssl       [2.4.2]
- prettyunits   [1.2.0]
- progress      [1.2.3]
- ragg          [1.5.2]
- readr         [2.2.0]
- readxl        [1.5.0]
- rematch       [2.0.0]
- rematch2      [2.1.2]
- reprex        [2.1.1]
- rex           [1.2.2]
- rvest         [1.0.5]
- selectr       [0.6-0]
- sys           [3.4.3]
- tidyverse     [2.0.0]
- uuid          [1.2-2]
- vroom         [1.7.1]
These packages will be installed into "~/PhD/01_thesis_chapters/03_koura_shoreline_habitats/renv/library/windows/R-4.6/x86_64-w64-mingw32".

# Installing packages --------------------------------------------------------
- Installing conflicted 1.2.0 ...               OK [linked from cache]
- Installing blob 1.3.0 ...                     OK [linked from cache]
- Installing dbplyr 2.6.0 ...                   OK [linked from cache]
- Installing dtplyr 1.3.3 ...                   OK [linked from cache]
- Installing forcats 1.0.1 ...                  OK [linked from cache]
- Installing curl 7.1.0 ...                     OK [linked from cache]
- Installing sys 3.4.3 ...                      OK [linked from cache]
- Installing askpass 1.2.1 ...                  OK [linked from cache]
- Installing openssl 2.4.2 ...                  OK [linked from cache]
- Installing httr 1.4.8 ...                     OK [linked from cache]
- Installing gargle 1.6.1 ...                   OK [linked from cache]
- Installing uuid 1.2-2 ...                     OK [linked from cache]
- Installing googledrive 2.1.2 ...              OK [linked from cache]
- Installing rematch 2.0.0 ...                  OK [linked from cache]
- Installing cellranger 1.1.0 ...               OK [linked from cache]
- Installing ids 1.0.1 ...                      OK [linked from cache]
- Installing rematch2 2.1.2 ...                 OK [linked from cache]
- Installing googlesheets4 1.1.2 ...            OK [linked from cache]
- Installing hms 1.1.4 ...                      OK [linked from cache]
- Installing clipr 0.8.1 ...                    OK [linked from cache]
- Installing bit 4.6.0 ...                      OK [linked from cache]
- Installing bit64 4.8.2 ...                    OK [linked from cache]
- Installing prettyunits 1.2.0 ...              OK [linked from cache]
- Installing progress 1.2.3 ...                 OK [linked from cache]
- Installing vroom 1.7.1 ...                    OK [linked from cache]
- Installing readr 2.2.0 ...                    OK [linked from cache]
- Installing haven 2.5.5 ...                    OK [linked from cache]
- Installing ragg 1.5.2 ...                     OK [linked from cache]
- Installing readxl 1.5.0 ...                   OK [linked from cache]
- Installing reprex 2.1.1 ...                   OK [linked from cache]
- Installing selectr 0.6-0 ...                  OK [linked from cache]
- Installing rvest 1.0.5 ...                    OK [linked from cache]
- Installing tidyverse 2.0.0 ...                OK [linked from cache]
- Installing rex 1.2.2 ...                      OK [linked from cache]
- Installing covr 3.6.5 ...                     OK [linked from cache]
- Installing assertthat 0.2.1 ...               OK [linked from cache]
- Installing arrow 25.0.0 ...                   OK [linked from cache]
- Installing feather 0.4.0 ...                  OK [linked from cache]
- Installing mockr 0.2.2 ...                    OK [linked from cache]
Successfully installed 39 packages in 0.44 seconds.
View Code

exc_file_dir <- "data/raw/Natural_habitat.xlsx"
der_data_dir <- "data/derived"
out_dir      <- "outputs"

dir.create(exc_file_dir, showWarnings = FALSE, recursive = TRUE)
dir.create(der_data_dir, showWarnings = FALSE, recursive = TRUE)
dir.create(out_dir,      showWarnings = FALSE, recursive = TRUE)

Site_info          <- read_excel(exc_file_dir, sheet = "Site_info")
Monitoring_data    <- read_excel(exc_file_dir, sheet = "Monitoring_data")
Weed_data          <- read_excel(exc_file_dir, sheet = "Weed_data")  %>% dplyr::select(-starts_with("..."))
New names:
• `` -> `...8`
• `` -> `...9`
• `` -> `...10`
• `` -> `...11`
• `` -> `...12`
View Code

Fish_data          <- read_excel(exc_file_dir, sheet = "Fish_data")  %>% dplyr::select(-starts_with("..."))
New names:
• `` -> `...22`
• `` -> `...23`
• `` -> `...24`
• `` -> `...25`
• `` -> `...26`
• `` -> `...27`
• `` -> `...28`
View Code

Macroinvertebrates <- read_excel(exc_file_dir, sheet = "Macroinvertebrates")

Site_info          <- Site_info          |> dplyr::filter(!stringr::str_ends(monitoring_id, "_1"))
Monitoring_data    <- Monitoring_data    |> dplyr::filter(!stringr::str_ends(monitoring_id, "_1"))
Weed_data          <- Weed_data          |> dplyr::filter(!stringr::str_ends(monitoring_id, "_1"))
Fish_data          <- Fish_data          |> dplyr::filter(!stringr::str_ends(monitoring_id, "_1"))
Macroinvertebrates <- Macroinvertebrates |> dplyr::filter(!stringr::str_ends(monitoring_id, "_1"))

# Save as csv to derived
write.csv(Site_info,          file.path(der_data_dir, "Site_info.csv"),          row.names = FALSE)
write.csv(Monitoring_data,    file.path(der_data_dir, "Monitoring_data.csv"),    row.names = FALSE)
write.csv(Weed_data,          file.path(der_data_dir, "Weed_data.csv"),          row.names = FALSE)
write.csv(Fish_data,          file.path(der_data_dir, "Fish_data.csv"),          row.names = FALSE)
write.csv(Macroinvertebrates, file.path(der_data_dir, "Macroinvertebrates.csv"), row.names = FALSE)

# Reproducibility note ___________________________________________________
# The sections above require data/raw/Natural_habitat.xlsx (not tracked).
# To reproduce analyses and figures without the raw Excel file,
# run from here using the derived CSVs in data/derived/:

Site_info          <- read.csv(file.path(der_data_dir, "Site_info.csv"))
Monitoring_data    <- read.csv(file.path(der_data_dir, "Monitoring_data.csv"))
Weed_data          <- read.csv(file.path(der_data_dir, "Weed_data.csv"))
Fish_data          <- read.csv(file.path(der_data_dir, "Fish_data.csv"))
Macroinvertebrates <- read.csv(file.path(der_data_dir, "Macroinvertebrates.csv"))

# Set plot theme
base_theme_bw <- theme_classic() +
  theme(
    text = element_text(family = "Arial", size = 8),
    axis.title = element_text(face = "plain"),
    axis.text = element_text(face = "plain"),
    plot.title = element_text(face = "plain"),
    strip.text = element_text(face = "plain"),
    panel.border = element_rect(colour = "black", fill = NA, linewidth = 0.3)
  )

theme_set(base_theme_bw)

clean_smooth_title <- function(x) {
  x <- gsub("^s\\((.*)\\)$", "\\1", x)
  x <- gsub("_", " ", x)
  x
}

# Format of p values in text
format_pval <- function(p) {
  if (p < 0.001) {
    return("<0.001")
  } else {
    return(format(round(p, 3), nsmall = 3))
  }
}

CPUE and BPUE derivation

Length–weight model and predicted weights

In [2]:
View Code

lw_model <- lm(
log10(weight_g) ~ log10(length_mm),
data = Fish_data,
subset = species_name == "Freshwater_crayfish" | maori_name == "Kōura",
na.action = na.exclude
)

sigma_log10 <- sigma(lw_model)
c <- 10^(0.5 * sigma_log10^2)

Fish_data <- Fish_data |>
mutate(
is_koura = species_name == "Freshwater_crayfish" | maori_name == "Kōura",
Predicted_weight = case_when(
!is.na(weight_g) ~ weight_g,
is_koura ~ {
pred_log <- predict(lw_model, newdata = pick(everything()))
10^(pred_log) * c
},
TRUE ~ NA_real_
),
Weight_source = case_when(
is_koura & !is.na(weight_g) ~ "measured",
is_koura & is.na(weight_g) ~ "predicted",
TRUE ~ NA_character_
)
)

a <- coef(lw_model)[1]
b <- coef(lw_model)[2]
formula_text <- paste0(
"log10(Weight[g]) = ", round(a, 3), " + ", round(b, 3),
" * log10(OCL Length[mm])"
)

length_weight_plot <- ggplot(
Fish_data |> dplyr::filter(is_koura),
aes(x = length_mm, y = Predicted_weight, shape = Weight_source)) +
geom_point(size = 2.2, colour = "black", stroke = 0.6) +
scale_shape_manual(values = c("measured" = 16, "predicted" = 1)) +
labs(x = "OCL length (mm)", y = "Weight (g)", shape = "Weight source") +
annotate("text", x = Inf, y = Inf, label = formula_text, hjust = 1.1, vjust = 1.3, size = 3) 

ggsave(filename = file.path(out_dir, "fig-length-weight.png"), plot = length_weight_plot, width = 7, height = 5, dpi = 1200, create.dir = TRUE)

length_weight_plot
Warning in grid.Call(C_stringMetric, as.graphicsAnnot(x$label)): font family
not found in Windows font database
Warning in grid.Call(C_stringMetric, as.graphicsAnnot(x$label)): font family
not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Length–weight relationship used to estimate missing kōura weights. Observed (measured) and model-predicted body weights are shown in relation to orbital carapace length (OCL). The log₁₀–log₁₀ regression was fitted to individuals with both length and weight measurements (n = 240) and used to estimate missing weights (n = 81). Predicted values were back-transformed using a lognormal bias correction factor. The fitted regression equation is shown on the plot.

CPUE and BPUE calculations

In [3]:
View Code

CPUE_BPUE_legacy <- Fish_data %>%
filter(!is.na(species)) %>%
group_by(monitoring_id, species, net_type) %>%
dplyr::reframe(
Total_Individuals = sum(amount, na.rm = TRUE),
Total_Weight      = sum(Predicted_weight, na.rm = TRUE),
Total_Effort      = dplyr::first(amount_nets),
CPUE              = Total_Individuals / Total_Effort,
BPUE              = Total_Weight / Total_Effort,
Mean_Length       = mean(length_mm, na.rm = TRUE),
Min_Length        = ifelse(all(is.na(length_mm)), NA, min(length_mm, na.rm = TRUE)),
Max_Length        = ifelse(all(is.na(length_mm)), NA, max(length_mm, na.rm = TRUE)),
Mean_Weight       = mean(Predicted_weight, na.rm = TRUE),
Min_Weight        = ifelse(all(is.na(Predicted_weight)), NA, min(Predicted_weight, na.rm = TRUE)),
Max_Weight        = ifelse(all(is.na(Predicted_weight)), NA, max(Predicted_weight, na.rm = TRUE))
)

CPUE_BPUE_weighted <- CPUE_BPUE_legacy %>%
group_by(monitoring_id, species) %>%
summarise(
Total_Individuals       = sum(Total_Individuals, na.rm = TRUE),
Total_Weight            = sum(Total_Weight,      na.rm = TRUE),
Weighted_CPUE_numerator = sum(CPUE * Total_Effort,  na.rm = TRUE),
Weighted_BPUE_numerator = sum(BPUE * Total_Effort,  na.rm = TRUE),
Total_Effort_sum        = sum(Total_Effort,         na.rm = TRUE),
Mean_Length             = mean(Mean_Length, na.rm = TRUE),
Min_Length              = ifelse(all(is.na(Min_Length)), NA, min(Min_Length, na.rm = TRUE)),
Max_Length              = ifelse(all(is.na(Max_Length)), NA, max(Max_Length, na.rm = TRUE)),
Mean_Weight             = mean(Mean_Weight, na.rm = TRUE),
Min_Weight              = ifelse(all(is.na(Min_Weight)), NA, min(Min_Weight, na.rm = TRUE)),
Max_Weight              = ifelse(all(is.na(Max_Weight)), NA, max(Max_Weight, na.rm = TRUE)),
.groups = "drop"
) %>%
mutate(
Total_Effort_sum = ifelse(monitoring_id %in% c("96_0", "101_0", "117_1", "119_1"), 3, 4),
Weighted_CPUE    = Weighted_CPUE_numerator / Total_Effort_sum,
Weighted_BPUE    = Weighted_BPUE_numerator / Total_Effort_sum
)

species_presence_absence <- Fish_data %>%
filter(!is.na(species)) %>%
distinct(monitoring_id, species) %>%
mutate(Presence = 1) %>%
pivot_wider(
names_from  = species,
values_from = Presence,
values_fill = list(Presence = 0),
names_prefix = "Presence_"
) %>%
mutate(Predator_Fish_Presence = pmax(Presence_Trout, Presence_Eel, Presence_Catfish))

CPUE_BPUE_weighted_summary <- CPUE_BPUE_weighted %>%
pivot_wider(
names_from  = species,
values_from = c(
Total_Individuals, Weighted_CPUE, Weighted_BPUE, Total_Weight,
Mean_Length, Mean_Weight, Weighted_CPUE_numerator, Weighted_BPUE_numerator,
Total_Effort_sum, Min_Length, Max_Length, Min_Weight, Max_Weight
),
names_sep   = "_",
values_fill = list(Total_Individuals = 0, Weighted_CPUE = 0, Weighted_BPUE = 0)
) %>%
mutate(
Richness  = rowSums(dplyr::select(., starts_with("Total_Individuals_")) > 0),
Abundance = rowSums(dplyr::select(., starts_with("Total_Individuals_") & !ends_with(c("_Bullies", "_Common_smelt"))))
)

Combined dataset and habitat classification

In [4]:
View Code

unit_metadata <- Monitoring_data %>%
dplyr::select(parameter, unit) %>%
distinct()

Monitoring_summary <- Monitoring_data %>%
dplyr::select(-site_id, -group, -notes, -unit) %>%
pivot_wider(
names_from  = c(parameter),
values_from = value,
values_fill = list(value = NA)
) %>%
mutate(across(
c(
Bottom_visible, Water_clarity, Depth_10m, Slope, Riparian_vegetation, Vegetation_nearby,
Overhanging_trees, Erosion, Structure, Bedrock, Boulders, Cobble, Gravel,
Sand, Mud, Organic_matter, Rock_size, Temperature, DO_mgl, DO_percent,
Conductivity, Specific_conductivity, pH, Wood_cover
),
~ as.numeric(.)
))

Weed_summary <- Weed_data %>%
group_by(monitoring_id, weed_type, native_status) %>%
summarise(Total_Cover = sum(percentage_cover, na.rm = TRUE), .groups = "drop") %>%
pivot_wider(
names_from  = c(weed_type, native_status),
values_from = Total_Cover,
values_fill = 0
)

Macroinvertebrates_sum <- Macroinvertebrates %>%
group_by(monitoring_id, species) %>%
summarise(Total_amount = sum(amount, na.rm = TRUE), .groups = "drop") %>%
pivot_wider(names_from = c(species), values_from = Total_amount, values_fill = 0) %>%
mutate(
Invertebrates_Richness  = rowSums(dplyr::select(., -monitoring_id) > 0),
Invertebrates_Abundance = rowSums(dplyr::select(., -monitoring_id))
)

Monitoring_CPUE_data <- Site_info %>%
left_join(Monitoring_summary, by = "monitoring_id") %>%
left_join(
Weed_summary %>% dplyr::select(
monitoring_id,
Emergent_Native, Emergent_Non_Native,
Submerged_Native, Submerged_Non_Native, Turf_Native
),
by = "monitoring_id"
) %>%
left_join(CPUE_BPUE_weighted_summary, by = "monitoring_id") %>%
left_join(species_presence_absence, by = "monitoring_id") %>%
left_join(Macroinvertebrates_sum, by = "monitoring_id")

Monitoring_CPUE_data <- Monitoring_CPUE_data %>%
  mutate(
    Presence_rocks = if_else(Cobble > 1 | Boulders > 1, 1, 0),
    Slope_5m       = 5 / distance_5m,
    site_id_       = site_id - 60,
    monitoring_id_ = paste0(
      as.numeric(sub("_.*", "", monitoring_id)) - 60,
      sub("^[^_]*", "", monitoring_id)
    ),
    Monitoring     = sub(".*?_", "", monitoring_id),
    Date           = as.Date(date_time),
    Time           = format(as.POSIXct(date_time), "%H:%M:%S"),
    Year           = lubridate::year(date_time),
    Month          = lubridate::month(date_time, label = TRUE),
    Day            = lubridate::day(date_time),
    Season         = case_when(
      Month %in% c("Dec", "Jan", "Feb") ~ "Summer",
      Month %in% c("Mar", "Apr", "May") ~ "Autumn",
      Month %in% c("Jun", "Jul", "Aug") ~ "Winter",
      Month %in% c("Sep", "Oct", "Nov") ~ "Spring",
      TRUE ~ NA_character_
    ),
    Date_Time_Numeric = as.numeric(date_time)
  )
Warning: There was 1 warning in `mutate()`.
ℹ In argument: `Date_Time_Numeric = as.numeric(date_time)`.
Caused by warning:
! NAs introduced by coercion
View Code

habitat_classification <- Monitoring_CPUE_data %>%
dplyr::select(
monitoring_id, DHT, lake,
Bedrock, Boulders, Cobble, Gravel, Sand, Mud, Organic_matter,
Emergent_Native, Emergent_Non_Native,
Submerged_Native, Submerged_Non_Native, Wood_cover
) %>%
pivot_longer(
cols = c(
Bedrock, Boulders, Cobble, Gravel, Sand, Mud, Organic_matter,
Emergent_Native, Emergent_Non_Native,
Submerged_Native, Submerged_Non_Native, Wood_cover
),
names_to = "Type",
values_to = "Percentage"
) %>%
group_by(monitoring_id) %>%
summarise(
Rocky_Percentage = sum(Percentage[Type %in% c("Bedrock", "Boulders", "Cobble")], na.rm = TRUE),
Sand_Percentage  = sum(Percentage[Type == "Sand"], na.rm = TRUE),
Mud_Percentage   = sum(Percentage[Type %in% c("Mud", "Organic_matter")], na.rm = TRUE),
Emergent_Percentage = sum(Percentage[Type == "Emergent_Native"], na.rm = TRUE),
Substrate_index = sum(
0.08 * Percentage[Type == "Bedrock"] +
0.07 * Percentage[Type == "Boulders"] +
0.06 * Percentage[Type == "Cobble"] +
0.04 * Percentage[Type == "Gravel"] +
0.03 * Percentage[Type == "Sand"] +
0.02 * Percentage[Type == "Organic_matter"] +
0.01 * Percentage[Type == "Mud"],
na.rm = TRUE
),
Substrate_CV = {
substrate_vals <- Percentage[Type %in% c("Bedrock", "Boulders", "Cobble", "Gravel", "Sand", "Mud", "Organic_matter")]
substrate_vals <- substrate_vals[!is.na(substrate_vals)]
if (length(substrate_vals) > 1 && mean(substrate_vals) > 0) {
sd(substrate_vals) / mean(substrate_vals)
} else {
NA_real_
}
},
.groups = "drop"
) %>%
mutate(
Habitat_Type = case_when(
Rocky_Percentage > 25 ~ "Rocky",
Emergent_Percentage > 25 ~ "Emergent Macrophyte",
Sand_Percentage >= Mud_Percentage ~ "Sandy",
TRUE ~ "Muddy"
)
) %>%
dplyr::select(monitoring_id, Habitat_Type, Substrate_index, Substrate_CV)

Monitoring_CPUE_data <- Monitoring_CPUE_data %>%
left_join(habitat_classification, by = "monitoring_id")

writexl::write_xlsx(Monitoring_CPUE_data, file.path(der_data_dir, "Monitoring_CPUE_data.xlsx"))
write.csv(Monitoring_CPUE_data, file.path(der_data_dir, "Monitoring_CPUE_data.csv"), row.names = FALSE)
write.csv(habitat_classification, file.path(der_data_dir, "habitat_classification.csv"), row.names = FALSE)

#head(Monitoring_CPUE_data)

habitat-correlation

In [5]:
View Code

cor(habitat_classification$Substrate_index,
habitat_classification$Substrate_CV,
use = "complete.obs")
[1] -0.4785045

Lake overview table

In [6]:
View Code

shoreline_comp <- data.frame(
  `Lake name`                   = c("Rotorua", "Rotoiti", "Rotoehu", "Rotomā", "Ōkāreka"),
  `Muddy (%)`                   = c(0, 0, 3.3, 0, 8.9),
  `Sandy (%)`                   = c(82.8, 68.5, 91.2, 62.0, 45.1),
  `Rocky (%)`                   = c(17.2, 21.8, 1.7, 17.7, 20.2),
  `Emergent macrophytes (%)`    = c(0, 9.7, 3.8, 20.4, 25.8),
  check.names = FALSE
)

lake_data <- data.frame(
  `Lake name`                       = c("Rotorua", "Rotoiti", "Rotoehu", "Rotomā", "Ōkāreka"),
  `Sampling date (n sites)`         = c("20/02/2025 (12)", "15/01/2025 (12)", "10/12/2024 (12)", "6/11/2024 (12)", "31/10/2024 (10), 22/01/2025 (2)"),
  `Surface area (km²)`              = c(81, 34, 8, 11, 3),
  `Perimeter length (km)`           = c(45, 61, 40, 24, 11),
  `Catchment area (km²)`            = c(508, 123.7, 49.2, 27.8, 19.6),
  `Mean depth (m)`                  = c(11, 31.5, 8, 36.9, 20),
  `Maximum depth (m)`               = c(45, 124, 13.5, 83, 33.5),
  `Elevation (m)`                   = c(280, 279, 295, 316, 355),
  `Mixing regime`                   = c("Polymictic", "Monomictic", "Polymictic", "Monomictic", "Monomictic"),
  `Trophic state`                   = c("Eutrophic", "Mesotrophic", "Eutrophic", "Oligotrophic", "Mesotrophic"),
  check.names = FALSE
) %>%
  dplyr::left_join(shoreline_comp, by = "Lake name")

write.csv(lake_data, file = file.path(out_dir, "tbl-lake-overview.csv"), row.names = FALSE)

if (knitr::is_latex_output()) {
  col_nms <- colnames(lake_data)
  rotated_nms <- c(
    col_nms[1],
    sapply(col_nms[-1], function(x)
      paste0("\\rotatebox{90}{\\parbox{3.2cm}{\\raggedright ", x, "}}")
    )
  )
  knitr::kable(lake_data,
               booktabs = TRUE,
               col.names = rotated_nms,
               escape = FALSE,
               linesep = "") %>%
    kableExtra::kable_styling(
      latex_options = "hold_position",
      full_width = FALSE,
      font_size = 9
    ) %>%
    kableExtra::add_header_above(
      c(" " = 10, "Shoreline composition (%)" = 4),
      bold = TRUE
    ) %>%
    kableExtra::column_spec(1, width = "2.2cm") %>%
    kableExtra::column_spec(2, width = "1.8cm") %>%
    kableExtra::column_spec(3:14, width = "0.9cm")
} else {
  knitr::kable(lake_data)
}
In [7]:
Physical, morphometric, and trophic characteristics of the five Rotorua Te Arawa lakes surveyed, including sampling dates and number of littoral sites sampled per lake. Lake surface area, perimeter length, catchment area, depth, elevation, mixing regime, and trophic state for 2024 are derived from the Land, Air, Water Aotearoa (LAWA) (2025) database. Shoreline composition percentages are calculated from useable shoreline only, excluding geothermal and cliff sections.
Lake name Sampling date (n sites) Surface area (km²) Perimeter length (km) Catchment area (km²) Mean depth (m) Maximum depth (m) Elevation (m) Mixing regime Trophic state Muddy (%) Sandy (%) Rocky (%) Emergent macrophytes (%)
Rotorua 20/02/2025 (12) 81 45 508.0 11.0 45.0 280 Polymictic Eutrophic 0.0 82.8 17.2 0.0
Rotoiti 15/01/2025 (12) 34 61 123.7 31.5 124.0 279 Monomictic Mesotrophic 0.0 68.5 21.8 9.7
Rotoehu 10/12/2024 (12) 8 40 49.2 8.0 13.5 295 Polymictic Eutrophic 3.3 91.2 1.7 3.8
Rotomā 6/11/2024 (12) 11 24 27.8 36.9 83.0 316 Monomictic Oligotrophic 0.0 62.0 17.7 20.4
Ōkāreka 31/10/2024 (10), 22/01/2025 (2) 3 11 19.6 20.0 33.5 355 Monomictic Mesotrophic 8.9 45.1 20.2 25.8

Overview of environmental and biotic variables

In [8]:
View Code

biotic_vars <- data.frame(
  `Variable`= c("Lake identity", "Substrate index", "Slope", "Riparian vegetation", "Overhanging trees", "Wood cover", "Emergent and submerged macrophytes", "Temperature", "Dissolved oxygen", "Specific conductivity", "pH", "Fish presence"),
  `Description / Unit`= c("Categorical variable identifying lake", "Index based on % cover of bedrock, boulders, cobble, gravel, sand, mud, and organic matter", "Slope from shoreline to the 5 m depth contour", "Percentage cover of vegetation growing in the riparian zone", "Percentage cover of trees hanging over the shoreline", "Cover of wooden logs and tree branches in the sample site", "Percentage cover of macrophytes in the sample site divided over emergent and submerged and native and non-native species", "Temperature of surface water in °C", "Dissolved oxygen concentration in mg L⁻¹", "Electrical conductivity of the water in µS cm⁻¹", "Acidity or alkalinity of the water", "Presence/absence of selected native and non-native fish species"),
  `Hypothesised importance for kōura` = c("Captures unmeasured lake-specific differences in water chemistry, productivity, and catchment characteristics.", "Important for burrowing and shelter availability. Coarser substrates increase shelter availability through more crevices.", "Steeper slopes facilitate access to deeper water during daylight refuging and associate with coarser substrates.", "Contributes detrital inputs, bank stability, and shading at the water's edge.", "Provides direct shading and structural inputs into littoral habitats.", "Provides physical structure creating refuge spaces and supports macroinvertebrate prey availability.", "Native macrophytes can provide cover and serve as a food source. Non-native macrophytes may alter movement pathways and modify local habitat and water quality conditions.", "Influences metabolic rate, activity, physiological stress, and habitat suitability.", "Essential for respiration; reduced oxygen may constrain activity and habitat use.", "Reflects overall lake productivity, supporting food availability.", "Influences moulting success and exoskeleton strength, affected by acidity or calcium levels.", "Fish act as predators, competitors, or indirectly modify habitat structure."),
  `References` = c("Zuur et al., (2009)", "Usio & Townsend, (2000); Kusabs et al., (2015b)", "Devcich, (1979); Kusabs et al., (2015b)", "Parkyn et al., (2002)", "Smith et al., (1996); Vedia et al., (2017)", "Parkyn et al., (2009)", "Coffey & Clayton, (1988); Kusabs & Quinn, (2009)", "Devcich, (1979); Hammond et al., (2006); Parkyn & Collier, (2002); Angilletta et al., (2004)", "Hammond et al., (2006); Broughton et al., (2017)", "Devcich, (1979)", "Olsson et al., (2006)", "Shave et al., (1994); Barnes, (1996); Usio & Townsend, (2000); Barnes & Hicks, (2003)"),
  check.names = FALSE
)

write.csv(biotic_vars, file = file.path(out_dir, "tbl-environmental-biotic-overview.csv"), row.names = FALSE)

if (knitr::is_latex_output()) {
  knitr::kable(biotic_vars,
               booktabs = TRUE,
               longtable = TRUE,
               linesep = "") %>%
    kableExtra::kable_styling(
      latex_options = c("repeat_header"),
      font_size = 9,
      full_width = FALSE
    ) %>%
    kableExtra::column_spec(1, width = "2.5cm") %>%
    kableExtra::column_spec(2, width = "3.2cm") %>%
    kableExtra::column_spec(3, width = "5.8cm") %>%
    kableExtra::column_spec(4, width = "4.0cm")
} else {
  knitr::kable(biotic_vars)
}
In [9]:
Overview of environmental and biotic variables measured at littoral sampling sites, including variable descriptions, units, and hypothesised relevance for kōura (Paranephrops planifrons). Variables were selected a priori based on known habitat requirements, physiological constraints, and potential biotic interactions influencing kōura occurrence, abundance, and biomass in lake littoral zones.
Variable Description / Unit Hypothesised importance for kōura References
Lake identity Categorical variable identifying lake Captures unmeasured lake-specific differences in water chemistry, productivity, and catchment characteristics. Zuur et al., (2009)
Substrate index Index based on % cover of bedrock, boulders, cobble, gravel, sand, mud, and organic matter Important for burrowing and shelter availability. Coarser substrates increase shelter availability through more crevices. Usio & Townsend, (2000); Kusabs et al., (2015b)
Slope Slope from shoreline to the 5 m depth contour Steeper slopes facilitate access to deeper water during daylight refuging and associate with coarser substrates. Devcich, (1979); Kusabs et al., (2015b)
Riparian vegetation Percentage cover of vegetation growing in the riparian zone Contributes detrital inputs, bank stability, and shading at the water’s edge. Parkyn et al., (2002)
Overhanging trees Percentage cover of trees hanging over the shoreline Provides direct shading and structural inputs into littoral habitats. Smith et al., (1996); Vedia et al., (2017)
Wood cover Cover of wooden logs and tree branches in the sample site Provides physical structure creating refuge spaces and supports macroinvertebrate prey availability. Parkyn et al., (2009)
Emergent and submerged macrophytes Percentage cover of macrophytes in the sample site divided over emergent and submerged and native and non-native species Native macrophytes can provide cover and serve as a food source. Non-native macrophytes may alter movement pathways and modify local habitat and water quality conditions. Coffey & Clayton, (1988); Kusabs & Quinn, (2009)
Temperature Temperature of surface water in °C Influences metabolic rate, activity, physiological stress, and habitat suitability. Devcich, (1979); Hammond et al., (2006); Parkyn & Collier, (2002); Angilletta et al., (2004)
Dissolved oxygen Dissolved oxygen concentration in mg L⁻¹ Essential for respiration; reduced oxygen may constrain activity and habitat use. Hammond et al., (2006); Broughton et al., (2017)
Specific conductivity Electrical conductivity of the water in µS cm⁻¹ Reflects overall lake productivity, supporting food availability. Devcich, (1979)
pH Acidity or alkalinity of the water Influences moulting success and exoskeleton strength, affected by acidity or calcium levels. Olsson et al., (2006)
Fish presence Presence/absence of selected native and non-native fish species Fish act as predators, competitors, or indirectly modify habitat structure. Shave et al., (1994); Barnes, (1996); Usio & Townsend, (2000); Barnes & Hicks, (2003)

Environmental and fish summaries

In [10]:
View Code

M_C_data <- Monitoring_CPUE_data
lake_order <- c("Rotorua", "Rotoiti", "Rotoehu", "Rotomā", "Ōkāreka")
M_C_data$lake <- factor(M_C_data$lake, levels = lake_order)

ci95 <- function(x) {
  x <- x[!is.na(x)]
  n <- length(x)
  if (n < 2) return(c(NA_real_, NA_real_))
  se <- sd(x) / sqrt(n)
  tcrit <- qt(0.975, df = n - 1)
  m <- mean(x)
  c(m - tcrit * se, m + tcrit * se)
}

unit_lookup <- Monitoring_data %>%
  dplyr::select(parameter, unit) %>%
  dplyr::distinct() %>%
  dplyr::mutate(
    Variable = dplyr::case_when(
      parameter == "Riparian_vegetation"       ~ "Riparian_vegetation",
      parameter == "Overhanging_trees"         ~ "Overhanging_trees",
      parameter == "Wood_cover"                ~ "Wood_cover",
      parameter == "Temperature"               ~ "Temperature",
      parameter == "DO_mgl"                    ~ "DO_mgl",
      parameter == "pH"                        ~ "pH",
      parameter == "Specific_conductivity"     ~ "Specific_conductivity",
      parameter == "Substrate_index"           ~ "Substrate_index",
      parameter == "Slope_5m"                  ~ "Slope_5m",
      TRUE ~ NA_character_
    )
  ) %>%
  dplyr::filter(!is.na(Variable)) %>%
  dplyr::select(Variable, unit) %>%
  dplyr::distinct()

# Add units for derived variables (not in Monitoring_data)
derived_units <- tibble::tibble(
  Variable = c("Emergent_vegetation", "Submerged_vegetation"),
  unit     = c("%", "%")
)

# Fish presence units (binary)
fish_units <- tibble::tibble(
  Variable = c(
    "Presence_Eel","Presence_Common_smelt","Presence_Catfish",
    "Presence_Goldfish","Presence_Kōaro","Presence_Trout"
  ),
  unit = ""
)

unit_lookup_all <- dplyr::bind_rows(unit_lookup, derived_units, fish_units) %>%
  dplyr::distinct(Variable, .keep_all = TRUE)

# ---- Environmental summary ----
Env_data <- M_C_data %>%
  dplyr::mutate(
    Emergent_vegetation  = Emergent_Native + Emergent_Non_Native,
    Submerged_vegetation = Submerged_Native + Submerged_Non_Native
  ) %>%
  dplyr::select(
    lake, Substrate_index, Slope_5m, Riparian_vegetation, Overhanging_trees, Wood_cover,
    Temperature, DO_mgl, pH, Specific_conductivity, Emergent_vegetation, Submerged_vegetation
  ) %>%
  tidyr::pivot_longer(-lake, names_to = "Variable", values_to = "Value")

Env_summary_table <- Env_data %>%
  dplyr::group_by(lake, Variable) %>%
  dplyr::summarise(
    n       = sum(!is.na(Value)),
    Mean    = mean(Value, na.rm = TRUE),
    Median  = median(Value, na.rm = TRUE),
    Min     = min(Value, na.rm = TRUE),
    Max     = max(Value, na.rm = TRUE),
    CI_low  = ci95(Value)[1],
    CI_high = ci95(Value)[2],
    .groups = "drop"
  )

Env_summary_all <- Env_data %>%
  dplyr::group_by(Variable) %>%
  dplyr::summarise(
    lake    = "All lakes",
    n       = sum(!is.na(Value)),
    Mean    = mean(Value, na.rm = TRUE),
    Median  = median(Value, na.rm = TRUE),
    Min     = min(Value, na.rm = TRUE),
    Max     = max(Value, na.rm = TRUE),
    CI_low  = ci95(Value)[1],
    CI_high = ci95(Value)[2],
    .groups = "drop"
  ) %>%
  dplyr::select(lake, dplyr::everything())

Env_summary_table <- dplyr::bind_rows(Env_summary_table, Env_summary_all)

# ---- Fish presence summary ----
Fish_data_long <- M_C_data %>%
  dplyr::select(
    lake, Presence_Eel, Presence_Common_smelt, Presence_Catfish,
    Presence_Goldfish, Presence_Kōaro, Presence_Trout
  ) %>%
  tidyr::pivot_longer(-lake, names_to = "Variable", values_to = "Presence")

Fish_summary_lake <- Fish_data_long %>%
  dplyr::group_by(lake, Variable) %>%
  dplyr::summarise(
    n       = sum(!is.na(Presence)),
    k       = sum(Presence, na.rm = TRUE),
    Mean    = mean(Presence, na.rm = TRUE),
    Median  = median(Presence, na.rm = TRUE),
    Min     = min(Presence, na.rm = TRUE),
    Max     = max(Presence, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  dplyr::rowwise() %>%
  dplyr::mutate(
    CI_low  = ifelse(n > 0, binom.test(k, n)$conf.int[1], NA_real_),
    CI_high = ifelse(n > 0, binom.test(k, n)$conf.int[2], NA_real_)
  ) %>%
  dplyr::ungroup() %>%
  dplyr::select(-k)

Fish_summary_all <- Fish_data_long %>%
  dplyr::group_by(Variable) %>%
  dplyr::summarise(
    lake    = "All lakes",
    n       = sum(!is.na(Presence)),
    k       = sum(Presence, na.rm = TRUE),
    Mean    = mean(Presence, na.rm = TRUE),
    Median  = median(Presence, na.rm = TRUE),
    Min     = min(Presence, na.rm = TRUE),
    Max     = max(Presence, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  dplyr::rowwise() %>%
  dplyr::mutate(
    CI_low  = ifelse(n > 0, binom.test(k, n)$conf.int[1], NA_real_),
    CI_high = ifelse(n > 0, binom.test(k, n)$conf.int[2], NA_real_)
  ) %>%
  dplyr::ungroup() %>%
  dplyr::select(lake, dplyr::everything(), -k)

Fish_summary_table <- dplyr::bind_rows(Fish_summary_lake, Fish_summary_all)

# ---- Combine + attach units + make Variable labels pretty ----
EnvBio_summary_table <- dplyr::bind_rows(Env_summary_table, Fish_summary_table) %>%
  dplyr::left_join(unit_lookup_all, by = "Variable") %>%
  dplyr::mutate(
    Variable = dplyr::recode(
      Variable,
      Substrate_index       = "Substrate index",
      Slope_5m              = "Slope 5m",
      Riparian_vegetation   = "Riparian vegetation",
      Overhanging_trees     = "Overhanging trees",
      Wood_cover            = "Wood cover",
      Emergent_vegetation   = "Emergent macrophytes",
      Submerged_vegetation  = "Submerged macrophytes",
      Temperature           = "Temperature",
      DO_mgl                = "Dissolved oxygen",
      Specific_conductivity = "Specific conductivity",
      pH                    = "pH",
      Presence_Catfish      = "Presence Catfish",
      Presence_Eel          = "Presence Eel",
      Presence_Goldfish     = "Presence Goldfish",
      Presence_Common_smelt = "Presence Common smelt",
      Presence_Kōaro        = "Presence Kōaro",
      Presence_Trout        = "Presence Trout"
    ),
    unit = dplyr::coalesce(unit, "")
  )

lake_order_exact <- c("Rotorua", "Rotoiti", "Rotoehu", "Rotomā", "Ōkāreka", "All lakes")

var_order_exact <- c("Substrate index","Slope 5m","Riparian vegetation","Overhanging trees","Wood cover","Emergent macrophytes", "Submerged macrophytes", "Temperature", "Dissolved oxygen", "Specific conductivity","pH", "Presence Catfish", "Presence Eel", "Presence Goldfish", "Presence Common smelt", "Presence Kōaro", "Presence Trout")

EnvBio_summary_table <- EnvBio_summary_table %>%
  dplyr::mutate(
    lake     = factor(as.character(lake), levels = lake_order_exact),
    Variable = factor(as.character(Variable), levels = var_order_exact)
  ) %>%
  dplyr::select(lake, Variable, unit, n, Mean, Median, Min, Max, CI_low, CI_high) %>%
  dplyr::arrange(Variable, lake) %>%
  dplyr::mutate(
    lake     = as.character(lake),
    Variable = as.character(Variable)
  )

# Save
write.csv(
  EnvBio_summary_table,
  file = file.path(out_dir, "tbl-env-fish-summary.csv"),
  row.names = FALSE
)
In [11]:
View Code

DT::datatable(
  EnvBio_summary_table,
  filter = "top",
  options = list(
    pageLength = 20,
    dom = 'Bfrtip',
    buttons = c('csv', 'excel'),
    columnDefs = list(list(className = 'dt-center', targets = 3:9))
  ),
  extensions = 'Buttons',
  rownames = FALSE,
  colnames = c("Lake", "Variable", "Unit", "n", "Mean", "Median", "Min", "Max", "CI low", "CI high")
) |>
  DT::formatRound(columns = c("Mean","Median","Min","Max","CI_low","CI_high"), digits = 2)
In [12]:
Distribution of environmental and biotic variables measured at littoral sampling sites across five Te Arawa lakes in the Rotorua region of Aotearoa New Zealand.
In [13]:
Distribution of environmental and biotic variables measured at littoral sampling sites across five Te Arawa lakes in the Rotorua region of Aotearoa New Zealand.
View Code

knitr::kable(EnvBio_summary_table, digits = 2,
             align = c("l","l","l","r","r","r","r","r","r","r"),
             col.names = c("Lake","Variable","Unit","n","Mean","Median","Min","Max","CI low","CI high"))

Kōura presence, CPUE, and BPUE across lakes

In [14]:
View Code

plot_koura_stats <- function(data, y_var, y_label,
type = c("continuous", "presence"),
show_x_title = FALSE) {

type <- match.arg(type)
lakes <- levels(data$lake)
xlab_text <- if (show_x_title) "Lake" else NULL

base_theme <- theme_classic() +
theme(
text = element_text(family = "Arial", size = 8),
axis.title = element_text(face = "plain"),
axis.text = element_text(face = "plain"),
panel.border = element_rect(colour = "black", fill = NA, linewidth = 0.3)
)

if (type == "continuous") {


ggplot(data, aes(lake, .data[[y_var]])) +
  geom_boxplot(fill = NA, colour = "black", linewidth = 0.3, outlier.size = 1.5) +
  labs(y = y_label, x = xlab_text) +
  base_theme
} else {
presence_summary <- data %>%
  group_by(lake) %>%
  summarise(
    n = sum(!is.na(.data[[y_var]])),
    k = sum(.data[[y_var]] == 1, na.rm = TRUE),
    Presence_Rate = ifelse(n > 0, k / n, NA_real_),
    .groups = "drop"
  ) %>%
  rowwise() %>%
  mutate(
    CI_low  = ifelse(n > 0, binom.test(k, n)$conf.int[1], NA_real_),
    CI_high = ifelse(n > 0, binom.test(k, n)$conf.int[2], NA_real_)
  ) %>%
  ungroup() %>%
  mutate(lake = factor(lake, levels = lakes))

ggplot(presence_summary, aes(x = lake, y = Presence_Rate)) +
  geom_col(fill = NA, colour = "black", linewidth = 0.3) +
  geom_errorbar(aes(ymin = CI_low, ymax = CI_high), width = 0.15, linewidth = 0.3) +
  scale_y_continuous(limits = c(0, 1)) +
  labs(x = xlab_text, y = y_label) 
}
}

KPRES_plot <- plot_koura_stats(M_C_data, "Presence_Kōura", "Kōura presence", type = "presence", show_x_title = FALSE)
KCPUE_plot <- plot_koura_stats(M_C_data, "Weighted_CPUE_Kōura", "Kōura CPUE", type = "continuous", show_x_title = FALSE)
KBPUE_plot <- plot_koura_stats(M_C_data, "Weighted_BPUE_Kōura", "Kōura BPUE", type = "continuous", show_x_title = TRUE)

Koura_plots <- KPRES_plot / KCPUE_plot / KBPUE_plot

ggsave(file.path(out_dir, "fig-koura-by-lake.png"), Koura_plots, 
       width = 3, height = 5, dpi = 1200)

Koura_plots
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Fig. 2 Kōura presence, CPUE, and BPUE across lakes. The upper panel shows the proportion of sampled sites with kōura present in each lake, with error bars indicating 95% binomial confidence intervals. The middle and lower panels show the distribution of kōura CPUE and BPUE, respectively, across lakes using boxplots (median, interquartile range, and range).

Lake differences glmm

In [15]:
View Code

fit_presence_glmm <- function(data, response, lake_var = "lake", random_effect = "Habitat_Type") {
  form <- as.formula(paste0(response, " ~ ", lake_var, " + (1|", random_effect, ")"))
  glmmTMB::glmmTMB(form, data = data, family = binomial(link = "logit"))
}

fit_tweedie_glmm <- function(data, response, lake_var = "lake", random_effect = "Habitat_Type") {
  form <- as.formula(paste0(response, " ~ ", lake_var, " + (1|", random_effect, ")"))
  glmmTMB::glmmTMB(form, data = data, family = glmmTMB::tweedie(link = "log"))
}

m_koura_pres <- fit_presence_glmm(M_C_data, response = "Presence_Kōura",       lake_var = "lake", random_effect = "Habitat_Type")
m_koura_cpue <- fit_tweedie_glmm( M_C_data, response = "Weighted_CPUE_Kōura",  lake_var = "lake", random_effect = "Habitat_Type")
m_koura_bpue <- fit_tweedie_glmm( M_C_data, response = "Weighted_BPUE_Kōura",  lake_var = "lake", random_effect = "Habitat_Type")

# Overall lake effect (LRT via drop1)
pres_lrt <- drop1(m_koura_pres, test = "Chisq")
cpue_lrt <- drop1(m_koura_cpue, test = "Chisq")
bpue_lrt <- drop1(m_koura_bpue, test = "Chisq")

# Helper to report a drop1 LRT inline, e.g. "chi^2_4 = 13.76, p = 0.008"
report_lrt <- function(lrt_obj, term = "lake") {
  df    <- as.character(lrt_obj[term, "Df"])
  chisq <- formatC(lrt_obj[term, "LRT"], format = "f", digits = 2)
  p_val <- formatC(lrt_obj[term, "Pr(>Chi)"], format = "f", digits = 3)
  sprintf("$\\chi^2_{%s} = %s$, *p* = %s", df, chisq, p_val)
}

# Pairwise lake comparisons (BH-adjusted)
pres_pairs <- pairs(emmeans(m_koura_pres, ~ lake, type = "response"), adjust = "BH")
cpue_pairs <- pairs(emmeans(m_koura_cpue, ~ lake, type = "response"), adjust = "BH")
bpue_pairs <- pairs(emmeans(m_koura_bpue, ~ lake, type = "response"), adjust = "BH")

pres_lrt
Single term deletions

Model:
Presence_Kōura ~ lake + (1 | Habitat_Type)
       Df    AIC    LRT Pr(>Chi)   
<none>    75.422                   
lake    4 81.180 13.758 0.008109 **
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
View Code

cpue_lrt
Single term deletions

Model:
Weighted_CPUE_Kōura ~ lake + (1 | Habitat_Type)
       Df    AIC    LRT Pr(>Chi)  
<none>    207.43                  
lake    4 212.03 12.601   0.0134 *
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
View Code

bpue_lrt
Single term deletions

Model:
Weighted_BPUE_Kōura ~ lake + (1 | Habitat_Type)
       Df    AIC    LRT Pr(>Chi)  
<none>    410.66                  
lake    4 412.32 9.6552  0.04665 *
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

GAM modelling

Helpers

In [16]:
View Code

Modeling_data <- Monitoring_CPUE_data

alpha_sig    <- 0.1
p_cutoff_ml  <- 0.05
vif_thresh   <- 5
INCLUDE_RE   <- TRUE

custom_k <- list(
Slope_5m             = 10,
Riparian_vegetation  = 7,
Overhanging_trees    = 5,
Wood_cover           = 10,
Substrate_index      = 10,
Temperature          = 10,
pH                   = 10,
DO_mgl               = 10,
Emergent_Native      = 9,
Submerged_Non_Native = 9,
Turf_Native          = 6,
Submerged_Native     = 6
)

fish_vars <- c(
"Presence_Goldfish",
"Presence_Eel",
"Presence_Catfish",
"Presence_Common_smelt",
"Presence_Kōaro"
)

vars_common <- c(
"LID",
"Slope_5m","Riparian_vegetation","Overhanging_trees","Wood_cover","Substrate_index",
"Temperature","pH","DO_mgl","DO_percent","Specific_conductivity",
"Emergent_Native","Submerged_Non_Native","Turf_Native","Submerged_Native","Emergent_Non_Native",
fish_vars
)

is_cont <- function(x) is.numeric(x) && dplyr::n_distinct(x, na.rm = TRUE) >= 5

build_smooth_gam <- function(var, data, custom_k = list(), include_re_for_LID = FALSE) {
if (identical(var, "LID") && include_re_for_LID) return("s(LID, bs='re')")
x <- data[[var]]
if (is.numeric(x)) {
nuniq <- dplyr::n_distinct(x, na.rm = TRUE)
if (nuniq < 5) return(var)
k_req <- if (!is.null(custom_k[[var]])) custom_k[[var]] else 10
k_cap <- max(3, min(k_req, nuniq - 1))
return(paste0("s(", var, ", bs='ts', k=", k_cap, ")"))
}
var
}

exclude_if_RE <- function(mod){
if (!length(mod$smooth)) return(NULL)
has_re <- vapply(mod$smooth, function(s) "LID" %in% s$term, logical(1))
if (any(has_re)) "s(LID)" else NULL
}

remove_high_vif_glmBI <- function(
data,
response,
predictors,
threshold    = vif_thresh,
protect_vars = character(0),
verbose      = FALSE
) {
nzv <- caret::nearZeroVar(data[, predictors, drop = FALSE])
if (length(nzv)) predictors <- predictors[-nzv]

rpt <- list(removed = character(), start = predictors)
last_vif <- NULL

repeat {
if (!length(predictors)) stop("All predictors removed during VIF pruning.")


fml <- as.formula(paste(response, "~", paste(predictors, collapse = " + ")))
model <- try(lm(fml, data = data), silent = TRUE)
if (inherits(model, "try-error")) break

vif_data <- performance::check_collinearity(model)
vif_data <- vif_data[!grepl("\\|", vif_data$Term), , drop = FALSE]
last_vif <- vif_data

if (!nrow(vif_data) || all(vif_data$VIF < threshold)) break

ord <- order(vif_data$VIF, decreasing = TRUE)
to_remove <- NA_character_
for (i in ord) {
  cand <- vif_data$Term[i]
  if (!(cand %in% protect_vars)) { to_remove <- cand; break }
}
if (is.na(to_remove)) break

predictors  <- setdiff(predictors, to_remove)
rpt$removed <- c(rpt$removed, to_remove)
}

rpt$final_vif <- last_vif
list(predictors = predictors, report = rpt)
}

remove_high_vif_glmmTMB <- function(
data,
response,
predictors,
threshold    = vif_thresh,
protect_vars = character(0),
verbose      = FALSE
) {
nzv <- caret::nearZeroVar(data[, predictors, drop = FALSE])
if (length(nzv)) predictors <- predictors[-nzv]

rpt <- list(removed = character(), start = predictors)
last_vif <- NULL

repeat {
if (!length(predictors)) stop("All predictors removed during VIF pruning.")


fml <- as.formula(paste(response, "~", paste(predictors, collapse = " + ")))
model <- try(lm(fml, data = data), silent = TRUE)
if (inherits(model, "try-error")) break

vif_data <- performance::check_collinearity(model)
vif_data <- vif_data[!grepl("\\|", vif_data$Term), , drop = FALSE]
last_vif <- vif_data

if (!nrow(vif_data) || all(vif_data$VIF < threshold)) break

ord <- order(vif_data$VIF, decreasing = TRUE)
to_remove <- NA_character_
for (i in ord) {
  cand <- vif_data$Term[i]
  if (!(cand %in% protect_vars)) { to_remove <- cand; break }
}
if (is.na(to_remove)) break

predictors  <- setdiff(predictors, to_remove)
rpt$removed <- c(rpt$removed, to_remove)

}

rpt$final_vif <- last_vif
list(predictors = predictors, report = rpt)
}

as_plot <- function(p) if (inherits(p, c("gg","ggplot","patchwork"))) p else patchwork::plot_spacer()

prepare_block <- function(Modeling_data, response, vars_common, id = "LID"){
vars <- c(response, vars_common)
Modeling_data %>%
dplyr::select(dplyr::all_of(vars)) %>%
dplyr::mutate(
"{id}" := factor(.data[[id]]),
dplyr::across(dplyr::any_of(fish_vars), ~ as.numeric(.x))
)
}

ref_row <- function(data){
dplyr::summarise(
data,
dplyr::across(
dplyr::everything(),
\(x){
if (is.numeric(x)) stats::median(x, na.rm = TRUE)
else if (is.factor(x)) levels(x)[1L]
else if (is.logical(x)) FALSE
else if (is.character(x)) unique(stats::na.omit(x))[1L]
else x[1L]
}
)
)
}

is_binary_numeric <- function(x) is.numeric(x) && dplyr::n_distinct(x, na.rm = TRUE) == 2

get_term_p <- function(m, var, kind = c("any","param","smooth")){
kind <- match.arg(kind)
sm <- summary(m)

if (kind %in% c("any","param")) {
if (!is.null(sm$p.table) && nrow(sm$p.table) > 0) {
pcol <- intersect(colnames(sm$p.table), c("Pr(>|t|)","Pr(>|z|)"))[1]
if (!is.na(pcol) && var %in% rownames(sm$p.table)) {
return(as.numeric(sm$p.table[var, pcol]))
}
}
}

if (kind %in% c("any","smooth")) {
if (!is.null(sm$s.table) && nrow(sm$s.table) > 0) {
sname <- paste0("s(", var, ")")
if (sname %in% rownames(sm$s.table)) {
return(as.numeric(sm$s.table[sname, "p-value"]))
}
}
}

NA_real_
}

plot_binary_single_component <- function(model, var, data,
family = c("binomial","gamma"),
S = 2000, alpha = 0.05,
exclude_RE = TRUE,
hold_binaries = c("mean","zero","one"),
id = "LID",
fish_covars = c("Presence_Goldfish","Presence_Eel","Presence_Catfish","Presence_Common_smelt"),
seed = 1) {
family <- match.arg(family)
hold_binaries <- match.arg(hold_binaries)

tl <- attr(terms(model), "term.labels")
has_term <- any(tl == var) || any(grepl(paste0("^s\\(", var, "(,|\\))"), tl))
if (!has_term) return(ggplot() + theme_void())

num_means <- data %>%
dplyr::summarise(across(where(is.numeric), ~ mean(.x, na.rm = TRUE)))
base <- as.list(num_means)
if (id %in% names(data) && is.factor(data[[id]]))
base[[id]] <- factor(levels(data[[id]])[1], levels = levels(data[[id]]))

others <- setdiff(intersect(fish_covars, names(data)), var)
set_bin <- function(x){
if (hold_binaries == "mean") round(mean(data[[x]], na.rm = TRUE))
else if (hold_binaries == "zero") 0L else 1L
}
for (x in others) base[[x]] <- set_bin(x)

mk_nd <- function(level){
nd <- as.data.frame(base, stringsAsFactors = FALSE)
nd[[var]] <- as.integer(level)
if (id %in% names(data) && is.factor(data[[id]])) {
nd <- do.call(
rbind,
lapply(levels(data[[id]]), function(lv){
r <- nd
r[[id]] <- factor(lv, levels = levels(data[[id]]))
r
})
)
}
if (!is.null(model$model)) {
common <- intersect(names(nd), names(model$model))
for (nm in common) if (is.factor(model$model[[nm]]))
nd[[nm]] <- factor(nd[[nm]], levels = levels(model$model[[nm]]))
}
nd
}
nd0 <- mk_nd(0L)
nd1 <- mk_nd(1L)
nd  <- dplyr::bind_rows(
dplyr::mutate(nd0, .level = 0L),
dplyr::mutate(nd1, .level = 1L)
)

excl <- if (exclude_RE) exclude_if_RE(model) else NULL
lp <- predict(model, newdata = nd, type = "link", se.fit = TRUE, exclude = excl)

set.seed(seed)
n <- nrow(nd)
Z <- matrix(
rnorm(n * S, lp$fit, pmax(lp$se.fit, .Machine$double.eps)),
nrow = n, ncol = S
)

if (family == "binomial") {
Y <- plogis(Z); ylab <- "Presence probability"
} else {
Y <- exp(Z);    ylab <- "Mean given presence (μ)"
}

sim_by_level <- function(level_flag){
sims <- Y[nd$.level == level_flag, , drop = FALSE]
colMeans(sims)
}
sim0 <- sim_by_level(0L)
sim1 <- sim_by_level(1L)

qlo <- alpha/2
qhi <- 1 - alpha/2
df <- data.frame(
level = factor(c(0,1), levels = c(0,1), labels = c("Absent","Present")),
mean  = c(mean(sim0), mean(sim1)),
lwr   = c(quantile(sim0, qlo), quantile(sim1, qlo)),
upr   = c(quantile(sim0, qhi), quantile(sim1, qhi))
)

ggplot(df, aes(x = level, y = mean)) +
geom_col(width = 0.6) +
geom_errorbar(aes(ymin = lwr, ymax = upr), width = 0.2) +
labs(x = var, y = ylab)
}

plot_linear_single_component <- function(model, var, data,
family = c("binomial","gamma"),
n = 100, alpha = 0.05,
exclude_RE = TRUE) {
family <- match.arg(family)

num_means <- data %>%
dplyr::summarise(across(where(is.numeric), ~ mean(.x, na.rm = TRUE)))
base <- as.list(num_means)

if (!is.null(model$model)) {
for (nm in names(model$model)) {
if (is.factor(model$model[[nm]])) {
base[[nm]] <- factor(
levels(model$model[[nm]])[1],
levels = levels(model$model[[nm]])
)
}
}
}

x  <- data[[var]]
xr <- range(x, na.rm = TRUE)
grid <- seq(xr[1], xr[2], length.out = n)

nd <- as.data.frame(base)
nd <- nd[rep(1, n), , drop = FALSE]
nd[[var]] <- grid

excl <- if (exclude_RE) exclude_if_RE(model) else NULL
pr <- predict(model, newdata = nd, type = "link", se.fit = TRUE, exclude = excl)

if (family == "binomial") {
fit <- plogis(pr$fit)
lwr <- plogis(pr$fit - qnorm(1 - alpha/2) * pr$se.fit)
upr <- plogis(pr$fit + qnorm(1 - alpha/2) * pr$se.fit)
ylab <- "Response (probability)"
} else {
fit <- exp(pr$fit)
lwr <- exp(pr$fit - qnorm(1 - alpha/2) * pr$se.fit)
upr <- exp(pr$fit + qnorm(1 - alpha/2) * pr$se.fit)
ylab <- "Response (μ)"
}

df <- data.frame(x = grid, fit = fit, lwr = lwr, upr = upr)
ggplot(df, aes(x, fit)) +
geom_ribbon(aes(ymin = lwr, ymax = upr), alpha = 0.2) +
geom_line() +
labs(x = var, y = ylab)
}

parametric_panel <- function(model, data, alpha = 0.1, exclude = character(0),
family = c("binomial","gamma"),
fish_covars = c("Presence_Goldfish","Presence_Eel","Presence_Catfish","Presence_Common_smelt"),
hold_binaries = "mean") {
family <- match.arg(family)

sm <- summary(model)
if (is.null(sm$p.table) || nrow(sm$p.table) == 0) return(ggplot() + theme_void())

pcol <- intersect(colnames(sm$p.table), c("Pr(>|t|)","Pr(>|z|)"))[1]
if (is.na(pcol)) return(ggplot() + theme_void())

par_names <- setdiff(rownames(sm$p.table), "(Intercept)")

smooth_rows <- if (!is.null(sm$s.table)) rownames(sm$s.table) else character(0)
smooth_vars <- sub("^s\\(([^,\\)]+).*$", "\\1", smooth_rows)
par_names <- setdiff(par_names, union(exclude, smooth_vars))

par_sig <- par_names[sm$p.table[par_names, pcol, drop = TRUE] <= alpha]
if (!length(par_sig)) return(ggplot() + theme_void())

plots <- lapply(par_sig, function(v) {
x <- data[[v]]
if (is.null(x)) return(ggplot() + theme_void())

is_bin_num <- is.numeric(x) && dplyr::n_distinct(x, na.rm = TRUE) <= 2
is_bin_fac <- is.factor(x)  && nlevels(x) == 2

if (is_bin_num || is_bin_fac) {
plot_binary_single_component(
model, v, data,
family        = family,
hold_binaries = hold_binaries,
fish_covars   = fish_covars
)
} else {
plot_linear_single_component(model, v, data, family = family)
}
})

if (!length(plots)) return(ggplot() + theme_void())
patchwork::wrap_plots(plots, ncol = 1)
}

Occupancy model (presence/absence)

Binomial GAMM with logit link. Stepwise backward elimination using ML estimation (α = 0.05), refitted with REML. Lake identity (LID) included as a random effect throughout and protected from removal.

Model fitting

In [17]:
View Code

PData <- prepare_block(Modeling_data, "Presence_Kōura", vars_common)

id <- "LID"
pred_fixed_occ <- setdiff(names(PData), c("Presence_Kōura", id))

vif_occ <- remove_high_vif_glmBI(
PData,
"Presence_Kōura",
pred_fixed_occ,
threshold    = vif_thresh,
protect_vars = character(0)
)
kept_fixed_occ <- vif_occ$predictors

vars_step_occ <- c(kept_fixed_occ, if (INCLUDE_RE) id)
rhs_full_occ <- paste(
vapply(
vars_step_occ,
function(v) build_smooth_gam(v, PData, custom_k, include_re_for_LID = INCLUDE_RE),
character(1)
),
collapse = " + "
)
form_full_occ <- as.formula(paste("Presence_Kōura ~", rhs_full_occ))


full_ml_occ <- mgcv::gam(
  form_full_occ,
  data   = PData,
  family = binomial(link = "logit"),
  method = "ML",
  select = TRUE
)

remaining_occ <- vars_step_occ
protected_step_vars <- if (INCLUDE_RE) id else character(0)

repeat {
rhs_now <- paste(
vapply(
remaining_occ,
function(v) build_smooth_gam(v, PData, custom_k, include_re_for_LID = INCLUDE_RE),
character(1)
),
collapse = " + "
)
m_now <- mgcv::gam(
as.formula(paste("Presence_Kōura ~", rhs_now)),
data   = PData,
family = binomial(link = "logit"),
method = "ML",
select = TRUE
)
sm <- summary(m_now)

ps <- c()
if (!is.null(sm$p.table) && nrow(sm$p.table) > 0) {
pcol <- intersect(colnames(sm$p.table), c("Pr(>|t|)","Pr(>|z|)"))[1]
pvec <- sm$p.table[, pcol]
ps <- c(ps, pvec[names(pvec) != "(Intercept)"])
}
if (!is.null(sm$s.table) && nrow(sm$s.table) > 0) {
pvec <- sm$s.table[, "p-value"]
names(pvec) <- rownames(sm$s.table)
ps <- c(ps, pvec)
}
drop_candidates <- ps[ps > p_cutoff_ml]
if (!length(drop_candidates)) { red_ml_occ <- m_now; break }

ordered <- names(sort(drop_candidates, decreasing = TRUE))
ordered_vars <- vapply(
ordered,
function(x) if (grepl("^s\\(", x)) sub("^s\\(([^,]+).*\\)$", "\\1", x) else x,
character(1)
)

ordered_vars <- setdiff(ordered_vars, protected_step_vars)
if (!length(ordered_vars)) { red_ml_occ <- m_now; break }

remove_v <- ordered_vars[1]
remaining_occ <- setdiff(remaining_occ, remove_v)
if (!length(remaining_occ)) { red_ml_occ <- m_now; break }
}

final_occ <- mgcv::gam(
formula(red_ml_occ),
data   = PData,
family = binomial(link = "logit"),
method = "REML",
select = TRUE
)

Model diagnostics

In [18]:
View Code

# Basis dimension check and residual diagnostics
gam.check(final_occ)


Method: REML   Optimizer: outer newton
full convergence after 7 iterations.
Gradient range [-1.302727e-06,2.078511e-08]
(score 23.01023 & scale 1).
Hessian positive definite, eigenvalue range [1.302724e-06,0.5351484].
Model rank =  41 / 41 

Basis dimension (k) checking results. Low p-value (k-index<1) may
indicate that k is too low, especially if edf is close to k'.

                               k'      edf k-index p-value
s(Riparian_vegetation)   6.00e+00 8.49e-01    1.20    0.89
s(Substrate_index)       9.00e+00 9.80e-01    0.99    0.42
s(Temperature)           9.00e+00 1.16e+00    1.00    0.46
s(Specific_conductivity) 9.00e+00 9.37e-01    0.95    0.30
s(LID)                   5.00e+00 5.93e-06      NA      NA
In [19]:
View Code

# Concurvity — values approaching 1 indicate potential instability
concurvity(final_occ, full = FALSE)
$worst
                                 para s(Riparian_vegetation) s(Substrate_index)
para                     1.000000e+00           1.047356e-25       4.795095e-20
s(Riparian_vegetation)   1.039998e-25           1.000000e+00       3.134858e-01
s(Substrate_index)       4.795079e-20           3.134858e-01       1.000000e+00
s(Temperature)           1.389459e-16           3.832410e-01       5.995062e-01
s(Specific_conductivity) 2.179319e-23           4.345519e-01       5.932841e-01
s(LID)                   1.000000e+00           1.800786e-01       3.202651e-01
                         s(Temperature) s(Specific_conductivity)    s(LID)
para                       1.389459e-16             2.213869e-23 1.0000000
s(Riparian_vegetation)     3.832410e-01             4.345519e-01 0.1800786
s(Substrate_index)         5.995062e-01             5.932841e-01 0.3202651
s(Temperature)             1.000000e+00             9.950952e-01 0.9451023
s(Specific_conductivity)   9.950952e-01             1.000000e+00 0.9999650
s(LID)                     9.451023e-01             9.999650e-01 1.0000000

$observed
                                 para s(Riparian_vegetation) s(Substrate_index)
para                     1.000000e+00           2.299600e-33       9.252980e-30
s(Riparian_vegetation)   1.039998e-25           1.000000e+00       6.650618e-02
s(Substrate_index)       4.795079e-20           2.982942e-01       1.000000e+00
s(Temperature)           1.389459e-16           1.145920e-01       1.664236e-01
s(Specific_conductivity) 2.179319e-23           1.873739e-01       2.891411e-01
s(LID)                   1.000000e+00           6.443269e-02       1.035715e-01
                         s(Temperature) s(Specific_conductivity)       s(LID)
para                       1.488107e-25             8.896327e-33 3.154799e-27
s(Riparian_vegetation)     5.283494e-02             2.252039e-02 1.216234e-01
s(Substrate_index)         1.245446e-01             1.319700e-01 2.222343e-01
s(Temperature)             1.000000e+00             4.781939e-01 4.170508e-01
s(Specific_conductivity)   5.533496e-01             1.000000e+00 3.199665e-01
s(LID)                     8.782662e-01             9.377584e-01 1.000000e+00

$estimate
                                 para s(Riparian_vegetation) s(Substrate_index)
para                     1.000000e+00           2.036602e-28       4.647586e-22
s(Riparian_vegetation)   1.039998e-25           1.000000e+00       5.909716e-02
s(Substrate_index)       4.795079e-20           2.890457e-01       1.000000e+00
s(Temperature)           1.389459e-16           1.246265e-01       1.711141e-01
s(Specific_conductivity) 2.179319e-23           1.954056e-01       2.236490e-01
s(LID)                   1.000000e+00           6.321359e-02       1.001232e-01
                         s(Temperature) s(Specific_conductivity)     s(LID)
para                       7.485138e-19             1.732558e-27 0.20000000
s(Riparian_vegetation)     5.656809e-02             2.466287e-02 0.07499055
s(Substrate_index)         1.218427e-01             1.455505e-01 0.14143842
s(Temperature)             1.000000e+00             4.606539e-01 0.40018000
s(Specific_conductivity)   5.360691e-01             1.000000e+00 0.51941617
s(LID)                     7.954349e-01             9.315973e-01 1.00000000

Model figure

In [20]:
View Code

# Smooth plots: keep draw() outputs as-is (they may be ggplot OR patchwork)
sm <- gratia::smooth_estimates(final_occ)
smooth_names <- unique(sm$.smooth)
smooth_4_names <- smooth_names[1:min(4, length(smooth_names))]

plots_4 <- lapply(smooth_4_names, function(s) {
  p <- gratia::draw(final_occ, select = s, scales = "fixed", se = FALSE) +
    labs(title = clean_smooth_title(s), x = NULL, y = "Partial effect") +
    base_theme_bw
  p
})

# Combine without forcing wrap_dims assumptions about panel counts
final_p_occ_smooths <- patchwork::wrap_plots(plots_4, ncol = length(plots_4))


# Parametric panel (unchanged call)
p_occ_param <- parametric_panel(
  final_occ,
  PData,
  alpha       = 0.1,
  exclude     = c("LID"),
  family      = "binomial",
  fish_covars = fish_vars
) + base_theme_bw

# ROC on fitted model predictions
pred_pres <- predict(final_occ, type = "response", exclude = exclude_if_RE(final_occ))
obs_pres  <- PData$Presence_Kōura == 1
roc_obj   <- pROC::roc(obs_pres, pred_pres)
Setting levels: control = FALSE, case = TRUE
Setting direction: controls < cases
View Code

auc_val   <- as.numeric(pROC::auc(roc_obj))

roc_df <- data.frame(
  tpr = roc_obj$sensitivities,
  fpr = 1 - roc_obj$specificities
)

p_pres_roc <- ggplot(roc_df, aes(fpr, tpr)) +
  geom_path(colour = "black", linewidth = 0.5) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", colour = "black", linewidth = 0.4) +
  annotate("text", x = 0.65, y = 0.1, label = paste0("AUC = ", round(auc_val, 3)), size = 3) +
  labs(x = "False positive rate", y = "True positive rate") +
  base_theme_bw

# ---- CV by LID (fix unseen LID warnings by forcing LID in test to a known training level) ----
set.seed(1)
k_occ      <- min(5, nlevels(PData$LID))
lid_levels <- levels(PData$LID)
lid_folds  <- sample(rep(1:k_occ, length.out = length(lid_levels)))
lid2fold   <- setNames(lid_folds, lid_levels)
fold_vec   <- unname(lid2fold[as.character(PData$LID)])
pred_cv_occ <- rep(NA_real_, nrow(PData))

final_terms <- {
  tl <- attr(terms(final_occ), "term.labels")
  sub("^s\\(([^,\\)]+).*$", "\\1", tl)
}

for (fold in 1:k_occ) {
  idx   <- which(fold_vec == fold)
  train <- PData[-idx, , drop = FALSE]
  test  <- PData[idx,  , drop = FALSE]

  rhs_now <- paste(
    vapply(
      final_terms,
      function(v) build_smooth_gam(v, train, custom_k, include_re_for_LID = INCLUDE_RE),
      character(1)
    ),
    collapse = " + "
  )
  fml_now <- as.formula(paste("Presence_Kōura ~", rhs_now))

  m <- mgcv::gam(
    fml_now,
    data   = train,
    family = binomial(link = "logit"),
    method = "REML",
    select = TRUE
  )

  # Align factor levels; then neutralise LID for prediction if RE is excluded
  for (nm in names(test)) {
    if (is.factor(train[[nm]])) {
      test[[nm]] <- factor(test[[nm]], levels = levels(train[[nm]]))
    }
  }

  if ("LID" %in% names(test) && is.factor(test$LID)) {
    test$LID <- factor(levels(train$LID)[1L], levels = levels(train$LID))
  }

  pred_cv_occ[idx] <- predict(m, newdata = test, type = "response", exclude = exclude_if_RE(m))
}
Warning in predict.gam(m, newdata = test, type = "response", exclude =
exclude_if_RE(m)): factor levels 11133 not in original fit
View Code

calib_data_occ <- tibble::tibble(pred = pred_cv_occ, obs = PData$Presence_Kōura) %>%
  dplyr::mutate(bin = dplyr::ntile(pred, 5)) %>%
  dplyr::group_by(bin) %>%
  dplyr::summarise(
    mean_pred = mean(pred, na.rm = TRUE),
    obs_rate  = mean(obs,  na.rm = TRUE),
    n         = dplyr::n(),
    .groups   = "drop"
  )


Calibration_plot_occ <- ggplot(calib_data_occ, aes(mean_pred, obs_rate)) +
  geom_point(size = 2.2, colour = "black") +
  geom_line(colour = "black", linewidth = 0.5) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", colour = "black", linewidth = 0.4) +
  labs(x = "Mean predicted probability", y = "Observed proportion") +
  base_theme_bw

# Layout (avoid forcing a layout that conflicts with nested patchworks)
top_row    <- final_p_occ_smooths
bottom_row <- (p_pres_roc + Calibration_plot_occ) + patchwork::plot_layout(ncol = 2)

final_plot_occupancy <- (top_row / bottom_row)+
  patchwork::plot_layout(heights = c(1, 2))

ggsave(filename = file.path(out_dir, "fig-occupancy-model.png"), plot = final_plot_occupancy,  width = 7, height = 5, dpi = 1200)

final_plot_occupancy
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_stringMetric, as.graphicsAnnot(x$label)): font family
not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Kōura occupancy GAMM results and model performance. a) Estimated smooth terms for riparian vegetation, substrate index, surface water temperature, and specific conductivity, shown as partial effects with 95% confidence intervals. See Fig. S2 for raw data underlying modelled relationships. b) Receiver operating characteristic (ROC) curve illustrating model discrimination, with the area under the curve (AUC) reported. c) Calibration plot based on five equal-frequency bins, comparing mean predicted occupancy probabilities with observed proportions of occupied sites; the dashed 1:1 line indicates perfect calibration.
In [21]:
View Code

smooth_4_names_reordered <- c(
  "s(Temperature)",
  "s(Riparian_vegetation)",
  "s(Substrate_index)",
  "s(Specific_conductivity)"
)

plots_4 <- lapply(seq_along(smooth_4_names_reordered), function(i) {
  s <- smooth_4_names_reordered[i]
  p <- gratia::draw(final_occ, select = s, scales = "fixed", se = FALSE) +
    labs(title = clean_smooth_title(s), x = NULL, y = "Partial effect") +
    base_theme_bw
  
  # Make middle two plots (2 & 3) have bold, larger titles
  if (i %in% c(2, 3)) {
    p <- p + theme(plot.title = element_text(face = "bold", size = 12))
  }
  
  p
})

# Combine plots
final_p_occ_smooths <- patchwork::wrap_plots(plots_4, ncol = length(plots_4))

ggsave(
  filename = file.path(out_dir, "fig-final_p_occ_smooths.png"), 
  plot = final_p_occ_smooths,  
  width = 10, 
  height = 3, 
  dpi = 300
)

CPUE hurdle model

Model fitting

In [22]:
View Code

CData <- prepare_block(Modeling_data, "Weighted_CPUE_Kōura", vars_common) %>%
dplyr::mutate(CPUE_pos = as.integer(Weighted_CPUE_Kōura > 0))

id <- "LID"

pred_fixed_cpue_pres <- setdiff(
names(CData),
c("CPUE_pos", id, "Weighted_CPUE_Kōura", "Weighted_CPUE_Koura", "CPUE")
)



Cpos <- dplyr::filter(CData, CPUE_pos == 1L)

pred_fixed_cpue_pos <- setdiff(names(Cpos), c("Weighted_CPUE_Kōura", id))

vif_cpue_pos <- remove_high_vif_glmmTMB(
Cpos,
"Weighted_CPUE_Kōura",
pred_fixed_cpue_pos,
threshold    = vif_thresh,
protect_vars = character(0)
)
kept_fixed_cpue_pos <- vif_cpue_pos$predictors

vars_step_cpue_pos <- c(kept_fixed_cpue_pos, if (INCLUDE_RE) id)

rhs_cpue_pos <- paste(
vapply(
vars_step_cpue_pos,
function(v) build_smooth_gam(v, Cpos, custom_k, include_re_for_LID = INCLUDE_RE),
character(1)
),
collapse = " + "
)


full_ml_cpue_pos <- mgcv::gam(
as.formula(paste("Weighted_CPUE_Kōura ~", rhs_cpue_pos)),
data   = Cpos,
family = Gamma(link = "log"),
method = "ML",
select = TRUE
)

remaining_cpue_pos <- vars_step_cpue_pos
protected_step_vars_pos <- if (INCLUDE_RE) id else character(0)

repeat {
rhs_now <- paste(
vapply(
remaining_cpue_pos,
function(v) build_smooth_gam(v, Cpos, custom_k, include_re_for_LID = INCLUDE_RE),
character(1)
),
collapse = " + "
)
m_now <- mgcv::gam(
as.formula(paste("Weighted_CPUE_Kōura ~", rhs_now)),
data   = Cpos,
family = Gamma(link = "log"),
method = "ML",
select = TRUE
)
sm <- summary(m_now)

ps <- c()
if (!is.null(sm$p.table) && nrow(sm$p.table) > 0) {
pcol <- intersect(colnames(sm$p.table), c("Pr(>|t|)", "Pr(>|z|)"))[1]
pvec <- sm$p.table[, pcol]
ps   <- c(ps, pvec[names(pvec) != "(Intercept)"])
}
if (!is.null(sm$s.table) && nrow(sm$s.table) > 0) {
pvec <- sm$s.table[, "p-value"]
names(pvec) <- rownames(sm$s.table)
ps   <- c(ps, pvec)
}
drop_candidates <- ps[ps > p_cutoff_ml]
if (!length(drop_candidates)) { red_ml_cpue_pos <- m_now; break }

ordered <- names(sort(drop_candidates, decreasing = TRUE))
ordered_vars <- vapply(
ordered,
function(x) if (grepl("^s\\(", x)) sub("^s\\(([^,]+).*\\)$", "\\1", x) else x,
character(1)
)

ordered_vars <- setdiff(ordered_vars, protected_step_vars_pos)
if (!length(ordered_vars)) { red_ml_cpue_pos <- m_now; break }

remove_v <- ordered_vars[1]
remaining_cpue_pos <- setdiff(remaining_cpue_pos, remove_v)
if (!length(remaining_cpue_pos)) { red_ml_cpue_pos <- m_now; break }
}

final_cpue_pos <- mgcv::gam(
formula(red_ml_cpue_pos),
data   = Cpos,
family = Gamma(link = "log"),
method = "REML",
select = TRUE
)

Model diagnostics

In [23]:
View Code

# Basis dimension check and residual diagnostics
gam.check(final_cpue_pos)


Method: REML   Optimizer: outer newton
full convergence after 12 iterations.
Gradient range [-2.08322e-05,6.435482e-05]
(score 58.0551 & scale 0.6129728).
Hessian positive definite, eigenvalue range [2.08349e-05,18.90857].
Model rank =  28 / 28 

Basis dimension (k) checking results. Low p-value (k-index<1) may
indicate that k is too low, especially if edf is close to k'.

                         k'      edf k-index p-value  
s(Temperature)     9.000000 0.823380    0.73   0.045 *
s(pH)              9.000000 1.020023    1.05   0.695  
s(Emergent_Native) 3.000000 0.980418    0.98   0.500  
s(LID)             5.000000 0.000184      NA      NA  
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
In [24]:
View Code

# Concurvity — values approaching 1 indicate potential instability
concurvity(final_cpue_pos, full = FALSE)
$worst
                           para s(Temperature)        s(pH) s(Emergent_Native)
para               1.000000e+00   1.400984e-17 4.262655e-21       3.898474e-28
s(Temperature)     1.400980e-17   1.000000e+00 9.786073e-01       3.499890e-01
s(pH)              4.262769e-21   9.786073e-01 1.000000e+00       6.595492e-01
s(Emergent_Native) 3.743399e-28   3.499890e-01 6.595492e-01       1.000000e+00
s(LID)             1.000000e+00   9.368544e-01 9.509125e-01       2.576175e-01
                      s(LID)
para               1.0000000
s(Temperature)     0.9368544
s(pH)              0.9509125
s(Emergent_Native) 0.2576175
s(LID)             1.0000000

$observed
                           para s(Temperature)        s(pH) s(Emergent_Native)
para               1.000000e+00   4.529500e-27 3.388150e-30       4.565615e-34
s(Temperature)     1.400980e-17   1.000000e+00 5.568819e-01       1.857481e-01
s(pH)              4.262769e-21   4.682138e-01 1.000000e+00       2.957955e-01
s(Emergent_Native) 3.743399e-28   5.402190e-02 4.620048e-02       1.000000e+00
s(LID)             1.000000e+00   9.068782e-01 5.954197e-01       1.461936e-01
                        s(LID)
para               0.001210928
s(Temperature)     0.434150004
s(pH)              0.811351811
s(Emergent_Native) 0.223542648
s(LID)             1.000000000

$estimate
                           para s(Temperature)        s(pH) s(Emergent_Native)
para               1.000000e+00   1.330017e-20 3.747157e-23       3.061818e-31
s(Temperature)     1.400980e-17   1.000000e+00 5.496669e-01       1.865601e-01
s(pH)              4.262769e-21   5.022515e-01 1.000000e+00       2.960202e-01
s(Emergent_Native) 3.743399e-28   5.320136e-02 8.007641e-02       1.000000e+00
s(LID)             1.000000e+00   8.910597e-01 4.604371e-01       1.469505e-01
                       s(LID)
para               0.22681359
s(Temperature)     0.45234022
s(pH)              0.49372556
s(Emergent_Native) 0.07691144
s(LID)             1.00000000

Model figure

In [25]:
View Code

# make plot
sm_cpue <- smooth_estimates(final_cpue_pos)
cpue_smooth_names <- setdiff(unique(sm_cpue$.smooth), "s(LID)")
cpue_smooth_plots <- lapply(cpue_smooth_names, function(s) {
gratia::draw(final_cpue_pos, select = s, scales = "fixed", se = FALSE) +
labs(title = clean_smooth_title(s), x = NULL, y = "Partial effect") +
base_theme_bw
})

p_cpue_pos_smooths <- patchwork::wrap_plots(cpue_smooth_plots, ncol = length(cpue_smooth_plots)) +
patchwork::plot_annotation(tag_levels = "a")

pred_pos <- predict(final_cpue_pos, type = "response")
obs_pos  <- final_cpue_pos$y
R2   <- cor(pred_pos, obs_pos, use = "complete.obs")^2
RMSE <- sqrt(mean((pred_pos - obs_pos)^2, na.rm = TRUE))

df_scat <- data.frame(Predicted = pred_pos, Observed = obs_pos)

p_cpue_pos_scatter <- ggplot(df_scat, aes(Predicted, Observed)) +
geom_point(size = 1.8, colour = "black") +
geom_abline(slope = 1, intercept = 0, linetype = "dashed", colour = "black", linewidth = 0.4) +
annotate(
"text",
x = max(df_scat$Predicted, na.rm = TRUE) * 0.7,
y = max(df_scat$Observed,  na.rm = TRUE) * 0.8,
label = paste0("R\u00B2 = ", round(R2, 2), "\nRMSE = ", round(RMSE, 2)),
size = 3
) +
labs(x = "Predicted CPUE", y = "Observed CPUE", tag = "b") +
base_theme_bw

CPUE_panel_positives <- (p_cpue_pos_smooths / p_cpue_pos_scatter) +
patchwork::plot_layout(heights = c(1, 2))

ggsave(filename = file.path(out_dir, "fig-cpue-hurdle.png"),plot = CPUE_panel_positives,width = 5.5, height = 5, dpi = 1200)

CPUE_panel_positives
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Kōura CPUE GAMM results and model performance. a) Estimated smooth terms for surface water temperature, pH, and emergent native macrophytes, shown with partial effects and 95% confidence intervals. See Fig. S2 for raw data underlying modelled relationships. b) Predicted versus observed CPUE from the positive (Gamma) component of the hurdle model, with predictions excluding the random lake effect. The dashed 1:1 line indicates perfect agreement; annotated values report R² and RMSE to summarise model fit.

BPUE hurdle model

Model fitting

In [26]:
View Code

BData <- prepare_block(Modeling_data, "Weighted_BPUE_Kōura", vars_common) %>%
dplyr::mutate(BPUE_pos = as.integer(Weighted_BPUE_Kōura > 0))

id <- "LID"

pred_fixed_BPUE_pres <- setdiff(names(BData),c("BPUE_pos", id, "Weighted_BPUE_Kōura", "Weighted_BPUE_Koura", "Weighted_BPUE"))

Bpos <- dplyr::filter(BData, BPUE_pos == 1L)

pred_fixed_BPUE_pos <- setdiff(names(Bpos), c("Weighted_BPUE_Kōura", id))

vif_BPUE_pos <- remove_high_vif_glmmTMB(Bpos,"Weighted_BPUE_Kōura",pred_fixed_BPUE_pos,threshold = vif_thresh,protect_vars = character(0))
kept_fixed_BPUE_pos <- vif_BPUE_pos$predictors

vars_step_BPUE_pos <- c(kept_fixed_BPUE_pos, if (INCLUDE_RE) id)

rhs_BPUE_pos <- paste(vapply(vars_step_BPUE_pos,function(v) build_smooth_gam(v, Bpos, custom_k, include_re_for_LID = INCLUDE_RE),
character(1)),
collapse = " + "
)

full_ml_BPUE_pos <- mgcv::gam(
as.formula(paste("Weighted_BPUE_Kōura ~", rhs_BPUE_pos)),
data   = Bpos,
family = Gamma(link = "log"),
method = "ML",
select = TRUE
)

remaining_BPUE_pos <- vars_step_BPUE_pos
protected_step_vars_bpos <- if (INCLUDE_RE) id else character(0)

repeat {
rhs_now <- paste(
vapply(
remaining_BPUE_pos,
function(v) build_smooth_gam(v, Bpos, custom_k, include_re_for_LID = INCLUDE_RE),
character(1)
),
collapse = " + "
)
m_now <- mgcv::gam(
as.formula(paste("Weighted_BPUE_Kōura ~", rhs_now)),
data   = Bpos,
family = Gamma(link = "log"),
method = "ML",
select = TRUE
)
sm <- summary(m_now)

ps <- c()
if (!is.null(sm$p.table) && nrow(sm$p.table) > 0) {
pcol <- intersect(colnames(sm$p.table), c("Pr(>|t|)", "Pr(>|z|)"))[1]
pvec <- sm$p.table[, pcol]
ps   <- c(ps, pvec[names(pvec) != "(Intercept)"])
}
if (!is.null(sm$s.table) && nrow(sm$s.table) > 0) {
pvec <- sm$s.table[, "p-value"]
names(pvec) <- rownames(sm$s.table)
ps   <- c(ps, pvec)
}
drop_candidates <- ps[ps > p_cutoff_ml]
if (!length(drop_candidates)) { red_ml_BPUE_pos <- m_now; break }

ordered <- names(sort(drop_candidates, decreasing = TRUE))
ordered_vars <- vapply(
ordered,
function(x) if (grepl("^s\\(", x)) sub("^s\\(([^,]+).*\\)$", "\\1", x) else x,
character(1)
)

ordered_vars <- setdiff(ordered_vars, protected_step_vars_bpos)
if (!length(ordered_vars)) { red_ml_BPUE_pos <- m_now; break }

remove_v <- ordered_vars[1]
remaining_BPUE_pos <- setdiff(remaining_BPUE_pos, remove_v)
if (!length(remaining_BPUE_pos)) { red_ml_BPUE_pos <- m_now; break }
}

final_bpue_pos <- mgcv::gam(
formula(red_ml_BPUE_pos),
data   = Bpos,
family = Gamma(link = "log"),
method = "REML",
select = TRUE
)

Model diagnostics

In [27]:
View Code

# Basis dimension check and residual diagnostics
gam.check(final_bpue_pos)


Method: REML   Optimizer: outer newton
full convergence after 6 iterations.
Gradient range [-3.720595e-06,7.313389e-06]
(score 158.6148 & scale 0.9394357).
Hessian positive definite, eigenvalue range [3.720601e-06,20.65057].
Model rank =  18 / 18 

Basis dimension (k) checking results. Low p-value (k-index<1) may
indicate that k is too low, especially if edf is close to k'.

                         k'      edf k-index p-value
s(pH)              9.00e+00 9.67e-01    1.24    0.98
s(Emergent_Native) 3.00e+00 7.61e-01    0.99    0.60
s(LID)             5.00e+00 2.35e-05      NA      NA
In [28]:
View Code

# Concurvity — values approaching 1 indicate potential instability
concurvity(final_bpue_pos, full = FALSE)
$worst
                           para        s(pH) s(Emergent_Native)    s(LID)
para               1.000000e+00 4.262655e-21       3.898474e-28 1.0000000
s(pH)              4.262706e-21 1.000000e+00       6.595492e-01 0.9509125
s(Emergent_Native) 3.228607e-28 6.595492e-01       1.000000e+00 0.2576175
s(LID)             1.000000e+00 9.509125e-01       2.576175e-01 1.0000000

$observed
                           para        s(pH) s(Emergent_Native)     s(LID)
para               1.000000e+00 3.314145e-30       1.369791e-33 0.01297495
s(pH)              4.262706e-21 1.000000e+00       2.955720e-01 0.39976445
s(Emergent_Native) 3.228607e-28 4.577087e-02       1.000000e+00 0.08782984
s(LID)             1.000000e+00 5.964829e-01       1.464507e-01 1.00000000

$estimate
                           para        s(pH) s(Emergent_Native)     s(LID)
para               1.000000e+00 3.747157e-23       3.061818e-31 0.22681359
s(pH)              4.262706e-21 1.000000e+00       2.960202e-01 0.49372556
s(Emergent_Native) 3.228607e-28 8.007641e-02       1.000000e+00 0.07691144
s(LID)             1.000000e+00 4.604371e-01       1.469505e-01 1.00000000

fig-bpue-hurdle

In [29]:
View Code

sm_bpue <- smooth_estimates(final_bpue_pos)
bpue_smooth_names <- setdiff(unique(sm_bpue$.smooth), "s(LID)")
bpue_smooth_plots <- lapply(bpue_smooth_names, function(s) {
gratia::draw(final_bpue_pos, select = s, scales = "fixed", se = FALSE) +
labs(title = clean_smooth_title(s), x = NULL, y = "Partial effect") +
base_theme_bw
})

p_BPUE_pos_smooths <- patchwork::wrap_plots(bpue_smooth_plots, ncol = length(bpue_smooth_plots)) +
patchwork::plot_annotation(tag_levels = "a")

pred_pos_b <- predict(final_bpue_pos, type = "response")
obs_pos_b  <- final_bpue_pos$y
R2_b   <- cor(pred_pos_b, obs_pos_b, use = "complete.obs")^2
RMSE_b <- sqrt(mean((pred_pos_b - obs_pos_b)^2, na.rm = TRUE))

df_scat_b <- data.frame(Predicted = pred_pos_b, Observed = obs_pos_b)

p_BPUE_pos_scatter <- ggplot(df_scat_b, aes(Predicted, Observed)) +
geom_point(size = 1.8, colour = "black") +
geom_abline(slope = 1, intercept = 0, linetype = "dashed", colour = "black", linewidth = 0.4) +
annotate(
"text",
x = max(df_scat_b$Predicted, na.rm = TRUE) * 0.7,
y = max(df_scat_b$Observed,  na.rm = TRUE) * 0.8,
label = paste0("R\u00B2 = ", round(R2_b, 2), "\nRMSE = ", round(RMSE_b, 2)),
size = 3
) +
labs(x = "Predicted BPUE", y = "Observed BPUE", tag = "b") +
base_theme_bw

BPUE_panel_positives <- (p_BPUE_pos_smooths / p_BPUE_pos_scatter) +
patchwork::plot_layout(heights = c(1, 2))

ggsave(filename = file.path(out_dir, "fig-bpue-hurdle.png"),plot = BPUE_panel_positives,width = 4, height = 5, dpi = 1200)

BPUE_panel_positives
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Kōura BPUE GAMM results and model performance. a) Estimated smooth terms for pH and emergent native macrophytes, shown with partial effects and 95% confidence intervals. See Fig. S2 for raw data underlying modelled relationships. b) Predicted versus observed BPUE from the positive (Gamma) component of the hurdle model, with predictions excluding the random lake effect. The dashed 1:1 line indicates perfect agreement; annotated values report R² and RMSE to summarise model fit.

Raw data plots all predictors

In [30]:
View Code

# Variables examined across all models
all_pred_vars <- c(
  "Substrate_index", "Riparian_vegetation", "Temperature", "pH",
  "Specific_conductivity", "DO_mgl", "Slope_5m",
  "Overhanging_trees", "Wood_cover",
  "Emergent_Native", "Submerged_Non_Native", "Submerged_Native"
)

# Clean labels
pred_labels <- c(
  Substrate_index       = "Substrate\nindex",
  Slope_5m              = "Slope to\n5 m depth", 
  Riparian_vegetation   = "Riparian\nvegetation\n(%)",
  Overhanging_trees     = "Overhanging\ntrees\n(%)",    
  Wood_cover            = "Wood cover\n(%)",
  Emergent_Native       = "Emergent\nnative\nmacrophytes\n(%)",
  Submerged_Non_Native  = "Submerged\nnon-native\nmacrophytes\n(%)",
  Submerged_Native      = "Submerged\nnative\nmacrophytes\n(%)",
  Temperature           = "Temperature\n(°C)",  
  DO_mgl                = "Dissolved\noxygen\n(mg/L)",  
  Specific_conductivity = "Specific\nconductivity\n(µS/cm)",
  pH                    = "pH")

# Retained predictors (bold in strips)
retained_vars <- c("Substrate_index", "Riparian_vegetation", "Temperature",
                   "pH", "Specific_conductivity", "Emergent_Native")

# Build long-format data for all three responses
raw_long <- Monitoring_CPUE_data %>%
  dplyr::select(all_of(c(all_pred_vars,
                          "Presence_Kōura",
                          "Weighted_CPUE_Kōura",
                          "Weighted_BPUE_Kōura"))) %>%
  tidyr::pivot_longer(
    cols      = all_of(all_pred_vars),
    names_to  = "Predictor",
    values_to = "Predictor_value"
  ) %>%
  tidyr::pivot_longer(
    cols      = c("Presence_Kōura", "Weighted_CPUE_Kōura", "Weighted_BPUE_Kōura"),
    names_to  = "Response",
    values_to = "Response_value"
  ) %>%
  dplyr::mutate(
    Predictor_label = factor(pred_labels[Predictor], levels = pred_labels),
    Response        = dplyr::recode(Response,
      "Presence_Kōura"        = "Kōura Presence",
      "Weighted_CPUE_Kōura"   = "Kōura CPUE",
      "Weighted_BPUE_Kōura"   = "Kōura BPUE"
    ),
    Response = factor(Response, levels = c("Kōura Presence", "Kōura CPUE", "Kōura BPUE")),
    is_retained = Predictor %in% retained_vars
  )

# Strip label face: bold for retained predictors
strip_faces <- ifelse(levels(raw_long$Predictor_label) %in% pred_labels[retained_vars],"bold", "plain")

fig_raw_all <- ggplot(raw_long, aes(x = Predictor_value, y = Response_value)) +
  geom_jitter(
    data   = ~ dplyr::filter(.x, Response == "Kōura Presence"),
    height = 0.05, width = 0, size = 0.8, alpha = 0.6, colour = "black"  ) +
  geom_point(
    data   = ~ dplyr::filter(.x, Response != "Kōura Presence"),
    size   = 0.8, alpha = 0.6, colour = "black"  ) +
  ggh4x::facet_grid2(
    Predictor_label ~ Response,
    scales      = "free",
    independent = "all",
    switch      = "y"  ) +
  labs(x = "Predictor value", y = "Response") +
  base_theme_bw +
  theme(
    strip.text.y.left = element_text(size = 6, lineheight = 0.9, face = strip_faces, angle = 0, hjust = 1), strip.text.x      = element_text(size = 7),
    strip.placement   = "outside",
    axis.text         = element_text(size = 5),
    axis.title        = element_text(size = 7),
    panel.spacing     = unit(0.3, "lines")  )
Warning: Vectorized input to `element_text()` is not officially supported.
ℹ Results may be unexpected or may change in future versions of ggplot2.
View Code

fig_raw_all
Warning in grid.Call(C_stringMetric, as.graphicsAnnot(x$label)): font family
not found in Windows font database
Warning in grid.Call(C_stringMetric, as.graphicsAnnot(x$label)): font family
not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_stringMetric, as.graphicsAnnot(x$label)): font family
not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
View Code

ggsave(file.path(out_dir, "fig-raw-data-all-predictors.png"),
       fig_raw_all, width = 5, height = 10, dpi = 300)
Raw relationships between all environmental predictors examined and kōura occurrence, CPUE, and BPUE across all 60 surveyed littoral sites. Each panel shows observed values at individual sites (n = 60 for occurrence; n = 33 for CPUE and BPUE). Points are jittered slightly for occurrence data to reduce overplotting. Predictors highlighted in bold were retained in final GAM models.

Full vs. reduced model comparison

In [31]:
View Code

compare_full_reduced <- function(full_model, reduced_model, final_model, model_name) {
  lrt <- anova(reduced_model, full_model, test = "LRT")

  tibble::tibble(
    model       = model_name,
    aic_full    = AIC(full_model),
    aic_reduced = AIC(reduced_model),
    lrt_chisq   = lrt[["Deviance"]][2],
    lrt_df      = lrt[["Df"]][2],
    lrt_p       = lrt[["Pr(>Chi)"]][2],
    dev_full    = summary(full_model)$dev.expl * 100,
    dev_reduced = summary(reduced_model)$dev.expl * 100,
    dev_final   = summary(final_model)$dev.expl * 100,
    r2_full     = summary(full_model)$r.sq,
    r2_reduced  = summary(reduced_model)$r.sq,
    r2_final    = summary(final_model)$r.sq
  )
}

model_comparison <- bind_rows(
  compare_full_reduced(full_ml_occ,      red_ml_occ,      final_occ,      "Occupancy"),
  compare_full_reduced(full_ml_cpue_pos, red_ml_cpue_pos, final_cpue_pos, "CPUE (pos)"),
  compare_full_reduced(full_ml_BPUE_pos, red_ml_BPUE_pos, final_bpue_pos, "BPUE (pos)")
)

write.csv(model_comparison, file = file.path(out_dir, "tbl-model-comparison.csv"), row.names = FALSE)

# Pull a value from model_comparison for inline reporting, e.g. mc_val("CPUE (pos)", "dev_final")
mc_val <- function(model_name, col) {
  model_comparison[[col]][model_comparison$model == model_name]
}

model_comparison |>
  mutate(
    across(c(aic_full, aic_reduced, lrt_chisq, lrt_df, dev_full, dev_reduced, dev_final, r2_full, r2_reduced, r2_final), ~ round(.x, 3)),
    lrt_p = format.pval(lrt_p, digits = 3, eps = 0.001)
  ) |>
  kable(
    col.names = c("Model", "AIC (full)", "AIC (reduced)", "LRT χ²", "LRT df", "LRT p-value",
                   "Deviance expl. full (%)", "Deviance expl. reduced (%)", "Deviance expl. final (%)",
                   "Adj. R² (full)", "Adj. R² (reduced)", "Adj. R² (final)"),
    align = c("l","r","r","r","r","r","r","r","r","r","r","r")
  )
In [32]:
Comparison of full and stepwise-reduced (ML-fitted) models for kōura occupancy, CPUE, and BPUE, including AIC, likelihood ratio test (LRT) results, and the percentage deviance explained by the full, reduced, and final (REML-refitted) models.
Model AIC (full) AIC (reduced) LRT χ² LRT df LRT p-value Deviance expl. full (%) Deviance expl. reduced (%) Deviance expl. final (%) Adj. R² (full) Adj. R² (reduced) Adj. R² (final)
Occupancy 50.895 49.998 6.411 3.932 0.165 64.995 57.232 57.915 0.647 0.588 0.592
CPUE (pos) 122.250 115.060 0.403 3.852 0.950 48.373 47.349 47.266 0.191 0.194 0.195
BPUE (pos) 265.731 318.232 32.472 27.478 <0.001 97.375 25.974 25.897 0.758 0.141 0.142

GAM model summary table

In [33]:
View Code

gam_results_table <- function(model, model_name = deparse(substitute(model)), digits = 3) {
sm <- summary(model)

ptab <- as.data.frame(sm$p.table)
ptab <- rownames_to_column(ptab, "term")
ptab <- as_tibble(ptab)

p_col <- names(ptab)[grepl("^Pr\\(>\\|", names(ptab))]
if (length(p_col) != 1) stop("Could not uniquely identify p-value column in p.table.")

stat_col <- names(ptab)[grepl("value$", names(ptab))]
if (length(stat_col) != 1) stop("Could not uniquely identify statistic column in p.table.")

param_tbl <- ptab |>
rename(
estimate  = Estimate,
std_error = `Std. Error`,
statistic = all_of(stat_col),
p_value   = all_of(p_col)
) |>
mutate(component = "parametric", model = model_name) |>
select(model, component, term, estimate, std_error, statistic, p_value) |>
mutate(across(where(is.numeric), ~ round(.x, digits)))

stab <- as.data.frame(sm$s.table)
stab <- rownames_to_column(stab, "term")
stab <- as_tibble(stab)

refdf_col <- names(stab)[grepl("^Ref\\.df$", names(stab))]
edf_col   <- names(stab)[grepl("^edf$", names(stab))]
pval_col  <- names(stab)[grepl("^p\\-value$", names(stab))]
stat_col2 <- names(stab)[grepl("^(F|Chi\\.sq)$", names(stab))]

if (length(edf_col) != 1) stop("Could not uniquely identify edf column in s.table.")
if (length(refdf_col) != 1) stop("Could not uniquely identify Ref.df column in s.table.")
if (length(pval_col) != 1) stop("Could not uniquely identify p-value column in s.table.")
if (length(stat_col2) != 1) stop("Could not uniquely identify test statistic column in s.table.")

smooth_tbl <- stab |>
rename(
edf = all_of(edf_col),
ref_df = all_of(refdf_col),
statistic = all_of(stat_col2),
p_value = all_of(pval_col)) |>
mutate(component = "smooth", model = model_name) |>
select(model, component, term, edf, ref_df, statistic, p_value) |>
mutate(across(where(is.numeric), ~ round(.x, digits)))

combined_tbl <- bind_rows(
param_tbl |>
mutate(edf = NA_real_, ref_df = NA_real_) |>
select(model, component, term, estimate, std_error, edf, ref_df, statistic, p_value),
smooth_tbl |>
mutate(estimate = NA_real_, std_error = NA_real_) |>
select(model, component, term, estimate, std_error, edf, ref_df, statistic, p_value)
)

list(parametric = param_tbl, smooth = smooth_tbl, combined = combined_tbl)
}

occ_tabs  <- gam_results_table(final_occ,      "Occupancy", digits = 3)
cpue_tabs <- gam_results_table(final_cpue_pos, "CPUE (pos)", digits = 3)
bpue_tabs <- gam_results_table(final_bpue_pos, "BPUE (pos)", digits = 3)

add_sig <- function(df) {
df |>
mutate(
sig = case_when(
is.na(p_value) ~ "",
p_value < 0.001 ~ "***",
p_value < 0.01  ~ "**",
p_value < 0.05  ~ "*",
p_value < 0.1   ~ ".",
TRUE ~ ""
)
)
}

occ_table  <- add_sig(occ_tabs$combined)
cpue_table <- add_sig(cpue_tabs$combined)
bpue_table <- add_sig(bpue_tabs$combined)


all_models_table <- bind_rows(occ_table, cpue_table, bpue_table)
write.csv(all_models_table, file = file.path(out_dir, "tbl-gam-models.csv"), row.names = FALSE)

gam_models_table <- all_models_table |>
  mutate(
    term    = gsub("^s\\((.+)\\)$", "\\1", term),
    p_value = ifelse(!is.na(p_value), format.pval(p_value, digits = 3, eps = 0.001), NA)
  ) |>
  kable(col.names = c("Model", "Component", "Term", "Estimate", "SE", "EDF", "Ref.df", "Statistic", "p-value", ""),
    align = c("l","l","l","r","r","r","r","r","r","l"))

# Returns the comparison operator and the italic "p" label baked in
# (e.g. "*p* < 0.001" or "*p* = 0.023"). Set label = FALSE for values chained
# after a leading "*p*" (e.g. "*p* < 0.001, 0.011, 0.004, respectively").
get_pval <- function(model_name, term_name, component = "smooth", label = TRUE) {
  p_value <- all_models_table |>
    dplyr::filter(
      model     == model_name,
      grepl(term_name, term, fixed = TRUE),
      component == component
    ) |>
    dplyr::pull(p_value)

  out <- ifelse(p_value < 0.001, "< 0.001",
                paste0("= ", format.pval(p_value, digits = 3, eps = 0.001)))
  if (label) paste0("*p* ", out) else out
}

gam_models_table
In [34]:
GAM model results for kōura occupancy, CPUE, and BPUE models. Significance codes: *** p < 0.001, ** p < 0.01, * p < 0.05, . p < 0.1
Model Component Term Estimate SE EDF Ref.df Statistic p-value
Occupancy parametric (Intercept) 2.098 0.886 NA NA 2.368 0.018 *
Occupancy parametric Presence_Goldfish -3.280 1.379 NA NA -2.379 0.017 *
Occupancy parametric Presence_Catfish 4.222 1.718 NA NA 2.458 0.014 *
Occupancy smooth Riparian_vegetation NA NA 0.849 6 3.965 0.026 *
Occupancy smooth Substrate_index NA NA 0.980 9 9.163 0.001 **
Occupancy smooth Temperature NA NA 1.161 9 13.049 <0.001 ***
Occupancy smooth Specific_conductivity NA NA 0.937 9 8.248 0.002 **
Occupancy smooth LID NA NA 0.000 4 0.000 0.622
CPUE (pos) parametric (Intercept) 0.928 0.194 NA NA 4.790 <0.001 ***
CPUE (pos) parametric Presence_Common_smelt -0.609 0.284 NA NA -2.145 0.041 *
CPUE (pos) smooth Temperature NA NA 0.823 9 0.561 0.019 *
CPUE (pos) smooth pH NA NA 1.020 9 2.085 <0.001 ***
CPUE (pos) smooth Emergent_Native NA NA 0.980 3 7.160 <0.001 ***
CPUE (pos) smooth LID NA NA 0.000 4 0.000 0.482
BPUE (pos) parametric (Intercept) 3.702 0.169 NA NA 21.943 <0.001 ***
BPUE (pos) smooth pH NA NA 0.967 9 1.238 0.002 **
BPUE (pos) smooth Emergent_Native NA NA 0.761 3 1.154 0.041 *
BPUE (pos) smooth LID NA NA 0.000 4 0.000 0.607

PCA (habitat complexity)

In [35]:
View Code

# Comment Frank about the modeling:
  # If you centered (on the mean) and standardized (scaled to unit variance) the predictors you could use the parameter estimates as an effect size to say something about the importance of the predictors (i.e. weak vs strong effects).


habitat_vars <- M_C_data %>%
dplyr::select(
Riparian_vegetation,
Substrate_index,
Wood_cover,
Overhanging_trees,
Emergent_Native,
Submerged_Native,
Submerged_Non_Native
) %>%
dplyr::filter(complete.cases(.))

habitat_pca <- prcomp(habitat_vars, center = TRUE, scale. = TRUE)

summary(habitat_pca)
Importance of components:
                          PC1    PC2    PC3     PC4     PC5     PC6     PC7
Standard deviation     1.4421 1.2825 1.1245 0.80595 0.76777 0.64184 0.60000
Proportion of Variance 0.2971 0.2350 0.1807 0.09279 0.08421 0.05885 0.05143
Cumulative Proportion  0.2971 0.5321 0.7127 0.80551 0.88972 0.94857 1.00000
View Code

biplot(habitat_pca)

View Code

habitat_pca$rotation
                            PC1        PC2        PC3         PC4        PC5
Riparian_vegetation   0.2691729 -0.5734874 -0.1219808 -0.46678738  0.1080919
Substrate_index       0.5006488  0.1730728 -0.3222116 -0.43522295 -0.2999105
Wood_cover            0.3362884 -0.3905629  0.3965271  0.53597482 -0.3013394
Overhanging_trees     0.5243168 -0.2768683 -0.1062900  0.21679548  0.3197312
Emergent_Native      -0.3921398 -0.4450793 -0.2763342  0.03053358  0.4729932
Submerged_Native     -0.3000816 -0.3609182 -0.4787215  0.13983049 -0.6683603
Submerged_Non_Native -0.2123113 -0.2891455  0.6381490 -0.48778723 -0.1868376
                             PC6         PC7
Riparian_vegetation  -0.51145371  0.30433475
Substrate_index       0.14622472 -0.56109649
Wood_cover           -0.25170700 -0.36838314
Overhanging_trees     0.64334259  0.27206657
Emergent_Native       0.08366168 -0.58319468
Submerged_Native      0.21080368  0.19954596
Submerged_Non_Native  0.43399293 -0.05375961

Koura catfish interaction

In [36]:
View Code

# 1. Catfish distribution across habitat types
M_C_data %>%
  group_by(Habitat_Type) %>%
  summarise(n_sites = n(),
            catfish_present = sum(Presence_Catfish, na.rm = TRUE),
            koura_present = sum(Presence_Kōura, na.rm = TRUE),
            mean_CPUE_koura = mean(Weighted_CPUE_Kōura, na.rm = TRUE))
# A tibble: 4 × 5
  Habitat_Type        n_sites catfish_present koura_present mean_CPUE_koura
  <chr>                 <int>           <dbl>         <dbl>           <dbl>
1 Emergent Macrophyte       9               1             5           0.370
2 Muddy                     7               1             1           0.25 
3 Rocky                    14               1            13           3.18 
4 Sandy                    30               3            14           1.09 
View Code

# 2. Kōura metrics: catfish-present vs absent sites
M_C_data %>%
  group_by(Presence_Catfish) %>%
  summarise(n = n(),
            koura_presence_rate = mean(Presence_Kōura, na.rm = TRUE),
            mean_CPUE_koura = mean(Weighted_CPUE_Kōura, na.rm = TRUE),
            mean_BPUE_koura = mean(Weighted_BPUE_Kōura, na.rm = TRUE))
# A tibble: 2 × 5
  Presence_Catfish     n koura_presence_rate mean_CPUE_koura mean_BPUE_koura
             <dbl> <int>               <dbl>           <dbl>           <dbl>
1                0    54               0.556           1.48            28.7 
2                1     6               0.5             0.417            7.88
View Code

# 3. Kōura metrics: catfish-invaded vs catfish-free lakes
M_C_data %>%
  mutate(catfish_lake = if_else(lake %in% c("Rotorua", "Rotoiti"),
                                "Catfish present", "Catfish absent")) %>%
  group_by(catfish_lake) %>%
  summarise(n_sites = n(),
            koura_presence_rate = mean(Presence_Kōura, na.rm = TRUE),
            mean_CPUE_koura = mean(Weighted_CPUE_Kōura, na.rm = TRUE),
            mean_BPUE_koura = mean(Weighted_BPUE_Kōura, na.rm = TRUE))
# A tibble: 2 × 5
  catfish_lake    n_sites koura_presence_rate mean_CPUE_koura mean_BPUE_koura
  <chr>             <int>               <dbl>           <dbl>           <dbl>
1 Catfish absent       36               0.667           1.75             35.4
2 Catfish present      24               0.375           0.812            13.5

Session info

In [37]:
View Code
sessionInfo()
R version 4.6.1 (2026-06-24 ucrt)
Platform: x86_64-w64-mingw32/x64
Running under: Windows 11 x64 (build 26100)

Matrix products: default
  LAPACK version 3.12.1

locale:
[1] LC_COLLATE=English_New Zealand.utf8  LC_CTYPE=English_New Zealand.utf8   
[3] LC_MONETARY=English_New Zealand.utf8 LC_NUMERIC=C                        
[5] LC_TIME=English_New Zealand.utf8    

time zone: Pacific/Auckland
tzcode source: internal

attached base packages:
[1] stats     graphics  grDevices datasets  utils     methods   base     

other attached packages:
 [1] readxl_1.5.0       lubridate_1.9.5    forcats_1.0.1      stringr_1.6.0     
 [5] dplyr_1.2.1        purrr_1.2.2        readr_2.2.0        tidyr_1.3.2       
 [9] tidyverse_2.0.0    tibble_3.3.1       performance_0.17.1 glmmTMB_1.1.14    
[13] mgcv_1.9-4         nlme_3.1-169       caret_7.0-1        lattice_0.22-9    
[17] ggplot2_4.0.3      pROC_1.19.0.1      gratia_0.11.2      patchwork_1.3.2   
[21] emmeans_2.0.4      kableExtra_1.4.1   DT_0.34.0         

loaded via a namespace (and not attached):
  [1] RColorBrewer_1.1-3   rstudioapi_0.19.0    jsonlite_2.0.0      
  [4] magrittr_2.0.5       TH.data_1.1-5        estimability_2.0.0  
  [7] farver_2.1.2         nloptr_2.2.1         rmarkdown_2.31      
 [10] ragg_1.5.2           vctrs_0.7.3          minqa_1.2.8         
 [13] htmltools_0.5.9      cellranger_1.1.0     sass_0.4.10         
 [16] parallelly_1.48.0    bslib_0.11.0         htmlwidgets_1.6.4   
 [19] plyr_1.8.9           sandwich_3.1-3       zoo_1.9-0           
 [22] cachem_1.1.0         TMB_1.9.23           lifecycle_1.0.5     
 [25] iterators_1.0.14     pkgconfig_2.0.3      Matrix_1.7-5        
 [28] R6_2.6.1             fastmap_1.2.0        rbibutils_2.4.1     
 [31] future_1.75.0        digest_0.6.39        numDeriv_2016.8-1.1 
 [34] textshaping_1.0.5    crosstalk_1.2.2      labeling_0.4.3      
 [37] timechange_0.4.0     compiler_4.6.1       withr_3.0.3         
 [40] S7_0.2.2             MASS_7.3-65          lava_1.9.2          
 [43] ModelMetrics_1.2.2.2 tools_4.6.1          otel_0.2.0          
 [46] future.apply_1.20.2  nnet_7.3-20          glue_1.8.1          
 [49] grid_4.6.1           reshape2_1.4.5       generics_0.1.4      
 [52] recipes_1.3.3        gtable_0.3.6         tzdb_0.5.0          
 [55] class_7.3-23         data.table_1.18.4    hms_1.1.4           
 [58] utf8_1.2.6           xml2_1.6.0           foreach_1.5.2       
 [61] pillar_1.11.1        splines_4.6.1        renv_1.1.8          
 [64] survival_3.8-6       tidyselect_1.2.1     knitr_1.51          
 [67] reformulas_0.4.4     svglite_2.2.2        stats4_4.6.1        
 [70] xfun_0.60            statmod_1.5.2        hardhat_1.4.3       
 [73] timeDate_4052.112    stringi_1.8.7        yaml_2.3.12         
 [76] boot_1.3-32          ggokabeito_0.1.0     evaluate_1.0.5      
 [79] codetools_0.2-20     cli_3.6.6            rpart_4.1.27        
 [82] tweedie_3.1.0        xtable_1.8-8         systemfonts_1.3.2   
 [85] Rdpack_2.6.6         jquerylib_0.1.4      mirai_2.7.2         
 [88] Rcpp_1.1.2           globals_0.19.1       coda_0.19-4.1       
 [91] nanonext_1.10.1      parallel_4.6.1       ggh4x_0.3.1         
 [94] gower_1.0.2          mvnfast_0.2.8        lme4_2.0-6          
 [97] listenv_1.0.0        viridisLite_0.4.3    mvtnorm_1.4-2       
[100] ipred_0.9-15         scales_1.4.0         prodlim_2026.03.11  
[103] insight_1.5.2        writexl_1.5.4        rlang_1.3.0         
[106] multcomp_1.4-31     
Land, Air, Water Aotearoa (LAWA), 2025. Lakes data for the Bay of Plenty region. https://www.lawa.org.nz/explore-data/bay-of-plenty-region/lakes.