9  Parameter Sensitivity Analysis

Effects of key model parameters on population dynamics

Published

September 9, 2026

Show code
# ── Baseline parameter set ────────────────────────────────────────────────────
# All sensitivity runs keep every parameter at these values except the focal one.
baseline <- scenarios[["null_cold"]]

exp_start_date <- as.Date(baseline$mindate) + lubridate::years(baseline$nyears_burnin)
burn_start     <- year(as.Date(baseline$mindate))
exp_start_yr   <- year(exp_start_date)
exp_end_yr     <- exp_start_yr + baseline$nyears_experiment

cat("Baseline parameters:\n")
Baseline parameters:
Show code
cat("  K_cold / K_warm           :", baseline$K_cold, "/", baseline$K_warm, "\n")
  K_cold / K_warm           : 200 / 200 
Show code
cat("  egg_surv                  :", baseline$egg_surv, "\n")
  egg_surv                  : 0.1 
Show code
cat("  dominance_beta            :", baseline$dominance_beta, "\n")
  dominance_beta            : 1 
Show code
cat("  pcmax_cold/warm           :", baseline$pcmax_cold, "/", baseline$pcmax_warm, "\n")
  pcmax_cold/warm           : 0.5 / 0.5 
Show code
cat("  A_cold/warm               :", baseline$A_cold, "/", baseline$A_warm, "\n")
  A_cold/warm               : 1 / 0 
Show code
cat("  s_min                     :", baseline$s_min, "\n")
  s_min                     : 0.96 
Show code
cat("  s_w0                      :", baseline$s_w0, "\n")
  s_w0                      : 7 
Show code
cat("  s_k                       :", baseline$s_k, "\n")
  s_k                       : 1 
Show code
cat("  age_structured_competition:", baseline$age_structured_competition, "\n")
  age_structured_competition: TRUE 
Show code
cat("Burn-in:", burn_start, "–", exp_start_yr - 1,
    " | Experiment:", exp_start_yr, "–", exp_end_yr, "\n")
Burn-in: 2001 – 2040  | Experiment: 2041 – 2101 
Show code
# ── Helper: run one sensitivity simulation ─────────────────────────────────────
run_sens <- function(param_name, param_val, base = baseline) {
  p   <- modifyList(base, setNames(list(param_val), param_name))
  # K must be set in both habitat and params
  if (param_name %in% c("K_cold", "K_warm")) {
    p <- modifyList(p, list(K_cold = param_val, K_warm = param_val))
  }
  hdf <- build_habitat(p)
  run_simulation(habitat_df = hdf, params = p, wt_growth = wt.growth)
}

# ── Helper: Oct 1 annual census (N and total biomass) ─────────────────────────
oct1_census <- function(sim, label) {
  sim$ibm_long |>
    filter(!is.na(date), survived == 1,
           month(date) == 10, day(date) == 1) |>
    mutate(year  = year(date),
           phase = if_else(year < exp_start_yr, "burn-in", "experiment")) |>
    group_by(year, phase) |>
    summarise(N        = n_distinct(pid),
              biomass_kg = sum(weight) / 1000,
              .groups  = "drop") |>
    mutate(label = label)
}

# ── Helper: population map (burn-in N[t+1] ~ N[t]) ───────────────────────────
pop_map <- function(census_df) {
  census_df |>
    filter(phase == "burn-in") |>
    group_by(label) |>
    arrange(year) |>
    mutate(N_next = lead(N)) |>
    ungroup() |>
    filter(!is.na(N_next))
}

# ── Helper: stock-recruitment (60-day survivor biomass vs spawner biomass) ─────
# Uses total biomass of fry on their 60th day of life as the recruitment metric.
# Biomass captures both the mortality effect (fewer survivors at high density)
# and the growth effect (survivors are lighter at high density due to food
# competition), giving a more complete picture of density-dependent compensation
# under the sigmoid fncSurviveSize than abundance alone would.
stock_recruit <- function(sim, label) {
  ibm <- sim$ibm_long
  hdf <- sim$habitat_df

  # Total biomass of fry on exactly their 60th day of life
  recruits_60d <- ibm |>
    filter(!is.na(parent_pid), !is.na(birth_dayofsim)) |>
    mutate(birth_yr    = year(hdf$date[birth_dayofsim]),
           day_of_life = dayofsim - birth_dayofsim) |>
    filter(birth_yr < exp_start_yr, day_of_life == 60) |>
    group_by(birth_yr) |>
    summarise(bio_surv60_kg = sum(weight) / 1000, .groups = "drop")

  # Spawner biomass: adults (≥ 100 g) present during spawning window (Apr–Jun)
  spawner_bio <- ibm |>
    filter(!is.na(date), survived == 1) |>
    mutate(year = year(date), doy = yday(date)) |>
    filter(doy >= 90, doy <= 150, weight >= 100, year < exp_start_yr) |>
    group_by(year) |>
    summarise(spawner_kg = sum(weight) / 1000, .groups = "drop")

  left_join(recruits_60d, spawner_bio, by = c("birth_yr" = "year")) |>
    mutate(label = label, spawner_kg = replace_na(spawner_kg, 0))
}

# ── Helper: size-based early thinning rate vs cohort size ─────────────────────
thinning_rate <- function(sim, label) {
  ibm <- sim$ibm_long
  hdf <- sim$habitat_df

  born <- ibm |>
    filter(!is.na(parent_pid)) |>
    mutate(birth_yr = year(hdf$date[birth_dayofsim])) |>
    filter(birth_yr < exp_start_yr) |>
    distinct(pid, birth_yr) |>
    count(birth_yr, name = "n_born")

  surv60 <- ibm |>
    filter(!is.na(parent_pid)) |>
    mutate(birth_yr = year(hdf$date[birth_dayofsim])) |>
    filter(birth_yr < exp_start_yr) |>
    group_by(pid, birth_yr) |>
    summarise(days_alive = n(), .groups = "drop") |>
    filter(days_alive >= 60) |>
    count(birth_yr, name = "n_surv60")

  left_join(born, surv60, by = "birth_yr") |>
    mutate(surv_rate = replace_na(n_surv60 / n_born, 0),
           label = label)
}

# ── Helper: biomass by age class (Oct 1, burn-in) ────────────────────────────
biomass_age <- function(sim, label) {
  sim$ibm_long |>
    filter(!is.na(date), survived == 1,
           month(date) == 10, day(date) == 1) |>
    mutate(year      = year(date),
           age_class = if_else(cohort == year, "Age-0 fry", "Age-1+"),
           phase     = if_else(year < exp_start_yr, "burn-in", "experiment")) |>
    filter(phase == "burn-in") |>
    group_by(year, age_class) |>
    summarise(biomass_kg = sum(weight) / 1000,
              n_fish     = n_distinct(pid), .groups = "drop") |>
    group_by(age_class) |>
    summarise(mean_bio_kg = mean(biomass_kg),
              mean_n      = mean(n_fish),
              mean_wt_g   = mean(biomass_kg * 1000 / pmax(n_fish, 1)),
              .groups = "drop") |>
    mutate(label = label)
}

# ── Helper: density–age-0 growth relationship (Aug 1 weight vs July density) ──
age0_density_growth <- function(sim, label) {
  ibm <- sim$ibm_long

  # Mean daily July abundance (all fish, all ages) per burn-in year
  july_dens <- ibm |>
    filter(!is.na(date), survived == 1, month(date) == 7,
           year(date) < exp_start_yr) |>
    group_by(year = year(date), date) |>
    summarise(n = n(), .groups = "drop") |>
    group_by(year) |>
    summarise(mean_july_n = mean(n), .groups = "drop")

  # Mean age-0 weight at Aug 1 (DOY 213) per burn-in cohort
  aug1_wt <- ibm |>
    filter(!is.na(date), survived == 1, !is.na(ggd),
           yday(date) == 213, year(date) < exp_start_yr) |>
    mutate(year = year(date)) |>
    filter(cohort == year) |>
    group_by(year) |>
    summarise(mean_wt = mean(weight), n = n(), .groups = "drop") |>
    filter(n >= 5)

  left_join(aug1_wt, july_dens, by = "year") |>
    mutate(label = label)
}

# ── Helper: weight-at-age (Oct 1, burn-in mean, ages 0-4) ────────────────────
wt_at_age <- function(sim, label) {
  sim$ibm_long |>
    filter(!is.na(date), survived == 1,
           month(date) == 10, day(date) == 1) |>
    mutate(year     = year(date),
           age_yrs  = floor(age),
           phase    = if_else(year < exp_start_yr, "burn-in", "experiment")) |>
    filter(phase == "burn-in", age_yrs <= 4) |>
    group_by(year, age_yrs) |>
    summarise(mean_wt = mean(weight), n = n_distinct(pid), .groups = "drop") |>
    filter(n >= 2) |>
    group_by(age_yrs) |>
    summarise(mean_wt = mean(mean_wt), .groups = "drop") |>
    mutate(label = label)
}

# ── Helper: age-0 daily weight (doy mean across burn-in cohorts) ──────────────
age0_daily_wt <- function(sim, label) {
  sim$ibm_long |>
    filter(!is.na(date), survived == 1, !is.na(ggd)) |>
    mutate(year = year(date),
           doy  = yday(date)) |>
    filter(cohort == year, year < exp_start_yr) |>
    group_by(doy) |>
    summarise(mean_wt = mean(weight), n = n(), .groups = "drop") |>
    filter(n >= 5) |>
    mutate(label = label)
}

# ── Stability summary ──────────────────────────────────────────────────────────
stability_stats <- function(census_df) {
  census_df |>
    group_by(label, phase) |>
    summarise(mean_N     = round(mean(N)),
              cv         = round(sd(N) / mean(N), 2),
              mean_bio   = round(mean(biomass_kg), 1),
              sim_yrs    = n(),
              .groups    = "drop")
}

# ── Total years simulated (burn-in + experiment combined) ─────────────────────
# Used in the cross-parameter summary to identify premature collapse.
# max possible = nyears_burnin + nyears_experiment = 100.
total_sim_yrs <- function(census_df) {
  census_df |>
    group_by(label) |>
    summarise(total_yrs = n_distinct(year), .groups = "drop")
}

# ── Helper: 4-panel stability bar chart (mean N and CV × phase) ───────────────
# stable_df: output of stability_stats(); cols: length-3 colour vector.
# Layout: rows = metric (mean N, CV), columns = phase (burn-in, experiment).
plot_stability_fig <- function(stable_df, cols) {
  stable_long <- stable_df |>
    select(label, phase, mean_N, cv) |>
    pivot_longer(c(mean_N, cv),
                 names_to  = "metric",
                 values_to = "value") |>
    mutate(
      metric = factor(metric,
                      levels = c("mean_N", "cv"),
                      labels = c("Mean N (Oct 1)", "CV (abundance)")),
      phase  = factor(phase,
                      levels = c("burn-in", "experiment"),
                      labels = c("Burn-in", "Experiment"))
    )

  ggplot(stable_long, aes(x = label, y = value, fill = label)) +
    geom_col(width = 0.65, show.legend = FALSE) +
    facet_grid(metric ~ phase, scales = "free_y") +
    scale_fill_manual(values = cols) +
    labs(x = NULL, y = NULL) +
    theme_sens() +
    theme(axis.text.x  = element_text(size = 7.5, lineheight = 0.85),
          strip.text   = element_text(size = 9),
          panel.spacing = unit(0.8, "lines"))
}

# ── Helper: days post-hatch until cohort-mean weight first exceeds s_w0 ───────
# Reads s_w0 from sim$params so it works for any parameter section:
# — non-s_w0 sections: all sims share the baseline s_w0 (one threshold line)
# — s_w0 section: each sim has its own s_w0 (three threshold lines)
days_to_threshold <- function(sim, label) {
  ibm    <- sim$ibm_long
  hdf    <- sim$habitat_df
  w0_val <- sim$params$s_w0

  cohort_wt <- ibm |>
    filter(!is.na(date), survived == 1, !is.na(ggd),
           year(date) < exp_start_yr) |>
    mutate(doy = yday(date)) |>
    filter(cohort == year(date)) |>
    group_by(cohort, doy) |>
    summarise(mean_wt = mean(weight), n = n(), .groups = "drop") |>
    filter(n >= 5)

  threshold_doy <- cohort_wt |>
    group_by(cohort) |>
    summarise(
      hatch_doy   = min(doy),
      cross_doy   = doy[which(mean_wt >= w0_val)[1]],
      .groups     = "drop"
    ) |>
    mutate(days_to_cross = cross_doy - hatch_doy)

  fry_born <- sim$spawn_log |>
    left_join(hdf |> select(dayofsim, date), by = "dayofsim") |>
    filter(!is.na(date), year(date) < exp_start_yr) |>
    mutate(cohort = year(date)) |>
    group_by(cohort) |>
    summarise(n_born = sum(n_offspring), .groups = "drop")

  left_join(threshold_doy, fry_born, by = "cohort") |>
    filter(!is.na(days_to_cross), !is.na(n_born)) |>
    mutate(label = label, w0 = w0_val)
}

# ── Helper: scatter + OLS plot of days-to-threshold vs fry born ───────────────
plot_danger_duration <- function(cross_df, cols) {
  slope_labs <- cross_df |>
    group_by(label, w0) |>
    summarise(
      slope = coef(lm(days_to_cross ~ n_born))[["n_born"]] * 1000,
      r_sq  = summary(lm(days_to_cross ~ n_born))$r.squared,
      .groups = "drop"
    ) |>
    arrange(w0) |>
    mutate(
      x   = max(cross_df$n_born, na.rm = TRUE) * 0.97,
      y   = seq(
        from       = quantile(cross_df$days_to_cross, 0.38, na.rm = TRUE),
        by         = diff(range(cross_df$days_to_cross, na.rm = TRUE)) * 0.15,
        length.out = n()
      ),
      txt = paste0("+", round(slope, 1), " d / 1000 fry  (R\u00b2 = ", round(r_sq, 2), ")")
    )

  ggplot(cross_df, aes(x = n_born, y = days_to_cross, color = label)) +
    geom_point(alpha = 0.55, size = 2) +
    geom_smooth(method = "lm", se = TRUE, linewidth = 0.9, alpha = 0.12) +
    # geom_text(data = slope_labs,
    #           aes(x = x, y = y, label = txt, color = label),
    #           hjust = 1, size = 2.8, fontface = "italic", show.legend = FALSE) +
    scale_color_manual(values = cols) +
    scale_x_continuous(labels = scales::comma) +
    labs(x     = "Fry born (cohort size)",
         y     = "Days post-hatch to exceed s_w0",
         color = NULL,
         title = "Density extends the low-survival window") +
    theme_sens()
}

# ── Shared colour palette (Okabe-Ito: blue / teal / vermillion) ──────────────
# Colorblind-friendly; all three are clearly distinguishable.
# Mapping: low value = blue, mid/baseline value = teal, high value = vermillion.
sens_cols <- c("#0072B2", "#009E73", "#D55E00")

# ── Shared plot theme ─────────────────────────────────────────────────────────
theme_sens <- function() {
  theme_bw() +
    theme(legend.position  = "top",
          strip.background = element_rect(fill = "grey88"),
          panel.grid.minor = element_blank(),
          plot.title       = element_text(size = 10, face = "bold"),
          plot.subtitle    = element_text(size = 8, color = "grey30"))
}

9.1 K_cold: strength of density dependence

The half-saturation constant K controls the density at which consumption suppression becomes binding. Smaller K creates stronger suppression at lower densities; larger K allows the population to grow more before density effects manifest.

Show code
K_vals   <- c(100, 200, 500)
K_labels <- paste0("K = ", K_vals)
K_cols   <- sens_cols

cat("Running K sensitivity simulations...\n")
Running K sensitivity simulations...
Show code
K_sims <- lapply(seq_along(K_vals), function(i) {
  cat(" K =", K_vals[i], "... ")
  p   <- modifyList(baseline, list(K_cold = K_vals[i], K_warm = K_vals[i]))
  hdf <- build_habitat(p)
  res <- run_simulation(hdf, p, wt_growth = wt.growth)
  cat("ends", format(max(res$ibm_long$date, na.rm = TRUE)), "\n")
  res
})
 K = 100 ... ends 2088-03-29 
 K = 200 ... ends 2098-10-29 
 K = 500 ... ends 2101-04-30 
Show code
names(K_sims) <- K_labels
Show code
K_census   <- bind_rows(Map(oct1_census,   K_sims, K_labels)) |>
  mutate(label = factor(label, levels = K_labels))
K_sr       <- bind_rows(Map(stock_recruit, K_sims, K_labels)) |>
  mutate(label = factor(label, levels = K_labels))
K_thin     <- bind_rows(Map(thinning_rate,        K_sims, K_labels)) |>
  mutate(label = factor(label, levels = K_labels))
K_dg       <- bind_rows(Map(age0_density_growth, K_sims, K_labels)) |>
  mutate(label = factor(label, levels = K_labels))

K_waa      <- bind_rows(Map(wt_at_age,     K_sims, K_labels)) |>
  mutate(label = factor(label, levels = K_labels))
K_age0_dly <- bind_rows(Map(age0_daily_wt, K_sims, K_labels)) |>
  mutate(label = factor(label, levels = K_labels))
K_stable   <- stability_stats(K_census)

9.1.1 Abundance and biomass

Show code
p_N <- ggplot(K_census, aes(x = year, y = N, color = label)) +
  annotate("rect", xmin = burn_start, xmax = exp_start_yr,
           ymin = -Inf, ymax = Inf, fill = "grey92", alpha = 0.7) +
  geom_vline(xintercept = exp_start_yr, linetype = "dashed",
             color = "grey55", linewidth = 0.4) +
  geom_line(linewidth = 0.75) +
  scale_color_manual(values = K_cols) +
  labs(x = NULL, y = "N (Oct 1)", color = NULL, title = "Annual abundance") +
  theme_sens() + theme(legend.position = "none")

p_B <- ggplot(K_census, aes(x = year, y = biomass_kg, color = label)) +
  annotate("rect", xmin = burn_start, xmax = exp_start_yr,
           ymin = -Inf, ymax = Inf, fill = "grey92", alpha = 0.7) +
  geom_vline(xintercept = exp_start_yr, linetype = "dashed",
             color = "grey55", linewidth = 0.4) +
  geom_line(linewidth = 0.75) +
  scale_color_manual(values = K_cols) +
  labs(x = NULL, y = "Total biomass (kg)", color = NULL, title = "Total biomass") +
  theme_sens()

p_N | p_B
Figure 9.1: Oct 1 annual abundance and total biomass for each K value. Grey band = burn-in.

9.1.2 Compensatory dynamics

Show code
K_map <- pop_map(K_census)

p_map <- ggplot(K_map, aes(x = N, y = N_next, color = label)) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey50") +
  geom_point(alpha = 0.4, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.75, linewidth = 0.9) +
  scale_color_manual(values = K_cols) +
  labs(x = "N (year t, Oct 1)", y = "N (year t+1, Oct 1)", color = NULL,
       title = "Population map (burn-in)",
       subtitle = "Curve crossing below dashed line = compensatory") +
  theme_sens() + theme(legend.position = "none")

p_sr <- ggplot(K_sr |> filter(spawner_kg > 0),
               aes(x = spawner_kg, y = bio_surv60_kg, color = label)) +
  geom_point(alpha = 0.5, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.7, linewidth = 0.9) +
  scale_color_manual(values = K_cols) +
  labs(x = "Spawner biomass (kg, Apr–Jun)", y = "Fry biomass at 60 days (kg)",
       color = NULL, title = "Stock-recruitment",
       subtitle = "60-day survivor biomass vs. spawner biomass \n— should show B-H plateau") +
  theme_sens() + theme(legend.position = "right")

p_thin <- ggplot(K_thin, aes(x = n_born, y = surv_rate, color = label)) +
  geom_point(alpha = 0.45, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.7, linewidth = 0.9) +
  scale_y_continuous(labels = scales::percent_format(accuracy = 1)) +
  scale_color_manual(values = K_cols) +
  labs(x = "Fry born", y = "% of fry surviving to day 60",
       color = NULL, title = "Size-based thinning",
       subtitle = "Declining rate = density-dependent compensation") +
  theme_sens() + theme(legend.position = "none")

p_dg <- ggplot(K_dg, aes(x = mean_july_n, y = mean_wt, color = label)) +
  geom_point(alpha = 0.5, size = 1.5) +
  geom_smooth(method = "lm", se = FALSE, linewidth = 0.9) +
  geom_hline(yintercept = 7, linetype = "dotted", color = "grey50") +
  scale_color_manual(values = K_cols) +
  labs(x = "Mean July N (all fish)", y = "Age-0 weight at Aug 1 (g)", color = NULL,
       title = "Density\u2013growth relationship",
       subtitle = "Higher density = slower early growth = more time below w\u2080") +
  theme_sens() + theme(legend.position = "none")

(p_map | p_sr) / (p_thin | p_dg)
Figure 9.2: Top-left: burn-in population map. Top-right: stock-recruitment relationship. Bottom-left: size-based early thinning (% of fry surviving to day 60). Bottom-right: density–growth relationship (age-0 weight at Aug 1 vs July abundance). The bottom row together shows the two pathways of size-based density-dependent compensation: cohort thinning (left) and growth suppression (right).

9.1.3 Age structure

Show code
p_waa <- ggplot(K_waa, aes(x = age_yrs, y = mean_wt, color = label)) +
  geom_line(linewidth = 0.9) +
  geom_point(size = 2.5) +
  scale_color_manual(values = K_cols) +
  scale_x_continuous(breaks = 0:4) +
  labs(x = "Age (years)", y = "Mean weight (g, Oct 1)",
       color = NULL, title = "Weight at age (burn-in mean)",
       subtitle = "Shows how K affects individual growth trajectories") +
  theme_sens()

month_doys   <- c(121, 152, 182, 213, 244, 274, 305, 335)
month_labels <- c("May","Jun","Jul","Aug","Sep","Oct","Nov","Dec")

p_aget <- ggplot(K_age0_dly |> filter(doy >= 118),
       aes(x = doy, y = mean_wt, color = label)) +
  geom_line(linewidth = 0.9) +
  scale_color_manual(values = K_cols) +
  scale_x_continuous(breaks = month_doys, labels = month_labels) +
  labs(x = NULL, y = "Mean weight (g)", color = NULL,
       title = "Age-0 daily weight — burn-in cohort mean",
       subtitle = "Smaller K = more density suppression = slower early growth") +
  theme_sens()

p_waa | p_aget
Figure 9.3: Left: mean weight at age (burn-in). Right: Mean age-0 fry weight by day of year, averaged across burn-in cohorts.

9.1.4 Density-dependent duration below s_w0

Show code
K_cross <- bind_rows(Map(days_to_threshold, K_sims, K_labels)) |>
  mutate(label = factor(label, levels = K_labels))

plot_danger_duration(K_cross, K_cols)
Figure 9.4: Days post-hatch until cohort-mean weight first exceeds s_w0, vs fry born. Each point is one burn-in cohort year; lines are OLS fits with 95% CI.

Stability summary

Show code
plot_stability_fig(K_stable, K_cols)
Figure 9.5: Mean Oct 1 abundance and CV by K value, split by simulation phase.
Show code
K_stable |>
  knitr::kable(col.names = c("K", "Phase", "Mean N",
                              "CV (abundance)", "Mean biomass (kg)", "Years"))
Table 9.1: Burn-in and experiment stability statistics by K value.
K Phase Mean N CV (abundance) Mean biomass (kg) Years
K = 100 burn-in 16 0.35 4.3 40
K = 100 experiment 10 0.52 2.8 47
K = 200 burn-in 31 0.30 7.6 40
K = 200 experiment 18 0.61 4.8 58
K = 500 burn-in 75 0.34 19.1 40
K = 500 experiment 45 0.57 11.8 60

9.2 egg_surv: proportional egg survival

Egg-to-fry survival scales the number of recruits entering the simulation each spring. It represents spawning habitat quality, redd conditions, and egg-incubation success.

Show code
egg_vals   <- c(0.01, 0.1, 0.2)
egg_labels <- paste0("egg_surv = ", egg_vals)
egg_cols   <- sens_cols

cat("Running egg_surv sensitivity simulations...\n")
Running egg_surv sensitivity simulations...
Show code
egg_sims <- lapply(seq_along(egg_vals), function(i) {
  cat(" egg_surv =", egg_vals[i], "... ")
  p   <- modifyList(baseline, list(egg_surv = egg_vals[i]))
  hdf <- build_habitat(p)
  res <- run_simulation(hdf, p, wt_growth = wt.growth)
  cat("ends", format(max(res$ibm_long$date, na.rm = TRUE)), "\n")
  res
})
 egg_surv = 0.01 ... ends 2085-08-23 
 egg_surv = 0.1 ... ends 2098-10-29 
 egg_surv = 0.2 ... ends 2098-03-28 
Show code
names(egg_sims) <- egg_labels
Show code
egg_census   <- bind_rows(Map(oct1_census,   egg_sims, egg_labels)) |>
  mutate(label = factor(label, levels = egg_labels))
egg_sr       <- bind_rows(Map(stock_recruit, egg_sims, egg_labels)) |>
  mutate(label = factor(label, levels = egg_labels))
egg_thin     <- bind_rows(Map(thinning_rate,        egg_sims, egg_labels)) |>
  mutate(label = factor(label, levels = egg_labels))
egg_dg       <- bind_rows(Map(age0_density_growth, egg_sims, egg_labels)) |>
  mutate(label = factor(label, levels = egg_labels))

egg_waa      <- bind_rows(Map(wt_at_age,     egg_sims, egg_labels)) |>
  mutate(label = factor(label, levels = egg_labels))
egg_age0_dly <- bind_rows(Map(age0_daily_wt, egg_sims, egg_labels)) |>
  mutate(label = factor(label, levels = egg_labels))
egg_stable   <- stability_stats(egg_census)

9.2.1 Abundance and biomass

Show code
p_N <- ggplot(egg_census, aes(x = year, y = N, color = label)) +
  annotate("rect", xmin = burn_start, xmax = exp_start_yr,
           ymin = -Inf, ymax = Inf, fill = "grey92", alpha = 0.7) +
  geom_vline(xintercept = exp_start_yr, linetype = "dashed",
             color = "grey55", linewidth = 0.4) +
  geom_line(linewidth = 0.75) +
  scale_color_manual(values = egg_cols) +
  labs(x = NULL, y = "N (Oct 1)", color = NULL, title = "Annual abundance") +
  theme_sens() + theme(legend.position = "none")

p_B <- ggplot(egg_census, aes(x = year, y = biomass_kg, color = label)) +
  annotate("rect", xmin = burn_start, xmax = exp_start_yr,
           ymin = -Inf, ymax = Inf, fill = "grey92", alpha = 0.7) +
  geom_vline(xintercept = exp_start_yr, linetype = "dashed",
             color = "grey55", linewidth = 0.4) +
  geom_line(linewidth = 0.75) +
  scale_color_manual(values = egg_cols) +
  labs(x = NULL, y = "Total biomass (kg)", color = NULL, title = "Total biomass") +
  theme_sens()

p_N | p_B
Figure 9.6: Oct 1 annual abundance and total biomass for each egg_surv value.

9.2.2 Compensatory dynamics

Show code
egg_map <- pop_map(egg_census)

p_map <- ggplot(egg_map, aes(x = N, y = N_next, color = label)) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey50") +
  geom_point(alpha = 0.4, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.75, linewidth = 0.9) +
  scale_color_manual(values = egg_cols) +
  labs(x = "N (year t, Oct 1)", y = "N (year t+1, Oct 1)", color = NULL,
       title = "Population map (burn-in)") +
  theme_sens() + theme(legend.position = "none")

p_sr <- ggplot(egg_sr |> filter(spawner_kg > 0),
               aes(x = spawner_kg, y = bio_surv60_kg, color = label)) +
  geom_point(alpha = 0.5, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.7, linewidth = 0.9) +
  scale_color_manual(values = egg_cols) +
  labs(x = "Spawner biomass (kg)", y = "Fry biomass at 60 days (kg)",
       color = NULL, title = "Stock-recruitment") +
  theme_sens() + theme(legend.position = "right")

p_thin <- ggplot(egg_thin, aes(x = n_born, y = surv_rate, color = label)) +
  geom_point(alpha = 0.45, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.7, linewidth = 0.9) +
  scale_y_continuous(labels = scales::percent_format(accuracy = 1)) +
  scale_color_manual(values = egg_cols) +
  labs(x = "Fry born", y = "% of fry surviving to day 60",
       color = NULL, title = "Size-based thinning") +
  theme_sens() + theme(legend.position = "none")

p_dg <- ggplot(egg_dg, aes(x = mean_july_n, y = mean_wt, color = label)) +
  geom_point(alpha = 0.5, size = 1.5) +
  geom_smooth(method = "lm", se = FALSE, linewidth = 0.9) +
  geom_hline(yintercept = 7, linetype = "dotted", color = "grey50") +
  scale_color_manual(values = egg_cols) +
  labs(x = "Mean July N (all fish)", y = "Age-0 weight at Aug 1 (g)", color = NULL,
       title = "Density\u2013growth relationship",
       subtitle = "Higher density = slower early growth = more time below w\u2080") +
  theme_sens() + theme(legend.position = "none")

(p_map | p_sr) / (p_thin | p_dg)
Figure 9.7: Top-left: burn-in population map. Top-right: stock-recruitment. Bottom-left: size-based early thinning (% of fry surviving to day 60). Bottom-right: density–growth relationship. The bottom row together shows the two pathways of size-based density-dependent compensation: cohort thinning (left) and growth suppression (right).

9.2.3 Age structure

Show code
p_waa <- ggplot(egg_waa, aes(x = age_yrs, y = mean_wt, color = label)) +
  geom_line(linewidth = 0.9) +
  geom_point(size = 2.5) +
  scale_color_manual(values = egg_cols) +
  scale_x_continuous(breaks = 0:4) +
  labs(x = "Age (years)", y = "Mean weight (g, Oct 1)",
       color = NULL, title = "Weight at age (burn-in mean)",
       subtitle = "Higher egg_surv: smaller age-0, larger age-2+ (size-selective thinning)") +
  theme_sens()

p_aget <- ggplot(egg_age0_dly |> filter(doy >= 118),
                 aes(x = doy, y = mean_wt, color = label)) +
  geom_line(linewidth = 0.9) +
  scale_color_manual(values = egg_cols) +
  scale_x_continuous(breaks = month_doys, labels = month_labels) +
  labs(x = NULL, y = "Mean weight (g)", color = NULL,
       title = "Age-0 daily weight — burn-in cohort mean") +
  theme_sens()

p_waa | p_aget
Figure 9.8: Left: mean weight at age (burn-in). Right: age-0 daily weight by day of year, averaged across burn-in cohorts.

9.2.4 Density-dependent duration below s_w0

Show code
egg_cross <- bind_rows(Map(days_to_threshold, egg_sims, egg_labels)) |>
  mutate(label = factor(label, levels = egg_labels))

plot_danger_duration(egg_cross, egg_cols)
Figure 9.9: Days post-hatch until cohort-mean weight first exceeds s_w0, vs fry born. Each point is one burn-in cohort year; lines are OLS fits with 95% CI.

Stability summary

Show code
plot_stability_fig(egg_stable, egg_cols)
Figure 9.10: Mean Oct 1 abundance and CV by egg_surv, split by simulation phase.
Show code
egg_stable |>
  knitr::kable(col.names = c("egg_surv", "Phase", "Mean N",
                              "CV (abundance)", "Mean biomass (kg)", "Years"))
Table 9.2: Stability statistics by egg_surv.
egg_surv Phase Mean N CV (abundance) Mean biomass (kg) Years
egg_surv = 0.01 burn-in 19 0.40 7.5 40
egg_surv = 0.01 experiment 9 0.46 4.0 44
egg_surv = 0.1 burn-in 31 0.30 7.6 40
egg_surv = 0.1 experiment 18 0.61 4.8 58
egg_surv = 0.2 burn-in 30 0.31 7.8 40
egg_surv = 0.2 experiment 18 0.49 4.3 57

9.3 dominance_beta: size-based competitive dominance

dominance_beta controls the strength of size-based competitive dominance within each age class. β = 0 is pure scramble (all fish experience the same effective density regardless of size); β = 1 means a fish twice the size of its competitor imposes a full competitive unit of pressure, while a fish half its size imposes only half a unit.

Show code
beta_vals   <- c(0, 0.5, 1)
beta_labels <- paste0("β = ", beta_vals)
beta_cols   <- sens_cols

cat("Running dominance_beta sensitivity simulations...\n")
Running dominance_beta sensitivity simulations...
Show code
beta_sims <- lapply(seq_along(beta_vals), function(i) {
  cat(" beta =", beta_vals[i], "... ")
  p   <- modifyList(baseline, list(dominance_beta = beta_vals[i]))
  hdf <- build_habitat(p)
  res <- run_simulation(hdf, p, wt_growth = wt.growth)
  cat("ends", format(max(res$ibm_long$date, na.rm = TRUE)), "\n")
  res
})
 beta = 0 ... ends 2098-08-01 
 beta = 0.5 ... ends 2101-04-30 
 beta = 1 ... ends 2098-10-29 
Show code
names(beta_sims) <- beta_labels
Show code
beta_census   <- bind_rows(Map(oct1_census,   beta_sims, beta_labels)) |>
  mutate(label = factor(label, levels = beta_labels))
beta_sr       <- bind_rows(Map(stock_recruit, beta_sims, beta_labels)) |>
  mutate(label = factor(label, levels = beta_labels))
beta_thin     <- bind_rows(Map(thinning_rate,        beta_sims, beta_labels)) |>
  mutate(label = factor(label, levels = beta_labels))
beta_dg       <- bind_rows(Map(age0_density_growth, beta_sims, beta_labels)) |>
  mutate(label = factor(label, levels = beta_labels))
beta_waa      <- bind_rows(Map(wt_at_age,     beta_sims, beta_labels)) |>
  mutate(label = factor(label, levels = beta_labels))
beta_age0_dly <- bind_rows(Map(age0_daily_wt, beta_sims, beta_labels)) |>
  mutate(label = factor(label, levels = beta_labels))
beta_stable   <- stability_stats(beta_census)

9.3.1 Abundance and biomass

Show code
p_N <- ggplot(beta_census, aes(x = year, y = N, color = label)) +
  annotate("rect", xmin = burn_start, xmax = exp_start_yr,
           ymin = -Inf, ymax = Inf, fill = "grey92", alpha = 0.7) +
  geom_vline(xintercept = exp_start_yr, linetype = "dashed",
             color = "grey55", linewidth = 0.4) +
  geom_line(linewidth = 0.75) +
  scale_color_manual(values = beta_cols) +
  labs(x = NULL, y = "N (Oct 1)", color = NULL, title = "Annual abundance") +
  theme_sens() + theme(legend.position = "none")

p_B <- ggplot(beta_census, aes(x = year, y = biomass_kg, color = label)) +
  annotate("rect", xmin = burn_start, xmax = exp_start_yr,
           ymin = -Inf, ymax = Inf, fill = "grey92", alpha = 0.7) +
  geom_vline(xintercept = exp_start_yr, linetype = "dashed",
             color = "grey55", linewidth = 0.4) +
  geom_line(linewidth = 0.75) +
  scale_color_manual(values = beta_cols) +
  labs(x = NULL, y = "Total biomass (kg)", color = NULL, title = "Total biomass") +
  theme_sens()

p_N | p_B
Figure 9.11: Oct 1 annual abundance and total biomass for each dominance_beta value.

9.3.2 Compensatory dynamics

Show code
beta_map <- pop_map(beta_census)

p_map <- ggplot(beta_map, aes(x = N, y = N_next, color = label)) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey50") +
  geom_point(alpha = 0.4, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.75, linewidth = 0.9) +
  scale_color_manual(values = beta_cols) +
  labs(x = "N (year t, Oct 1)", y = "N (year t+1, Oct 1)", color = NULL,
       title = "Population map (burn-in)") +
  theme_sens() + theme(legend.position = "none")

p_sr <- ggplot(beta_sr |> filter(spawner_kg > 0),
               aes(x = spawner_kg, y = bio_surv60_kg, color = label)) +
  geom_point(alpha = 0.5, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.7, linewidth = 0.9) +
  scale_color_manual(values = beta_cols) +
  labs(x = "Spawner biomass (kg)", y = "Fry biomass at 60 days (kg)",
       color = NULL, title = "Stock-recruitment") +
  theme_sens() + theme(legend.position = "right")

p_thin <- ggplot(beta_thin, aes(x = n_born, y = surv_rate, color = label)) +
  geom_point(alpha = 0.45, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.7, linewidth = 0.9) +
  scale_y_continuous(labels = scales::percent_format(accuracy = 1)) +
  scale_color_manual(values = beta_cols) +
  labs(x = "Fry born", y = "% of fry surviving to day 60",
       color = NULL, title = "Size-based thinning") +
  theme_sens() + theme(legend.position = "none")

p_dg <- ggplot(beta_dg, aes(x = mean_july_n, y = mean_wt, color = label)) +
  geom_point(alpha = 0.5, size = 1.5) +
  geom_smooth(method = "lm", se = FALSE, linewidth = 0.9) +
  geom_hline(yintercept = 7, linetype = "dotted", color = "grey50") +
  scale_color_manual(values = beta_cols) +
  labs(x = "Mean July N (all fish)", y = "Age-0 weight at Aug 1 (g)", color = NULL,
       title = "Density\u2013growth relationship",
       subtitle = "Higher density = slower early growth = more time below w\u2080") +
  theme_sens() + theme(legend.position = "none")

(p_map | p_sr) / (p_thin | p_dg)
Figure 9.12: Top-left: burn-in population map. Top-right: stock-recruitment. Bottom-left: size-based early thinning (% of fry surviving to day 60). Bottom-right: density–growth relationship. The bottom row together shows the two pathways of size-based density-dependent compensation: cohort thinning (left) and growth suppression (right).

9.3.3 Age structure

Show code
p_waa <- ggplot(beta_waa, aes(x = age_yrs, y = mean_wt, color = label)) +
  geom_line(linewidth = 0.9) +
  geom_point(size = 2.5) +
  scale_color_manual(values = beta_cols) +
  scale_x_continuous(breaks = 0:4) +
  labs(x = "Age (years)", y = "Mean weight (g, Oct 1)",
       color = NULL, title = "Weight at age (burn-in mean)",
       subtitle = "Higher β: larger dominant adults, more skewed size distribution") +
  theme_sens()

p_aget <- ggplot(beta_age0_dly |> filter(doy >= 118),
                 aes(x = doy, y = mean_wt, color = label)) +
  geom_line(linewidth = 0.9) +
  scale_color_manual(values = beta_cols) +
  scale_x_continuous(breaks = month_doys, labels = month_labels) +
  labs(x = NULL, y = "Mean weight (g)", color = NULL,
       title = "Age-0 daily weight — burn-in cohort mean") +
  theme_sens()

p_waa | p_aget
Figure 9.13: Left: mean weight at age (burn-in). Right: age-0 daily weight by day of year, averaged across burn-in cohorts.

9.3.4 Density-dependent duration below s_w0

Show code
beta_cross <- bind_rows(Map(days_to_threshold, beta_sims, beta_labels)) |>
  mutate(label = factor(label, levels = beta_labels))

plot_danger_duration(beta_cross, beta_cols)
Figure 9.14: Days post-hatch until cohort-mean weight first exceeds s_w0, vs fry born. Each point is one burn-in cohort year; lines are OLS fits with 95% CI.

Stability summary

Show code
plot_stability_fig(beta_stable, beta_cols)
Figure 9.15: Mean Oct 1 abundance and CV by dominance_beta, split by simulation phase.
Show code
beta_stable |>
  knitr::kable(col.names = c("β", "Phase", "Mean N",
                              "CV (abundance)", "Mean biomass (kg)", "Years"))
Table 9.3: Stability statistics by dominance_beta.
β Phase Mean N CV (abundance) Mean biomass (kg) Years
β = 0 burn-in 29 0.34 7.2 40
β = 0 experiment 18 0.62 5.1 57
β = 0.5 burn-in 30 0.32 7.6 40
β = 0.5 experiment 18 0.64 4.8 60
β = 1 burn-in 31 0.30 7.6 40
β = 1 experiment 18 0.61 4.8 58

9.4 s_min: size-survival floor (minprob)

s_min is the daily survival probability approached by the smallest fish (weight → 0). It sets the intensity of early-life mortality for fish that have not yet grown past the sigmoid inflection weight s_w0. Elliott (1993) reports ~6.4% daily mortality during the early critical period for brown trout (s_min ≈ 0.936). Lower s_min strengthens size-based thinning; higher s_min weakens it, reducing the compensatory effect of density-dependent growth suppression.

Scenario s_min Daily mortality floor Worst-case 60-day survival at 0.5g
Low 0.94 6% 2.5%
Baseline 0.96 4% 9%
High 0.98 2% 30%

All other parameters held at baseline.

Show code
s_min_vals   <- c(0.94, 0.96, 0.98)
s_min_labels <- paste0("s_min = ", s_min_vals)
s_min_cols   <- sens_cols

cat("Running s_min sensitivity simulations...\n")
Running s_min sensitivity simulations...
Show code
s_min_sims <- lapply(seq_along(s_min_vals), function(i) {
  cat(" s_min =", s_min_vals[i], "... ")
  p   <- modifyList(baseline, list(s_min = s_min_vals[i]))
  hdf <- build_habitat(p)
  res <- run_simulation(hdf, p, wt_growth = wt.growth)
  cat("ends", format(max(res$ibm_long$date, na.rm = TRUE)), "\n")
  res
})
 s_min = 0.94 ... ends 2093-02-23 
 s_min = 0.96 ... ends 2098-10-29 
 s_min = 0.98 ... ends 2070-04-15 
Show code
names(s_min_sims) <- s_min_labels
Show code
s_min_census   <- bind_rows(Map(oct1_census,   s_min_sims, s_min_labels)) |>
  mutate(label = factor(label, levels = s_min_labels))
s_min_sr       <- bind_rows(Map(stock_recruit, s_min_sims, s_min_labels)) |>
  mutate(label = factor(label, levels = s_min_labels))
s_min_thin     <- bind_rows(Map(thinning_rate, s_min_sims, s_min_labels)) |>
  mutate(label = factor(label, levels = s_min_labels))
s_min_dg       <- bind_rows(Map(age0_density_growth, s_min_sims, s_min_labels)) |>
  mutate(label = factor(label, levels = s_min_labels))
s_min_waa      <- bind_rows(Map(wt_at_age, s_min_sims, s_min_labels)) |>
  mutate(label = factor(label, levels = s_min_labels))
s_min_age0_dly <- bind_rows(Map(age0_daily_wt, s_min_sims, s_min_labels)) |>
  mutate(label = factor(label, levels = s_min_labels))
s_min_stable   <- stability_stats(s_min_census)

9.4.1 Abundance and biomass

Show code
p_N <- ggplot(s_min_census, aes(x = year, y = N, color = label)) +
  annotate("rect", xmin = burn_start, xmax = exp_start_yr,
           ymin = -Inf, ymax = Inf, fill = "grey92", alpha = 0.7) +
  geom_vline(xintercept = exp_start_yr, linetype = "dashed",
             color = "grey55", linewidth = 0.4) +
  geom_line(linewidth = 0.75) +
  scale_color_manual(values = s_min_cols) +
  labs(x = NULL, y = "N (Oct 1)", color = NULL, title = "Annual abundance") +
  theme_sens() + theme(legend.position = "none")

p_B <- ggplot(s_min_census, aes(x = year, y = biomass_kg, color = label)) +
  annotate("rect", xmin = burn_start, xmax = exp_start_yr,
           ymin = -Inf, ymax = Inf, fill = "grey92", alpha = 0.7) +
  geom_vline(xintercept = exp_start_yr, linetype = "dashed",
             color = "grey55", linewidth = 0.4) +
  geom_line(linewidth = 0.75) +
  scale_color_manual(values = s_min_cols) +
  labs(x = NULL, y = "Total biomass (kg)", color = NULL, title = "Total biomass") +
  theme_sens()

p_N | p_B
Figure 9.16: Oct 1 annual abundance and total biomass for each s_min value.

9.4.2 Compensatory dynamics

Show code
s_min_map <- pop_map(s_min_census)

p_map <- ggplot(s_min_map, aes(x = N, y = N_next, color = label)) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey50") +
  geom_point(alpha = 0.4, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.75, linewidth = 0.9) +
  scale_color_manual(values = s_min_cols) +
  labs(x = "N (year t, Oct 1)", y = "N (year t+1, Oct 1)", color = NULL,
       title = "Population map (burn-in)") +
  theme_sens() + theme(legend.position = "none")

p_sr <- ggplot(s_min_sr |> filter(spawner_kg > 0),
               aes(x = spawner_kg, y = bio_surv60_kg, color = label)) +
  geom_point(alpha = 0.5, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.7, linewidth = 0.9) +
  scale_color_manual(values = s_min_cols) +
  labs(x = "Spawner biomass (kg)", y = "Fry biomass at 60 days (kg)",
       color = NULL, title = "Stock-recruitment") +
  theme_sens() + theme(legend.position = "right")

p_thin <- ggplot(s_min_thin, aes(x = n_born, y = surv_rate, color = label)) +
  geom_point(alpha = 0.45, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.7, linewidth = 0.9) +
  scale_y_continuous(labels = scales::percent_format(accuracy = 1)) +
  scale_color_manual(values = s_min_cols) +
  labs(x = "Fry born", y = "% of fry surviving to day 60",
       color = NULL, title = "Size-based thinning") +
  theme_sens() + theme(legend.position = "none")

p_dg <- ggplot(s_min_dg, aes(x = mean_july_n, y = mean_wt, color = label)) +
  geom_point(alpha = 0.5, size = 1.5) +
  geom_smooth(method = "lm", se = FALSE, linewidth = 0.9) +
  geom_hline(yintercept = 7, linetype = "dotted", color = "grey50") +
  scale_color_manual(values = s_min_cols) +
  labs(x = "Mean July N (all fish)", y = "Age-0 weight at Aug 1 (g)", color = NULL,
       title = "Density\u2013growth relationship",
       subtitle = "Higher density = slower early growth = more time below w\u2080") +
  theme_sens() + theme(legend.position = "none")

(p_map | p_sr) / (p_thin | p_dg)
Figure 9.17: Top-left: burn-in population map. Top-right: stock-recruitment. Bottom-left: size-based early thinning (% of fry surviving to day 60). Bottom-right: density–growth relationship. The bottom row together shows the two pathways of size-based density-dependent compensation: cohort thinning (left) and growth suppression (right).

9.4.3 Age structure

Show code
p_waa <- ggplot(s_min_waa, aes(x = age_yrs, y = mean_wt, color = label)) +
  geom_line(linewidth = 0.9) +
  geom_point(size = 2.5) +
  scale_color_manual(values = s_min_cols) +
  scale_x_continuous(breaks = 0:4) +
  labs(x = "Age (years)", y = "Mean weight (g, Oct 1)",
       color = NULL, title = "Weight at age (burn-in mean)",
       subtitle = "Lower s_min = stronger size selection = heavier surviving cohorts") +
  theme_sens()

p_aget <- ggplot(s_min_age0_dly |> filter(doy >= 118),
                 aes(x = doy, y = mean_wt, color = label)) +
  geom_line(linewidth = 0.9) +
  scale_color_manual(values = s_min_cols) +
  scale_x_continuous(breaks = month_doys, labels = month_labels) +
  labs(x = NULL, y = "Mean weight (g)", color = NULL,
       title = "Age-0 daily weight — burn-in cohort mean") +
  theme_sens()

p_waa | p_aget
Figure 9.18: Left: mean weight at age (burn-in). Right: age-0 daily weight by day of year, averaged across burn-in cohorts.

9.4.4 Density-dependent duration below s_w0

Show code
s_min_cross <- bind_rows(Map(days_to_threshold, s_min_sims, s_min_labels)) |>
  mutate(label = factor(label, levels = s_min_labels))

plot_danger_duration(s_min_cross, s_min_cols)
Figure 9.19: Days post-hatch until cohort-mean weight first exceeds s_w0, vs fry born. Each point is one burn-in cohort year; lines are OLS fits with 95% CI.

Stability summary

Show code
plot_stability_fig(s_min_stable, s_min_cols)
Figure 9.20: Mean Oct 1 abundance and CV by s_min value, split by simulation phase.
Show code
s_min_stable |>
  knitr::kable(col.names = c("s_min", "Phase", "Mean N",
                              "CV (abundance)", "Mean biomass (kg)", "Years"))
Table 9.4: Stability statistics by s_min value.
s_min Phase Mean N CV (abundance) Mean biomass (kg) Years
s_min = 0.94 burn-in 16 0.45 6.2 40
s_min = 0.94 experiment 11 0.44 4.3 52
s_min = 0.96 burn-in 31 0.30 7.6 40
s_min = 0.96 experiment 18 0.61 4.8 58
s_min = 0.98 burn-in 42 0.52 4.0 40
s_min = 0.98 experiment 40 0.39 3.8 29

9.5 s_w0: size-survival inflection weight

s_w0 is the fish weight (g) at which daily survival is halfway between s_min and the habitat-specific maximum. It defines how long fish remain in the high-mortality zone after hatching: smaller s_w0 means fish exit the danger zone sooner; larger s_w0 prolongs exposure. Based on visual inspection of age-0 daily weight trajectory plots, ~7g corresponds to approximately 60 days post-hatch in cold habitat — the ecological basis for the default. This section tests sensitivity to that assumption.

Scenario s_w0 Approx. days post-hatch to reach inflection (cold)
Low 4g ~35 days
Baseline 7g ~60 days
High 10g ~90 days

All other parameters held at baseline.

Show code
w0_vals   <- c(4, 7, 10)
w0_labels <- paste0("s_w0 = ", w0_vals, "g")
w0_cols   <- sens_cols

cat("Running s_w0 sensitivity simulations...\n")
Running s_w0 sensitivity simulations...
Show code
w0_sims <- lapply(seq_along(w0_vals), function(i) {
  cat(" s_w0 =", w0_vals[i], "... ")
  p   <- modifyList(baseline, list(s_w0 = w0_vals[i]))
  hdf <- build_habitat(p)
  res <- run_simulation(hdf, p, wt_growth = wt.growth)
  cat("ends", format(max(res$ibm_long$date, na.rm = TRUE)), "\n")
  res
})
 s_w0 = 4 ... ends 2101-04-30 
 s_w0 = 7 ... ends 2098-10-29 
 s_w0 = 10 ... ends 2101-04-30 
Show code
names(w0_sims) <- w0_labels
Show code
w0_census   <- bind_rows(Map(oct1_census,         w0_sims, w0_labels)) |>
  mutate(label = factor(label, levels = w0_labels))
w0_sr       <- bind_rows(Map(stock_recruit,       w0_sims, w0_labels)) |>
  mutate(label = factor(label, levels = w0_labels))
w0_thin     <- bind_rows(Map(thinning_rate,       w0_sims, w0_labels)) |>
  mutate(label = factor(label, levels = w0_labels))
w0_dg       <- bind_rows(Map(age0_density_growth, w0_sims, w0_labels)) |>
  mutate(label = factor(label, levels = w0_labels))
w0_waa      <- bind_rows(Map(wt_at_age,           w0_sims, w0_labels)) |>
  mutate(label = factor(label, levels = w0_labels))
w0_age0_dly <- bind_rows(Map(age0_daily_wt,       w0_sims, w0_labels)) |>
  mutate(label = factor(label, levels = w0_labels))
w0_stable   <- stability_stats(w0_census)

9.5.1 Abundance and biomass

Show code
p_N <- ggplot(w0_census, aes(x = year, y = N, color = label)) +
  annotate("rect", xmin = burn_start, xmax = exp_start_yr,
           ymin = -Inf, ymax = Inf, fill = "grey92", alpha = 0.7) +
  geom_vline(xintercept = exp_start_yr, linetype = "dashed",
             color = "grey55", linewidth = 0.4) +
  geom_line(linewidth = 0.75) +
  scale_color_manual(values = w0_cols) +
  labs(x = NULL, y = "N (Oct 1)", color = NULL, title = "Annual abundance") +
  theme_sens() + theme(legend.position = "none")

p_B <- ggplot(w0_census, aes(x = year, y = biomass_kg, color = label)) +
  annotate("rect", xmin = burn_start, xmax = exp_start_yr,
           ymin = -Inf, ymax = Inf, fill = "grey92", alpha = 0.7) +
  geom_vline(xintercept = exp_start_yr, linetype = "dashed",
             color = "grey55", linewidth = 0.4) +
  geom_line(linewidth = 0.75) +
  scale_color_manual(values = w0_cols) +
  labs(x = NULL, y = "Total biomass (kg)", color = NULL, title = "Total biomass") +
  theme_sens()

p_N | p_B
Figure 9.21: Oct 1 annual abundance and total biomass for each s_w0 value. Grey band = burn-in.

9.5.2 Compensatory dynamics

Show code
w0_map <- pop_map(w0_census)

p_map <- ggplot(w0_map, aes(x = N, y = N_next, color = label)) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey50") +
  geom_point(alpha = 0.4, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.75, linewidth = 0.9) +
  scale_color_manual(values = w0_cols) +
  labs(x = "N (year t, Oct 1)", y = "N (year t+1, Oct 1)", color = NULL,
       title = "Population map (burn-in)",
       subtitle = "Larger s_w0 = longer time below threshold = stronger compensatory attractor") +
  theme_sens() + theme(legend.position = "none")

p_sr <- ggplot(w0_sr |> filter(spawner_kg > 0),
               aes(x = spawner_kg, y = bio_surv60_kg, color = label)) +
  geom_point(alpha = 0.5, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.7, linewidth = 0.9) +
  scale_color_manual(values = w0_cols) +
  labs(x = "Spawner biomass (kg)", y = "Fry biomass at 60 days (kg)",
       color = NULL, title = "Stock-recruitment") +
  theme_sens() + theme(legend.position = "right")

p_thin <- ggplot(w0_thin, aes(x = n_born, y = surv_rate, color = label)) +
  geom_point(alpha = 0.45, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.7, linewidth = 0.9) +
  scale_y_continuous(labels = scales::percent_format(accuracy = 1)) +
  scale_color_manual(values = w0_cols) +
  labs(x = "Fry born", y = "% of fry surviving to day 60",
       color = NULL, title = "Size-based thinning",
       subtitle = "Larger s_w0 = more time in danger zone = steeper survival decline") +
  theme_sens() + theme(legend.position = "none")

p_dg <- ggplot(w0_dg, aes(x = mean_july_n, y = mean_wt, color = label)) +
  geom_point(alpha = 0.5, size = 1.5) +
  geom_smooth(method = "lm", se = FALSE, linewidth = 0.9) +
  geom_hline(yintercept = c(4, 7, 10), linetype = "dotted", color = "grey50") +
  scale_color_manual(values = w0_cols) +
  labs(x = "Mean July N (all fish)", y = "Age-0 weight at Aug 1 (g)", color = NULL,
       title = "Density\u2013growth relationship",
       subtitle = "Dotted lines = s_w0 values; high density pushes cohorts below their own threshold") +
  theme_sens() + theme(legend.position = "none")

(p_map | p_sr) / (p_thin | p_dg)
Figure 9.22: Top-left: burn-in population map. Top-right: stock-recruitment. Bottom-left: size-based early thinning (% of fry surviving to day 60). Bottom-right: density–growth relationship. The bottom row together shows the two pathways of size-based density-dependent compensation: cohort thinning (left) and growth suppression (right). Dotted lines in the density–growth panel mark the three s_w0 values tested.

9.5.3 Age structure

Show code
p_waa <- ggplot(w0_waa, aes(x = age_yrs, y = mean_wt, color = label)) +
  geom_line(linewidth = 0.9) +
  geom_point(size = 2.5) +
  scale_color_manual(values = w0_cols) +
  scale_x_continuous(breaks = 0:4) +
  labs(x = "Age (years)", y = "Mean weight (g, Oct 1)",
       color = NULL, title = "Weight at age (burn-in mean)",
       subtitle = "Larger s_w0 = more early thinning = heavier surviving cohorts") +
  theme_sens()

p_aget <- ggplot(w0_age0_dly |> filter(doy >= 118),
                 aes(x = doy, y = mean_wt, color = label)) +
  geom_line(linewidth = 0.9) +
  scale_color_manual(values = w0_cols) +
  scale_x_continuous(breaks = month_doys, labels = month_labels) +
  labs(x = NULL, y = "Mean weight (g)", color = NULL,
       title = "Age-0 daily weight \u2014 burn-in cohort mean",
       subtitle = "Larger s_w0 = survivor bias persists longer into summer") +
  theme_sens()

p_waa | p_aget
Figure 9.23: Left: mean weight at age (burn-in). Right: age-0 daily weight by day of year, averaged across burn-in cohorts.

9.5.4 Density-dependent duration below s_w0

Larger cohorts experience slower growth through food competition, which delays the date at which mean cohort weight first exceeds s_w0. This plot directly quantifies that effect: the x-axis is cohort size (fry born), the y-axis is days post-hatch until the burn-in cohort-mean weight trajectory first crosses s_w0. A positive slope confirms that density extends the low-survival window, and the slope magnitude indicates how many extra days of size-based mortality each additional 1,000 fry born imposes.

n_born is used as the density metric because it is causally prior — it is the initial condition that drives food competition. An alternative is age-0 abundance in early June (the competitive environment fish actually experience), but in practice this is highly correlated with n_born and harder to extract.

Show code
w0_cross <- bind_rows(Map(days_to_threshold, w0_sims, w0_labels)) |>
  mutate(label = factor(label, levels = w0_labels))

plot_danger_duration(w0_cross, w0_cols)
Figure 9.24: Days post-hatch until the burn-in cohort-mean weight first exceeds s_w0, plotted against cohort size (fry born). Each point is one burn-in cohort year. Lines are OLS fits with 95% CI. The near-identical slopes across s_w0 values indicate that the density-dependent extension of the low-survival window (~20 extra days per 1,000 fry born) is a property of the growth response to density, not the threshold level.

Stability summary

Show code
plot_stability_fig(w0_stable, w0_cols)
Figure 9.25: Mean Oct 1 abundance and CV by s_w0 value, split by simulation phase.
Show code
w0_stable |>
  knitr::kable(col.names = c("s_w0", "Phase", "Mean N",
                              "CV (abundance)", "Mean biomass (kg)", "Years"))
Table 9.5: Stability statistics by s_w0 value.
s_w0 Phase Mean N CV (abundance) Mean biomass (kg) Years
s_w0 = 4g burn-in 43 0.30 6.5 40
s_w0 = 4g experiment 24 0.60 4.0 60
s_w0 = 7g burn-in 31 0.30 7.6 40
s_w0 = 7g experiment 18 0.61 4.8 58
s_w0 = 10g burn-in 24 0.32 7.4 40
s_w0 = 10g experiment 14 0.62 4.5 60

9.6 s_k: sigmoid steepness

s_k controls how sharply daily survival transitions from s_min to the habitat-specific maximum near s_w0. Low s_k produces a gradual transition spread across a wide size range; high s_k creates a near-cliff where fish below s_w0 experience near-s_min mortality and fish above it experience near-maximum survival. The transition width from 10% to 90% of the full s_mins_max range spans w₀ ± ln(9)/s_k grams.

Scenario s_k Transition width (10%–90% of range) Character
Shallow 0.5 ±4.4g around s_w0 Gradual — broad size range under partial penalty
Baseline 1 ±2.2g around s_w0 Moderate transition
Steep 10 ±0.2g around s_w0 Near-cliff — survival almost binary at s_w0

All other parameters held at baseline.

Show code
sk_vals   <- c(0.5, 1, 10)
sk_labels <- paste0("s_k = ", sk_vals)
sk_cols   <- sens_cols

cat("Running s_k sensitivity simulations...\n")
Running s_k sensitivity simulations...
Show code
sk_sims <- lapply(seq_along(sk_vals), function(i) {
  cat(" s_k =", sk_vals[i], "... ")
  p   <- modifyList(baseline, list(s_k = sk_vals[i]))
  hdf <- build_habitat(p)
  res <- run_simulation(hdf, p, wt_growth = wt.growth)
  cat("ends", format(max(res$ibm_long$date, na.rm = TRUE)), "\n")
  res
})
 s_k = 0.5 ... ends 2101-04-30 
 s_k = 1 ... ends 2098-10-29 
 s_k = 10 ... ends 2098-02-17 
Show code
names(sk_sims) <- sk_labels
Show code
sk_census   <- bind_rows(Map(oct1_census,         sk_sims, sk_labels)) |>
  mutate(label = factor(label, levels = sk_labels))
sk_sr       <- bind_rows(Map(stock_recruit,       sk_sims, sk_labels)) |>
  mutate(label = factor(label, levels = sk_labels))
sk_thin     <- bind_rows(Map(thinning_rate,       sk_sims, sk_labels)) |>
  mutate(label = factor(label, levels = sk_labels))
sk_dg       <- bind_rows(Map(age0_density_growth, sk_sims, sk_labels)) |>
  mutate(label = factor(label, levels = sk_labels))
sk_waa      <- bind_rows(Map(wt_at_age,           sk_sims, sk_labels)) |>
  mutate(label = factor(label, levels = sk_labels))
sk_age0_dly <- bind_rows(Map(age0_daily_wt,       sk_sims, sk_labels)) |>
  mutate(label = factor(label, levels = sk_labels))
sk_stable   <- stability_stats(sk_census)

9.6.1 Abundance and biomass

Show code
p_N <- ggplot(sk_census, aes(x = year, y = N, color = label)) +
  annotate("rect", xmin = burn_start, xmax = exp_start_yr,
           ymin = -Inf, ymax = Inf, fill = "grey92", alpha = 0.7) +
  geom_vline(xintercept = exp_start_yr, linetype = "dashed",
             color = "grey55", linewidth = 0.4) +
  geom_line(linewidth = 0.75) +
  scale_color_manual(values = sk_cols) +
  labs(x = NULL, y = "N (Oct 1)", color = NULL, title = "Annual abundance") +
  theme_sens() + theme(legend.position = "none")

p_B <- ggplot(sk_census, aes(x = year, y = biomass_kg, color = label)) +
  annotate("rect", xmin = burn_start, xmax = exp_start_yr,
           ymin = -Inf, ymax = Inf, fill = "grey92", alpha = 0.7) +
  geom_vline(xintercept = exp_start_yr, linetype = "dashed",
             color = "grey55", linewidth = 0.4) +
  geom_line(linewidth = 0.75) +
  scale_color_manual(values = sk_cols) +
  labs(x = NULL, y = "Total biomass (kg)", color = NULL, title = "Total biomass") +
  theme_sens()

p_N | p_B
Figure 9.26: Oct 1 annual abundance and total biomass for each s_k value. Grey band = burn-in.

9.6.2 Compensatory dynamics

Show code
sk_map <- pop_map(sk_census)

p_map <- ggplot(sk_map, aes(x = N, y = N_next, color = label)) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey50") +
  geom_point(alpha = 0.4, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.75, linewidth = 0.9) +
  scale_color_manual(values = sk_cols) +
  labs(x = "N (year t, Oct 1)", y = "N (year t+1, Oct 1)", color = NULL,
       title = "Population map (burn-in)",
       subtitle = "Steeper s_k = more abrupt size threshold = stronger compensation near s_w0") +
  theme_sens() + theme(legend.position = "none")

p_sr <- ggplot(sk_sr |> filter(spawner_kg > 0),
               aes(x = spawner_kg, y = bio_surv60_kg, color = label)) +
  geom_point(alpha = 0.5, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.7, linewidth = 0.9) +
  scale_color_manual(values = sk_cols) +
  labs(x = "Spawner biomass (kg)", y = "Fry biomass at 60 days (kg)",
       color = NULL, title = "Stock-recruitment") +
  theme_sens() + theme(legend.position = "right")

p_thin <- ggplot(sk_thin, aes(x = n_born, y = surv_rate, color = label)) +
  geom_point(alpha = 0.45, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.7, linewidth = 0.9) +
  scale_y_continuous(labels = scales::percent_format(accuracy = 1)) +
  scale_color_manual(values = sk_cols) +
  labs(x = "Fry born", y = "% of fry surviving to day 60",
       color = NULL, title = "Size-based thinning",
       subtitle = "Steeper s_k = sharper thinning threshold around s_w0") +
  theme_sens() + theme(legend.position = "none")

p_dg <- ggplot(sk_dg, aes(x = mean_july_n, y = mean_wt, color = label)) +
  geom_point(alpha = 0.5, size = 1.5) +
  geom_smooth(method = "lm", se = FALSE, linewidth = 0.9) +
  geom_hline(yintercept = 7, linetype = "dotted", color = "grey50") +
  scale_color_manual(values = sk_cols) +
  labs(x = "Mean July N (all fish)", y = "Age-0 weight at Aug 1 (g)", color = NULL,
       title = "Density\u2013growth relationship",
       subtitle = "Higher density = slower early growth = more time below w\u2080") +
  theme_sens() + theme(legend.position = "none")

(p_map | p_sr) / (p_thin | p_dg)
Figure 9.27: Top-left: burn-in population map. Top-right: stock-recruitment. Bottom-left: size-based early thinning (% of fry surviving to day 60). Bottom-right: density–growth relationship. The bottom row together shows the two pathways of size-based density-dependent compensation: cohort thinning (left) and growth suppression (right).

9.6.3 Age structure

Show code
p_waa <- ggplot(sk_waa, aes(x = age_yrs, y = mean_wt, color = label)) +
  geom_line(linewidth = 0.9) +
  geom_point(size = 2.5) +
  scale_color_manual(values = sk_cols) +
  scale_x_continuous(breaks = 0:4) +
  labs(x = "Age (years)", y = "Mean weight (g, Oct 1)",
       color = NULL, title = "Weight at age (burn-in mean)",
       subtitle = "Steeper s_k = more abrupt threshold = stronger survivor size bias") +
  theme_sens()

p_aget <- ggplot(sk_age0_dly |> filter(doy >= 118),
                 aes(x = doy, y = mean_wt, color = label)) +
  geom_line(linewidth = 0.9) +
  scale_color_manual(values = sk_cols) +
  scale_x_continuous(breaks = month_doys, labels = month_labels) +
  labs(x = NULL, y = "Mean weight (g)", color = NULL,
       title = "Age-0 daily weight \u2014 burn-in cohort mean",
       subtitle = "Steeper s_k = sharper inflection in mean weight trajectory near s_w0") +
  theme_sens()

p_waa | p_aget
Figure 9.28: Left: mean weight at age (burn-in). Right: age-0 daily weight by day of year, averaged across burn-in cohorts.

9.6.4 Density-dependent duration below s_w0

Show code
sk_cross <- bind_rows(Map(days_to_threshold, sk_sims, sk_labels)) |>
  mutate(label = factor(label, levels = sk_labels))

plot_danger_duration(sk_cross, sk_cols)
Figure 9.29: Days post-hatch until cohort-mean weight first exceeds s_w0, vs fry born. Each point is one burn-in cohort year; lines are OLS fits with 95% CI.

Stability summary

Show code
plot_stability_fig(sk_stable, sk_cols)
Figure 9.30: Mean Oct 1 abundance and CV by s_k value, split by simulation phase.
Show code
sk_stable |>
  knitr::kable(col.names = c("s_k", "Phase", "Mean N",
                              "CV (abundance)", "Mean biomass (kg)", "Years"))
Table 9.6: Stability statistics by s_k value.
s_k Phase Mean N CV (abundance) Mean biomass (kg) Years
s_k = 0.5 burn-in 32 0.29 7.8 40
s_k = 0.5 experiment 19 0.60 5.2 60
s_k = 1 burn-in 31 0.30 7.6 40
s_k = 1 experiment 18 0.61 4.8 58
s_k = 10 burn-in 30 0.34 7.9 40
s_k = 10 experiment 18 0.58 5.0 57

9.7 pcmax_cold / pcmax_warm: maximum consumption

pcmax sets the ceiling on achievable daily consumption as a proportion of physiological maximum Cmax. The effective ration is pcmax_adjusted = min(fT(temp), pcmax), so at peak summer cold-patch temperatures (T ≈ 15°C, fT ≈ 0.91), pcmax_cold = 0.5 is the binding constraint — fish are food-limited by habitat quality, not by temperature. Raising pcmax relaxes this ceiling and directly increases pcmax_adjusted_dd for all fish.

Show code
pcmax_vals   <- c(0.50, 0.75, 1.00)
pcmax_labels <- paste0("pcmax = ", pcmax_vals)
pcmax_cols   <- c("#0072B2", "#009E73", "#D55E00")

cache_file <- "cache/pcmax_sims.rds"
if (file.exists(cache_file)) {
  cat("Loading pcmax simulations from cache...\n")
  pcmax_sims <- readRDS(cache_file)
} else {
  cat("Running pcmax sensitivity simulations...\n")
  pcmax_sims <- lapply(seq_along(pcmax_vals), function(i) {
    pv <- pcmax_vals[i]
    cat(" pcmax =", pv, "... ")
    p   <- modifyList(baseline, list(pcmax_cold = pv, pcmax_warm = pv))
    hdf <- build_habitat(p)
    res <- run_simulation(hdf, p, wt_growth = wt.growth)
    cat("ends", format(max(res$ibm_long$date, na.rm = TRUE)), "\n")
    res
  })
  names(pcmax_sims) <- pcmax_labels
  dir.create("cache", showWarnings = FALSE)
  saveRDS(pcmax_sims, cache_file)
}
Loading pcmax simulations from cache...
Show code
pcmax_census   <- bind_rows(Map(oct1_census,   pcmax_sims, pcmax_labels)) |>
  mutate(label = factor(label, levels = pcmax_labels))
pcmax_sr       <- bind_rows(Map(stock_recruit, pcmax_sims, pcmax_labels)) |>
  mutate(label = factor(label, levels = pcmax_labels))
pcmax_thin     <- bind_rows(Map(thinning_rate,        pcmax_sims, pcmax_labels)) |>
  mutate(label = factor(label, levels = pcmax_labels))
pcmax_dg       <- bind_rows(Map(age0_density_growth, pcmax_sims, pcmax_labels)) |>
  mutate(label = factor(label, levels = pcmax_labels))
pcmax_waa      <- bind_rows(Map(wt_at_age,     pcmax_sims, pcmax_labels)) |>
  mutate(label = factor(label, levels = pcmax_labels))
pcmax_age0_dly <- bind_rows(Map(age0_daily_wt, pcmax_sims, pcmax_labels)) |>
  mutate(label = factor(label, levels = pcmax_labels))
pcmax_stable   <- stability_stats(pcmax_census)

9.7.1 Abundance and biomass

Show code
p_N <- ggplot(pcmax_census, aes(x = year, y = N, color = label)) +
  annotate("rect", xmin = burn_start, xmax = exp_start_yr,
           ymin = -Inf, ymax = Inf, fill = "grey92", alpha = 0.7) +
  geom_vline(xintercept = exp_start_yr, linetype = "dashed",
             color = "grey55", linewidth = 0.4) +
  geom_line(linewidth = 0.75) +
  scale_color_manual(values = pcmax_cols) +
  labs(x = NULL, y = "N (Oct 1)", color = NULL, title = "Annual abundance") +
  theme_sens() + theme(legend.position = "none")

p_B <- ggplot(pcmax_census, aes(x = year, y = biomass_kg, color = label)) +
  annotate("rect", xmin = burn_start, xmax = exp_start_yr,
           ymin = -Inf, ymax = Inf, fill = "grey92", alpha = 0.7) +
  geom_vline(xintercept = exp_start_yr, linetype = "dashed",
             color = "grey55", linewidth = 0.4) +
  geom_line(linewidth = 0.75) +
  scale_color_manual(values = pcmax_cols) +
  labs(x = NULL, y = "Total biomass (kg)", color = NULL, title = "Total biomass") +
  theme_sens()

p_N | p_B
Figure 9.31: Oct 1 abundance and biomass by pcmax value.

9.7.2 Compensatory dynamics

Show code
pcmax_map <- pop_map(pcmax_census)

p_map <- ggplot(pcmax_map, aes(x = N, y = N_next, color = label)) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey50") +
  geom_point(alpha = 0.4, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.75, linewidth = 0.9) +
  scale_color_manual(values = pcmax_cols) +
  labs(x = "N (year t, Oct 1)", y = "N (year t+1, Oct 1)", color = NULL,
       title = "Population map (burn-in)") +
  theme_sens() + theme(legend.position = "none")

p_sr <- ggplot(pcmax_sr |> filter(spawner_kg > 0),
               aes(x = spawner_kg, y = bio_surv60_kg, color = label)) +
  geom_point(alpha = 0.5, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.7, linewidth = 0.9) +
  scale_color_manual(values = pcmax_cols) +
  labs(x = "Spawner biomass (kg)", y = "Fry biomass at 60 days (kg)", color = NULL,
       title = "Stock-recruitment") +
  theme_sens() + theme(legend.position = "right")

p_thin <- ggplot(pcmax_thin, aes(x = n_born, y = surv_rate, color = label)) +
  geom_point(alpha = 0.45, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.7, linewidth = 0.9) +
  scale_y_continuous(labels = scales::percent_format(accuracy = 1)) +
  scale_color_manual(values = pcmax_cols) +
  labs(x = "Fry born", y = "% of fry surviving to day 60",
       color = NULL, title = "Size-based thinning",
       subtitle = "Rising survival rate at higher pcmax = compensation breaking down") +
  theme_sens() + theme(legend.position = "none")

p_dg <- ggplot(pcmax_dg, aes(x = mean_july_n, y = mean_wt, color = label)) +
  geom_point(alpha = 0.5, size = 1.5) +
  geom_smooth(method = "lm", se = FALSE, linewidth = 0.9) +
  geom_hline(yintercept = 7, linetype = "dotted", color = "grey50") +
  scale_color_manual(values = pcmax_cols) +
  labs(x = "Mean July N (all fish)", y = "Age-0 weight at Aug 1 (g)", color = NULL,
       title = "Density\u2013growth relationship",
       subtitle = "Higher density = slower early growth = more time below w\u2080") +
  theme_sens() + theme(legend.position = "none")

(p_map | p_sr) / (p_thin | p_dg)
Figure 9.32: Top-left: burn-in population map. Top-right: stock-recruitment. Bottom-left: size-based early thinning (% of fry surviving to day 60). Bottom-right: density–growth relationship. The bottom row together shows the two pathways of size-based density-dependent compensation: cohort thinning (left) and growth suppression (right). Weakening compensation at higher pcmax indicates threshold recalibration is needed.

9.7.3 Age structure

Show code
p_waa <- ggplot(pcmax_waa, aes(x = age_yrs, y = mean_wt, color = label)) +
  geom_line(linewidth = 0.9) +
  geom_point(size = 2.5) +
  scale_color_manual(values = pcmax_cols) +
  scale_x_continuous(breaks = 0:4) +
  labs(x = "Age (years)", y = "Mean weight (g, Oct 1)",
       color = NULL, title = "Weight at age (burn-in mean)",
       subtitle = "Higher pcmax = better ration = larger fish at all ages") +
  theme_sens()

p_aget <- ggplot(pcmax_age0_dly |> filter(doy >= 118),
                 aes(x = doy, y = mean_wt, color = label)) +
  geom_line(linewidth = 0.9) +
  scale_color_manual(values = pcmax_cols) +
  scale_x_continuous(breaks = month_doys, labels = month_labels) +
  labs(x = NULL, y = "Mean weight (g)", color = NULL,
       title = "Age-0 daily weight — burn-in cohort mean",
       subtitle = "Higher pcmax lifts the growth ceiling; peak weight and winter decline both affected") +
  theme_sens()

p_waa | p_aget
Figure 9.33: Left: mean weight at age (burn-in). Right: age-0 daily weight by day of year, averaged across burn-in cohorts.

9.7.4 Density-dependent duration below s_w0

Show code
pcmax_cross <- bind_rows(Map(days_to_threshold, pcmax_sims, pcmax_labels)) |>
  mutate(label = factor(label, levels = pcmax_labels))

plot_danger_duration(pcmax_cross, pcmax_cols)
Figure 9.34: Days post-hatch until cohort-mean weight first exceeds s_w0, vs fry born. Each point is one burn-in cohort year; lines are OLS fits with 95% CI.

Stability summary

Show code
plot_stability_fig(pcmax_stable, pcmax_cols)
Figure 9.35: Stability by pcmax.
Show code
pcmax_stable |>
  knitr::kable(col.names = c("pcmax", "Phase", "Mean N",
                              "CV (abundance)", "Mean biomass (kg)", "Years"))
Table 9.7: Stability statistics by pcmax.
pcmax Phase Mean N CV (abundance) Mean biomass (kg) Years
pcmax = 0.5 burn-in 31 0.30 7.6 40
pcmax = 0.5 experiment 18 0.61 4.8 58
pcmax = 0.75 burn-in 85 0.24 61.1 40
pcmax = 0.75 experiment 46 0.57 35.1 60
pcmax = 1 burn-in 116 0.18 114.4 40
pcmax = 1 experiment 61 0.58 60.8 60

9.8 A_cold: habitat area

A_cold sets the area of cold habitat available to fish (in arbitrary area units; eff_density = (n - 1) / A_cold). For the null cold-only scenario, A_warm = 0 throughout, and cold habitat declines linearly from A_cold to 0 over the 60-year experiment period regardless of starting area. Larger starting area means a larger effective carrying capacity during the burn-in and a longer absolute decline before the population is forced into critically small habitat.

WarningInteraction with K

Increasing A_cold is mathematically equivalent to scaling K upward: both reduce eff_density for a given N. K and A_cold are partially substitutable

Show code
area_vals   <- c(1, 2, 4)
area_labels <- paste0("A_cold = ", area_vals)
area_cols   <- c("#0072B2", "#009E73", "#D55E00")

cache_file <- "cache/area_sims.rds"
if (file.exists(cache_file)) {
  cat("Loading habitat area simulations from cache...\n")
  area_sims <- readRDS(cache_file)
} else {
  cat("Running habitat area sensitivity simulations...\n")
  area_sims <- lapply(seq_along(area_vals), function(i) {
    av <- area_vals[i]
    cat(" A_cold =", av, "... ")
    # A_cold_target = 0 retained: cold habitat still declines to 0 over experiment.
    # A_warm = 0 retained: null cold-only scenario.
    p   <- modifyList(baseline, list(A_cold = av))
    hdf <- build_habitat(p)
    res <- run_simulation(hdf, p, wt_growth = wt.growth)
    cat("ends", format(max(res$ibm_long$date, na.rm = TRUE)), "\n")
    res
  })
  names(area_sims) <- area_labels
  dir.create("cache", showWarnings = FALSE)
  saveRDS(area_sims, cache_file)
}
Loading habitat area simulations from cache...
Show code
area_census   <- bind_rows(Map(oct1_census,   area_sims, area_labels)) |>
  mutate(label = factor(label, levels = area_labels))
area_sr       <- bind_rows(Map(stock_recruit, area_sims, area_labels)) |>
  mutate(label = factor(label, levels = area_labels))
area_thin     <- bind_rows(Map(thinning_rate,        area_sims, area_labels)) |>
  mutate(label = factor(label, levels = area_labels))
area_dg       <- bind_rows(Map(age0_density_growth, area_sims, area_labels)) |>
  mutate(label = factor(label, levels = area_labels))
area_waa      <- bind_rows(Map(wt_at_age,     area_sims, area_labels)) |>
  mutate(label = factor(label, levels = area_labels))
area_age0_dly <- bind_rows(Map(age0_daily_wt, area_sims, area_labels)) |>
  mutate(label = factor(label, levels = area_labels))
area_stable   <- stability_stats(area_census)

9.8.1 Abundance and biomass

Show code
p_N <- ggplot(area_census, aes(x = year, y = N, color = label)) +
  annotate("rect", xmin = burn_start, xmax = exp_start_yr,
           ymin = -Inf, ymax = Inf, fill = "grey92", alpha = 0.7) +
  geom_vline(xintercept = exp_start_yr, linetype = "dashed",
             color = "grey55", linewidth = 0.4) +
  geom_line(linewidth = 0.75) +
  scale_color_manual(values = area_cols) +
  labs(x = NULL, y = "N (Oct 1)", color = NULL, title = "Annual abundance") +
  theme_sens() + theme(legend.position = "none")

p_B <- ggplot(area_census, aes(x = year, y = biomass_kg, color = label)) +
  annotate("rect", xmin = burn_start, xmax = exp_start_yr,
           ymin = -Inf, ymax = Inf, fill = "grey92", alpha = 0.7) +
  geom_vline(xintercept = exp_start_yr, linetype = "dashed",
             color = "grey55", linewidth = 0.4) +
  geom_line(linewidth = 0.75) +
  scale_color_manual(values = area_cols) +
  labs(x = NULL, y = "Total biomass (kg)", color = NULL, title = "Total biomass") +
  theme_sens()

p_N | p_B
Figure 9.36: Oct 1 abundance and biomass by cold habitat area. All scenarios decline to extinction as A_cold → 0; larger starting area supports a larger population and may delay collapse.

9.8.2 Compensatory dynamics

Show code
area_map <- pop_map(area_census)

p_map <- ggplot(area_map, aes(x = N, y = N_next, color = label)) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey50") +
  geom_point(alpha = 0.4, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.75, linewidth = 0.9) +
  scale_color_manual(values = area_cols) +
  labs(x = "N (year t, Oct 1)", y = "N (year t+1, Oct 1)", color = NULL,
       title = "Population map (burn-in)") +
  theme_sens() + theme(legend.position = "none")

p_sr <- ggplot(area_sr |> filter(spawner_kg > 0),
               aes(x = spawner_kg, y = bio_surv60_kg, color = label)) +
  geom_point(alpha = 0.5, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.7, linewidth = 0.9) +
  scale_color_manual(values = area_cols) +
  labs(x = "Spawner biomass (kg)", y = "Fry biomass at 60 days (kg)", color = NULL,
       title = "Stock-recruitment") +
  theme_sens() + theme(legend.position = "right")

p_thin <- ggplot(area_thin, aes(x = n_born, y = surv_rate, color = label)) +
  geom_point(alpha = 0.45, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.7, linewidth = 0.9) +
  scale_y_continuous(labels = scales::percent_format(accuracy = 1)) +
  scale_color_manual(values = area_cols) +
  labs(x = "Fry born", y = "% of fry surviving to day 60",
       color = NULL, title = "Size-based thinning",
       subtitle = "Larger area = lower eff_density = weaker thinning (same interaction as high K)") +
  theme_sens() + theme(legend.position = "none")

p_dg <- ggplot(area_dg, aes(x = mean_july_n, y = mean_wt, color = label)) +
  geom_point(alpha = 0.5, size = 1.5) +
  geom_smooth(method = "lm", se = FALSE, linewidth = 0.9) +
  geom_hline(yintercept = 7, linetype = "dotted", color = "grey50") +
  scale_color_manual(values = area_cols) +
  labs(x = "Mean July N (all fish)", y = "Age-0 weight at Aug 1 (g)", color = NULL,
       title = "Density\u2013growth relationship",
       subtitle = "Higher density = slower early growth = more time below w\u2080") +
  theme_sens() + theme(legend.position = "none")

(p_map | p_sr) / (p_thin | p_dg)
Figure 9.37: Top-left: burn-in population map. Top-right: stock-recruitment. Bottom-left: size-based early thinning (% of fry surviving to day 60). Bottom-right: density–growth relationship. The bottom row together shows the two pathways of size-based density-dependent compensation: cohort thinning (left) and growth suppression (right).

9.8.3 Age structure

Show code
p_waa <- ggplot(area_waa, aes(x = age_yrs, y = mean_wt, color = label)) +
  geom_line(linewidth = 0.9) +
  geom_point(size = 2.5) +
  scale_color_manual(values = area_cols) +
  scale_x_continuous(breaks = 0:4) +
  labs(x = "Age (years)", y = "Mean weight (g, Oct 1)",
       color = NULL, title = "Weight at age (burn-in mean)",
       subtitle = "Larger area = lower competition = better individual growth") +
  theme_sens()

p_aget <- ggplot(area_age0_dly |> filter(doy >= 118),
                 aes(x = doy, y = mean_wt, color = label)) +
  geom_line(linewidth = 0.9) +
  scale_color_manual(values = area_cols) +
  scale_x_continuous(breaks = month_doys, labels = month_labels) +
  labs(x = NULL, y = "Mean weight (g)", color = NULL,
       title = "Age-0 daily weight — burn-in cohort mean") +
  theme_sens()

p_waa | p_aget
Figure 9.38: Left: mean weight at age (burn-in). Right: age-0 daily weight by day of year, averaged across burn-in cohorts.

9.8.4 Density-dependent duration below s_w0

Show code
area_cross <- bind_rows(Map(days_to_threshold, area_sims, area_labels)) |>
  mutate(label = factor(label, levels = area_labels))

plot_danger_duration(area_cross, area_cols)
Figure 9.39: Days post-hatch until cohort-mean weight first exceeds s_w0, vs fry born. Each point is one burn-in cohort year; lines are OLS fits with 95% CI.

Stability summary

Show code
plot_stability_fig(area_stable, area_cols)
Figure 9.40: Stability by cold habitat area.
Show code
area_stable |>
  knitr::kable(col.names = c("A_cold", "Phase", "Mean N",
                              "CV (abundance)", "Mean biomass (kg)", "Years"))
Table 9.8: Stability statistics by cold habitat area. Experiment years are comparable across scenarios only if populations survive long enough; earlier collapse at smaller area is expected.
A_cold Phase Mean N CV (abundance) Mean biomass (kg) Years
A_cold = 1 burn-in 31 0.30 7.6 40
A_cold = 1 experiment 18 0.61 4.8 58
A_cold = 2 burn-in 60 0.32 15.1 40
A_cold = 2 experiment 35 0.57 9.1 60
A_cold = 4 burn-in 119 0.37 31.1 40
A_cold = 4 experiment 70 0.58 19.0 60

9.9 Size and age structured competition (age_structured_competition × dominance_beta)

age_structured_competition controls whether fry (age-0) and older fish (age-1+) compete as separate pools. When TRUE, fry compete only with other fry and adults only with other adults, reflecting ontogenetic habitat segregation (channel margins vs. pools and runs). When FALSE, all fish in a patch compete in one pool.

The effect of this toggle depends critically on dominance_beta:

  • At β = 1 adults are already near-immune to fry competition — a fry imposes a competitive cost of (m_fry / m_adult)^1 ≈ 0 on a large adult regardless of age-class structuring. The toggle has little practical effect.
  • At β = 0.5 a fry imposes (m_fry / m_adult)^0.5 ≈ 0.05 — non-trivial — so adults feel meaningful fry competition when structuring is off.

This section uses a 2×2 design (age_structured × dominance_beta) to make the interaction explicit. sim_crit (TRUE, β = 1) and sim_b05 (TRUE, β = 0.5) are aliased from beta_sims; two new simulations complete the grid.

Show code
# Pull the structured runs directly from beta_sims (baseline has
# age_structured_competition = TRUE, so these are identical to sim_crit / sim_b05)
sim_crit <- beta_sims[["β = 1"]]
sim_b05  <- beta_sims[["β = 0.5"]]

cache_file <- "cache/agestr_sims.rds"
if (file.exists(cache_file)) {
  cat("Loading age-structure simulations from cache...\n")
  agestr_cache     <- readRDS(cache_file)
  sim_agestr_F_b05 <- agestr_cache$sim_agestr_F_b05
  sim_agestr_F_b1  <- agestr_cache$sim_agestr_F_b1
} else {
  cat("Running age-class competition 2x2 simulations...\n")
  sim_agestr_F_b05 <- {
    cat(" age_structured=FALSE, beta=0.5 ... ")
    p   <- modifyList(baseline, list(age_structured_competition = FALSE,
                                     dominance_beta             = 0.5))
    res <- run_simulation(build_habitat(p), p, wt_growth = wt.growth)
    cat("ends", format(max(res$ibm_long$date, na.rm = TRUE)), "\n")
    res
  }
  sim_agestr_F_b1 <- {
    cat(" age_structured=FALSE, beta=1.0 ... ")
    p   <- modifyList(baseline, list(age_structured_competition = FALSE,
                                     dominance_beta             = 1))
    res <- run_simulation(build_habitat(p), p, wt_growth = wt.growth)
    cat("ends", format(max(res$ibm_long$date, na.rm = TRUE)), "\n")
    res
  }
  dir.create("cache", showWarnings = FALSE)
  saveRDS(list(sim_agestr_F_b05 = sim_agestr_F_b05,
               sim_agestr_F_b1  = sim_agestr_F_b1),
          cache_file)
}
Loading age-structure simulations from cache...
Show code
agestr_sims <- list(
  "Structured, β = 1\n(baseline)" = sim_crit,
  "Structured, β = 0.5"           = sim_b05,
  "Unstructured, β = 1"           = sim_agestr_F_b1,
  "Unstructured, β = 0.5"         = sim_agestr_F_b05
)
agestr_labels <- names(agestr_sims)

# Colour = beta; linetype = competition structure
agestr_cols <- c("β = 1" = "#D55E00", "β = 0.5" = "#0072B2")
agestr_lty  <- c("Structured" = "solid", "Unstructured" = "dashed")

mk_agestr <- function(df)
  df |>
    mutate(beta_lab = if_else(grepl("0\\.5", label), "β = 0.5", "β = 1"),
           str_lab  = if_else(grepl("Unstr", label), "Unstructured", "Structured"))

agestr_census <- bind_rows(Map(oct1_census,   agestr_sims, agestr_labels)) |>
  mutate(label = factor(label, levels = agestr_labels)) |> mk_agestr()
agestr_thin   <- bind_rows(Map(thinning_rate, agestr_sims, agestr_labels)) |>
  mutate(label = factor(label, levels = agestr_labels)) |> mk_agestr()
agestr_waa    <- bind_rows(Map(wt_at_age,     agestr_sims, agestr_labels)) |>
  mutate(label = factor(label, levels = agestr_labels)) |> mk_agestr()
agestr_stable <- stability_stats(agestr_census)

9.9.1 Abundance over time and population map

Show code
p_ts <- ggplot(agestr_census,
               aes(x = year, y = N, color = beta_lab, linetype = str_lab)) +
  annotate("rect", xmin = burn_start, xmax = exp_start_yr,
           ymin = -Inf, ymax = Inf, fill = "grey92", alpha = 0.7) +
  geom_vline(xintercept = exp_start_yr, linetype = "dashed",
             color = "grey55", linewidth = 0.35) +
  geom_line(linewidth = 0.8) +
  scale_color_manual(values = agestr_cols) +
  scale_linetype_manual(values = agestr_lty) +
  labs(x = NULL, y = "N (Oct 1)", color = "dominance_beta",
       linetype = "Competition structure",
       title = "A: Annual abundance",
       subtitle = "Divergence between solid and dashed of same colour shows effect of age-class structuring") +
  theme_sens()

agestr_map <- agestr_census |>
  filter(phase == "burn-in") |>
  group_by(label, beta_lab, str_lab) |> arrange(year) |>
  mutate(N_next = lead(N)) |> ungroup() |> filter(!is.na(N_next))

p_map <- ggplot(agestr_map,
                aes(x = N, y = N_next, color = beta_lab, linetype = str_lab)) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey50") +
  geom_point(alpha = 0.4, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.75, linewidth = 0.9) +
  scale_color_manual(values = agestr_cols) +
  scale_linetype_manual(values = agestr_lty) +
  labs(x = "N (year t, Oct 1)", y = "N (year t+1, Oct 1)",
       color = "dominance_beta", linetype = "Competition structure",
       title = "B: Population map (burn-in only)") +
  theme_sens()

p_ts / p_map
Figure 9.41: Oct 1 abundance and burn-in population map for the 2×2 age-structured × beta design. Colour = beta; linetype = competition structure.

9.9.2 Size-based thinning and weight at age

Show code
p_thin <- ggplot(agestr_thin,
                 aes(x = n_born, y = surv_rate,
                     color = beta_lab, linetype = str_lab)) +
  geom_point(alpha = 0.4, size = 1.5) +
  geom_smooth(method = "loess", se = FALSE, span = 0.7, linewidth = 0.9) +
  scale_y_continuous(labels = scales::percent_format(accuracy = 1)) +
  scale_color_manual(values = agestr_cols) +
  scale_linetype_manual(values = agestr_lty) +
  labs(x = "Fry born", y = "% surviving 60 days",
       color = "dominance_beta", linetype = "Competition structure",
       title = "Size-based thinning") +
  theme_sens() + theme(legend.position = "none")

p_waa <- ggplot(agestr_waa,
                aes(x = age_yrs, y = mean_wt,
                    color = beta_lab, linetype = str_lab)) +
  geom_line(linewidth = 0.9) + geom_point(size = 2.5) +
  scale_color_manual(values = agestr_cols) +
  scale_linetype_manual(values = agestr_lty) +
  scale_x_continuous(breaks = 0:4) +
  labs(x = "Age (years)", y = "Mean weight (g, Oct 1)",
       color = "dominance_beta", linetype = "Competition structure",
       title = "Weight at age (burn-in mean)") +
  theme_sens()

p_thin | p_waa
Figure 9.42: Thinning rate and weight-at-age for the 2×2 design.

Stability summary

Show code
agestr_stable |>
  knitr::kable(col.names = c("Scenario", "Phase", "Mean N",
                              "CV (abundance)", "Mean biomass (kg)", "Years"))
Table 9.9: Stability statistics for the 2×2 age-class structured competition design.
Scenario Phase Mean N CV (abundance) Mean biomass (kg) Years
Structured, β = 1
(baseline) burn-in 31 0.30 7.6 40
Structured, β = 1
(baseline) experiment 18 0.61 4.8 58
Structured, β = 0.5 burn-in 30 0.32 7.6 40
Structured, β = 0.5 experiment 18 0.64 4.8 60
Unstructured, β = 1 burn-in 22 0.33 5.6 40
Unstructured, β = 1 experiment 17 0.35 4.6 41
Unstructured, β = 0.5 burn-in 23 0.33 5.8 40
Unstructured, β = 0.5 experiment 13 0.62 3.1 60

9.10 Cross-parameter summary

Show code
param_levels <- c("K_cold", "egg_surv", "dominance_beta",
                  "pcmax_cold/warm", "A_cold",
                  "s_min", "s_w0", "s_k")
param_labels <- c("K", "egg_surv", "dom.\nbeta",
                  "pcmax\ncold/warm", "A_cold",
                  "s_min", "s_w0", "s_k")

# Mean length in days of the critical period (time to escape the period of reduced juvenile survival) per label
critical_time_all <- bind_rows(
  K_cross     |> group_by(label) |> summarise(critical_time = mean(days_to_cross, na.rm = TRUE), .groups = "drop") |> mutate(parameter = "K_cold"),
  egg_cross   |> group_by(label) |> summarise(critical_time = mean(days_to_cross, na.rm = TRUE), .groups = "drop") |> mutate(parameter = "egg_surv"),
  beta_cross  |> group_by(label) |> summarise(critical_time = mean(days_to_cross, na.rm = TRUE), .groups = "drop") |> mutate(parameter = "dominance_beta"),
  pcmax_cross |> group_by(label) |> summarise(critical_time = mean(days_to_cross, na.rm = TRUE), .groups = "drop") |> mutate(parameter = "pcmax_cold/warm"),
  area_cross  |> group_by(label) |> summarise(critical_time = mean(days_to_cross, na.rm = TRUE), .groups = "drop") |> mutate(parameter = "A_cold"),
  s_min_cross |> group_by(label) |> summarise(critical_time = mean(days_to_cross, na.rm = TRUE), .groups = "drop") |> mutate(parameter = "s_min"),
  w0_cross    |> group_by(label) |> summarise(critical_time = mean(days_to_cross, na.rm = TRUE), .groups = "drop") |> mutate(parameter = "s_w0"),
  sk_cross    |> group_by(label) |> summarise(critical_time = mean(days_to_cross, na.rm = TRUE), .groups = "drop") |> mutate(parameter = "s_k")
)

# Peak age-0 daily weight (max of burn-in cohort-mean trajectory) per label
peak_age0_all <- bind_rows(
  K_age0_dly     |> group_by(label) |> summarise(peak_age0_wt = max(mean_wt, na.rm = TRUE), .groups = "drop") |> mutate(parameter = "K_cold"),
  egg_age0_dly   |> group_by(label) |> summarise(peak_age0_wt = max(mean_wt, na.rm = TRUE), .groups = "drop") |> mutate(parameter = "egg_surv"),
  beta_age0_dly  |> group_by(label) |> summarise(peak_age0_wt = max(mean_wt, na.rm = TRUE), .groups = "drop") |> mutate(parameter = "dominance_beta"),
  pcmax_age0_dly |> group_by(label) |> summarise(peak_age0_wt = max(mean_wt, na.rm = TRUE), .groups = "drop") |> mutate(parameter = "pcmax_cold/warm"),
  area_age0_dly  |> group_by(label) |> summarise(peak_age0_wt = max(mean_wt, na.rm = TRUE), .groups = "drop") |> mutate(parameter = "A_cold"),
  s_min_age0_dly |> group_by(label) |> summarise(peak_age0_wt = max(mean_wt, na.rm = TRUE), .groups = "drop") |> mutate(parameter = "s_min"),
  w0_age0_dly    |> group_by(label) |> summarise(peak_age0_wt = max(mean_wt, na.rm = TRUE), .groups = "drop") |> mutate(parameter = "s_w0"),
  sk_age0_dly    |> group_by(label) |> summarise(peak_age0_wt = max(mean_wt, na.rm = TRUE), .groups = "drop") |> mutate(parameter = "s_k")
)

# Burn-in stability (mean N, CV) joined with total years across both phases
total_yrs_all <- bind_rows(
  total_sim_yrs(K_census)      |> mutate(parameter = "K_cold"),
  total_sim_yrs(egg_census)    |> mutate(parameter = "egg_surv"),
  total_sim_yrs(beta_census)   |> mutate(parameter = "dominance_beta"),
  total_sim_yrs(pcmax_census)  |> mutate(parameter = "pcmax_cold/warm"),
  total_sim_yrs(area_census)   |> mutate(parameter = "A_cold"),
  total_sim_yrs(s_min_census)  |> mutate(parameter = "s_min"),
  total_sim_yrs(w0_census)     |> mutate(parameter = "s_w0"),
  total_sim_yrs(sk_census)     |> mutate(parameter = "s_k")
)

cv_all <- bind_rows(
  K_stable      |> filter(phase == "burn-in") |> mutate(parameter = "K_cold"),
  egg_stable    |> filter(phase == "burn-in") |> mutate(parameter = "egg_surv"),
  beta_stable   |> filter(phase == "burn-in") |> mutate(parameter = "dominance_beta"),
  pcmax_stable  |> filter(phase == "burn-in") |> mutate(parameter = "pcmax_cold/warm"),
  area_stable   |> filter(phase == "burn-in") |> mutate(parameter = "A_cold"),
  s_min_stable  |> filter(phase == "burn-in") |> mutate(parameter = "s_min"),
  w0_stable     |> filter(phase == "burn-in") |> mutate(parameter = "s_w0"),
  sk_stable     |> filter(phase == "burn-in") |> mutate(parameter = "s_k")
) |>
  left_join(total_yrs_all,  by = c("label", "parameter")) |>
  left_join(peak_age0_all, by = c("label", "parameter")) |>
  left_join(critical_time_all, by = c("label", "parameter")) |>
  mutate(parameter = factor(parameter, levels = param_levels,
                            labels = param_labels))

cv_all_long <- cv_all |>
  pivot_longer(c(mean_N, cv, total_yrs, peak_age0_wt, critical_time),
               names_to  = "metric",
               values_to = "value") |>
  mutate(metric = factor(metric,
                         levels = c("mean_N", "cv", "total_yrs", "peak_age0_wt", "critical_time"),
                         labels = c("Mean N (Oct 1)", 
                                    "CV (abundance)",
                                    "Years simulated (max = 100)",
                                    "Peak age-0 weight (g)",
                                    "Critical period (days)")))

# Okabe-Ito 8-color palette (colorblind-friendly)
param_fill <- c("#0072B2", "#009E73", "#D55E00", "#56B4E9", "#999999",
                "#E69F00", "#CC79A7", "#000000")
names(param_fill) <- param_labels

ggplot(cv_all_long, aes(x = label, y = value, fill = parameter)) +
  geom_col(width = 0.65, show.legend = FALSE) +
  # Year 40: end of burn-in (bars below = collapsed during burn-in)
  geom_hline(
    data = \(d) filter(d, metric == "Years simulated (max = 100)"),
    aes(yintercept = 40),
    linetype = "dotted", color = "grey35", linewidth = 0.6
  ) +
  # Year 100: end of full simulation (bars below = collapsed during experiment)
  geom_hline(
    data = \(d) filter(d, metric == "Years simulated (max = 100)"),
    aes(yintercept = 100),
    linetype = "dashed", color = "grey35", linewidth = 0.6
  ) +
  facet_grid(metric ~ parameter, scales = "free") +
  scale_fill_manual(values = param_fill) +
  scale_y_continuous(expand = expansion(mult = c(0, 0.12))) +
  labs(x = NULL, y = NULL,
       title = "Burn-in stability across all parameter sensitivity analyses",
       subtitle = paste0(
         "Row 1: burn-in mean N.  Row 2: burn-in CV (lower = more stable).  ",
         "Row 3: total years simulated (burn-in + experiment). ",
         "Row 4: peak mean age-0 weight (g) — summarises early growth potential.  ",
         "Dotted = yr 40 (burn-in end); dashed = yr 100 (full simulation). "
         )) +
  theme_bw() +
  theme(strip.background  = element_rect(fill = "grey88"),
        strip.text.x      = element_text(size = 8),
        strip.text.y      = element_text(size = 7.5, lineheight = 0.85),
        panel.grid.minor  = element_blank(),
        axis.text.x       = element_text(size = 6.5, lineheight = 0.8),
        plot.title        = element_text(size = 10, face = "bold"),
        plot.subtitle     = element_text(size = 7.5, color = "grey30"),
        panel.spacing     = unit(0.6, "lines"))
Figure 9.43: Sensitivity analysis summary. Row 1: mean Oct 1 abundance. Row 2: CV of abundance (lower = more stable). Row 3: total years simulated (burn-in + experiment; < 40 = collapsed before burn-in end). Row 4: peak mean age-0 weight (g) — the burn-in cohort-mean weight trajectory maximum, a summary of early growth potential under each parameter value. Row 5: among-cohort average time (days) to escape the critical period for survival (the size-based survival inflection weight, s_w0).

Age-class structured competition — 2×2 stability

The age-class section uses a factorial design rather than a single-axis sweep, so it is not directly comparable to the single-parameter bar charts above. The table below summarises burn-in stability for all four combinations.

Show code
agestr_stable |>
  left_join(
    bind_rows(Map(function(s, l)
      tibble(label = l, total_yrs = n_distinct(
        year(s$ibm_long$date[!is.na(s$ibm_long$date)]))),
      agestr_sims, agestr_labels)),
    by = "label"
  ) |>
  filter(phase == "burn-in") |>
  select(label, mean_N, cv, total_yrs) |>
  knitr::kable(col.names = c("Scenario", "Burn-in mean N",
                              "Burn-in CV", "Total years simulated"))
Table 9.10: Burn-in stability for the 2×2 age-structured × dominance_beta design. Rows where years simulated < 100 indicate premature collapse.
Scenario Burn-in mean N Burn-in CV Total years simulated
Structured, β = 1
(baseline) 31 0.30 98
Structured, β = 0.5 30 0.32 101
Unstructured, β = 1 22 0.33 82
Unstructured, β = 0.5 23 0.33 101

9.11 Final tune

This section runs a single candidate parameterisation that combines adjustments identified during the sensitivity analysis: egg_surv = 0.2 (higher egg-to-fry survival than the baseline of 0.1), s_w0 = 5 (a lower size-based survival inflection weight), s_k = 0.8 (a slightly lower inflection steepness), and K_cold/K_warm = 400 (a high half-saturation density, i.e., lower strength of density density dependence). All other parameters are held at their baseline values. The purpose is to assess whether this combination produces acceptable burn-in dynamics and compensatory regulation before committing to it as the working parameterisation for scenario runs.

Show code
cat("Running final tune simulation...\n")
Running final tune simulation...
Show code
p          <- modifyList(baseline, list(egg_surv = 0.15, s_w0 = 5, s_k = 0.8, K_cold = 400, K_warm = 400))
hdf        <- build_habitat(p)
final_sims <- run_simulation(hdf, p, wt_growth = wt.growth)
cat("ends", format(max(final_sims$ibm_long$date, na.rm = TRUE)), "\n")
ends 2101-04-30 
Show code
final_label   <- "egg_surv=0.15 / s_w0=5 / s_k=0.8 / K=400"
final_col     <- c("egg_surv=0.15 / s_w0=5 / s_k=0.8 / K=400" = "#009E73")

final_census  <- oct1_census(         final_sims, final_label)
final_sr      <- stock_recruit(        final_sims, final_label)
final_thin    <- thinning_rate(        final_sims, final_label)
final_dg      <- age0_density_growth(  final_sims, final_label)
final_waa     <- wt_at_age(            final_sims, final_label)
final_age0_dly <- age0_daily_wt(       final_sims, final_label)
final_stable  <- stability_stats(final_census)
final_cross   <- days_to_threshold(    final_sims, final_label)

9.11.1 Abundance and biomass

Show code
p_N <- ggplot(final_census, aes(x = year, y = N)) +
  annotate("rect", xmin = burn_start, xmax = exp_start_yr,
           ymin = -Inf, ymax = Inf, fill = "grey92", alpha = 0.7) +
  geom_vline(xintercept = exp_start_yr, linetype = "dashed",
             color = "grey55", linewidth = 0.4) +
  geom_line(linewidth = 0.75, color = "#009E73") +
  labs(x = NULL, y = "N (Oct 1)", title = "Annual abundance") +
  theme_sens()

p_B <- ggplot(final_census, aes(x = year, y = biomass_kg)) +
  annotate("rect", xmin = burn_start, xmax = exp_start_yr,
           ymin = -Inf, ymax = Inf, fill = "grey92", alpha = 0.7) +
  geom_vline(xintercept = exp_start_yr, linetype = "dashed",
             color = "grey55", linewidth = 0.4) +
  geom_line(linewidth = 0.75, color = "#009E73") +
  labs(x = NULL, y = "Total biomass (kg)", title = "Total biomass") +
  theme_sens()

p_N | p_B
Figure 9.44: Oct 1 annual abundance and total biomass for the final-tune parameterisation. Grey band = burn-in.

9.11.2 Compensatory dynamics

Show code
final_map <- pop_map(final_census)

p_map <- ggplot(final_map, aes(x = N, y = N_next)) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey50") +
  geom_point(alpha = 0.4, size = 1.5, color = "#009E73") +
  geom_smooth(method = "loess", se = FALSE, span = 0.75,
              linewidth = 0.9, color = "#009E73") +
  labs(x = "N (year t, Oct 1)", y = "N (year t+1, Oct 1)",
       title = "Population map (burn-in)") +
  theme_sens()

p_sr <- ggplot(final_sr |> filter(spawner_kg > 0),
               aes(x = spawner_kg, y = bio_surv60_kg)) +
  geom_point(alpha = 0.5, size = 1.5, color = "#009E73") +
  geom_smooth(method = "loess", se = FALSE, span = 0.7,
              linewidth = 0.9, color = "#009E73") +
  labs(x = "Spawner biomass (kg)", y = "Fry biomass at 60 days (kg)",
       title = "Stock-recruitment") +
  theme_sens()

p_thin <- ggplot(final_thin, aes(x = n_born, y = surv_rate)) +
  geom_point(alpha = 0.45, size = 1.5, color = "#009E73") +
  geom_smooth(method = "loess", se = FALSE, span = 0.7,
              linewidth = 0.9, color = "#009E73") +
  scale_y_continuous(labels = scales::percent_format(accuracy = 1)) +
  labs(x = "Fry born", y = "% of fry surviving to day 60",
       title = "Size-based thinning") +
  theme_sens()

p_dg <- ggplot(final_dg, aes(x = mean_july_n, y = mean_wt)) +
  geom_point(alpha = 0.5, size = 1.5, color = "#009E73") +
  geom_smooth(method = "lm", se = FALSE, linewidth = 0.9, color = "#009E73") +
  geom_hline(yintercept = 7, linetype = "dotted", color = "grey50") +
  labs(x = "Mean July N (all fish)", y = "Age-0 weight at Aug 1 (g)",
       title = "Density\u2013growth relationship",
       subtitle = "Higher density = slower early growth = more time below w\u2080") +
  theme_sens()

(p_map | p_sr) / (p_thin | p_dg)
Figure 9.45: Top-left: burn-in population map. Top-right: stock-recruitment. Bottom-left: size-based early thinning (% of fry surviving to day 60). Bottom-right: density–growth relationship. The bottom row together shows the two pathways of size-based density-dependent compensation: cohort thinning (left) and growth suppression (right).

9.11.3 Age structure

Show code
p_waa <- ggplot(final_waa, aes(x = age_yrs, y = mean_wt)) +
  geom_line(linewidth = 0.9, color = "#009E73") +
  geom_point(size = 2.5, color = "#009E73") +
  scale_x_continuous(breaks = 0:4) +
  labs(x = "Age (years)", y = "Mean weight (g, Oct 1)",
       title = "Weight at age (burn-in mean)") +
  theme_sens()

p_aget <- ggplot(final_age0_dly |> filter(doy >= 118),
                 aes(x = doy, y = mean_wt)) +
  geom_line(linewidth = 0.9, color = "#009E73") +
  scale_x_continuous(breaks = month_doys, labels = month_labels) +
  labs(x = NULL, y = "Mean weight (g)",
       title = "Age-0 daily weight \u2014 burn-in cohort mean") +
  theme_sens()

p_waa | p_aget
Figure 9.46: Left: mean weight at age (burn-in). Right: age-0 daily weight by day of year, averaged across burn-in cohorts.

9.11.4 Density-dependent duration below s_w0

Show code
plot_danger_duration(final_cross, final_col)
Figure 9.47: Days post-hatch until cohort-mean weight first exceeds s_w0, vs fry born. Each point is one burn-in cohort year; line is an OLS fit with 95% CI.

Stability summary

Show code
plot_stability_fig(final_stable, final_col)
Figure 9.48: Mean Oct 1 abundance and CV for the final-tune parameterisation, split by simulation phase.
Show code
final_stable |>
  knitr::kable(col.names = c("Scenario", "Phase", "Mean N",
                              "CV (abundance)", "Mean biomass (kg)", "Years"))
Table 9.11: Stability statistics for the final-tune parameterisation.
Scenario Phase Mean N CV (abundance) Mean biomass (kg) Years
egg_surv=0.15 / s_w0=5 / s_k=0.8 / K=400 burn-in 72 0.31 12.8 40
egg_surv=0.15 / s_w0=5 / s_k=0.8 / K=400 experiment 43 0.57 8.3 60