Fleet Movement Model: A Proportional Redistribution Approach to Bycatch Evaluation

Published

March 6, 2026

1. Setup

This vignette evaluates a simulated trade-off in bycatch effects under a hypothetical closure scenario. All data is simulated to show process only. This is also where we load the background grid and any parameters such as timeframes. Here, we use a baseline grid of the state statistical areas used for commercial catch accounting in Alaska, and are going to simulate data to years 2021:2023.

Code
# Load packages
library(dplyr)
library(terra)
library(ggplot2)
library(lubridate)
library(tidyr)
library(units)
library(gridExtra)
library(tidyterra)
library(ggpubr)
library(patchwork)
library(ggpattern)
library(data.table)
library(sf)
library(kableExtra)
library(igraph)
library(knitr)

options(scipen=999)

# Parameters
spp_title = "Species"
title_name = "Closure"
timeframe <- c(2021:2023)

# Note: Ensure these paths are relative to your .qmd file location
AK <- terra::vect("shapefiles/mv_alaska_1mil_py.shp")
stat_areas <- terra::vect("shapefiles/State_Stat_2004.shp")


# Create timeseries list
stat.list <- list()
for (i in timeframe){
  stat_shape <- stat_areas
  stat_shape$YEAR <- i
  stat.list[[as.character(i)]] <- stat_shape
}
stat.areas.timeseries <- terra::vect(stat.list)

2. Load Catch Data

This is where you load your catch data [‘catch_data’]. For this example, we are simulating weekly catch data for 20 stat_areas closest to a hypothetical closure area. Here, we simulate two species (A and B), where Species A has a bias of higher PSC catch inside the area and Species B has a higher PSC catch outside the area.

Code
#| label: simulation


# --- 5. Simulation Data (Biased for high PSC inside a closure) ---

closure.stat.areas <- c(665730, 665700, 655730, 655700)

closure <- subset(stat_areas, stat_areas$STAT_AREA %in% closure.stat.areas)
closure$closed <- "yes"
closure2 <- terra::aggregate(closure)

non_closure <- stat_areas[!(stat_areas$STAT_AREA %in% closure.stat.areas), ]
non_closure$closed <- "no"

set.seed(123)
num_to_select <- 20

# 1. Calculate distances from non-closure to closure areas
distances_to_closure <- terra::distance(non_closure, closure2)
min_distances <- apply(distances_to_closure, MARGIN = 1, FUN = min)
non_closure$dist_to_closure <- min_distances

# 2. Select 20 areas outside but close to the closure
non_closure_ordered <- non_closure[order(non_closure$dist_to_closure), ]
sim_areas <- non_closure_ordered %>% slice(1:num_to_select)

# 3. Merge back closure areas
sim_areas <- rbind(sim_areas, closure)

# 4. Generate Weekly Data with PSC Bias
simulated_years <- c(2021, 2022, 2023)
sim.list <- list()


# Define the two scenarios
species_configs <- list(
  "Species_A" = list(inside_mu = 20, outside_mu = 10), # High inside
  "Species_B" = list(inside_mu = 10, outside_mu = 15)  # High outside (The "Opposite")
)

all_species_list <- list()

for(spp_name in names(species_configs)){
  
  conf <- species_configs[[spp_name]]
  sim.list <- list()
  
  for(i in simulated_years){
    week_sequence <- seq.Date(from = as.Date(paste0(i, "-08-01")), 
                              by = "week", length.out = 4)
    year_weeks_list <- list()
    
    for(week_date in as.character(week_sequence)) {
      is_inside <- sim_areas$STAT_AREA %in% closure.stat.areas
      num_inside <- sum(is_inside)
      num_outside <- sum(!is_inside)
      
      wk_dat <- data.frame(
        STAT_AREA = sim_areas$STAT_AREA,
        SPECIES = spp_name,  # Track which species this is
        YEAR = i,
        WEEK_END_DATE = week_date,
        CATCH_ACTIVITY_DATE = as.character(as.Date(week_date) - 2),
        PROCESSING_SECTOR = "X"
      )
      
      wk_dat$GF_TOTAL_CATCH_MT <- round(rnorm(nrow(sim_areas), mean = 50, sd = 10), 2)
      
      # Apply the bias based on the species config
      wk_dat$PSC_TOTAL_COUNT <- NA
      # Inside Areas
      wk_dat$PSC_TOTAL_COUNT[is_inside] <- round(rnorm(num_inside, mean = conf$inside_mu, sd = 3), 2)
      # Outside Areas (Using rnorm for both to keep the 'opposite' effect clean)
      wk_dat$PSC_TOTAL_COUNT[!is_inside] <- round(rnorm(num_outside, mean = conf$outside_mu, sd = 5), 2)
      
      year_weeks_list[[week_date]] <- wk_dat
    }
    sim.list[[as.character(i)]] <- do.call(rbind, year_weeks_list)
  }
  all_species_list[[spp_name]] <- do.call(rbind, sim.list)
}

# Combine both species into one master dataframe
catch_data_combined <- do.call(rbind, all_species_list)

# Merge with SpatVector
catch_data <- merge(stat_areas, catch_data_combined, by = "STAT_AREA", all.x = FALSE)

3. Explore Bycatch Data

In this example, the average highest PSC catch of Species A are in areas 665730, 665700, 655730, 655700, while these areas have the lowest PSC catch for Species B.

Code
# This groups by STAT_AREA and calculates the mean of all other numeric columns
catch_data_means <- st_as_sf(catch_data)
catch_data_means <- catch_data_means %>% dplyr::group_by(STAT_AREA, SPECIES) %>%
  summarise(
    mean_psc = mean(PSC_TOTAL_COUNT)
  )
catch_data_means2 <- terra::vect(catch_data_means)


ggplot() +
  tidyterra::geom_spatvector(data = catch_data_means2, aes(fill = mean_psc), 
                             alpha = 0.8, col = NA, na.rm = TRUE) +
  tidyterra::geom_spatvector_text(data = catch_data_means2, aes(label = STAT_AREA),
                                  size = 2.5,       # Adjust size as needed
                                  fontface = "bold", 
                                  check_overlap = TRUE) + # Prevents messy overlapping text
  scale_fill_distiller(palette = "RdYlBu", name = "Mean PSC Catch") +
  theme_bw() + theme(plot.title = element_text(hjust = 0.5), axis.title = element_blank()) +
  facet_wrap(~SPECIES)
Figure 1: Simulated weekly PSC distributions.

4. Closure Assignment

Defining the closure area based on the statistical areas to reduce bycatch of Species A (665730, 665700, 655730, 655700). We will later look at the trade-off with the closure’s effect on Species B.

Code
closure.stat.areas <- c(665730, 665700, 655730, 655700)

closure <- subset(stat_areas, stat_areas$STAT_AREA %in% closure.stat.areas)
closure$closed <- "yes"
closure2 <- terra::aggregate(closure)

non_closure <- stat_areas[!(stat_areas$STAT_AREA %in% closure.stat.areas), ]
non_closure$closed <- "no"

5. Do we have the right area?

The above method is a user-defined selection of stat_areas. We can also use a spatial optimization approach to allow the data to choose the stat_areas with the highest reduction in PSC. In this example, select the four adjacent stat_areas with the highest PSC catch. Because we use simulated data, this approach unsurprisingly gives us the same four areas for Species A as above.

Code
#| label: automated-selection-fixed

# 1. Convert to sf and ensure we have a unique ID for indexing
stat_sf <- st_as_sf(stat_areas)
stat_sf$row_id <- 1:nrow(stat_sf)

# 2. Define Neighbors (Contiguity)
# 'queen = TRUE' includes corners; 'FALSE' is Rook (edges only)
nb <- st_touches(stat_sf) 

# 3. Create a summary table of Species A PSC
# Ensure it matches the stat_sf order
psc_lookup <- catch_data_means %>%
  filter(SPECIES == "Species_A") %>%
  as_tibble()

# 4. The "Best 4" Search Logic
results <- list()

for(i in 1:nrow(stat_sf)) {
  # Get the seed area ID
  seed_area <- stat_sf$STAT_AREA[i]
  
  # Find all neighbors of that seed
  neighbor_indices <- nb[[i]]
  neighbor_areas <- stat_sf$STAT_AREA[neighbor_indices]
  
  # Get PSC values for the seed + all its neighbors
  potential_group <- psc_lookup %>%
    filter(STAT_AREA %in% c(seed_area, neighbor_areas)) %>%
    arrange(desc(mean_psc))
  
  # If we have at least 4 areas in this "cluster", take the top 4
  if(nrow(potential_group) >= 4) {
    top_4 <- potential_group %>% slice(1:4)
    
    results[[i]] <- data.frame(
      seed = seed_area,
      total_psc = sum(top_4$mean_psc),
      area_list = paste(top_4$STAT_AREA, collapse = ",")
    )
  }
}

# Combine and find the winner
optimization_results <- do.call(rbind, results)
winner <- optimization_results[order(-optimization_results$total_psc), ][1, ]

# 5. Extract the 4 Stat Areas
closure.stat.areas <- as.numeric(unlist(strsplit(winner$area_list, ",")))

# --- Prepare Summary Table ---
# We want to see how the Top 4 contribute to the total PSC for BOTH species
final.summary.table <- catch_data_means %>%
  filter(STAT_AREA %in% closure.stat.areas) %>%
  as_tibble() %>%
  arrange(SPECIES, desc(mean_psc)) %>%
  group_by(SPECIES) %>%
  mutate(
    Rank = row_number(),
    cum_psc = cumsum(mean_psc)
  ) %>%
  ungroup() %>%
  select(SPECIES, Rank, STAT_AREA, mean_psc, cum_psc)

# Calculate Cluster Totals for a 'Total' row
totals <- final.summary.table %>%
  group_by(SPECIES) %>%
  summarise(
    STAT_AREA = "CLUSTER TOTAL",
    mean_psc = sum(mean_psc),
    Rank = NA
  )

final.summary.table <- bind_rows(final.summary.table, totals) %>%
  arrange(SPECIES, is.na(Rank), Rank)

#print results

# 1. Extract the winning data
winner_areas <- paste(closure.stat.areas, collapse = ", ")
total_psc_value <- round(winner$total_psc, 2)
seed_area <- winner$seed

# 2. Print the dynamic sentence
# Print the concise output
cat(paste0("**Automated Selection:** ", winner_areas))
**Automated Selection:** 655700, 665700, 655730, 665730

6. Plot Weekly Groundfish and PSC Catch

Weekly groundfish catch - simulated to be randomly distributed across the study area (closure + 20 adjacent stat areas). These are used to calculate PSC rates per stat_area.

Code
ggplot() +
  tidyterra::geom_spatvector(data = catch_data, aes(fill = GF_TOTAL_CATCH_MT), 
                             alpha = 0.8, col = NA, na.rm = TRUE) +
  scale_fill_distiller(palette = "GnBu", name = "Catch (MT)") +
  tidyterra::geom_spatvector(data = closure2, aes(color = "Closure"), 
                             fill = NA, alpha = 0.5, lwd = 0.5) +
  scale_color_manual(values = c("Closure" = "red"), name = NULL) +
  facet_wrap(~WEEK_END_DATE, ncol=4) +
  theme_bw() + theme(plot.title = element_text(hjust = 0.5), axis.title = element_blank())
Figure 2: Simulated weekly groundfish catch distributions.

Weekly PSC catch.

Code
ggplot() +
  # Use subsetting [ ] to filter the SpatVector rows for Species_A
  tidyterra::geom_spatvector(data = catch_data[catch_data$SPECIES == "Species_A", ], 
                             aes(fill = PSC_TOTAL_COUNT), 
                             alpha = 0.8, col = NA, na.rm = TRUE) +
  
  scale_fill_distiller(palette = "RdYlBu", name = "PSC Catch (Number)") +
  
  # Keep the closure overlay (this is independent of species)
  tidyterra::geom_spatvector(data = closure2, aes(color = "Closure"), 
                             fill = NA, alpha = 0.5, lwd = 0.5) +
  
  scale_color_manual(values = c("Closure" = "red"), name = NULL) +
  facet_wrap(~WEEK_END_DATE, ncol=4) +
  theme_bw() + 
  theme(plot.title = element_text(hjust = 0.5), axis.title = element_blank())
Figure 3: Simulated weekly PSC distributions.

7. Assign Closure Dates & Isolate Impacted Hauls

For this exercise, January 1 was chosen for a simple year-long closure.

Code
#| label: impacted-hauls
#| echo: false

#sum status quo PSC by year for later joins
all_psc_status_quo_annual <- as.data.frame(catch_data) %>%
  group_by(YEAR, SPECIES) %>%
  reframe(
    sum_psc_status_quo = sum(PSC_TOTAL_COUNT, na.rm = TRUE)
  )
#assign dates to data ---
#set closure date
closure_dates <- data.frame(
  YEAR = c(2021,2022,2023),
  PROCESSING_SECTOR = "X",
  closure_date = c('2021-01-01','2022-01-01', '2023-01-01')
)

# 1. Join dates to the data
all_psc_closures_dates <- as.data.frame(catch_data) %>%
  left_join(closure_dates, by = c("YEAR", "PROCESSING_SECTOR"))

# 2. Assign impacts based on dates
all_psc_closures_dates <- all_psc_closures_dates %>%
  mutate(
    # Ensure dates are actually Date objects for the comparison to work
    CATCH_ACTIVITY_DATE = as.Date(CATCH_ACTIVITY_DATE),
    closure_date = as.Date(closure_date),
    
    impacted = ifelse(CATCH_ACTIVITY_DATE > closure_date, "yes", "no"),
    
    # Calculate tonnage bins
    tons_gf_after_closure = ifelse(impacted == "yes", GF_TOTAL_CATCH_MT, 0),
    tons_gf_no_closure    = ifelse(impacted == "no" | is.na(impacted), GF_TOTAL_CATCH_MT, 0)
  )

# 3. Isolate impacted hauls (Keeping SPECIES column intact)
all_psc_impacted <- subset(all_psc_closures_dates, impacted == "yes")

8. Calculate Catch and Proportions Inside/Outside Closure

This step summarizes the data used in the main calculations - the proportions of outside groundish catch where displaced catch is applied to, under the assumption that the fishery will meet the same overall total catch, the total catch inside the closure to be applied to the proportions, the new PSC as a result of the new GF catch applied to local PSC rates, and the PSC caught inside the closure that will be subtracted from the net total.

Code
#| label: rates-props
#| echo: false

# Identify Inside/Outside after a closure would have occurred  ---

#identify the area
stat_areas_closed <- closure.stat.areas 

#identify which hauls occurred inside/outside the closed area
all_psc_impacted_cluster <- all_psc_impacted %>%
  mutate(inside = ifelse(STAT_AREA %in% stat_areas_closed, "yes", "no"))


# -- inside catch -- #
#isolate hauls occurring inside closed area
gf_inside_cluster <- subset(all_psc_impacted_cluster, inside == "yes")

#sum gf and psc inside closed area for later calcs
gf_inside_summary <- all_psc_impacted_cluster %>%
  filter(inside == "yes") %>%
  group_by(SPECIES, YEAR, WEEK_END_DATE, PROCESSING_SECTOR) %>%
  summarise(
    tons_gf_inside_wk = sum(GF_TOTAL_CATCH_MT, na.rm = TRUE),
    psc_measure_inside_wk = sum(PSC_TOTAL_COUNT, na.rm = TRUE),
    .groups = "drop"
  )
# Calculate mean PSC rates per Stat Area (Outside only)
sum.stat.timeseries.gf_all <- all_psc_impacted_cluster %>%
  filter(inside == "no") %>%
  group_by(SPECIES, YEAR, WEEK_END_DATE, STAT_AREA, PROCESSING_SECTOR) %>%
  summarise(
    tons_gf_wk = sum(GF_TOTAL_CATCH_MT, na.rm = TRUE),
    mean_psc_wk = mean(PSC_TOTAL_COUNT, na.rm = TRUE),
    mean_gf_tons_wk = mean(GF_TOTAL_CATCH_MT, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  mutate(mean_psc_rate_wk = mean_psc_wk / mean_gf_tons_wk)

# Calculate total catch outside (denominator for proportions)
gf.outside.total <- all_psc_impacted_cluster %>%
  filter(inside == "no") %>%
  group_by(SPECIES, YEAR, WEEK_END_DATE, PROCESSING_SECTOR) %>%
  summarise(
    tons_gf_outside_total = sum(GF_TOTAL_CATCH_MT, na.rm = TRUE),
    .groups = "drop"
  )

# Join and calculate the redistribution proportion
sum.stat.timeseries.gf_all3 <- sum.stat.timeseries.gf_all %>%
  left_join(gf.outside.total, by = c("SPECIES", "YEAR", "WEEK_END_DATE", "PROCESSING_SECTOR")) %>%
  mutate(proportion_outside_wk = tons_gf_wk / tons_gf_outside_total)

9. Plot Weekly Proportions

This plot shows the proportions of groundfish catch outside of the closure. These proportions will be what displaced catch is applied to, under the assumption that the fishery will meet the same overall total catch.

Code
#proportion
sum.stat.timeseries.gf_all_shp <- dplyr::left_join(stat.areas.timeseries, sum.stat.timeseries.gf_all3, by=c("YEAR", "STAT_AREA"))
sum.stat.timeseries.gf_all_shp <- terra::na.omit(sum.stat.timeseries.gf_all_shp, "PROCESSING_SECTOR")

propdat <- sum.stat.timeseries.gf_all_shp
propdat$label <- round(propdat$proportion_outside_wk, 2)

ggplot() +
  tidyterra::geom_spatvector(data=subset(propdat, propdat$proportion_outside_wk>0), aes(fill=proportion_outside_wk), alpha = 0.8, col=NA, na.rm=TRUE) +
  scale_fill_distiller(palette = "GnBu", name = "Proportion of GF Catch") +
  tidyterra::geom_spatvector(data = closure2, aes(color = "Closure"), 
                             fill = NA, alpha = 0.5, lwd = 0.5) +
  scale_color_manual(values = c("Closure" = "red"), name = NULL) +
  facet_wrap(~WEEK_END_DATE, ncol=4) +
  theme_bw() + theme(plot.title = element_text(hjust = 0.5), axis.title = element_blank())
Figure 4: Simulated weekly GF Catch Proportions.

10. Calculate Weekly and Annual Summary Statistics

Code
#| label: summary-stats
#| echo: false

# Join catch from inside area (Matching by Species)
total.join <- dplyr::left_join(
    sum.stat.timeseries.gf_all3,
    gf_inside_summary %>% dplyr::select(SPECIES, YEAR, WEEK_END_DATE, PROCESSING_SECTOR, tons_gf_inside_wk),
    by = c("SPECIES", "YEAR", "WEEK_END_DATE", "PROCESSING_SECTOR")
  ) %>%
  mutate(
    # NAs to 0s for weeks with no inside catch
    tons_gf_inside_wk = tidyr::replace_na(tons_gf_inside_wk, 0),
    # Multiply displaced catch by the outside proportions
    displaced_gf_catch_wk = tons_gf_inside_wk * proportion_outside_wk,
    # Calculate new PSC incurred in the new areas
    new_PSC_wk = displaced_gf_catch_wk * mean_psc_rate_wk
  )


total.join2 <- total.join %>% 
  group_by(SPECIES, YEAR, WEEK_END_DATE, PROCESSING_SECTOR) %>%
  summarise(
    tons_gf_inside_wk = max(tons_gf_inside_wk),
    displaced_gf_catch_wk = sum(displaced_gf_catch_wk),
    new_PSC_wk = sum(new_PSC_wk),
    mean_psc_rate_wk = mean(mean_psc_rate_wk),
    .groups = "drop"
  )

# Join the status quo inside psc numbers (Matching by Species)
total.join3 <- dplyr::left_join(
    total.join2,
    gf_inside_summary %>% dplyr::select(SPECIES, YEAR, WEEK_END_DATE, PROCESSING_SECTOR, psc_measure_inside_wk),
    by = c("SPECIES", "YEAR", "WEEK_END_DATE", "PROCESSING_SECTOR")
  ) %>%
  mutate(
    psc_measure_inside_wk = tidyr::replace_na(psc_measure_inside_wk, 0),
    # The "Magic Number": New outside PSC minus what we saved inside
    net_change_PSC_wk = new_PSC_wk - psc_measure_inside_wk
  )

total.join.plot.annual <- total.join3 %>%
  group_by(SPECIES, YEAR) %>%
  summarise(
    est_change_fleet = sum(net_change_PSC_wk),
    .groups = "drop"
  )

11. Bootstrap

To provide a measure of uncertainty, we are bootstrapping 1,000 alternative scenarios by randomly shuffling the PSC rates where fishing is redistributed. We calculated a 95% Confidence Interval using the middle 95% of all simulated outcomes (between 2.5th and 97.5th percentiles).

Code
# 1. Create a base dataframe that keeps STAT_AREA and SPECIES for resampling
df_bs_base <- total.join %>%
  left_join(gf_inside_summary %>% 
              select(SPECIES, YEAR, WEEK_END_DATE, PROCESSING_SECTOR, psc_measure_inside_wk),
            by = c("SPECIES", "YEAR", "WEEK_END_DATE", "PROCESSING_SECTOR")) %>%
  mutate(
    psc_measure_inside_wk = replace_na(psc_measure_inside_wk, 0),
    displaced_gf_catch_wk = tons_gf_inside_wk * proportion_outside_wk
  )

# 2. Set Bootstrap Parameters
set.seed(345)
n_iterations <- 1000
species_list <- unique(df_bs_base$SPECIES)
all_species_boot_list <- list()

# 3. Run Bootstrap Loop per Species
for(spp in species_list) {
  
  # Filter base data for the specific species
  df_spp <- df_bs_base %>% filter(SPECIES == spp)
  weeks <- unique(df_spp$WEEK_END_DATE)
  
  # Matrix to store results for this species (Rows = Iterations, Cols = Weeks)
  spp_boot_results <- matrix(NA, nrow = n_iterations, ncol = length(weeks))
  colnames(spp_boot_results) <- weeks
  
  for(i in 1:n_iterations) {
    
    # Temporary storage for new PSC calculations in this iteration
    new_psc_iteration <- numeric(nrow(df_spp))
    
    for(w in weeks) {
      wk_indices <- which(df_spp$WEEK_END_DATE == w)
      
      # Sample from the pool of rates available for THIS species in THIS week
      psc_pool <- df_spp$mean_psc_rate_wk[wk_indices]
      
      # Randomly assign a rate from the pool to each STAT_AREA
      sampled_rates <- sample(psc_pool, size = length(wk_indices), replace = TRUE)
      
      # Calculate new PSC: (Fixed Displaced Catch) * (Randomly Sampled Rate)
      new_psc_iteration[wk_indices] <- df_spp$displaced_gf_catch_wk[wk_indices] * sampled_rates
    }
    
    # Summarize net change for this iteration
    iter_summary <- df_spp %>%
      mutate(new_psc_boot = new_psc_iteration) %>%
      group_by(WEEK_END_DATE) %>%
      summarise(
        # New PSC total minus the status quo PSC that was avoided inside
        net_change = sum(new_psc_boot) - max(psc_measure_inside_wk),
        .groups = "drop"
      )
    
    # Store iteration results in the matrix
    for(j in seq_along(weeks)) {
      spp_boot_results[i, j] <- iter_summary$net_change[iter_summary$WEEK_END_DATE == weeks[j]]
    }
  }
  
  # Convert matrix to a long-format dataframe for this species
  all_species_boot_list[[spp]] <- as.data.frame(spp_boot_results) %>%
    mutate(iteration = 1:n(), SPECIES = spp) %>%
    pivot_longer(-c(iteration, SPECIES), names_to = "WEEK_END_DATE", values_to = "net_change")
}

# 4. Combine all species and calculate Annual Confidence Intervals
boot_long <- bind_rows(all_species_boot_list) %>%
  mutate(
    WEEK_END_DATE = as.Date(WEEK_END_DATE),
    YEAR = lubridate::year(WEEK_END_DATE)
  )

# Sum by iteration, species, and year to get annual distribution
boot_annual_dist <- boot_long %>%
  group_by(iteration, SPECIES, YEAR) %>%
  summarise(annual_psc_change = sum(net_change), .groups = "drop")

# Calculate final CIs for the annual change
boot_annual_ci <- boot_annual_dist %>%
  group_by(SPECIES, YEAR) %>%
  summarise(
    mean_change = mean(annual_psc_change),
    lower_change = quantile(annual_psc_change, 0.025),
    upper_change = quantile(annual_psc_change, 0.975),
    .groups = "drop"
  )

12. Evaluate Impacts

In this example, we can confidently say that the closure may reduce bycatch of Species A, but with a trade-off of increased bycatch in Species B.

Code
# --- 12. Final Status Quo Comparison ---

# 1. Clean build of the annual comparison data (Anchored by SPECIES)
total.join.plot.annual_clean <- all_psc_status_quo_annual %>%
  left_join(total.join.plot.annual, by = c("SPECIES", "YEAR")) %>%
  rename(psc_statusquo = sum_psc_status_quo, 
         psc_change = est_change_fleet) %>%
  mutate(psc_change = replace_na(psc_change, 0),
         net_new_PSC = psc_statusquo + psc_change) %>%
  # Join bootstrap CIs by SPECIES and YEAR
  left_join(boot_annual_ci, by = c("SPECIES", "YEAR")) %>%
  
  # 2. THE VERTICAL FIX (Relative to net_new_PSC)
  mutate(
    half_width_lower = mean_change - lower_change,
    half_width_upper = upper_change - mean_change,
    
    ymin_val = net_new_PSC - half_width_lower,
    ymax_val = net_new_PSC + half_width_upper
  ) %>%
  
  # 3. Pivot for ggplot
  pivot_longer(
    cols = c("net_new_PSC", "psc_statusquo"),
    names_to = "psc_type",
    values_to = "psc_amount"
  ) %>%
  mutate(
    ymin = ifelse(psc_type == "net_new_PSC", ymin_val, NA),
    ymax = ifelse(psc_type == "net_new_PSC", ymax_val, NA)
  )

# 4. Final Plot with Faceting by Species
ggplot(total.join.plot.annual_clean, aes(x = factor(YEAR), y = psc_amount, fill = psc_type)) + 
  geom_bar(stat = "identity", 
           position = position_dodge(width = 0.9)) + 
  geom_errorbar(aes(ymin = ymin, ymax = ymax), 
                position = position_dodge(width = 0.9), 
                width = 0.25, 
                na.rm = TRUE) +
  # Add faceting so you see the "Opposite" effects side-by-side
  facet_wrap(~SPECIES, scales = "free_y") + 
  
  scale_y_continuous(expand = expansion(mult = c(0, 0.15))) + 
  theme_bw() + 
  labs(title = "Annual Impact Comparison: Scenario vs. Status Quo",
       subtitle = "Error bars represent 95% Bootstrap Confidence Interval",
       x = "Year", 
       y = "Estimated Annual PSC (# Fish)") +
  scale_fill_brewer(palette = "Paired", 
                    name = "Scenario", 
                    labels = c("Closure Scenario", "Status Quo")) +
  theme(plot.title = element_text(hjust = 0.5),
        plot.subtitle = element_text(hjust = 0.5),
        legend.position = "bottom")
Figure 5

13. Summary Statistics

Based on the years explored, Species A may have had roughly 12-14% reductions in bycatch, at the expense of roughly 4-7% increases in bycatch of Species B.

Code
# --- 13. Final Summary Table ---

# 1. Re-align with the species-aware naming convention
total.join.plot.annual2 <- all_psc_status_quo_annual %>%
  left_join(total.join.plot.annual, by = c("SPECIES", "YEAR")) %>%
  rename(psc_statusquo = sum_psc_status_quo, 
         psc_change = est_change_fleet) %>%
  mutate(psc_change = replace_na(psc_change, 0),
         net_new_PSC = psc_statusquo + psc_change) %>%
  left_join(boot_annual_ci, by = c("SPECIES", "YEAR")) %>%
  # 2. Add the CI bounds calculated for the plot
  mutate(
    # Note: Using the bootstrap bounds directly for the table
    lowerCI_closure = net_new_PSC - (mean_change - lower_change),
    upperCI_closure = net_new_PSC + (upper_change - mean_change)
  )

# 3. Create the final summary table
final.summary.table <- total.join.plot.annual2 %>% 
  dplyr::select(SPECIES, YEAR, psc_statusquo, net_new_PSC, lowerCI_closure, upperCI_closure, psc_change) %>%
  mutate(percent_change = (psc_change / psc_statusquo) * 100) %>%
  arrange(SPECIES, YEAR) # Group species together for the report

# 4. Format for the report
final.summary.table <- as.data.frame(final.summary.table)
colnames(final.summary.table) <- c("Species", "Year", "PSC_Status_Quo", "PSC_Closure", 
                                   "Lower_CI", "Upper_CI", "Net_Change", "Percent_Change")

# 5. Render as a professional HTML table for Quarto

kable(final.summary.table, 
      digits = 2, 
      caption = "Annual PSC Comparison: Status Quo vs. Closure Scenarios by Species",
      format = "html") %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed")) %>%
  collapse_rows(columns = 1, valign = "top") # Visually groups Species A and B rows
Annual PSC Comparison: Status Quo vs. Closure Scenarios by Species
Species Year PSC_Status_Quo PSC_Closure Lower_CI Upper_CI Net_Change Percent_Change
Species_A 2021 1139.28 976.84 961.39 992.34 -162.44 -14.26
2022 1132.31 998.01 977.93 1018.08 -134.30 -11.86
2023 1160.12 1016.46 999.16 1033.81 -143.66 -12.38
Species_B 2021 1384.40 1507.74 1482.82 1533.06 123.34 8.91
2022 1395.65 1449.12 1429.28 1469.17 53.47 3.83
2023 1351.37 1434.26 1414.60 1454.19 82.89 6.13