Environmental Monitoring for Antimicrobial Resistance in a Metropolitan Watershed

Version 1.4

Author

David A Holcomb

Published

August 28, 2026

Description

This report provides R code to process the data and perform the analysis of antimicrobial-resistant E. coli in river water reported by:

Holcomb DA, Cantrell ME, Feistel DJ, Meyer M, Sterling J, Chen J, Krishnan K, Im S, Folster JP, Kim J, Burroughs M, Nobles S, Stahl A, Mattioli MC. (2026). Environmental monitoring for antimicrobial resistance in a metropolitan watershed. Journal of Water and Health. https://doi.org/10.2166/wh.2026.219

This analysis code and the study data are available in the manuscript repository. The whole-genome sequencing data are available from NCBI under BioProject PRJNA1423195 with accession numbers SRR55333857–SRR55334224.

Setup

Load packages and set options.

Code
## Options
options(tidyverse.quiet = TRUE)
options(dplyr.summarise.inform = FALSE)
options(knitr.kable.NA = '')

## Packages
# Utilities
library(readxl)         # I/O
library(writexl)        # I/O
library(lubridate)      # date formatting
library(prettyunits)    # number formatting
library(zoo)            # time series data
library(units)          # handle units in dataframes

# Spatial
library(sf)             # spatial data & operations
library(nngeo)          # spatial operations
library(rmapshaper)     # spatial operations
library(terra)          # raster data & operations
library(exactextractr)  # summarize raster data within polygons
library(FedData)        # download US federal data sources
library(tigris)         # download US census shapefiles
library(SSNbler)        # prepare spatial stream networks
library(SSN2)           # fit spatial stream network models

# Models
library(broom)          # extract model estimates
library(fitdistrplus)   # fit distributions to data
library(truncnorm)      # draw from truncated distribution
library(DescTools)      # gives correlation CIs

# Figures
library(patchwork)    # combine plots
library(ragg)         # more consistent graphics output
library(ggtext)       # plot label formatting
library(scales)       # axis formatting

# Tidyverse
library(knitr)        # table rendering
library(kableExtra)   # table headers
library(tidyverse)    # load last to avoid conflicts

Data processing

Water samples

The variables measured in surface water grab samples, including microbial concentrations, turbidity, and optical brightener fluorescence, along with sampling site information, are provided in the file sample_data.xlsx. This file is structured with the data in the first sheet and a codebook in the second sheet. Each row in the data sheet corresponds to the measured E. coli concentration in a particular water sample from a single site and date, selected with a given antibiotic at a particular dose.

Read in the sample data, fix the dates, and save as RDS for later use:

# read sample data
df_samp <- read_xlsx("../data/sample_data.xlsx", sheet = "data") |>
  mutate(dt_collect = as_date(dt_collect))

# save in RDS format for stability
saveRDS(df_samp, "../out/sample_data.rds")

Sampling site locations

Information about each of the sampling sites is contained in the waster sample data set. Extract the site information from the sample data, condense, and save for later:

df_site <- df_samp |>
  select(starts_with("site_")) |>
  distinct()

saveRDS(df_site, "../out/site_data.rds")

Extract the coordinate reference system (CRS) from the site data and use it to convert the lat/lon coordinates for each of the sites into sf format to facilitate future spatial operations. Project the site locations into the NAD83(2011) / Georgia West (m) projection (EPSG:6446) and save for later use:

# extract existing CRS
crs_site <- df_site |> 
  select(site_crs) |>
  distinct() |>
  pull()


# transform to simple features
sf_site <- df_site |>
  st_as_sf(coords = c("site_lon", "site_lat"),
           crs = crs_site) |>
  st_transform(crs = 6446) |>  # project as NAD83(2011) / Georgia West (m)
  mutate(epsg = "EPSG:6446",
         proj = "NAD83(2011) / Georgia West")

saveRDS(sf_site, "../gis/sf/site_pt.rds")

Drainage basin boundaries

Polygons representing the perimeter of the drainage basin contributing to each sampling location were obtained using the USGS StreamStats Batch Processor v1.5.1. Sample site locations have already been manually adjusted in to coincide with the nearest feasible USGS StreamGrid raster cell prior to basin delineation. In addition to the sampling sites, a drainage basin was delineated for all areas upstream of the USGS monitoring station at Buford Dam (USGS02334430) to encompass Lake Lanier and all the Upper Chattahoochee watershed. This basin upstream of Buford Dam is used to truncate the drainage basins for all the sampling sites to restrict them to downstream of Lake Lanier, as all the river mainstem sites would otherwise share fully overlapping drainage basins upstream of the dam.

The raw drainage basin polygons output by USGS StreamStats are provided as the shapefile basin_raw.shp in the compressed folder basin.zip. After downloading and extracting the compressed file, read in the raw basins shapefile and perform spatial quality control operations, including filling holes in the polygons and validating the resulting geometries:

# raw basin polygons from streamstats shapefile
basin_raw <- read_sf("../gis/shape/basin/basin_raw.shp")


# join to site info, fill holes, validate
basin_full <- readRDS("../out/site_data.rds") |>  # start with site info
  # add a row corresponding to the Buford Dam basin, upstream of all the other sites.
  add_case(site_num = 1, site_id = "BUD", site_lab = "Buford Dam", site_type = "lake") |>
  left_join(basin_raw, by = c("site_id" = "Name")) |>  # join the basin polygons
  select(site_num, site_id,
         site_lab, site_type,
         geometry) |>
  st_as_sf(crs = st_crs(basin_raw)) |>  # convert back to sf format using original crs
  st_transform(crs = 6446) |>           # project as NAD83(2011) / Georgia West
  st_remove_holes() |>                  # remove holes from the polygons 
  st_make_valid() |>                    # validate geometry          
  arrange(site_num) |>                  # order by site number (roughly upstream -> downstream)
  mutate(site_id = fct_inorder(site_id),
         epsg = "EPSG:6446",
         proj = "NAD83(2011) / Georgia West")

saveRDS(basin_full, "../gis/sf/basin_full.rds")

Separate the Buford Dam drainage basin (upstream-most) from the rest:

# upper basin as geometry so the attributes don't conflict
basin_upper <- basin_full |>
  filter(site_id == "BUD")

# everything else
basin_lower <- basin_full |>
  filter(site_id != "BUD")

Use the upper basin to clip the rest of the basins so they are all truncated at Buford Dam, then re-validate the geometries:

basin_clip <- ms_erase(basin_lower, basin_upper,
                       remove_slivers = TRUE) |>
  st_make_valid()

Re-append the upper basin polygon to the other truncated ones, repeat geometry quality control measures, and save:

basin <- basin_full |>
  filter(site_id == "BUD") |>
  bind_rows(basin_clip) |>
  st_remove_holes() |>
  st_make_valid() |>
  arrange(site_num) |>
  mutate(site_id = fct_inorder(site_id))

saveRDS(basin, "../gis/sf/basin_poly.rds")

Take a quick look at the basin outlines:

plot(st_geometry(basin))

Merge (union) all the truncated basins below the dam to obtain the perimeter (scope) of the study area, repeating geometry quality control measures:

basin_scope <- basin |>
  filter(site_num > 2) |>
  st_union() |>
  st_remove_holes() |>
  st_make_valid()

saveRDS(basin_scope, "../gis/sf/basin_scope.rds")

Plot the scope:

plot(st_geometry(basin_scope))

Land cover

Land cover data were obtained from the USGS National Land Cover Database (NLCD) 2019 vintage via the FedData package. Both land cover classes and percent impervious surface data were obtained as separate raster files. For mapping purposes, the full study area scope (including the shared watershed upstream of Buford Dam) was used to select the raster coverage area.

Merge all the basins, including the basin above the dam, to obtain the extended scope for which to download land cover rasters:

basin_scope_full <- readRDS("../gis/sf/basin_poly.rds") |>
  st_union() |>
  st_remove_holes() |>
  st_make_valid()

Add a small (2 km) buffer to the scope so the retrieved rasters fully cover the study area extent and transform to WGS84 to work with the FedData package:

basin_scope_buff <- basin_scope_full |>
  st_buffer(dist = 2000) |>
  st_transform(crs = 4326)  # transform to WGS84 to work with FedData

Download NLCD LULC 2019 and NLCD Impervious 2019 as separate rasters and save as GeoTIFF format:

# LULC
nlcd_lulc <- get_nlcd(template = basin_scope_buff,
                      label = "river_ar",
                      year = 2019,
                      dataset = "landcover",
                      landmass = "L48",
                      extraction.dir = "../gis/lulc/",
                      force.redo = TRUE
                      )

writeRaster(nlcd_lulc, "../gis/lulc/nlcd_2019_lulc.tif",
            overwrite = TRUE)



# impervious
nlcd_impv <- get_nlcd(template = basin_scope_buff,
                      label = "river_ar",
                      year = 2019,
                      dataset = "impervious",
                      landmass = "L48",
                      extraction.dir = "../gis/lulc/",
                      force.redo = TRUE
                      )

writeRaster(nlcd_impv, "../gis/lulc/nlcd_2019_impv.tif",
            overwrite = TRUE)

Because re-projecting rasters requires a non-reversible transformation of the data, extract the existing CRS information from the downloaded rasters so it can be used to re-project the vector data for analyses:

# reload from saved TIFF
nlcd_lulc <- rast("../gis/lulc/nlcd_2019_lulc.tif")

# extract CRS as sf
nlcd_crs <- st_crs(nlcd_lulc)

saveRDS(nlcd_crs, "../gis/lulc/nlcd_crs_sf.rds")

Meteorology

Meteorological data are obtained from the NOAA GHCN-Daily records using the FedData package. The following code demonstrates how the data were originally obtained, but because the data available from NOAA may change over time, re-executing this code could return a slightly different data set than was used for the canonical analysis reported in the manuscript.

Set a search zone for weather stations within 20 km of the study area (defined as the truncated drainage basins), then transform to WGS84 to work with the FedData package:

template_fd <- readRDS("../gis/sf/basin_scope.rds") |>
  st_buffer(dist = 20000) |>  # search within 20 km buffer of study area
  st_transform(crs = 4326)  # transform to WGS84 to work with FedData

Different stations report different weather variables, so download the GHCN data separately for precipitation and temperature to ensure broadest swath of stations are available for each for both full years of the study period (2018 & 2019):

# stations with precip
ghcn_prcp <- get_ghcn_daily(template = template_fd,
                            label = "river_ar",
                            years = 2018:2019,
                            elements = c("prcp")
                            )

# stations with temp
ghcn_temp <- get_ghcn_daily(template = template_fd,
                            label = "river_ar",
                            years = 2018:2019,
                            elements = c("tmin", "tmax", "tavg")
                            )

Extract station location information:

ghcn_loc <- bind_rows(ghcn_prcp$spatial,
                      ghcn_temp$spatial) |>
  distinct() |>
  rename(ghcn_id = ID,
         ghcn_name = NAME) |>
  st_transform(crs = 6446) |>  # project as NAD83(2011) / Georgia West
  mutate(epsg = "EPSG:6446",
         proj = "NAD83(2011) / Georgia West")

saveRDS(ghcn_loc, "../gis/sf/meteo_pt.rds")

Write a utility function to parse the GHCN tabluar data and wrangle into long format:

extract_ghcn <- function(x){
  
  tibble(val = x) |>
    unnest_longer(col = val,
                  indices_to = "meteo_type") |>
    unnest(col = val) |>
    pivot_longer(D1:D31,
                 names_to = "day",
                 values_to = "meteo_val") |>
    mutate(day = str_remove(day, "D"),
           dt_meteo = ymd(str_c(YEAR, MONTH, day, sep = "-")),
           meteo_val = meteo_val / 10) |>  # values for 1/10 of unit, divide by 10 to get desired units
    filter(!is.na(dt_meteo)) |>
    select(ghcn_id = STATION,
           dt_meteo,
           meteo_type,
           meteo_val)
  
}

Apply the utility function to the data downloads, combine the resulting long-format dataframes, and save:

# precipitation data
df_ghcn_prcp <- ghcn_prcp$tabular %>%
  extract_ghcn() %>%
  mutate(meteo_unit = "mm")


# temperature data
df_ghcn_temp <- ghcn_temp$tabular %>%
  extract_ghcn() %>%
  mutate(meteo_unit = "degC")


# combine
df_ghcn <- bind_rows(df_ghcn_prcp,
                     df_ghcn_temp) %>%
  mutate(meteo_type = str_to_lower(meteo_type))

saveRDS(df_ghcn, "../out/ghcn_data.rds")

Infrastructure

Wastewater infrastructure data were obtained from two sources:

Requests for the raw infrastructure data should be directed to the respective organizations that provided them for the study.

Wastewater outfalls

Information on the outfalls in the study area is provided in the file `outfall_info.xlsx’. Read the outfall data:

df_outfall <- read_xlsx("../data/outfall_info.xlsx", sheet = "data")

Convert coordinates to sf format and project for spatial operations:

sf_outfall <- df_outfall |>
  st_as_sf(coords = c("lon", "lat"),
           crs = 4269) |>
  st_transform(crs = 6446) |>  # project as NAD83(2011) / Georgia West (m)
  mutate(epsg = "EPSG:6446",
         proj = "NAD83(2011) / Georgia West")

Perform a spatial join with the drainage basin polygons to associate the outfalls with the basins to which they contribute:

# reload basins and filter for study area
basin_out <- readRDS("../gis/sf/basin_poly.rds") |>
  filter(site_num > 2)
  

# spatial join
outfall_loc <- st_intersection(sf_outfall, basin_out) |>
  select(starts_with("site"),
         starts_with("facil"),
         starts_with("out"),
         epsg, proj,
         geometry)

saveRDS(outfall_loc, "../gis/sf/outfall_pt.rds")

Septic systems

Septic system locations are not shared for privacy reasons. Contact Metropolitan North Georgia Water Planning District to request access to septic system data.

The data cover septic system locations for 15 counties in the metropolitan Atlanta, GA area. These 15 counties fully encompass the study area, with the exception of a small portion of the Fairburn Rd drainage basin (downstream-most site), which extends into Carroll County, not covered in the septic system data. In order to associate septic systems with drainage basins, the Carroll County portion of the Fairburn Rd basin must be masked and the truncated basin area used in density calculations.

Download county boundary polygons from the US Census using the tigris package, project, and reformat:

# download Georgia Counties
cnty_poly_raw <- tigris::counties(year = 2020,
                                  state = "GA",
                                  cb = TRUE)


# project and reformat
cnty_poly <- cnty_poly_raw |>
  st_transform(crs = 6446) %>%  # project as NAD83(2011) / Georgia West
  mutate(cnty_area = st_area(.) |> set_units(km^2),
         cnty_perim = st_perimeter(.) |> set_units(km),
         GEOID = as.character(GEOID),
         epsg = "EPSG:6446",
         proj = "NAD83(2011) / Georgia West") |>
  st_make_valid() |>
  select(cnty_id = GEOID,
         state_fips = STATEFP,
         state_id = STUSPS,
         state_name = STATE_NAME,
         cnty_fips = COUNTYFP,
         cnty_name = NAME,
         cnty_area,
         cnty_perim,
         cnty_land = ALAND,
         cnty_water = AWATER,
         epsg, proj,
         geometry) |>
  drop_units() |>
  select(cnty_id,
         starts_with("state"),
         starts_with("cnty"),
         epsg, proj, geometry) |>
  arrange(cnty_id)

saveRDS(cnty_poly, "../gis/sf/cnty_poly_2020.rds")

Pull out Carroll County and validate geometry:

cnty_carroll <- readRDS("../gis/sf/cnty_poly_2020.rds") |>
  filter(cnty_name == "Carroll") |>
  st_remove_holes() |>
  st_make_valid()

Re-load the basin polygons, restrict to downstream of the dam, and calculate area:

basin_septic <- readRDS("../gis/sf/basin_poly.rds") |>
  arrange(site_num) |>
  filter(site_num > 2)  %>%
  mutate(wshed_area = st_area(.) |> units::set_units("km^2"),
         wshed_perim = st_perimeter(.) |> units::set_units("km")) |>
  drop_units()

Clip the basins to exclude Carroll County, validate geometry, and re-calculate area:

basin_clip <- ms_erase(basin_septic, cnty_carroll,
                       remove_slivers = TRUE) |>
  st_remove_holes() |>
  st_make_valid() %>%
  mutate(wshed_area_clp = st_area(.) |> units::set_units("km^2")) |>
  drop_units() %>%
  mutate(area_diff = wshed_area - wshed_area_clp,
         pct_reduction = (area_diff / wshed_area) * 100)

Read and project septic system locations, then perform a spatial join with the basin polygons and save:

# read and project septic locations
sf_septic <- readRDS("../gis/sf/septic_loc.rds") |>
  st_transform(crs = 6446) |> # project as NAD83(2011) / Georgia West
  mutate(epsg = "EPSG:6446",
         proj = "NAD83(2011) / Georgia West")


# spatial join to basins
septic_loc <- st_intersection(sf_septic, basin_clip) |>
  select(septic_id,
         starts_with("site"),
         starts_with("wshed"),
         starts_with("dt"),
         source,
         septic_lat = lat, septic_lon = lon,
         epsg, proj,
         geometry)

saveRDS(septic_loc, "../gis/sf/septic_pt.rds")

Variable generation

The variables used to represent E. coli outcomes and environmental factors in the analyses were created from the data sets obtained above through additional processing.

E. coli concentration: imputation of censored observations

Raw concentration data for both generic and resistant E. coli include values that are problematic for statistical analysis of microbial concentrations. First, the concentration of the target in samples where it was not detected was not necessarily zero, just less than the concentration that corresponds to the lower limit of detection (LOD). Because zeros are also problematic for logarithmic transformation, which is typically appropriate for analyzing microbial concentration data that scale geometrically rather than arithmetically, it is useful to consider non-detects as censored below the LOD. Similarly, samples in which the target is so abundant that all the wells in the most probable number (MPN) test are positive (i.e., TNTC: “too numerous to count”) are only known to have a concentration that is greater than that corresponding to the upper limit of quantification (ULOQ) of the test.

Censoring below the LOD and above the ULOQ correspond to left and right censoring, respectively, and can be addressed using imputation techniques to substitute informed values for the left- and right-censored observations to facilitate log-transformation and downstream analysis. Imputation is performed separately for each target (i.e., each for each selective antibiotic and dose) by fitting a probability distribution to the censored data and drawing a random value for each censored observation from the fitted distribution truncated below the LOD or above the ULOQ for left- and right-censored observations, respectively.

Prepare a data set of microbial concentrations for censored imputation processing. Because few meropenem-nonsusceptible E. coli (MEM-Ec) were detected and a relatively small number of samples were selected by cefotaxime at 4 μg/mL, restrict the data set to generic E. coli, cefotaxime-selected E. coli at 1 μg/mL (ESBL-Ec), and colistin-selected E. coli (CST-Ec). All concentrations will be log10-transformed for this analysis; for this reason, non-detect and TNTC observations have already been replaced with their respective LOD or ULOQ values, respectively, along with an indicator variables denoting whether they are left-, right-, or non-censored. These substituted values are simply for convenience and will be treated as missing in the imputation procedure.

For compatibility with the function from the fitdistrplus package for fitting censored distributions to these data, the concentration variables (mpn, mpn_lod, and mpn_uloq) along with this censored indicator variable (cens) need to be converted into a pair of variables named left and right that both contain the observed concentration when not censored and contain a missing value in corresponding column and the censoring value in the opposite column for censored observations. I.e., a left-censored observation should be missing in left and contain the LOD value in right, while a right-censored observation will contain the ULOQ value in left and be missing in right:

df_micro_cens <- readRDS("../out/sample_data.rds") |>
  filter(drug %in% c("cefotaxime", "colistin", "none"),
         drug_dose != 4,
         site_num > 2,
         !is.na(mpn)) |>
  group_by(drug, drug_dose) |>
  mutate(nobs = n(),
         npos = sum(cens == "no"),
         ncens = sum(cens == "left" | cens == "right")) |>
  ungroup() |>
  mutate(# log10-transform the concentration and limits
         across(c(mpn, mpn_lod, mpn_uloq), ~log10(.x)),
         
         # create left-censored values variable
         left = case_when(cens == "left" ~ NA_real_,
                          cens == "no" ~ mpn,
                          cens == "right" ~ mpn_uloq),
         
         # create right-censored value variable
         right = case_when(cens == "right" ~ NA_real_,
                           cens == "no" ~ mpn,
                           cens == "left" ~ mpn_lod))

Extract the number of censored observations by outcome:

ncens <- df_micro_cens |>
  select(drug, ncens) |>
  distinct()

Fit censored normal distributions to the concentrations separately for each outcome, starting with cefotaxime.

First, filter the data set, extract just the left and right variables, and convert to basic dataframe for compatibility:

df_cens_ctx <- df_micro_cens |>
  filter(drug == "cefotaxime") |>
  select(left, right) |>
  as.data.frame()

Fit the censored normal distribution and extracte the estimated mean and standard deviation with their standard errors:

# fit distribution
fit_cens_ctx <- fitdistcens(censdata = df_cens_ctx,
                            distr = "norm")

# extract parameter estimates
est_cens_ctx <- tibble(drug = "cefotaxime",
                       nobs = fit_cens_ctx$n,             # number of observations used to fit the distribution
                       mu = fit_cens_ctx$estimate[1],     # estimated mean of the normal distribution
                       mu_se = fit_cens_ctx$sd[1],        # standard error of the mean estimate
                       sigma = fit_cens_ctx$estimate[2],  # estimated standard deviation of the normal distribution
                       sigma_se = fit_cens_ctx$sd[2])     # standard error of the SD estimate

Repeat for colistin-selected and generic E. coli:

Code
## Colistin-selected (CST)
# dataframe
df_cens_cst <- df_micro_cens |>
  filter(drug == "colistin") |>
  select(left, right) |>
  as.data.frame()

# fit distribution
fit_cens_cst <- fitdistcens(censdata = df_cens_cst,
                            distr = "norm")

# extract parameter estimates
est_cens_cst <- tibble(drug = "colistin",
                       nobs = fit_cens_cst$n,
                       mu = fit_cens_cst$estimate[1],
                       mu_se = fit_cens_cst$sd[1],
                       sigma = fit_cens_cst$estimate[2],
                       sigma_se = fit_cens_cst$sd[2])


## Generic
# dataframe
df_cens_gen <- df_micro_cens |>
  filter(drug == "none") |>
  select(left, right) |>
  as.data.frame()

# fit distribution
fit_cens_gen <- fitdistcens(censdata = df_cens_gen,
                            distr = "norm")

# extract parameter estimates
est_cens_gen <- tibble(drug = "none",
                       nobs = fit_cens_gen$n,
                       mu = fit_cens_gen$estimate[1],
                       mu_se = fit_cens_gen$sd[1],
                       sigma = fit_cens_gen$estimate[2],
                       sigma_se = fit_cens_gen$sd[2])

Combine the estimates, join the counts of censored observations, and save:

est_cens <- bind_rows(est_cens_ctx, est_cens_cst, est_cens_gen) |>
  left_join(ncens, by = "drug") |>
  select(drug,
         nobs, ncens,
         mu, mu_se,
         sigma, sigma_se)

saveRDS(est_cens, "../est/distr_cens_mpn.rds")

The MLE means and standard deviations from the fitted censored normal distributions are used to parameter truncated normal distributions, from which suitable values are drawn to represent the censored observations. A random draw for each censored observation is used for analysis, but the expected value of the distribution truncated at the sample-specific LOD/ULOQ is also calculated for each censored observation for additional context.

Set a random seed for reproducibility, join the estimates to the censored observation data set, prepare the estimates for drawing from a truncated normal distribution. Define the lower bound a and the upper bound b for each sample to correspond with the censoring status of that observation. Imputes values for every observation (including the non-censored ones, which are then drawn from a normal distribution without truncation), retaining the imputed values only for censored observations:

# seed for reproducibility
set.seed(30333)

# join estimates to data and impute
imp_cens <- df_micro_cens |>
  select(starts_with("site"),
         starts_with("drug"),
         wk_collect, dt_collect,
         samp_event, samp_id,
         test_id, lab_vol,
         uid, uid_gen, uid_wq,
         wq_turb, wq_optic,
         target, detect,
         mpn, cens,
         mpn_lod, mpn_uloq,
         left, right) |>
  
  # merge distribution estimates 
  left_join(est_cens, by = "drug") |>
  
  # add bounds variables
  mutate(# Define lower bound variable
         a = case_when(cens == "left" ~ -Inf,
                       cens == "no" ~ -Inf,
                       cens == "right" ~ mpn_uloq),
         # Define upper bound variable
         b = case_when(cens == "left" ~ mpn_lod,
                       cens == "no" ~ Inf,
                       cens == "right" ~ Inf)) |>
  
  # perform imputation and only keep censored
  mutate(imp_e = etruncnorm(a, b, mu, sigma),           # expected value of truncated normal dist
         imp_r = rtruncnorm(1, a, b, mu, sigma),        # random draw from truncated normal dist
         mpn_imp_e = ifelse(cens == "no", mpn, imp_e),  # only keep expected value for censored obs
         mpn_imp_r = ifelse(cens == "no", mpn, imp_r))  # only keep random draw for censored obs

Separate out the generic E. coli values:

imp_cens_gen <- imp_cens |>
  filter(drug == "none") |>
  select(uid,
         cens_gen = cens,
         mpn_lod_gen = mpn_lod,
         mpn_uloq_gen = mpn_uloq,
         mpn_e_gen = mpn_imp_e,
         mpn_r_gen = mpn_imp_r)

Clean up the data set and join the imputed values for generic E. coli to their corresponding resistance-selected (and non-selected [generic], for consistency) E. coli observations:

df_imp <- imp_cens |>
  select(starts_with("site"),
         starts_with("drug"),
         wk_collect, dt_collect,
         samp_event, samp_id,
         test_id, lab_vol,
         uid, uid_gen, uid_wq,
         wq_turb, wq_optic,
         target, detect, cens,
         mpn_lod, mpn_uloq,
         mpn_e = mpn_imp_e,
         mpn_r = mpn_imp_r) |>
  left_join(imp_cens_gen,
            by = c("uid_gen" = "uid"))

saveRDS(df_imp, "../out/sample_imputed.rds")

Basin land cover: distance-weighted, areal proportion

Variables representing the influence of land cover classes on each sampling location are generated by weighting each raster cell of a given class contained within a site’s drainage basin by the inverse of the squared Euclidian distance between that cell’s centroid and the sampling location. Summing these cells by class provides inverse distance squared weighted (IDSW) cell counts, which are divided by the total IDSW cell count across all classes to obtain the IDSW proportion of the basin represented by that land cover class. Similarly, the IDSW mean imperviousness is obtained as a weighted average of the percent imperviousness of each cell in the drainage basin.

Calculate distance and weights

Read in the site locations and basin polygons and reproject to match the NLCD projection extracted previously:

# CRS
nlcd_crs <- readRDS("../gis/lulc/nlcd_crs_sf.rds")

# basin
basin_nlcd <- readRDS("../gis/sf/basin_poly.rds") |>
  filter(site_num > 2) |>
  st_transform(nlcd_crs) |>
  arrange(site_num, site_id)

# sites
site_nlcd <- readRDS("../gis/sf/site_pt.rds") |>
  st_transform(nlcd_crs) |>
  arrange(site_num, site_id)

Write a function to calculate the distance between a sampling site and the centroid of each raster cell. The function should take as inputs a raster with the data to be extracted and an sf dataframe with the point locations to calculate distances from. The output will be a raster stack with a layer for each sampling location where the raster value in each cell records its distance from the sampling point. Include functionality to encode the resulting rasters with a weight based on the inverse of the distance (raised to an arbitrary power) rather than the raw distance itself.

site_dist <- function(x_rast,       # raster to calculate distances for
                      y_sf,         # sf tibble with point locations to measure distances from
                      idw = FALSE,  # return inverse distance weights instead of the distance?
                      power = 1     # distance raised to power for calculating weights
                      ){
  
  n_feature <- nrow(y_sf)       # number of sites to calculate distances to
  
  # loop through each site
  dist_list <- list()           # Prepare empty list object 
  
  for(i in 1:n_feature){
    
    y <- y_sf[i, ]              # select site
    
    x <- distance(x_rast, y)    # calculate distances
    
    # apply inverse distance weighting
    if(idw){
      
      x_idw <- 1 / (x^power)            # raise distances to specified power
      
      dist_list <- c(dist_list, x_idw)  # insert weights into list object
      
    } else {
      
      dist_list <- c(dist_list, x)      # insert distances into list object
      
    }
    
    dist_stack <- rast(dist_list)       # convert list to raster stack
    
  }
  
  return(dist_stack)
  
}

Apply function to calculate inverse distance squared weights by specifying idw = TRUE and power = 2. Because the LULC and impervious surface rasters contain identical cell locations and extents, and differ only in the values recorded for each cell, the distance function need only be applied once to obtain the raster stack of weights that can be used for both:

# read in LULC raster (impervious would also work)
nlcd_lulc <- rast("../gis/lulc/nlcd_2019_lulc.tif")


# calculate inverse distance weights
nlcd_idsw <- site_dist(nlcd_lulc, site_nlcd,
                       idw = TRUE, power = 2)

# name the raster layers by corresponding site id
names(nlcd_idsw) <- site_nlcd$site_id

# save to drive as GeoTIFF
writeRaster(nlcd_idsw, "../gis/lulc/nlcd_site_idsw.tif",
            overwrite = TRUE)

Extract weighted land cover class proportions

Write a function for use with the exactextractr package to calculate the weighted counts of each class for a given basin, as well as basin-wide statistics. The exactextractr::exact_extract() function calculates fractional coverage for cells partially covered by the basin polygon and returns a dataframe with a row-per-cell with computed values given pre-specified names (e.g., coverage_fraction) that are accessed directly in the function before summarizing land cover class:

extract_idw <- function(df){
  
  df |>
    # calculate cell-level characteristics
    mutate(cover_tot = sum(coverage_fraction),              # total cells in basin, accounting for partial coverage
           frac = coverage_fraction / cover_tot,            # proportion of total in this cell
           cover_wt = coverage_fraction * weight,           # cell's weighted coverage fraction
           cover_wt_tot = sum(coverage_fraction * weight),  # total weighted cells in basin
           frac_wt = cover_wt / cover_wt_tot,               # proportion of weighted total in this cell
           wt_tot = sum(weight)) |>                         # sum of all the weights across the basin
    
    # specify variables to retain after summarizing
    # only one site at a time so site and tot variables are static for all cells
    # `value` contains the class and is the only variable that separate summaries are calculated by
    group_by(site_num, site_id, value,
             cover_tot, cover_wt_tot, wt_tot) |>
    
    # tally up cells
    summarize(freq = sum(frac),                 # raw class proportion  
              freq_wt = sum(frac_wt),           # weighted class proportion
              wt = sum(weight),                 # sum of weights within class
              count = sum(coverage_fraction),   # raw class cell count in basin
              count_wt = sum(cover_wt),         # weighted class cell count in basin
              area = sum(area),                 # total area covered by class
              area_wt = sum(area * weight))     # weighted area covered by class
}

Write a second function to loop through each basin and its corresponding IDSW raster in the stack to apply the extraction function to each basin in turn. Include an option :

loop_idw <- function(x_rast,              # raster with the values to extract
                     y_poly,              # sf with basin polygons (one per row)
                     wt_rast,             # raster stack with weights (must perfectly align with x_rast)          
                     include_cols = NULL  # character vector of column names to retain in output
                     ){
  
  n_feature <- nrow(y_poly)   # number of basins
  
  extract_df <- tibble()      # prepare empty dataframe
  
  # loop through each basin
  for(i in 1:n_feature){
    
    y <- y_poly[i, ]          # select basin
    
    wt <- wt_rast[[i]]        # select weights raster
    
    x <- exact_extract(x_rast,
                       y,
                       fun = extract_idw,           # custom weighting function
                       weights = wt,
                       summarize_df = TRUE,         # produce summarized output dataframe
                       include_cols = include_cols, # column names to retain (as character vector)
                       include_area = TRUE,         # calculate area within basin
                       progress = TRUE)             # show progress indicator
    
    extract_df <- bind_rows(extract_df, x)          # add basin summary to dataframe (inefficient)
    
  }
  
  return(extract_df)
  
}

Apply the functions to the NLCD LULC raster to calculate IDSW land cover class proportions:

basin_lulc_idsw <- loop_idw(x_rast = nlcd_lulc,
                            y_poly = basin_nlcd,
                            wt_rast = nlcd_idsw,
                            include_cols = c("site_num", "site_id"))

saveRDS(basin_lulc_idsw, "../out/basin_lulc_idsw.rds")

Extract weighted mean imperviousness

Each cell in the land cover raster contains a single, discrete value corresponding to the land cover class assigned to that cell (cell value is a categorical variable). By contrast, the imperviousness raster contains an integer value between 0 and 100 that represents the proportion of that cell that is covered by impervious surfaces. Rather than a weighted sum of the cells assigned to each class, a weighted mean imperviousness across all cells in the basin in calculated.

Weighted mean operations are built into exactextractr::exact_extract(), so revise the looping function to apply the built-in statistical functions (which require slightly different syntax in the arguments) to each basin in turn:

loop_idw_impv <- function(x_rast,              # raster with the values to extract
                          y_poly,              # sf with basin polygons (one per row)
                          wt_rast,             # raster stack with weights (must perfectly align with x_rast)          
                          include_cols = NULL  # character vector of column names to retain in output
                          ){
  
  n_feature <- nrow(y_poly)   # number of basins
  
  extract_df <- tibble()      # prepare empty dataframe
  
  # loop through each basin
  for(i in 1:n_feature){
    
    y <- y_poly[i, ]          # select basin
    
    wt <- wt_rast[[i]]        # select weights raster
    
    x <- exact_extract(x_rast,
                       y,
                       fun = c("count",
                               "mean", "weighted_mean",
                               "stdev", "weighted_stdev"),
                       weights = wt,
                       append_cols = include_cols,
                       progress = TRUE)
    
    extract_df <- bind_rows(extract_df, x)          # add basin summary to dataframe 
    
  }
  
  return(extract_df)
  
}

Apply the functions to the NLCD LULC raster to calculate IDSW land cover class proportions:

# impervious raster
nlcd_impv <- rast("../gis/lulc/nlcd_2019_impv.tif")

# reload weights raster stack
nlcd_idsw <- rast("../gis/lulc/nlcd_site_idsw.tif")

# apply the loop
basin_impv_idsw <- loop_idw_impv(x_rast = nlcd_impv,
                                 y_poly = basin_nlcd,
                                 wt_rast = nlcd_idsw,
                                 include_cols = c("site_num", "site_id"))


saveRDS(basin_impv_idsw, "../out/basin_impv_idsw.rds")

Condense

The NLCD land cover classes are narrowly defined and are collapsed into broader categories for analysis:

  • Developed: 22, 23, 24
  • Forest: 41, 42, 43, 90
  • Open: a combined category encompassing
    • Agriculture: 81, 82
    • Barren: 31
    • Grassland: 52, 71, 72, 95
    • Open developed: 21

For convenience, a key is provided at nlcd_lulc_key.xlsx to map the numerical class values contained in the NLCD LULC raster to the condensed categories used in the analysis. Read in the key, join to the IDSW proportion estimates, and aggregate the class proportions up to the broader categories:

# land use classes
nlcd_class <- read_xlsx("../data/nlcd_lulc_key.xlsx")


# join and aggregate
basin_lulc_long <- readRDS("../out/basin_lulc_idsw.rds") |>
  left_join(nlcd_class, by = c("value")) |>
  mutate(method = "idsw") |>
  group_by(site_num, site_id,
           cat, method) |>
  summarise(freq = sum(freq),
            freq_wt = sum(freq_wt),
            cells = sum(count)) |>
  group_by(site_num, site_id,
           method) |>
  mutate(freq_tot = sum(freq),
         freq_wt_tot = sum(freq_wt),
         cells_tot = sum(cells)) |>
  ungroup()

Pivot the aggregated LULC category values wider to obtain separate variables for each class:

basin_lulc <- basin_lulc_long |>
  select(site_num, site_id,
         method, cat,
         freq_wt) |>
  pivot_wider(names_from = cat,
              values_from = freq_wt,
              names_prefix = "lc_") |>
  mutate(across(starts_with("lc_"), ~replace_na(.x, 0))) |>
  rowwise() |>
  mutate(lc_tot = sum(c_across(starts_with("lc_")))) |>
  ungroup()

Trim the weighted mean impervious values:

basin_impv <- readRDS("../out/basin_impv_idsw.rds") |>
  mutate(method = "idsw") |>
  select(site_num, site_id,
         method, wshed_cells = count,
         lc_impv = weighted_mean,
         lc_impv_sd = weighted_stdev)

Calculate the basin area in km2, merge land cover variables, and save:

df_basin_feat <- readRDS("../gis/sf/basin_poly.rds") |>
  arrange(site_num) |>
  filter(site_num > 2)  %>%
  mutate(wshed_area = st_area(.) |> units::set_units("km^2"),
         wshed_perim = st_perimeter(.) |> units::set_units("km")) |>
  drop_units() |>
  st_drop_geometry() |>
  left_join(basin_impv, by = c("site_num", "site_id")) |>
  left_join(basin_lulc,
            by = c("site_num", "site_id", "method"))

saveRDS(df_basin_feat, "../out/basin_features.rds")

Daily weather: distance-weighted, rolling aggregate

Weather variables (precipitation in mm and maximum temperature in °C) are computed for each sampling location on each date of the study period (September 2018 - September 2019). Data from weather stations are weighted by the inverse of the squared distance of the station from the sample location to interpolate IDSW weather values for each site and date. Water samples are assigned the IDSW value for the day prior to that sample’s collection to ensure the weather values used reflect antecedent meteorological conditions. For precipitation, rolling sums over the preceding 2 days (“2-day rainfall”) and preceding 7 days, excluding the 2 most recent days (“3-7 day rainfall”), are computed to represent more- and less-recent cumulative antecedent precipitation, respectively.

Inverse distance squared interpolation

Load weather station and sampling site location data and re-project to match:

# weather station locations
loc_ghcn <- readRDS("../gis/sf/meteo_pt.rds") |>
  st_transform(crs = 6446)  # project as NAD83(2011) / Georgia West
  

# sample site locations
loc_site <- readRDS("../gis/sf/site_pt.rds") |>
  st_transform(crs = 6446)  # project as NAD83(2011) / Georgia West

Compute the Euclidean distance matrix between weather stations and sampling sites:

# distance matrix
distmat_site_ghcn <- st_distance(loc_site,
                                 loc_ghcn)

# add labels
colnames(distmat_site_ghcn) <- loc_ghcn$ghcn_id   # weather stations in columns
row.names(distmat_site_ghcn) <- loc_site$site_id  # sample sites in rows

Convert distance matrix to a long-format dataframe:

dist_site_ghcn <- distmat_site_ghcn |>
  as_tibble(rownames = "site_id") |>
  pivot_longer(-site_id,
               names_to = "ghcn_id",
               values_to = "dist") |>
  mutate(dist = units::drop_units(dist))

Read in weather data, join distances, drop missing values, and add an indicator ranking values for a given weather type on each day on the basis of distance from the sampling site to assist with identifying “neighboring” stations:

df_meteo_join <- readRDS("../out/ghcn_data.rds") |>
  filter(!is.na(meteo_val)) |>  # drop missing meteo values
  inner_join(dist_site_ghcn,    # join distances to values, retaining only records with both
             by = "ghcn_id",                # join by station id
             relationship = "many-to-many"  # allow multiple matches on both sides (multiple stations inform multiple sites)
             ) |>
  arrange(site_id, meteo_type, dt_meteo, dist) |>   # ascending sort by distance within each site, type, and date
  group_by(site_id, meteo_type, dt_meteo) |>
  mutate(neighbor = row_number()) |>                # label values within a site, type, date with increasing distance
  ungroup()

Calculate inverse distance squared weights, identify k-nearest neighbors, and compute simple and weighted means to interpolate values from stations to sampling sites for each study date:

df_meteo <- df_meteo_join |>
  mutate(wt = 1 / (dist^2),                             # IDSW weight
         wt_val = meteo_val * wt,                       # weighted weather value
         wt_nn = ifelse(neighbor == 1, meteo_val, NA),  # identify nearest station
         wt_5nn = ifelse(neighbor <= 6, meteo_val, NA)  # identify 5 nearest stations
         ) |>
  group_by(site_id, dt_meteo, meteo_type, meteo_unit) |>  # aggregate by site, date, and weather type
  summarise(n_station = sum(!is.na(meteo_val)),           # count stations with values
            wt_tot = sum(wt, na.rm = TRUE),               # sum of weights
            dist_near = dist[neighbor == 1],              # distance to nearest station
            dist_5nn = ifelse(n_station < 5,              # distance to 5th nearest station
                              dist[neighbor == n_station],
                              dist[neighbor == 5]),
            dist_far = max(dist, na.rm = TRUE),               # distance to furthest station
            meteo_nn = mean(wt_nn, na.rm = TRUE),             # assign value from nearest station
            meteo_5nn = mean(wt_5nn, na.rm = TRUE),           # assign mean of 5 nearest stations  
            meteo_avg = mean(meteo_val, na.rm = TRUE),        # simple mean of all stations
            meteo_idsw = sum(wt_val, na.rm = TRUE) / wt_tot   # IDSW mean
            ) |>
  ungroup() |>
  select(-wt_tot) |>
  mutate(across(meteo_nn:meteo_idsw,
                ~ifelse(is.nan(.x), NA, .x)))   # replace NAN with NA


saveRDS(df_meteo, "../out/meteo_site_idsw.rds")

Rolling cumulative precipitation

Compute moving-window (“rolling”) sums of precipitation in the previous 2 days, 7 days, and 3 - 7 days:

df_roll_prcp <- df_meteo |>
  rename(phenom = meteo_type, unit = meteo_unit) |> # change names for convenience
  filter(phenom == "prcp") |>                       # just rain
  pivot_longer(starts_with("meteo_"),               # long format
               names_to = "method",                 # weighting method (idsw, simple, nn, 5nn)
               values_to = "meteo0"                 # value on day 0 (sample date)
               ) |>
  mutate(method = str_remove(method, "meteo_")) |>  # drop prefix
  arrange(site_id, phenom, method, dt_meteo) |>     # order by date last (important for rolling)
  group_by(site_id, phenom, unit, method) |>        # by site, type, and weighting method
  mutate(meteo1 = lag(meteo0, n = 1L),              # value on previous day (enforce temporality)
         meteo2 = rollapply(meteo1,                 
                            width = 2,              # 2-day moving window
                            FUN = sum,              # rolling sum
                            fill = NA,              # fill values outside window with NA
                            partial = TRUE,         # allow missing values in window
                            align = "right"),       # assign output to right-end of window (most recent)
         meteo7 = rollapply(meteo1,
                            width = 7,              # 7-day moving window
                            FUN = sum,
                            fill = NA,
                            partial = TRUE,
                            align = "right"),
         meteo3_7 = rollapply(lag(meteo0, 3),       # start with 3 days prior
                              width = 5,            # 5-day window starting day -3, ending day -7
                              FUN = sum,
                              fill = NA,
                              partial = TRUE,
                              align = "right"),
         rollfun = "sum"                            # record the rolling function applied
         ) |>
  ungroup()

Assign previous day’s temperatures to each study date. Calculate rolling mean temperatures over the same windows used for rainfall, for consistency in the data structure:

Code
df_roll_temp <- df_meteo |>
  rename(phenom = meteo_type, unit = meteo_unit) |> # change names for convenience
  filter(phenom != "prcp") |>                       # not rain
  pivot_longer(starts_with("meteo_"),               # long format
               names_to = "method",                 # weighting method (idsw, simple, nn, 5nn)
               values_to = "meteo0"                 # value on day 0 (sample date)
               ) |>
  mutate(method = str_remove(method, "meteo_")) |>  # drop prefix
  arrange(site_id, phenom, method, dt_meteo) |>     # order by date last (important for rolling)
  group_by(site_id, phenom, unit, method) |>        # by site, type, and weighting method
  mutate(meteo1 = lag(meteo0, n = 1L),              # value on previous day (enforce temporality)
         meteo2 = rollapply(meteo1,                 
                            width = 2,              # 2-day moving window
                            FUN = mean,              # rolling mean
                            fill = NA,              # fill values outside window with NA
                            partial = TRUE,         # allow missing values in window
                            align = "right"),       # assign output to right-end of window (most recent)
         meteo7 = rollapply(meteo1,
                            width = 7,              # 7-day moving window
                            FUN = mean,
                            fill = NA,
                            partial = TRUE,
                            align = "right"),
         meteo3_7 = rollapply(lag(meteo0, 3),       # start with 3 days prior
                              width = 5,            # 5-day window starting day -3, ending day -7
                              FUN = mean,
                              fill = NA,
                              partial = TRUE,
                              align = "right"),
         rollfun = "mean"                           # record the rolling function applied
         ) |>
  ungroup()

Stack, sort, and save:

df_meteo_roll <- bind_rows(df_roll_prcp,
                           df_roll_temp) %>%
  arrange(site_id, phenom, method, dt_meteo)

saveRDS(df_meteo_roll, "../out/meteo_site_roll.rds")

Wastewater discharge: hydrological distance-weighted load

Treated wastewater and CSO inputs potentially impacting each site are represented by the aggregate maximum permitted load from all upstream outfalls, weighted by the inverse of the squared hydrological distance between the site and each upstream outfall along the stream network. While conceptually a similar IDSW operation, this approach requires measuring the distance along the stream network and restricting the calculations to outfalls that are both flow connected to, and upstream of, the sampling site. These operations are facilitated by constructing a spatial stream network (SSN) using the SSNbler and SSN2 packages, the later of which provides functions for identifying upstream, flow-connected points and calculating network distances.

Stream vector data were obtained from USGS National Hydrography Dataset Plus (NHDPlus) High Resolution and manually corrected in ArcGIS Pro to resolve topological inconsistencies, ensuring the entire network within the study watershed was flow-connected and discharged at a single terminal outlet located at the downstream-most sampling site (Fairburn Rd). The topology correction process created a small number of new, very short reaches to connect sections of the network. These new reaches are not in the NHD database and therefore lack cumulative drainage area values, which will later be used as weights as a proxy for flow contributions downstream and cannot be missing or zero. The smallest existing cummulative area (area_cum) value in the dataset is substituted for reaches with area_cum = 0 to overcome this limitation.

The pre-processed stream network vector data are provided as the shapefile stream_line.shp in the compressed folder stream.zip. After downloading and extracting the compressed file, read in the stream shapefile and substitute the minimum cumulative area value for reaches with zero cumulative area values and save the sf for future use:

stream <- read_sf("../gis/shape/stream/stream_line.shp") |>
  mutate(area_cum = ifelse(area_cum == 0,
                           min(area_cum[area_cum > 0], na.rm = TRUE),
                           area_cum))

saveRDS(stream, "../gis/sf/stream_line.rds")

Convert stream polyline vector to a landscape network (LSN) that represents the flow-directed network as nodes and edges using the SSNbler package:

stream_net <- lines_to_lsn(streams = stream,
                           lsn_path = "../gis/ssn/lsn",
                           snap_tolerance = 0.1,
                           check_topology = TRUE,
                           topo_tolerance = 20,
                           overwrite = TRUE,
                           verbose = TRUE,
                           remove_ZM = TRUE)

Snap sample points to stream network:

# sample site locations
sites <- readRDS("../gis/sf/site_pt.rds")


# snap to stream LSN
sites_lsn <- sites_to_lsn(sites = sites,
                          edges = stream_net,
                          lsn_path = "../gis/ssn/lsn",
                          file_name = "sites",
                          snap_tolerance = 100,
                          save_local = TRUE,
                          overwrite = TRUE)

Identify unique outfall locations and snap to stream network

# outfall locations
outfalls <- readRDS("../gis/sf/outfall_pt.rds") |>
  select(out_id, out_type, out_vol, geometry) |>
  distinct()


# snap to stream LSN
outfalls_lsn <- sites_to_lsn(sites = outfalls,
                             edges = stream_net,
                             lsn_path = "../gis/ssn/lsn",
                             file_name = "outfalls",
                             snap_tolerance = 100,
                             save_local = TRUE,
                             overwrite = TRUE)

Calculate the upstream distance along each edge (stream segment):

edges <- updist_edges(edges = stream_net,
                      save_local = TRUE,
                      lsn_path = "../gis/ssn/lsn",
                      calc_length = TRUE)

Calculate the upstream distance between sites and outfalls:

site_list <- updist_sites(sites = list(sites = sites_lsn,
                                       outfalls = outfalls_lsn),
                          edges = edges,
                          length_col = "Length",
                          save_local = TRUE,
                          lsn_path = "../gis/ssn/lsn")

Additive function values (AFV) are numerical values associated with each edge (stream reach) that represent the contribution of that edge to the edges downstream of it. For stream networks, the AFV can encode the flow contributed by each reach (which is an additive function of the flows from all reaches upstream). Because flow is variable and unknown for most reaches, the cumulative area drained by each reach is used as a proxy for its potential flow. The AFVs are not used directly for the network distance calculations but are required to create a valid SSN object, from which the distances are later calculated.

Assign AFVs to the stream network edges and compute the AFVs at each site:

# edges (reaches)
edges_afv <- afv_edges(edges = edges,
                       lsn_path = "../gis/ssn/lsn",
                       save_local = TRUE,
                       overwrite = TRUE,
                       infl_col = "area_cum",
                       segpi_col = "segpi_area",
                       afv_col = "afv_area")

# sample sites
sites_afv <- afv_sites(sites = site_list,
                       edges = edges_afv,
                       lsn_path = "../gis/ssn/lsn",
                       save_local = TRUE,
                       overwrite = TRUE,
                       afv_col = "afv_area")

Assemble an SNN from the LSN and various components generated above; while LSNs are network data objects and can be reused, SSNs are data models that are specific to the sites and data associated with them and are best assembled separately for each use case. For this application, record the sampling sites as the “observed sites” (obs_sites) and the outfall locations as the “prediction sites” (preds_list):

outfall_ssn <- ssn_assemble(edges = edges_afv,
                            lsn_path = "../gis/ssn/lsn",
                            obs_sites = sites_afv$sites,
                            preds_list = sites_afv[c("outfalls")],
                            ssn_path = "../gis/ssn/outfalls.ssn",
                            import = FALSE,
                            check = TRUE,
                            afv_col = "afv_area",
                            overwrite = TRUE)

Calculate the network distance matrix between the sites and outfalls (writes to disk):

# import SSN
outfall_ssn <- ssn_import("../gis/ssn/outfalls.ssn",
                          predpts = "outfalls",
                          overwrite = TRUE)

# calculate distance matrix
ssn_create_distmat(outfall_ssn,
                   predpts = "outfalls",
                   only_predpts = TRUE,
                   among_predpts = FALSE,
                   overwrite = TRUE)

Two distance matrices are returned in a list; only the first, with the sites in rows and the outfalls in columns, provides the flow-connected, upstream network distances required. Extract the correct distance matrix and label with IDs extracted from the SSN elements:

# ssn elements
outfall_ssn_stream <- outfall_ssn$edges
outfall_ssn_site <- outfall_ssn$obs
outfall_ssn_out <- outfall_ssn$preds$outfalls


# extract downstream distances (from outfalls to sites)
outfall_distmat <- ssn_get_stream_distmat(outfall_ssn,
                                          name = "outfalls") |>
  pluck("dist.net1.a")

colnames(outfall_distmat) <- outfall_ssn_out$out_id
rownames(outfall_distmat) <- outfall_ssn_site$site_id

Re-format the matrix as a long dataframe and save:

outfall_dist <- outfall_distmat %>%
  as_tibble(rownames = "site_id") %>%
  pivot_longer(-site_id,
               names_to = "out_id",
               values_to = "dist")

saveRDS(outfall_dist, "../out/outfall_dist_ssn.rds")

Merge the outfall data with the distance values:

df_out_dist <- readRDS("../gis/sf/outfall_pt.rds") |>
  st_drop_geometry() %>%
  inner_join(outfall_dist,
             by = c("site_id", "out_id"))

saveRDS(df_out_dist, "../out/outfall_riverdist.rds")

Weighted total (cumulative) permitted loads are used to represent the influence of wastewater infrastructure on sites. Because weighted sums are not normalized by the total weights, as was the case for IDSW interpolation of weather data and IDSW land cover proportions. As such, the weighted output values are dependent on the distance unit used and cannot be interpreted in isolation or compared to other contexts. For convenience, distance is converted to kilometers, such that an outfall 1 km from the site receives a weight of 1, outfalls further than 1 km from the site receive smaller weights less than 1, and outfalls closer than 1 km to the site receive larger weights greater than 1.

Calculate weights and and IDSW cumulative load on the basis of permitted maximum discharge separately for WWTP treated effluent outfalls and for CSOs:

df_out_idsw <- readRDS("../out/outfall_riverdist.rds") |>
  mutate(dist = dist / 1000,  # scale to kilometers
         wt = 1 / (dist^2),
         wt_val = out_vol * wt) |>
  group_by(site_num, site_id, site_lab, site_type,
           out_type) |>
  summarise(n_outfall = sum(!is.na(out_vol)),
            wt_tot = sum(wt, na.rm = TRUE),
            out_tot = sum(out_vol, na.rm = TRUE),
            out_idsw = sum(wt_val, na.rm = TRUE),
            out_tot_avg = out_tot / n_outfall,
            out_idsw_avg = out_idsw / wt_tot) |>
  ungroup() |>
  select(-wt_tot) |>
  mutate(across(out_tot:out_idsw_avg, ~ifelse(is.nan(.x), NA, .x)))

saveRDS(df_out_idsw, "../out/outfall_site_idsw.rds")

Septic systems: basin spatial density

As point locations lacking additional scale information (like the permitted discharge for outfalls) and heterogeneously distributed within the study area, inverse distance weighting approaches are less appropriate for constructing site-level variables from septic system locations. Rather, the simple spatial density of all septic systems within a drainage basin is used to represent the potential influence of septic dystems on a sampling site.

Load the septic locations, tally up the systems in each drainage basin, and calculate the spatial density; because some basins contained no septic systems, restore the dropped basins and set septic density to zero:

septic_dens <- readRDS("../gis/sf/septic_pt.rds") |>
  st_drop_geometry() |>
  group_by(site_num, wshed_area_clp) |>
  summarise(septic_n = n()) |>
  ungroup() |>
  mutate(septic_dens = septic_n / wshed_area_clp) |>
  
  # some basins had no septic so restore and set density to zero
  full_join(tibble(site_num = 3:13), by = "site_num") |>
  mutate(across(starts_with("septic_"), ~replace_na(.x, 0))) |>
  arrange(site_num)

saveRDS(septic_dens, "../out/septic_density.rds")

Data set assembly

The generated variables are assembled into a full data set of E. coli observations and the environmental factors corresponding to each sampling site and date. Subsets of this full data set are extracted and assembled into specific SSNs to facilitate analysis by spatial generalized linear mixed models (GLMM) that account for flow-connected network distances between sites and and temporal clustering by time of sample collection.

Full data table

Read in the processed sample data (E. coli imputed concentrations, water quality; meropenem-selected and cefotaxime-selected at 4 μg/mL already excluded):

df_samp <- readRDS("../out/sample_imputed.rds")

Read in the rolling weather data, restrict to study time period and desired variables, pivot to wide format (each weather variable is a column), and scale the rain values to units of cm (10 mm):

df_meteo <- readRDS("../out/meteo_site_roll.rds") |>
  select(site_id, dt_meteo,
         phenom, method,
         `1` = meteo1, `2` = meteo2, `7` = meteo3_7) |>
  filter(method == "idsw",
         phenom %in% c("prcp", "tmax", "tavg"),
         dt_meteo > ymd("2018-09-01"),
         dt_meteo < ymd("2019-09-30")) |>
  mutate(phenom = ifelse(phenom == "prcp", "rain", phenom)) |>
  pivot_wider(names_from = phenom,
              values_from = c(`1`, `2`, `7`),
              names_glue = "{phenom}{.value}",
              names_vary = "slowest") |>
  select(-method) |>
  mutate(across(starts_with("rain"), ~.x/10))

Read in land cover proportions and mean imperviousness, select desired variables, and scale so all land cover variables are in units of 10 percentage points and basin area is log10-transformed:

df_lulc <- readRDS("../out/basin_features.rds") |>
  filter(method == "idsw") |> 
  select(site_num, site_id,
         wshed_area,
         starts_with("lc")) |>
  mutate(lc_impv = lc_impv / 100,
         across(starts_with("lc_"), ~.x*10 ),
         wshed_area = log10(wshed_area))

Read in outfall loads, select desired variables, differentiate between WWTPs and CSOs, and restore sites without outfalls, substituting zero for missing load:

df_outfall <- readRDS("../out/outfall_site_idsw.rds") |>
  select(site_num, out_type,
         n = n_outfall,
         load = out_idsw) %>%
  mutate(out_type = ifelse(out_type == "treated", "wwtp", out_type)) %>%
  pivot_wider(names_from = out_type,
              values_from = c(n, load),
              names_glue = "out_{out_type}_{.value}",
              names_vary = "slowest") %>%
  full_join(tibble(site_num = 3:13), by = "site_num") %>%
  mutate(across(starts_with("out_"), ~replace_na(.x, 0)))

Read in septic density and join all the flat data together:

# septic density
df_septic <- readRDS("../out/septic_density.rds") |>
  select(site_num, septic_n, septic_dens)


# combine for analysis
df_analysis <- df_samp |>
  left_join(df_lulc, by = c("site_num", "site_id")) %>%
  left_join(df_outfall, by = "site_num") %>%
  left_join(df_septic, by = "site_num") %>%
  left_join(df_meteo, by = c("site_id", "dt_collect" = "dt_meteo"))

saveRDS(df_analysis, "../out/analysis_data.rds")

With exceptions for the outcome E. coli concentrations and raw counts, variables are standard deviation-scaled for analysis. Variables without a meaningful zero value are also centered around the mean prior to scaling. Calculate the mean and standard deviation of each variable by the selective drug used to inform centering and scaling:

tab_analysis <- df_analysis |>
  select(drug, drug_dose,
         mpn = mpn_r,
         mpn_gen = mpn_r_gen,
         starts_with("wq_"),
         wshed_area,
         starts_with("lc_"),
         starts_with("out_"),
         starts_with("septic_"),
         rain2, rain7,
         tmax1, tavg1) |>
  pivot_longer(-drug,
               names_to = "var",
               values_to = "val") |>
  filter(!is.na(val)) |>
  group_by(drug, var) |>
  summarise(nobs = n(),
            avg = mean(val),
            sd = sd(val),
            min = min(val),
            max = max(val)) |>
  ungroup() |>
  mutate(scl_method = case_when(var %in% c("mpn") ~ "none",             # response unscaled (already log10)
                                var %in% c("mpn_gen") ~ "scl",          # predictor EC scaled (already log10)
                                str_detect(var, "lc_") ~ "scl",         # lulc proportions scaled (0 meaningful)
                                str_detect(var, "wq_") ~ "std",         # water quality centered, scaled
                                str_detect(var, "load") ~ "scl",        # outfall loads scaled (0 meaningful)
                                str_detect(var, "_n") ~ "none",         # outfall counts unscaled (0 meaningful)
                                str_detect(var, "_dens") ~ "scl",       # septic density scaled (0 meaningful)
                                var %in% c("wshed_area") ~ "std",       # area centered, scaled (already log10)
                                str_detect(var, "rain") ~ "scl",        # rain scaled (0 meaningful)
                                var %in% c("tmax1", "tavg1") ~ "std"))  # temp centered, scaled


saveRDS(tab_analysis, "../out/covar_summ_stats.rds")

Apply centering and scaling as determined:

df_analysis_scl <- df_analysis |>
  select(site_num,
         drug, drug_dose,
         wk_collect, dt_collect,
         samp_event, samp_id,
         uid, uid_gen, uid_wq,
         detect,
         mpn = mpn_r,
         mpn_gen = mpn_r_gen,
         starts_with("wq_"),
         wshed_area,
         starts_with("lc_"),
         starts_with("out_"),
         starts_with("septic_"),
         rain2, rain7,
         tmax1, tavg1) |>
  select(-lc_tot) |>
  pivot_longer(-c(site_num,
                  drug, drug_dose,
                  wk_collect, dt_collect,
                  samp_event, samp_id,
                  uid, uid_gen, uid_wq,
                  detect),
               names_to = "var",
               values_to = "val") |>
  left_join(tab_analysis, by = c("drug", "var")) |>
  mutate(val_scl = case_when(scl_method == "none" ~ val,
                             scl_method == "scl" ~ val / sd,
                             scl_method == "std" ~ (val - avg) / sd) )|>
  select(-c(val, nobs, avg, sd, min, max, scl_method)) |>
  pivot_wider(names_from = var,
              values_from = val_scl)

saveRDS(df_analysis_scl, "../out/analysis_data_scl.rds")

Spatial stream networks

The SSN objects are somewhat particular about the data used to assemble them and the opportunities for subsetting post-assembly. While inefficient, a reliable approach is to assemble separate SSNs for each set of outcome variables that are to be analyzed. The E. coli occurrence under each selective antibiotic is a separate outcome for the purposes of these analyses. Furthermore, the co-measured sample-level covariates (turbidity, optical brighteners, and generic E. coli for the antibiotic-selected observations) are missing for some of the E. coli observations, resulting in different sample sizes under complete-cases analyses. Separate SSNs are therefore assembled for each combination of E. coli outcome and covariate completeness. Fortunately, the majority of environmental factors covariates considered, which were derived from comprehensive spatial data sources, are complete for all observations, allowing a single SSN to be used for all analyses of a given outcome that do not include co-measured, sample-level covariates.

Landscape network

The LSN previously created in the process of calculating network distances to wastewater outfalls has those outfall locations associated with it as prediction locations. For simplicity, construct a new LSN with just the site data associated to use in assembling the new SSNs.

Generate new LSN stream network:

# stream vector
stream <- readRDS("../gis/sf/stream_line.rds")


# create new LSN for full dataset
stream_net <- lines_to_lsn(streams = stream,
                           lsn_path = "../gis/ssn/lsn_full",
                           snap_tolerance = 0.1,
                           check_topology = TRUE,
                           topo_tolerance = 20,
                           overwrite = TRUE,
                           verbose = TRUE,
                           remove_ZM = TRUE)

Snap site locations to network:

# sample site locations
sites <- readRDS("../gis/sf/site_pt.rds")

# snap to stream LSN
sites_lsn <- sites_to_lsn(sites = sites,
                          edges = stream_net,
                          lsn_path = "../gis/ssn/lsn_full",
                          file_name = "sites",
                          snap_tolerance = 100,
                          save_local = TRUE,
                          overwrite = TRUE)

Calculate the upstream distance along each edge (stream segment) and for sample sites:

# edges (stream reaches)
edges <- updist_edges(edges = stream_net,
                      save_local = TRUE,
                      lsn_path = "../gis/ssn/lsn_full",
                      calc_length = TRUE)

# sample sites
site_list <- updist_sites(sites = list(sites = sites_lsn),
                          edges = edges,
                          length_col = "Length",
                          save_local = TRUE,
                          lsn_path = "../gis/ssn/lsn_full")

Calculate additive function values for edges and sites, saving as sf for easy access when generating SSNs:

# edges (stream reaches)
edges_afv <- afv_edges(edges = edges,
                       lsn_path = "../gis/ssn/lsn_full",
                       save_local = TRUE,
                       overwrite = TRUE,
                       infl_col = "area_cum",
                       segpi_col = "segpi_area",
                       afv_col = "afv_area")

saveRDS(edges_afv, "../gis/sf/lsn_edges_afv_line.rds")

# sample sites
sites_afv <- afv_sites(sites = site_list,
                       edges = edges_afv,
                       lsn_path = "../gis/ssn/lsn_full",
                       save_local = TRUE,
                       overwrite = TRUE,
                       afv_col = "afv_area")

saveRDS(sites_afv$sites, "../gis/sf/lsn_sites_afv_pt.rds")

Join the full, scaled analysis data set with all observations (dropping any missing E. coli outcomes) to the LSN sites:

# full data set for all observations
obs_full <- readRDS("../out/analysis_data_scl.rds") |>
  filter(!is.na(detect))

# merge with LSN sites
sites_full <- readRDS("../gis/sf/lsn_sites_afv_pt.rds") |>
  left_join(obs_full, by = "site_num")

saveRDS(sites_full, "../out/analysis_data_lsn.rds")

Cefotaxime-selected SSNs

Assemble SSNs for ESBL-Ec (cefotaxime-selected at 1 μg/mL), the main environmental AR outcome for the modeling analysis.

First assemble an SSN for all the ESBL-Ec observations and create the distance matrix:

# LSN edges
edges_afv <- readRDS("../gis/sf/lsn_edges_afv_line.rds")

# filter for all ESBL obs
sites_esbl <- readRDS("../out/analysis_data_lsn.rds") |>
  filter(drug == "cefotaxime")

# assemble full ESBL SSN
esbl_ssn <- ssn_assemble(edges = edges_afv,
                         lsn_path = "../gis/ssn/lsn_full",
                         obs_sites = sites_esbl,
                         preds_list = NULL,
                         ssn_path = "../gis/ssn/esbl.ssn",
                         import = TRUE,
                         check = TRUE,
                         afv_col = "afv_area",
                         overwrite = TRUE)

# generate distance matrix
ssn_create_distmat(esbl_ssn,
                   overwrite = TRUE)

Assemble an SSN and distance matrix for only the ESBL-Ec observations with non-missing generic E. coli concentrations:

# non-missing generic E. coli data
sites_esbl_gen <- sites_esbl %>%
  filter(!is.na(mpn_gen))

# assemble ssn
esbl_ssn_gen <- ssn_assemble(edges = edges_afv,
                             lsn_path = "../gis/ssn/lsn_full",
                             obs_sites = sites_esbl_gen,
                             preds_list = NULL,
                             ssn_path = "../gis/ssn/esbl_gen.ssn",
                             import = TRUE,
                             check = TRUE,
                             afv_col = "afv_area",
                             overwrite = TRUE)

# distance matrix
ssn_create_distmat(esbl_ssn_gen,
                   overwrite = TRUE)

Assemble SSNs for only the ESBL-Ec observations with complete water quality covariates:

## Turbidity
# non-missing turbidity
sites_esbl_turb <- sites_esbl %>%
  filter(!is.na(wq_turb))

# assemble ssn
esbl_ssn_turb <- ssn_assemble(edges = edges_afv,
                              lsn_path = "../gis/ssn/lsn_full",
                              obs_sites = sites_esbl_turb,
                              preds_list = NULL,
                              ssn_path = "../gis/ssn/esbl_turb.ssn",
                              import = TRUE,
                              check = TRUE,
                              afv_col = "afv_area",
                              overwrite = TRUE)

# distance matrix
ssn_create_distmat(esbl_ssn_turb,
                   overwrite = TRUE)



## Optical brighteners
# non-missing optical brighteners
sites_esbl_optic <- sites_esbl %>%
  filter(!is.na(wq_optic))

# assemble ssn
esbl_ssn_optic <- ssn_assemble(edges = edges_afv,
                               lsn_path = "../gis/ssn/lsn_full",
                               obs_sites = sites_esbl_optic,
                               preds_list = NULL,
                               ssn_path = "../gis/ssn/esbl_optic.ssn",
                               import = TRUE,
                               check = TRUE,
                               afv_col = "afv_area",
                               overwrite = TRUE)

# distance matrix
ssn_create_distmat(esbl_ssn_optic,
                   overwrite = TRUE)

Colistin-selected SSNs

Assemble the same set of SSNs for the CST-Ec (colistin-selected at 2 μg/mL) outcome:

Code
## All observations
# filter for all ESBL obs
sites_cst <- readRDS("../out/analysis_data_lsn.rds") |>
  filter(drug == "colistin")

# assemble full ESBL SSN
cst_ssn <- ssn_assemble(edges = edges_afv,
                         lsn_path = "../gis/ssn/lsn_full",
                         obs_sites = sites_cst,
                         preds_list = NULL,
                         ssn_path = "../gis/ssn/cst.ssn",
                         import = TRUE,
                         check = TRUE,
                         afv_col = "afv_area",
                         overwrite = TRUE)

# generate distance matrix
ssn_create_distmat(cst_ssn,
                   overwrite = TRUE)



## Generic E. coli
# non-missing generic E. coli data
sites_cst_gen <- sites_cst %>%
  filter(!is.na(mpn_gen))

# assemble ssn
cst_ssn_gen <- ssn_assemble(edges = edges_afv,
                             lsn_path = "../gis/ssn/lsn_full",
                             obs_sites = sites_cst_gen,
                             preds_list = NULL,
                             ssn_path = "../gis/ssn/cst_gen.ssn",
                             import = TRUE,
                             check = TRUE,
                             afv_col = "afv_area",
                             overwrite = TRUE)

# distance matrix
ssn_create_distmat(cst_ssn_gen,
                   overwrite = TRUE)


## Turbidity
# non-missing turbidity
sites_cst_turb <- sites_cst %>%
  filter(!is.na(wq_turb))

# assemble ssn
cst_ssn_turb <- ssn_assemble(edges = edges_afv,
                              lsn_path = "../gis/ssn/lsn_full",
                              obs_sites = sites_cst_turb,
                              preds_list = NULL,
                              ssn_path = "../gis/ssn/cst_turb.ssn",
                              import = TRUE,
                              check = TRUE,
                              afv_col = "afv_area",
                              overwrite = TRUE)

# distance matrix
ssn_create_distmat(cst_ssn_turb,
                   overwrite = TRUE)



## Optical brighteners
# non-missing optical brighteners
sites_cst_optic <- sites_cst %>%
  filter(!is.na(wq_optic))

# assemble ssn
cst_ssn_optic <- ssn_assemble(edges = edges_afv,
                               lsn_path = "../gis/ssn/lsn_full",
                               obs_sites = sites_cst_optic,
                               preds_list = NULL,
                               ssn_path = "../gis/ssn/cst_optic.ssn",
                               import = TRUE,
                               check = TRUE,
                               afv_col = "afv_area",
                               overwrite = TRUE)

# distance matrix
ssn_create_distmat(cst_ssn_optic,
                   overwrite = TRUE)

Generic E. coli SSNs

Finally, assemble a comparable set of SSNs for the generic E. coli (no antibiotic selection) outcome, skipping the SSN for non-missing generic E. coli as a covariate:

Code
## All observations
# filter for all ESBL obs
sites_gen <- readRDS("../out/analysis_data_lsn.rds") |>
  filter(drug == "none")

# assemble full ESBL SSN
gen_ssn <- ssn_assemble(edges = edges_afv,
                         lsn_path = "../gis/ssn/lsn_full",
                         obs_sites = sites_gen,
                         preds_list = NULL,
                         ssn_path = "../gis/ssn/gen.ssn",
                         import = TRUE,
                         check = TRUE,
                         afv_col = "afv_area",
                         overwrite = TRUE)

# generate distance matrix
ssn_create_distmat(gen_ssn,
                   overwrite = TRUE)



## Turbidity
# non-missing turbidity
sites_gen_turb <- sites_gen %>%
  filter(!is.na(wq_turb))

# assemble ssn
gen_ssn_turb <- ssn_assemble(edges = edges_afv,
                              lsn_path = "../gis/ssn/lsn_full",
                              obs_sites = sites_gen_turb,
                              preds_list = NULL,
                              ssn_path = "../gis/ssn/gen_turb.ssn",
                              import = TRUE,
                              check = TRUE,
                              afv_col = "afv_area",
                              overwrite = TRUE)

# distance matrix
ssn_create_distmat(gen_ssn_turb,
                   overwrite = TRUE)



## Optical brighteners
# non-missing optical brighteners
sites_gen_optic <- sites_gen %>%
  filter(!is.na(wq_optic))

# assemble ssn
gen_ssn_optic <- ssn_assemble(edges = edges_afv,
                               lsn_path = "../gis/ssn/lsn_full",
                               obs_sites = sites_gen_optic,
                               preds_list = NULL,
                               ssn_path = "../gis/ssn/gen_optic.ssn",
                               import = TRUE,
                               check = TRUE,
                               afv_col = "afv_area",
                               overwrite = TRUE)

# distance matrix
ssn_create_distmat(gen_ssn_optic,
                   overwrite = TRUE)

Descriptive analysis

Before developing statistical models to estimate associations between surface water E. coli and environmental factors, conduct descriptive analyses to better understand the contours of the prepared data.

E. coli time series

Explore the occurrence of generic E. coli, ESBL-Ec, CST-EC, and MEM-Ec at each site across the study period by plotting time series of their detection and concentration.

Read in the (non-imputed) sample data time series and arrange for plotting:

df_plot_ec <- readRDS("../out/sample_data.rds") |>
  filter(dt_collect > ymd("2018-09-05"),
         dt_collect < ymd("2019-09-06")) |>
  filter(!is.na(mpn),
         drug %in% c("cefotaxime", "colistin", "none"),
         drug_dose < 4) |>
  mutate(mpn_log10 = log10(mpn),
         cens_lab = fct_relevel(cens, c("no", "left", "right")) |>
           fct_recode("detect" = "no",
                      "non-detect" = "left",
                      "TNTC" = "right"),
         site_type = fct_relevel(site_type, c("river", "tributary"))) |>
  arrange(site_type, site_num) |>
  mutate(site_id = fct_inorder(site_id),
         site_lab = fct_inorder(site_lab),
         type = ifelse(drug == "none", "generic", "resistant"),
         drug_lab = fct_recode(drug,
                               "ESBL" = "cefotaxime",
                               "CST" = "colistin",
                               "Generic" = "none") |>
           fct_relevel(c("Generic", "ESBL", "CST")))

Separately, read in the MEM-Ec detections and arrange for plotting:

Code
df_plot_mem <- readRDS("../out/sample_data.rds") |>
  filter(dt_collect > ymd("2018-09-05"),
         dt_collect < ymd("2019-09-06")) |>
  filter(site_num > 2,
         !is.na(mpn),
         drug == "meropenem",
         detect == 1) |>
  mutate(site_type = fct_relevel(site_type, c("river", "tributary"))) |>
  arrange(site_type, site_num) |>
  mutate(site_id = fct_inorder(site_id),
         site_lab = fct_inorder(site_lab)) |>
  select(site_type, site_lab, dt_collect, detect)

Extract the range of dates and concentration values to set axis limits:

df_range <- df_plot_ec %>%
  ungroup() %>%
  summarise(dt_min = min(dt_collect),
            dt_max = max(dt_collect),
            mpn_max = max(mpn_log10))

Set color and line-type palettes for plotting:

val_pal <- c("Generic" = "#e66101", "ESBL" = "#5e3c99", "CST" = "seagreen")
val_line <- c("Generic" = "solid", "ESBL" = "32", "CST" = "11")

To allow for the nice arrangement of 11 different sites, plot the river and tributary sites separately and combine. First plot the river sites, adding LOESS smooths for each E. coli concentration outcome as a visual aid and differentiating between detect, non-detect, and TNTC observations by shape:

p_river <- df_plot_ec |>
  filter(site_type == "river") %>%
  ggplot(data = .,
         aes(x = dt_collect, y = mpn_log10)) +
  facet_grid(rows = vars(site_lab)) +
  geom_vline(aes(xintercept = dt_collect),
             data = df_plot_mem %>% filter(site_type == "river"),
             color = "cornflowerblue",
             lwd = 1.3, alpha = 0.85) +
  geom_smooth(aes(color = drug_lab, fill = drug_lab, linetype = drug_lab),
              se = FALSE) +
  geom_point(aes(shape = cens_lab, fill = drug_lab, colour = drug_lab),
             show.legend = TRUE,
             size = 2.5, stroke = 1.5,
             alpha = 0.85) +
  scale_x_date(breaks = date_breaks(width = "2 month"),
               limits = c(df_range$dt_min, df_range$dt_max),
               date_labels = "%b '%y") +
  annotation_logticks(side = "l") +
  scale_y_continuous(labels = math_format(10^.x)) +
  coord_cartesian(ylim = c(0, 5)) +
  scale_fill_manual(values =  val_pal,
                    drop = FALSE,
                    name = "*E. coli* phenotype") +
  scale_color_manual(values =  val_pal,
                     drop = FALSE,
                     name = "*E. coli* phenotype") +
  scale_linetype_manual(values = val_line,
                     drop = FALSE,
                     name = "*E. coli* phenotype") +
  scale_shape_manual(values = c("detect" = 16, "non-detect" = 4, "TNTC" = 0),
                     drop = FALSE,
                     name = "Status") +
  labs(x = NULL,
       y = "MPN/100 mL",
       subtitle = "River sites")

Repeat for tributary sites:

Code
p_trib <- df_plot_ec |>
  filter(site_type == "tributary") %>%
  ggplot(data = .,
         aes(x = dt_collect, y = mpn_log10)) +
  facet_grid(rows = vars(site_lab)) +
  geom_vline(aes(xintercept = dt_collect),
             data = df_plot_mem %>% filter(site_type == "tributary"),
             color = "cornflowerblue",
             lwd = 1.3, alpha = 0.85) +
  geom_smooth(aes(color = drug_lab, fill = drug_lab, linetype = drug_lab),
              se = FALSE) +
  geom_point(aes(shape = cens_lab, fill = drug_lab, colour = drug_lab),
             show.legend = TRUE,
             size = 2.5, stroke = 1.5,
             alpha = 0.85) +
  scale_x_date(breaks = date_breaks(width = "2 month"),
               limits = c(df_range$dt_min, df_range$dt_max),
               date_labels = "%b '%y") +
  annotation_logticks(side = "l") +
  scale_y_continuous(labels = math_format(10^.x)) +
  coord_cartesian(ylim = c(0, 5)) +
  scale_fill_manual(values =  val_pal,
                    drop = FALSE,
                    name = "*E. coli* phenotype") +
  scale_color_manual(values =  val_pal,
                     drop = FALSE,
                     name = "*E. coli* phenotype") +
  scale_linetype_manual(values = val_line,
                     drop = FALSE,
                     name = "*E. coli* phenotype") +
  scale_shape_manual(values = c("detect" = 16, "non-detect" = 4, "TNTC" = 0),
                     drop = FALSE,
                     name = "Status") +
  labs(x = NULL,
       y = NULL,
       subtitle = "Tributary sites")

Arrange the two plots and save the image as a .png with specific dimensions to ensure proper display:

p_ts <- p_river + p_trib +
  plot_layout(ncol = 2,
              design = "
              12
              12
              12
              12
              12
              1#
              ",
              heights = c(6, 4.6),
              guides = "collect") &
  guides(colour = guide_legend(order = 1),
         fill = guide_legend(order = 1),
         linetype = guide_legend(order = 1),
         shape = guide_legend(order = 2)) &
  theme_bw() &
  theme(axis.title.y = element_markdown(),
        plot.title = element_markdown(),
        panel.grid.minor.y = element_blank(),
        legend.title = element_markdown(),
        legend.position = "bottom",
        legend.justification = "center",
        legend.key.width = unit(.4, "in"),
        legend.direction = "horizontal")

ggsave(filename = "../fig/ecoli_timeseries.png",
       plot = p_ts,
       device = agg_png, dpi = 300,
       height = 11, width = 8.5, units = "in")

This plot reproduces Figure 2 from Holcomb et al. (2026):

Figure 2. Measured concentration (points) and trend (lines) of generic E. coli (orange, solid lines), ESBL-Ec (purple, dashed lines), and CST-Ec (green, dotted lines) by site. Dates on which MEM-Ec were detected are indicated by blue vertical lines. Non-detect (X) and too numerous to count (TNTC; square) samples are plotted at their limit of detection (LOD) or upper limit of quantification (ULOQ), respectively. Trend lines were generated using non-parametric locally estimated scatterplot smoothing (LOESS) to aid visual interpretation.

E. coli correlation

While overall associations between generic and resistant E. coli are estimated later using models to account for clustering in space and in time, compute non-parametric correlations to examine concordance between paired concentrations of generic and resistant E. coli measured in the same samples for each site and site type (river mainstem vs. tributary).

Define a function to compute Kendall’s \(\tau_{b}\) correlations between paired observations with 95% confidence intervals (CI) and export in tidy format:

corr_ci <- function(x,                # vector of values
                   y,                 # vector of paired values (same length as x)
                   conf.level = 0.95  # CI width
                   ){ 
  
  nobs = length(x)                            # number of paired observations
  
  ci <- KendallTauB(x, y,                     # estimate Kendall's tau with CI
                    conf.level = conf.level)
  
  pval <- cor.test(x, y, 
                   method = "kendall",
                   exact = FALSE)
  
  out <- tibble(test = "Tau-b",
                nobs = nobs,
                corr = unname(ci[1]),
                corr_lo = unname(ci[2]),
                corr_hi = unname(ci[3]),
                pval = pval$p.value)
  
  return(out)
}

Because Kendall’s \(\tau_{b}\) correlation is based on ranks and accounts for ties, it is robust to the values used to represent left- and right-censored observations. Prepare the generic and resistant E. coli concentration data by substituting LOD/2 for non-detects and ULOQ + 1 for TNTC observations:

df_corr_sub <- readRDS("../out/sample_data.rds") |>
  filter(site_num > 2,
         !is.na(detect)) |>
  mutate(mpn = case_when(cens == "left" ~ mpn_lod / 2,
                         cens == "right" ~ mpn_uloq + 1,
                         cens == "no" ~ mpn))

Filter for just the generic E. coli to join back to the resistant E. coli from the same samples:

df_corr_gen <- df_corr_sub |>
  filter(drug == "none") |>
  select(uid,
         mpn,
         mpn_lod,
         mpn_uloq)

Join generic E. coli to resistant E. coli and retain only non-missing pairs:

df_corr <- df_corr_sub |>
  filter(drug %in% c("cefotaxime", "colistin"),
         drug_dose < 4) |>
  left_join(df_corr_gen,
            by = c("uid_gen" = "uid"),
            suffix = c("", "_gen")) |>
  filter(!is.na(mpn),
         !is.na(mpn_gen))

Nest the data by selective antibiiotic and site and apply the corr_ci() by group to calculate site-specific correlations:

tab_corr_site <- df_corr |>
  group_by(site_num, site_id, site_lab, site_type,
           drug) |>
  mutate(n_detect = sum(detect)) |>
  filter(n_detect > 0) |>
  nest() |>
  ungroup() |>
  mutate(cor = map(data, ~corr_ci(.x$mpn, .x$mpn_gen))) |>
  select(-data)

Repeat, nesting by site type:

Code
tab_corr_type <- df_corr |>
  group_by(site_type,
           drug) |>
  mutate(n_detect = sum(detect)) |>
  filter(n_detect > 0) |>
  nest() |>
  ungroup() |>
  mutate(cor = map(data, ~corr_ci(.x$mpn, .x$mpn_gen))) |>
  select(-data) |>
  mutate(site_lab = site_type,
         site_num = 0)

Calculate across all sites and types, then arrange to output as table:

tab_corr <- df_corr |>
  group_by(drug) |>
  mutate(n_detect = sum(detect)) |>
  filter(n_detect > 0) |>
  nest() |>
  ungroup() |>
  mutate(site_lab = "Total",
         cor = map(data, ~corr_ci(.x$mpn, .x$mpn_gen))) |>
  select(-data) |>
  bind_rows(tab_corr_type,
            tab_corr_site) |>
  unnest(cor) |>
  mutate(ci_val = str_c(pretty_round(corr_lo, 2), "–",
                         pretty_round(corr_hi, 2)), 
         pval_val = ifelse(pval < 0.001, "<0.001", pretty_round(pval, 3)),
         drug = case_when(drug == "cefotaxime" ~ "CTX",
                          drug == "colistin" ~ "CST")) |>
  select(starts_with("site_"),
         drug,
         nobs,
         corr,
         ci = ci_val,
         pval = pval_val) |>
  pivot_wider(names_from = drug,
              values_from = c(nobs, corr, ci, pval),
              names_glue = "{drug}_{.value}",
              names_vary = "slowest") |>
  arrange(site_type, site_num) |>
  select(-site_num, -site_id)

This reproduces the results (if not quite the formatting) of Table S3 in the Supplementary Material of Holcomb et al. (2026):

ESBL-Ec vs Generic
CST-Ec vs Generic
Site Type N τ 95% CI p N τ 95% CI p
river river 78 0.75 0.67–0.83 <0.001 74 0.55 0.43–0.67 <0.001
Roswell Rd river 7 0.84 0.63–1.00 0.011 7 0.75 0.40–1.00 0.028
Johnson Ferry river 17 0.83 0.68–0.99 <0.001 17 0.75 0.61–0.89 <0.001
Powers Ferry river 11 0.88 0.68–1.00 <0.001 11 0.60 0.28–0.92 0.021
Paces Ferry river 12 0.76 0.53–1.00 <0.001 11 0.60 0.28–0.91 0.021
S Cobb Dr river 10 0.72 0.35–1.00 0.006 9 0.33 -0.20–0.86 0.259
Fairburn Rd river 21 0.71 0.57–0.85 <0.001 19 0.47 0.15–0.79 0.014
tributary tributary 53 0.42 0.23–0.60 <0.001 53 -0.19 -0.40–0.02 0.073
Tanyard Creek tributary 18 0.53 0.26–0.80 0.003 18 -0.01 -0.45–0.42 0.939
Clear Creek tributary 4 0.67 0.01–1.00 0.174 4 -0.18 -1.00–0.88 0.718
Ansley Stream tributary 4 0.24 -0.55–1.00 0.655
Proctor Creek tributary 19 0.01 -0.37–0.38 0.972 19 0.09 -0.42–0.60 0.655
Utoy Creek tributary 8 0.50 -0.05–1.00 0.083 8 0.50 0.11–0.89 0.127
Total 131 0.60 0.51–0.68 <0.001 127 0.25 0.13–0.36 <0.001

Regression modeling

The primary analysis approach in this study is the application of spatial generalized linear mixed models (GLMM) to estimate associations between environmental factors and the occurrence of generic and resistant E. coli in surface water. The spatial GLMMs are implemented as spatial stream network models using the SSN objects generated previously.

Linear SSN models are used to estimate the expected change in log10 concentration of generic E. coli and ESBL-Ec and logistic SSN models are used to estimate the odds ratio (OR) for CST-Ec detection associated with a unit-increase in each environmental factor. The SSN models all include a random effect for sampling event to account for temporal clustering among contemporaneous samples and a flow-weighted spatial random effect with exponential distance decay to account for spatial autocorrelation between flow-connected sampling locations (a “tail-up” covariance component in the SSN parlance).

Model-fitting function

Write a utility function to execute an SSN model and extract the model estimates in a convenient format with identifying labels:

fit_ssn <- function(lab,                    # model label to assign to output (string)
                    set,                    # model set to label output (string)
                    outcome,                # outcome assessed (string)
                    response,               # response variable representing outcome (string)
                    predictor,              # explanatory variable set label (string)
                    error,                  # covariance structure label (string)
                    re,                     # random effect label (string)
                    path,                   # path to SSN for model fit
                    form,                   # model formula
                    random = NULL,          # random effect formula
                    family = "Gaussian",    # likelihood
                    additive = "afv_area",  # AFV column name (string)
                    tailup_type = "none",   # correlation model for tail-up (flow-connected) covariance component
                    taildown_type = "none", # correlation model for tail-down (flow-disconnected) covariance component
                    euclid_type = "none",   # correlation model for Euclidean distance covariance component
                    nugget_type = "nugget", # include unstructured error component (aka nugget)
                    est_method = "reml"     # estimation algorithm
                    ){
  
  
  ## import SSN
  net <- ssn_import(path,
                    overwrite = TRUE)
  
  ## check formula
  form <- as.formula(form)
  
  ## fit model
  mod <- ssn_glm(formula = form,
                 random = random,
                 ssn.object = net,
                 family = {{family}},
                 tailup_type = tailup_type,
                 taildown_type = taildown_type,
                 euclid_type = euclid_type,
                 nugget_type = nugget_type,
                 additive = {{additive}},
                 est_method = est_method)
  
  ## extract estimates
  est_coef <- tidy(mod, conf.int = TRUE, effects = "fixed") |>    # regression coefs ("fixed effects")
    mutate(effect = "coef")
  
  est_var <- tidy(mod, conf.int = TRUE, effects = "ssn") |>       # variance components
    filter(!is_known)
  
  if(!is.null(random)){
    est_re <- tidy(mod, conf.int = TRUE, effects = "randcov") |>  # random effect variance
      mutate(effect = "re")
    
    est_raw <- bind_rows(est_coef, est_var, est_re)
    
  } else {
    
    est_raw <-  bind_rows(est_coef, est_var)
    
  }
    
  
  # record estimation scale based on link (implied by likelihood) used
  scale <- case_when(family == "Gaussian" ~ "response",
                     family %in% c("binomial", "beta") ~  "logit",
                     family %in% c("poisson", "nbinomial", "Gamma") ~ "log")
  
  # combine estimates
  est <- est_raw |>
    mutate(scale = scale) |>
    select(effect, scale, term,
           est = estimate,
           se = std.error,
           lo = conf.low,
           hi = conf.high,
           pval = p.value)

  
  ## extract fit
  fit <- glance(mod) |>
    bind_cols(loocv(mod)) |>
    mutate(method = est_method) |>
    select(n_obs = n,
           n_coef = p,
           n_varcomp = npar,
           method,
           aic = AIC,
           aicc = AICc,
           loglik = logLik,
           dev = deviance,
           dev_explain = pseudo.r.squared,
           bias,
           mse = MSPE,
           rmse = RMSPE,
           rav = RAV)
  
  
  ## partition variance
  varpart <- varcomp(mod) |>
    mutate(effect = "varcomp",
           scale = "proportion") |>
    select(effect, scale,
           term = varcomp,
           est = proportion) |>
    mutate(term = ifelse(term == "Covariates (PR-sq)", "predictor", term))
  
  
  ## output as dataframe
  out <- tibble_row(
    lab = lab,                # model fit label
    set = set,                # model set label
    outcome = outcome,        # outcome assessed label
    response = response,      # response variable representing outcome label
    predictor = predictor,    # explanatory variable set label
    error = error,            # covariance structure label
    re = re,                  # random effect label
    ssn = path,               # path to SSN used for model
    formula = deparse1(form), # formula used in model as string
    est = list(est),          # model estimates as nested dataframe
    fit = list(fit),          # model fit metrics as nested dataframe
    varcomp = list(varpart)   # model variance partitioning as nested dataframe
  )
  
  return(out)
  
}

Define a formula for a random intercept by sampling event ID (as a categorical variable, nut numeric) to be reused across all models as the temporal random effect:

form_re <- formula(~ 1 | as.factor(samp_event))

Univariable models

Univariable models are fit separately for each environmental factor as the explanatory variable to assess associations with each of the three outcome variables (generic E. coli log10 concentration, ESBL-Ec log10 concentration, and CST-Ec detection). While it would be possible to write a second wrapper function to execute the model fits and estimate extractions for each of the three outcomes, there is just enough idiosyncrasy across the covariates in naming convention and other nuances, and few enough separate covariates, to defend hard-coding the univariable model for each combination of outcome and covariate.

Generic E. coli

Start by estimating the univariable associations between the resistant outcomes (ESBL-Ec and CST-Ec) and generic E. coli. Apply the fit_ssn() function to the ESBL-Ec SSN assembled previously:

m_gen_esbl <- fit_ssn(lab = "gen",                      # label for generic Ec-as-predictor models
                      set = "uni",                      # univariable model set
                      outcome = "esbl",                 # ESBL-Ec outcome
                      response = "mpn",                 # concentration (MPN) continuous response var
                      predictor = "mpn_gen",            # generic EC concentration (MPN) explanatory var
                      error = "flow",                   # flow-connected (tail-up) covariance structure
                      re = "event",                     # random effect by sampling event
                      path = "../gis/ssn/esbl_gen.ssn", # path to ESBL SSN with complete generic Ec data
                      form = formula(mpn ~ mpn_gen),    # model formula
                      random = form_re,                 # random effect formula
                      family = "Gaussian",              # linear model (identity link) (defaut)
                      additive = "afv_area",            # cumulative area drained as flow proxy (default)
                      tailup_type = "exponential",      # YES tailup covariance with exponential model
                      taildown_type = "none",           # NO taildown covariance (default)
                      euclid_type = "none",             # NO Euclidean covariance component (default)
                      nugget_type = "nugget",           # YES nugget (unstructured covariance) (default)
                      est_method = "reml")              # use restricted maximum likelihood (default)

Repeat for CST-Ec, specifying detection (rather than concentration) as the response, binomial family for logistic regression, and dropping default arguments for simplicity:

m_gen_cst <- fit_ssn(lab = "gen",                       # label for generic Ec-as-predictor models
                     set = "uni",                       # univariable model set
                     outcome = "cst",                   # CST-Ec outcome
                     response = "detect",               # detection binary response var
                     predictor = "mpn_gen",             # generic EC concentration (MPN) explanatory var
                     error = "flow",                    # flow-connected (tail-up) covariance structure
                     re = "event",                      # random effect by sampling event
                     path = "../gis/ssn/cst_gen.ssn",   # path to ESBL SSN with complete generic Ec data
                     form = formula(detect ~ mpn_gen),  # model formula
                     family = "binomial",               # binomial likelihood (logistic link)
                     random = form_re,                  # random effect formula
                     tailup_type = "exponential")       # YES tailup covariance with exponential model

Stack the model outputs and save:

m_gen <- bind_rows(m_gen_esbl,
                   m_gen_cst)

saveRDS(m_gen, "../est/ssn_uni_gen.rds")

Water quality

Models using the co-measured water quality characteristics, turbidity and optical brighteners, also use the specific SSNs developed to account for missing covariate values, but are fit using generic E. coli as an outcome, as well as ESBL-Ec and CST-Ec.

Fit models with turbidity as the explanatory variable for all three outcomes:

Code
# ESBL-Ec concentration outcome
m_turb_esbl <- fit_ssn(lab = "turb",
                       set = "uni",
                       outcome = "esbl",
                       response = "mpn",
                       predictor = "wq_turb",
                       error = "flow",
                       re = "event",
                       path = "../gis/ssn/esbl_turb.ssn",
                       form = formula(mpn ~ wq_turb),
                       random = form_re,
                       tailup_type = "exponential")


# generic Ec concentration outcome
m_turb_gen <- fit_ssn(lab = "turb",
                      set = "uni",
                      outcome = "gen",
                      response = "mpn",
                      predictor = "wq_turb",
                      error = "flow",
                      re = "event",
                      path = "../gis/ssn/gen_turb.ssn",
                      form = formula(mpn ~ wq_turb),
                      random = form_re,
                      tailup_type = "exponential")


# CST-Ec detection outcome
m_turb_cst <- fit_ssn(lab = "turb",
                      set = "uni",
                      outcome = "cst",
                      response = "detect",
                      predictor = "wq_turb",
                      error = "flow",
                      re = "event",
                      path = "../gis/ssn/cst_turb.ssn",
                      form = formula(detect ~ wq_turb),
                      random = form_re,
                      family = "binomial",
                      tailup_type = "exponential")


# combine
m_turb <- bind_rows(m_turb_esbl,
                    m_turb_gen,
                    m_turb_cst)

saveRDS(m_turb, "../est/ssn_uni_turb.rds")

Repeat for optical brighteners, using the appropriate prepared SSNs:

Code
# ESBL-Ec concentration outcome
m_optic_esbl <- fit_ssn(lab = "optic",
                        set = "uni",
                        outcome = "esbl",
                        response = "mpn",
                        predictor = "wq_optic",
                        error = "flow",
                        re = "event",
                        path = "../gis/ssn/esbl_optic.ssn",
                        form = formula(mpn ~ wq_optic),
                        random = form_re,
                        tailup_type = "exponential")


# generic Ec concentration outcome
m_optic_gen <- fit_ssn(lab = "optic",
                       set = "uni",
                       outcome = "gen",
                       response = "mpn",
                       predictor = "wq_optic",
                       error = "flow",
                       re = "event",
                       path = "../gis/ssn/gen_optic.ssn",
                       form = formula(mpn ~ wq_optic),
                       random = form_re,
                       tailup_type = "exponential")


# CST-Ec detection outcome
m_optic_cst <- fit_ssn(lab = "optic",
                       set = "uni",
                       outcome = "cst",
                       response = "detect",
                       predictor = "wq_optic",
                       error = "flow",
                       re = "event",
                       path = "../gis/ssn/cst_optic.ssn",
                       form = formula(detect ~ wq_optic),
                       random = form_re,
                       family = "binomial",
                       tailup_type = "exponential")


# combine
m_optic <- bind_rows(m_optic_esbl,
                     m_optic_gen,
                     m_optic_cst)

saveRDS(m_optic, "../est/ssn_uni_optic.rds")

Land cover

Fit univariable models with basin features (land cover, imperviousness, and area) as the explanatory variable for each of the three outcomes. The full-observation SSNs are used because all basin feature covariate data are complete.

Fit models for basin area:

Code
# ESBL-Ec concentration outcome
m_area_esbl <- fit_ssn(lab = "area",
                       set = "uni",
                       outcome = "esbl",
                       response = "mpn",
                       predictor = "wshed_area",
                       error = "flow",
                       re = "event",
                       path = "../gis/ssn/esbl.ssn",
                       form = formula(mpn ~ wshed_area),
                       random = form_re,
                       tailup_type = "exponential")


# generic Ec concentration outcome
m_area_gen <- fit_ssn(lab = "area",
                      set = "uni",
                      outcome = "gen",
                      response = "mpn",
                      predictor = "wshed_area",
                      error = "flow",
                      re = "event",
                      path = "../gis/ssn/gen.ssn",
                      form = formula(mpn ~ wshed_area),
                      random = form_re,
                      tailup_type = "exponential")


# CST-Ec detection outcome
m_area_cst <- fit_ssn(lab = "area",
                      set = "uni",
                      outcome = "cst",
                      response = "detect",
                      predictor = "wshed_area",
                      error = "flow",
                      re = "event",
                      path = "../gis/ssn/cst.ssn",
                      form = formula(detect ~ wshed_area),
                      random = form_re,
                      family = "binomial",
                      tailup_type = "exponential")


# combine
m_area <- bind_rows(m_area_esbl,
                    m_area_gen,
                    m_area_cst)

saveRDS(m_area, "../est/ssn_uni_area.rds")

Fit models for imperviousness:

Code
# ESBL-Ec concentration outcome
m_impv_esbl <- fit_ssn(lab = "impv",
                       set = "uni",
                       outcome = "esbl",
                       response = "mpn",
                       predictor = "lc_impv",
                       error = "flow",
                       re = "event",
                       path = "../gis/ssn/esbl.ssn",
                       form = formula(mpn ~ lc_impv),
                       random = form_re,
                       tailup_type = "exponential")


# generic Ec concentration outcome
m_impv_gen <- fit_ssn(lab = "impv",
                      set = "uni",
                      outcome = "gen",
                      response = "mpn",
                      predictor = "lc_impv",
                      error = "flow",
                      re = "event",
                      path = "../gis/ssn/gen.ssn",
                      form = formula(mpn ~ lc_impv),
                      random = form_re,
                      tailup_type = "exponential")


# CST-Ec detection outcome
m_impv_cst <- fit_ssn(lab = "impv",
                      set = "uni",
                      outcome = "cst",
                      response = "detect",
                      predictor = "lc_impv",
                      error = "flow",
                      re = "event",
                      path = "../gis/ssn/cst.ssn",
                      form = formula(detect ~ lc_impv),
                      random = form_re,
                      family = "binomial",
                      tailup_type = "exponential")


# combine
m_impv <- bind_rows(m_impv_esbl,
                    m_impv_gen,
                    m_impv_cst)

saveRDS(m_impv, "../est/ssn_uni_impv.rds")

Fit models for forested land cover:

Code
# ESBL-Ec concentration outcome
m_forest_esbl <- fit_ssn(lab = "forest",
                         set = "uni",
                         outcome = "esbl",
                         response = "mpn",
                         predictor = "lc_forest",
                         error = "flow",
                         re = "event",
                         path = "../gis/ssn/esbl.ssn",
                         form = formula(mpn ~ lc_forest),
                         random = form_re,
                         tailup_type = "exponential")


# generic Ec concentration outcome
m_forest_gen <- fit_ssn(lab = "forest",
                        set = "uni",
                        outcome = "gen",
                        response = "mpn",
                        predictor = "lc_forest",
                        error = "flow",
                        re = "event",
                        path = "../gis/ssn/gen.ssn",
                        form = formula(mpn ~ lc_forest),
                        random = form_re,
                        tailup_type = "exponential")


# CST-Ec detection outcome
m_forest_cst <- fit_ssn(lab = "forest",
                        set = "uni",
                        outcome = "cst",
                        response = "detect",
                        predictor = "lc_forest",
                        error = "flow",
                        re = "event",
                        path = "../gis/ssn/cst.ssn",
                        form = formula(detect ~ lc_forest),
                        random = form_re,
                        family = "binomial",
                        tailup_type = "exponential")


# combine
m_forest <- bind_rows(m_forest_esbl,
                      m_forest_gen,
                      m_forest_cst)

saveRDS(m_forest, "../est/ssn_uni_forest.rds")

Fit models for open land cover:

Code
# ESBL-Ec concentration outcome
m_open_esbl <- fit_ssn(lab = "open",
                       set = "uni",
                       outcome = "esbl",
                       response = "mpn",
                       predictor = "lc_open",
                       error = "flow",
                       re = "event",
                       path = "../gis/ssn/esbl.ssn",
                       form = formula(mpn ~ lc_open),
                       random = form_re,
                       tailup_type = "exponential")


# generic Ec concentration outcome
m_open_gen <- fit_ssn(lab = "open",
                      set = "uni",
                      outcome = "gen",
                      response = "mpn",
                      predictor = "lc_open",
                      error = "flow",
                      re = "event",
                      path = "../gis/ssn/gen.ssn",
                      form = formula(mpn ~ lc_open),
                      random = form_re,
                      tailup_type = "exponential")


# CST-Ec detection outcome
m_open_cst <- fit_ssn(lab = "open",
                      set = "uni",
                      outcome = "cst",
                      response = "detect",
                      predictor = "lc_open",
                      error = "flow",
                      re = "event",
                      path = "../gis/ssn/cst.ssn",
                      form = formula(detect ~ lc_open),
                      random = form_re,
                      family = "binomial",
                      tailup_type = "exponential")


# combine
m_open <- bind_rows(m_open_esbl,
                    m_open_gen,
                    m_open_cst)

saveRDS(m_open, "../est/ssn_uni_open.rds")

Meteorology

Fit models for rain in the previous two days:

Code
# ESBL-Ec concentration outcome
m_rain2_esbl <- fit_ssn(lab = "rain2",
                        set = "uni",
                        outcome = "esbl",
                        response = "mpn",
                        predictor = "rain2",
                        error = "flow",
                        re = "event",
                        path = "../gis/ssn/esbl.ssn",
                        form = formula(mpn ~ rain2),
                        random = form_re,
                        tailup_type = "exponential")


# generic Ec concentration outcome
m_rain2_gen <- fit_ssn(lab = "rain2",
                       set = "uni",
                       outcome = "gen",
                       response = "mpn",
                       predictor = "rain2",
                       error = "flow",
                       re = "event",
                       path = "../gis/ssn/gen.ssn",
                       form = formula(mpn ~ rain2),
                       random = form_re,
                       tailup_type = "exponential")


# CST-Ec detection outcome
m_rain2_cst <- fit_ssn(lab = "rain2",
                       set = "uni",
                       outcome = "cst",
                       response = "detect",
                       predictor = "rain2",
                       error = "flow",
                       re = "event",
                       path = "../gis/ssn/cst.ssn",
                       form = formula(detect ~ rain2),
                       random = form_re,
                       family = "binomial",
                       tailup_type = "exponential")


# combine
m_rain2 <- bind_rows(m_rain2_esbl,
                     m_rain2_gen,
                     m_rain2_cst)

saveRDS(m_rain2, "../est/ssn_uni_rain2.rds")

Fit models for rain in the previous 3–7 days:

Code
# ESBL-Ec concentration outcome
m_rain7_esbl <- fit_ssn(lab = "rain7",
                        set = "uni",
                        outcome = "esbl",
                        response = "mpn",
                        predictor = "rain7",
                        error = "flow",
                        re = "event",
                        path = "../gis/ssn/esbl.ssn",
                        form = formula(mpn ~ rain7),
                        random = form_re,
                        tailup_type = "exponential")


# generic Ec concentration outcome
m_rain7_gen <- fit_ssn(lab = "rain7",
                       set = "uni",
                       outcome = "gen",
                       response = "mpn",
                       predictor = "rain7",
                       error = "flow",
                       re = "event",
                       path = "../gis/ssn/gen.ssn",
                       form = formula(mpn ~ rain7),
                       random = form_re,
                       tailup_type = "exponential")


# CST-Ec detection outcome
m_rain7_cst <- fit_ssn(lab = "rain7",
                       set = "uni",
                       outcome = "cst",
                       response = "detect",
                       predictor = "rain7",
                       error = "flow",
                       re = "event",
                       path = "../gis/ssn/cst.ssn",
                       form = formula(detect ~ rain7),
                       random = form_re,
                       family = "binomial",
                       tailup_type = "exponential")


# combine
m_rain7 <- bind_rows(m_rain7_esbl,
                     m_rain7_gen,
                     m_rain7_cst)

saveRDS(m_rain7, "../est/ssn_uni_rain7.rds")

Fit models for previous day maximum temperature:

Code
# ESBL-Ec concentration outcome
m_tmax1_esbl <- fit_ssn(lab = "tmax1",
                        set = "uni",
                        outcome = "esbl",
                        response = "mpn",
                        predictor = "tmax1",
                        error = "flow",
                        re = "event",
                        path = "../gis/ssn/esbl.ssn",
                        form = formula(mpn ~ tmax1),
                        random = form_re,
                        tailup_type = "exponential")


# generic Ec concentration outcome
m_tmax1_gen <- fit_ssn(lab = "tmax1",
                       set = "uni",
                       outcome = "gen",
                       response = "mpn",
                       predictor = "tmax1",
                       error = "flow",
                       re = "event",
                       path = "../gis/ssn/gen.ssn",
                       form = formula(mpn ~ tmax1),
                       random = form_re,
                       tailup_type = "exponential")


# CST-Ec detection outcome
m_tmax1_cst <- fit_ssn(lab = "tmax1",
                       set = "uni",
                       outcome = "cst",
                       response = "detect",
                       predictor = "tmax1",
                       error = "flow",
                       re = "event",
                       path = "../gis/ssn/cst.ssn",
                       form = formula(detect ~ tmax1),
                       random = form_re,
                       family = "binomial",
                       tailup_type = "exponential")


# combine
m_tmax1 <- bind_rows(m_tmax1_esbl,
                     m_tmax1_gen,
                     m_tmax1_cst)

saveRDS(m_tmax1, "../est/ssn_uni_tmax1.rds")

Wastewater

Fit models for WWTP treated effluent loads:

Code
# ESBL-Ec concentration outcome
m_wwtp_esbl <- fit_ssn(lab = "wwtp",
                       set = "uni",
                       outcome = "esbl",
                       response = "mpn",
                       predictor = "out_wwtp_load",
                       error = "flow",
                       re = "event",
                       path = "../gis/ssn/esbl.ssn",
                       form = formula(mpn ~ out_wwtp_load),
                       random = form_re,
                       tailup_type = "exponential")


# generic Ec concentration outcome
m_wwtp_gen <- fit_ssn(lab = "wwtp",
                      set = "uni",
                      outcome = "gen",
                      response = "mpn",
                      predictor = "out_wwtp_load",
                      error = "flow",
                      re = "event",
                      path = "../gis/ssn/gen.ssn",
                      form = formula(mpn ~ out_wwtp_load),
                      random = form_re,
                      tailup_type = "exponential")


# CST-Ec detection outcome
m_wwtp_cst <- fit_ssn(lab = "wwtp",
                      set = "uni",
                      outcome = "cst",
                      response = "detect",
                      predictor = "out_wwtp_load",
                      error = "flow",
                      re = "event",
                      path = "../gis/ssn/cst.ssn",
                      form = formula(detect ~ out_wwtp_load),
                      random = form_re,
                      family = "binomial",
                      tailup_type = "exponential")


# combine
m_wwtp <- bind_rows(m_wwtp_esbl,
                    m_wwtp_gen,
                    m_wwtp_cst)

saveRDS(m_wwtp, "../est/ssn_uni_wwtp.rds")

Fit models for CSO effluent loads:

Code
# ESBL-Ec concentration outcome
m_cso_esbl <- fit_ssn(lab = "cso",
                      set = "uni",
                      outcome = "esbl",
                      response = "mpn",
                      predictor = "out_cso_load",
                      error = "flow",
                      re = "event",
                      path = "../gis/ssn/esbl.ssn",
                      form = formula(mpn ~ out_cso_load),
                      random = form_re,
                      tailup_type = "exponential")


# generic Ec concentration outcome
m_cso_gen <- fit_ssn(lab = "cso",
                     set = "uni",
                     outcome = "gen",
                     response = "mpn",
                     predictor = "out_cso_load",
                     error = "flow",
                     re = "event",
                     path = "../gis/ssn/gen.ssn",
                     form = formula(mpn ~ out_cso_load),
                     random = form_re,
                     tailup_type = "exponential")


# CST-Ec detection outcome
m_cso_cst <- fit_ssn(lab = "cso",
                     set = "uni",
                     outcome = "cst",
                     response = "detect",
                     predictor = "out_cso_load",
                     error = "flow",
                     re = "event",
                     path = "../gis/ssn/cst.ssn",
                     form = formula(detect ~ out_cso_load),
                     random = form_re,
                     family = "binomial",
                     tailup_type = "exponential")


# combine
m_cso <- bind_rows(m_cso_esbl,
                   m_cso_gen,
                   m_cso_cst)

saveRDS(m_cso, "../est/ssn_uni_cso.rds")

Fit models for septic density:

Code
# ESBL-Ec concentration outcome
m_dens_esbl <- fit_ssn(lab = "dens",
                       set = "uni",
                       outcome = "esbl",
                       response = "mpn",
                       predictor = "septic_dens",
                       error = "flow",
                       re = "event",
                       path = "../gis/ssn/esbl.ssn",
                       form = formula(mpn ~ septic_dens),
                       random = form_re,
                       tailup_type = "exponential")


# generic Ec concentration outcome
m_dens_gen <- fit_ssn(lab = "dens",
                      set = "uni",
                      outcome = "gen",
                      response = "mpn",
                      predictor = "septic_dens",
                      error = "flow",
                      re = "event",
                      path = "../gis/ssn/gen.ssn",
                      form = formula(mpn ~ septic_dens),
                      random = form_re,
                      tailup_type = "exponential")


# CST-Ec detection outcome
m_dens_cst <- fit_ssn(lab = "dens",
                      set = "uni",
                      outcome = "cst",
                      response = "detect",
                      predictor = "septic_dens",
                      error = "flow",
                      re = "event",
                      path = "../gis/ssn/cst.ssn",
                      form = formula(detect ~ septic_dens),
                      random = form_re,
                      family = "binomial",
                      tailup_type = "exponential")


# combine
m_dens <- bind_rows(m_dens_esbl,
                    m_dens_gen,
                    m_dens_cst)

saveRDS(m_dens, "../est/ssn_uni_dens.rds")

Although univariable models are not applicable for the interaction of septic density and rainfall (which also includes terms for the main effects of septic density and rainfall), fit a model for the interaction in the “univariable” set for consistency with the later multivariable estimates:

Code
# ESBL-Ec concentration outcome
m_raindens_esbl <- fit_ssn(lab = "raindens",
                           set = "uni",
                           outcome = "esbl",
                           response = "mpn",
                           predictor = "interaction",
                           error = "flow",
                           re = "event",
                           path = "../gis/ssn/esbl.ssn",
                           form = formula(mpn ~ septic_dens*rain7),
                           random = form_re,
                           tailup_type = "exponential")


# generic Ec concentration outcome
m_raindens_gen <- fit_ssn(lab = "raindens",
                          set = "uni",
                          outcome = "gen",
                          response = "mpn",
                          predictor = "interaction",
                          error = "flow",
                          re = "event",
                          path = "../gis/ssn/gen.ssn",
                          form = formula(mpn ~ septic_dens*rain7),
                          random = form_re,
                          tailup_type = "exponential")


# CST-Ec detection outcome
m_raindens_cst <- fit_ssn(lab = "raindens",
                          set = "uni",
                          outcome = "cst",
                          response = "detect",
                          predictor = "interaction",
                          error = "flow",
                          re = "event",
                          path = "../gis/ssn/cst.ssn",
                          form = formula(detect ~ septic_dens*rain7),
                          random = form_re,
                          family = "binomial",
                          tailup_type = "exponential")


# combine
m_raindens <- bind_rows(m_raindens_esbl,
                        m_raindens_gen,
                        m_raindens_cst)

saveRDS(m_raindens, "../est/ssn_uni_raindens.rds")

Multivariable models

Multivariable models are also fit that include all environmental factors except for those measured in the water samples (i.e., excluding generic E. coli, turbidity, and optical brighteners) to explore the independent contribution of each external environmental factor conditional on the others. To account for potential subsurface transport of septic effluent mobilized by antecedent precipitation, include an interaction term in the multivariable models for the product of septic density and 3–7 day rainfall. Exclude the basin area and open land cover-class variables from the multivariable models to reduce collinearity among the basin-feature variable set.

Use the same fit_ssn() function to fit a multivariable model to the ESBL-Ec concentration data:

# ESBL-Ec concentration outcome
m_multi_esbl <- fit_ssn(lab = "multi",
                        set = "multi",
                        outcome = "esbl",
                        response = "mpn",
                        predictor = "external",
                        error = "flow",
                        re = "event",
                        path = "../gis/ssn/esbl.ssn",
                        form = formula(mpn ~ out_wwtp_load + out_cso_load +
                                         lc_impv + lc_forest + 
                                         rain2 + tmax1 +
                                         septic_dens*rain7),
                        random = form_re,
                        tailup_type = "exponential")

Repeat for generic E. coli concentration and CST-Ec detection, then stack the estimates and save:

Code
# generic Ec concentration outcome
m_multi_gen <- fit_ssn(lab = "multi",
                       set = "multi",
                       outcome = "gen",
                       response = "mpn",
                       predictor = "external",
                       error = "flow",
                       re = "event",
                       path = "../gis/ssn/gen.ssn",
                       form = formula(mpn ~ out_wwtp_load + out_cso_load +
                                        lc_impv + lc_forest + 
                                        rain2 + tmax1 +
                                        septic_dens*rain7),
                       random = form_re,
                       tailup_type = "exponential")


# CST-Ec detection outcome
m_multi_cst <- fit_ssn(lab = "multi",
                       set = "multi",
                       outcome = "cst",
                       response = "detect",
                       predictor = "external",
                       error = "flow",
                       re = "event",
                       path = "../gis/ssn/cst.ssn",
                       form = formula(detect ~ out_wwtp_load + out_cso_load +
                                        lc_impv + lc_forest + 
                                        rain2 + tmax1 +
                                        septic_dens*rain7),
                       random = form_re,
                       family = "binomial",
                       tailup_type = "exponential")


# combine
m_multi <- bind_rows(m_multi_esbl,
                     m_multi_gen,
                     m_multi_cst)

saveRDS(m_multi, "../est/ssn_multi.rds")

Model estimates

Read and stack the univariable model output files:

m_uni <- bind_rows(
  readRDS("../est/ssn_uni_gen.rds"),
  readRDS("../est/ssn_uni_turb.rds"),
  readRDS("../est/ssn_uni_optic.rds"),
  readRDS("../est/ssn_uni_area.rds"),
  readRDS("../est/ssn_uni_impv.rds"),
  readRDS("../est/ssn_uni_forest.rds"),
  readRDS("../est/ssn_uni_open.rds"),
  readRDS("../est/ssn_uni_rain2.rds"),
  readRDS("../est/ssn_uni_rain7.rds"),
  readRDS("../est/ssn_uni_tmax1.rds"),
  readRDS("../est/ssn_uni_wwtp.rds"),
  readRDS("../est/ssn_uni_cso.rds"),
  readRDS("../est/ssn_uni_dens.rds"),
  readRDS("../est/ssn_uni_raindens.rds")
)

saveRDS(m_uni, "../est/ssn_mod_uni.rds")

Drop the nested model fit and variance component dataframes and unnest the univariable model coefficient estimates:

est_uni <- m_uni |>
  select(-fit, -varcomp) |>
  unnest(est)

saveRDS(est_uni, "../est/ssn_est_uni.rds")

Separately unnest the model fit and variance component estimates:

Code
# model fit metrics
fit_uni <- m_uni |>
  select(-est, -varcomp) |>
  unnest(fit)

saveRDS(fit_uni, "../est/ssn_fit_uni.rds")


# variance partitioning estimates
var_uni <- m_uni |>
  select(-est, - fit) |>
  unnest(varcomp)

saveRDS(var_uni, "../est/ssn_var_uni.rds")

Likewise, extract the estimates, model fits, and variance components for the multivariable models:

Code
# multivariable model output
m_multi <- readRDS("../est/ssn_multi.rds")

  
# coefficient estimates
est_multi <- m_multi |>
  select(-fit, -varcomp) |>
  unnest(est)

saveRDS(est_multi, "../est/ssn_est_multi.rds")


# model fit metrics
fit_multi <- m_multi |>
  select(-est, -varcomp) |>
  unnest(fit)

saveRDS(fit_multi, "../est/ssn_fit_multi.rds")


# variance partitioning estimates
var_multi <- m_multi |>
  select(-est, - fit) |>
  unnest(varcomp)

saveRDS(var_multi, "../est/ssn_var_multi.rds")

Environmental factor associations

For convenience, a key mapping the shorthand variable names used in the analysis to their full display labels, along with grouping and ordering information, is provided in covar_labels.xlsx. Read in the labels from the key and the means and standard deviations previously used to scale the variables:

# covariate labels
var_lab <- read_xlsx("../data/covar_labels.xlsx")


# covariate scaling factors
var_scl <- readRDS("../out/covar_summ_stats.rds") |>
  mutate(outcome = case_when(drug == "cefotaxime" ~ "esbl",
                             drug == "colistin" ~ "cst",
                             drug == "none" ~ "gen"))

Pull in the univariable model coefficient estimates, adding empty rows for the generic E. coli outcome with generic E. coli as the explanatory variable and setting the septic-rainfall interaction term to missing (and dropping the associated main effects) for display purposes when merged with the multivariable model estimates:

coef_uni <- readRDS("../est/ssn_est_uni.rds") |>
  
  # empty row for generic Ec predicted by generic Ec
  add_case(outcome = "gen",
           set = "uni",
           effect = "coef",
           term = "mpn_gen",
           predictor = "mpn_gen") |>
  
  # set septic-rainfall interaction to NA and drop septic and rainfal main effects from interaction model
  mutate(across(c(est, se, lo, hi, pval),
                ~ifelse(term == "septic_dens:rain7", NA, .x))) |>
  filter(!(lab == "raindens" & term %in% c("septic_dens", "rain7")))

Obtain a list of unique effects estimated in univariable models, join the multivariable model estimates to match, stack with univariable estimates, and join to display labels and scaling factors:

coef_all <- coef_uni |>
  
  # identify unique outcome-predictor combinations from univariable models 
  select(outcome, response, effect, term) |>
  distinct() |>
  mutate(set = "multi") |>
  
  # join matching multivariable model estimates
  left_join(readRDS("../est/ssn_est_multi.rds"),
            by = c("outcome", "response", "effect", "term", "set")) |>
  
  # stack with univariable estimates
  bind_rows(coef_uni) |>
  
  # label with display names, grouping info
  left_join(var_lab, by = c("effect", "term")) |>
  
  # join covariate scaling factors
  left_join(var_scl, by = c("outcome", "term" = "var"))

Filter for just the main effect estimates (i.e., dropping intercepts and variance component hyperparameters), calculate descaled estimate values, exponentiate estimates from logistic models to obtain odds ratios (OR), and re-order for display:

coef_slct <- coef_all |>
  
  # filter main effect estimates (identified in covar_label key)
  filter(effect == "coef",
         include == "yes") %>%
  mutate(# descale coef estimates to obtain on original covariate scale
         est_descl = ifelse(scl_method == "none", est, est / sd),
         lo_descl = ifelse(scl_method == "none", lo, lo / sd),
         hi_descl = ifelse(scl_method == "none", hi, hi / sd),
         
         # exponentiate logistic model estimates for odds ratios
         across(c(est, lo, hi, ends_with("_descl")),
                ~ifelse(scale == "logit", exp(.x), .x)),
         
         # order by covariate group
         group = fct_relevel(group,
                             c("wq", "out", "lc", "meteo")),
         set = fct_relevel(set, c("uni", "multi")) |>
           fct_recode("Univariable" = "uni",
                      "Multivariable" = "multi") |>
           fct_rev()) |>
  arrange(group, term_order) |>
  mutate(group_lab = fct_inorder(group_lab),
         term_lab = fct_inorder(term_lab),
         drug_lab = case_when(outcome == "esbl" ~ "ESBL-Ec<br>concentration",
                              outcome == "cst" ~ "CST-Ec<br>detection",
                              outcome == "gen" ~ "Generic *E. coli*<br>concentration"))

Display the univariable and multivariable coefficient estimates together as the point estimates and their 95% confidence intervals. Prepare a similar forest plot for each of the three E. coli outcomes and stack the plots together for display, beginning with the generic E. coli concentration outcome:

p_coef_gen <- coef_slct |>
  filter(outcome == "gen") %>%
  ggplot(data = .) +
  facet_grid(cols = vars(drug_lab),
             rows = vars(group_lab),
             drop = TRUE,
             scales = "free_y",
             space = "free_y") +
  geom_vline(aes(xintercept = 0),
             linetype = "dashed") +
  geom_pointrange(aes(x = est, y = term_lab,
                      xmin = lo, xmax = hi,
                      color = group_lab, fill = group_lab,
                      shape = set, linetype = set),
                  size = 0.6, linewidth = 0.9,
                  position = position_dodge(width = 0.75)) +
  coord_cartesian(xlim = c(-0.75, 1)) +
  scale_y_discrete(limits = rev) +
  scale_shape_manual(values = c("Univariable" = 21, "Multivariable" = 23),
                     name = NULL) +
  scale_linetype_manual(values = c("Univariable" = "solid", "Multivariable" = "11"),
                        name = NULL) +
  scale_color_brewer(palette = "Set2") +
  scale_fill_brewer(palette = "Set2") +
  guides(color = "none", fill = "none",
         shape = guide_legend(reverse = TRUE,
                              override.aes = list(fill = "black")),
         linetype = guide_legend(reverse = TRUE)) +
  labs(x = "Δ log~10~  MPN/100 mL",
       y = NULL) +
  theme_bw() +
  theme(axis.text = element_markdown(),
        axis.title = element_markdown(),
        strip.text.y = element_blank())

Repeat for the ESBL-Ec concentration outcome, dropping axis ticks and related decorations:

Code
p_coef_esbl <- coef_slct |>
  filter(outcome == "esbl") %>%
  ggplot(data = .) +
  facet_grid(cols = vars(drug_lab),
             rows = vars(group_lab),
             drop = TRUE,
             scales = "free_y",
             space = "free_y") +
  geom_vline(aes(xintercept = 0),
             linetype = "dashed") +
  geom_pointrange(aes(x = est, y = term_lab,
                      xmin = lo, xmax = hi,
                      color = group_lab, fill = group_lab,
                      shape = set, linetype = set),
                  size = 0.6, linewidth = 0.9,
                  position = position_dodge(width = 0.75)) +
  coord_cartesian(xlim = c(-0.75, 1)) +
  scale_y_discrete(limits = rev) +
  scale_shape_manual(values = c("Univariable" = 21, "Multivariable" = 23),
                     name = NULL) +
  scale_linetype_manual(values = c("Univariable" = "solid", "Multivariable" = "11"),
                        name = NULL) +
  scale_color_brewer(palette = "Set2") +
  scale_fill_brewer(palette = "Set2") +
  guides(color = "none", fill = "none",
         shape = guide_legend(reverse = TRUE,
                              override.aes = list(fill = "black")),
         linetype = guide_legend(reverse = TRUE)) +
  labs(x = "Δ log~10~  MPN/100 mL",
       y = NULL) +
  theme_bw() +
  theme(axis.text.y = element_blank(),
        axis.ticks.y = element_blank(),
        axis.title = element_markdown(),
        strip.text.y = element_blank())

Finally, plot odds ratio estimates for CST-Ec detection, adjusting the axes and labels as appropriate:

Code
p_coef_cst <- coef_slct |>
  filter(outcome == "cst") %>%
  ggplot(data = .) +
  facet_grid(cols = vars(drug_lab),
             rows = vars(group_lab),
             drop = TRUE,
             scales = "free_y",
             space = "free_y") +
  geom_vline(aes(xintercept = 1),
             linetype = "dashed") +
  geom_pointrange(aes(x = est, y = term_lab,
                      xmin = lo, xmax = hi,
                      color = group_lab, fill = group_lab,
                      shape = set, linetype = set),
                  size = 0.6, linewidth = 0.9,
                  position = position_dodge(width = 0.75)) +
  scale_x_log10() +
  scale_y_discrete(limits = rev) +
  scale_shape_manual(values = c("Univariable" = 21, "Multivariable" = 23),
                     name = NULL) +
  scale_linetype_manual(values = c("Univariable" = "solid", "Multivariable" = "11"),
                        name = NULL) +
  scale_color_brewer(palette = "Set2") +
  scale_fill_brewer(palette = "Set2") +
  guides(color = "none", fill = "none",
         shape = guide_legend(reverse = TRUE,
                              override.aes = list(fill = "black")),
         linetype = guide_legend(reverse = TRUE)) +
  labs(x = "odds ratio",
       y = NULL) + 
  theme_bw() +
  theme(axis.text.y = element_blank(),
        axis.ticks.y = element_blank(),
        axis.title = element_markdown())

Arrange the three forest plots together and save the image as a .png with specific dimensions to ensure proper display:

# arrange plots
p_coef <- p_coef_gen + p_coef_esbl + p_coef_cst +
  plot_layout(ncol = 3,
              guides = "collect") &
  theme(legend.key.width = unit(1.5, "lines"),
        legend.key = element_rect(fill = "white", colour = NA),
        legend.position = "bottom",
        legend.direction = "horizontal",
        strip.text = element_markdown(),
        panel.border = element_rect(colour = "black", fill=NA),
        panel.spacing = unit(0, "lines"),,
        legend.margin = margin(c(1, 1, 1, 1)),
        plot.margin = margin(c(2, 1, 1, 1)))


# save PNG image
ggsave(filename = "../fig/coef_multi.png",
       plot = p_coef,
       device = agg_png, dpi = 300,
       width = 9, height = 8, units = "in")

This reproduces Figure 3 from Holcomb et al. (2026). Note that the estimates are for the scaled covariates and thus correspond to the expected change in the outcome for a 1 standard deviation increase in the environmental factor:

Figure 3. Estimates (points) and 95% confidence intervals (lines) of the expected change in concentration of generic E. coli (left column) and extended-spectrum β-lactamase-producing E. coli (ESBL-Ec; center column) or odds of detecting colistin-resistant E. coli (CST-Ec; right column) associated with a standard deviation-increase in the value of environmental explanatory variables in univariable (circles, solid lines) and multivariable (diamonds, dotted lines) spatial generalized linear mixed models (GLMM).

Generate an alternative version of the forest plot with the descaled coefficient estimates that correspond to to the expected change in each outcome for a 1-unit increase in the environmental factor on its original measurement scale:

Code
# generic E. coli
p_descl_gen <- coef_slct |>
  filter(outcome == "gen") %>%
  ggplot(data = .) +
  facet_grid(cols = vars(drug_lab),
             rows = vars(group_lab),
             drop = TRUE,
             scales = "free_y",
             space = "free_y") +
  geom_vline(aes(xintercept = 0),
             linetype = "dashed") +
  geom_pointrange(aes(x = est_descl, y = term_lab,
                      xmin = lo_descl, xmax = hi_descl,
                      color = group_lab, fill = group_lab,
                      shape = set, linetype = set),
                  size = 0.6, linewidth = 0.9,
                  position = position_dodge(width = 0.75)) +
  scale_y_discrete(limits = rev) +
  scale_shape_manual(values = c("Univariable" = 21, "Multivariable" = 23),
                     name = NULL) +
  scale_linetype_manual(values = c("Univariable" = "solid", "Multivariable" = "11"),
                        name = NULL) +
  scale_color_brewer(palette = "Set2") +
  scale_fill_brewer(palette = "Set2") +
  guides(color = "none", fill = "none",
         shape = guide_legend(reverse = TRUE,
                              override.aes = list(fill = "black")),
         linetype = guide_legend(reverse = TRUE)) +
  labs(x = "Δ log~10~  MPN/100 mL",
       y = NULL) +
  theme_bw() +
  theme(axis.text = element_markdown(),
        axis.title = element_markdown(),
        strip.text.y = element_blank())


# ESBL-Ec
p_descl_esbl <- coef_slct |>
  filter(outcome == "esbl") %>%
  ggplot(data = .) +
  facet_grid(cols = vars(drug_lab),
             rows = vars(group_lab),
             drop = TRUE,
             scales = "free_y",
             space = "free_y") +
  geom_vline(aes(xintercept = 0),
             linetype = "dashed") +
  geom_pointrange(aes(x = est_descl, y = term_lab,
                      xmin = lo_descl, xmax = hi_descl,
                      color = group_lab, fill = group_lab,
                      shape = set, linetype = set),
                  size = 0.6, linewidth = 0.9,
                  position = position_dodge(width = 0.75)) +
  scale_y_discrete(limits = rev) +
  scale_shape_manual(values = c("Univariable" = 21, "Multivariable" = 23),
                     name = NULL) +
  scale_linetype_manual(values = c("Univariable" = "solid", "Multivariable" = "11"),
                        name = NULL) +
  scale_color_brewer(palette = "Set2") +
  scale_fill_brewer(palette = "Set2") +
  guides(color = "none", fill = "none",
         shape = guide_legend(reverse = TRUE,
                              override.aes = list(fill = "black")),
         linetype = guide_legend(reverse = TRUE)) +
  labs(x = "Δ log~10~  MPN/100 mL",
       y = NULL) +
  theme_bw() +
  theme(axis.text.y = element_blank(),
        axis.ticks.y = element_blank(),
        axis.title = element_markdown(),
        strip.text.y = element_blank())


# CST-Ec
p_descl_cst <- coef_slct |>
  filter(outcome == "cst") %>%
  ggplot(data = .) +
  facet_grid(cols = vars(drug_lab),
             rows = vars(group_lab),
             drop = TRUE,
             scales = "free_y",
             space = "free_y") +
  geom_vline(aes(xintercept = 1),
             linetype = "dashed") +
  geom_pointrange(aes(x = est_descl, y = term_lab,
                      xmin = lo_descl, xmax = hi_descl,
                      color = group_lab, fill = group_lab,
                      shape = set, linetype = set),
                  size = 0.6, linewidth = 0.9,
                  position = position_dodge(width = 0.75)) +
  scale_x_log10() +
  scale_y_discrete(limits = rev) +
  scale_shape_manual(values = c("Univariable" = 21, "Multivariable" = 23),
                     name = NULL) +
  scale_linetype_manual(values = c("Univariable" = "solid", "Multivariable" = "11"),
                        name = NULL) +
  scale_color_brewer(palette = "Set2") +
  scale_fill_brewer(palette = "Set2") +
  guides(color = "none", fill = "none",
         shape = guide_legend(reverse = TRUE,
                              override.aes = list(fill = "black")),
         linetype = guide_legend(reverse = TRUE)) +
  labs(x = "odds ratio",
       y = NULL) + 
  theme_bw() +
  theme(axis.text.y = element_blank(),
        axis.ticks.y = element_blank(),
        axis.title = element_markdown())

## combine
p_descl <- p_descl_gen + p_descl_esbl + p_descl_cst +
  plot_layout(ncol = 3,
              guides = "collect") &
 theme(legend.key.width = unit(1.5, "lines"),
        legend.key = element_rect(fill = "white", colour = NA),
        legend.position = "bottom",
        legend.direction = "horizontal",
        strip.text = element_markdown(),
        panel.border = element_rect(colour = "black", fill=NA),
        panel.spacing = unit(0, "lines"),,
        legend.margin = margin(c(1, 1, 1, 1)),
        plot.margin = margin(c(2, 1, 1, 1)))



# save PNG image
ggsave(filename = "../fig/coef_descl.png",
       plot = p_descl,
       device = agg_png, dpi = 300,
       width = 9, height = 8, units = "in")

This alternative version of Figure 3 with coefficient estimates on the original scale of the covariate data makes comparisons between different environmental factors difficult, but does serve to highlight the large magnitude of the associations between resistant E. coli and generic E. coli concentrations (in units of log10 MPN/100 mL), as well as recent rainfall (in units of cm):

Figure 3-Alt. Estimates and 95% confidence intervals of the expected change in concentration of generic E. coli ESBL-Ec or odds of detecting CST-Ec associated with a one-unit increase in the value of environmental explanatory variables on the original scale of the environmental factor data.

Variance components

Examine the proportion of the total variance in the E. coli outcomes explained by each of the model structural components in the multivariable models, as well as the estimated spatial range (km) for the flow-connected spatial random effect.

Join the variable label key used for the coefficents to the previously extracted variance component estimates and filter for the variables of interest:

varcomp <- readRDS("../est/ssn_var_multi.rds") |>
  left_join(var_lab, by = c("effect", "term")) |>
  filter(include == "yes") |>
  select(outcome, set,
         effect,
         term_order, term, term_lab, est)

Extract the spatial range estimates:

range <- readRDS("../est/ssn_est_multi.rds") |>
  filter(effect %in% c("tailup", "nugget", "re")) |>
  left_join(var_lab, by = c("effect", "term")) |>
  filter(include == "yes") |>
  mutate(effect = "est") |>
  select(outcome, set,
         effect,
         term_order, term, term_lab, est)

Stack and reformat for presentation:

tab_varcomp <- range |>
  bind_rows(varcomp) |>
  mutate(outcome = fct_relevel(outcome, c("gen", "esbl", "cst")) |>
           fct_recode("Generic Ec concentration" = "gen",
                      "ESBL-Ec concentration" = "esbl",
                      "CST-Ec detection"= "cst"),
         term_lab = fct_relevel(term_lab,
                                c("Residual variance",
                                  "Explanatory variables",
                                  "Sampling event variance",
                                  "Flow-connected variance",
                                  "Flow-connected range"))) |>
  arrange(outcome, term_lab, set) |>
  filter(effect == "varcomp" | term == "range") |>
  mutate(est = ifelse(term == "range", est / 1000, est * 100)) |>
  select(outcome, term_lab, est) |>
  pivot_wider(names_from = term_lab, values_from = est)

This essentially reproduces Table S9 from the Supplementary Material of Holcomb et al. (2026):

Variance explained (%)
Response Nugget Explanatory variables Sampling event random effect Flow-connected spatial random effect Flow-connected spatial range (km)
Generic Ec concentration 42.9 6.1 9.0 42.0 9929
ESBL-Ec concentration 69.8 11.4 17.9 0.8 56
CST-Ec detection 0.2 31.2 68.4 0.2 33




Disclaimer:

The findings and conclusions of this report are those of the authors and do not necessarily represent the official position of the Centers for Disease Control and Prevention.