Skip to contents

Every time step, each fleet in marlin decides how much effort to deploy (fleet_model) and where to deploy it (spatial_allocation). This vignette is about where. spatial_allocation sets the signal a fleet uses to rank patches, and different signals give very different maps of effort, and so of where each species is fished down.

The options are not a ranking from crude to realistic. They are a menu of models people actually use: effort that follows catch or catch per unit effort, their revenue and profit versions, a marginal refinement, and a few others. Each has stories that can justify it, such as a sole owner piling effort where the profits are, or competing vessels spreading out until catch rates even out, but none maps one-to-one onto a real fishery.

The first half of the vignette explains how the allocator works, how the objectives differ, and where effort ends up. Two controlled comparisons follow: open access vs sole owner (Scenario 1), and fish that move every season (Scenario 2). The last section is a use case that gives each fleet in a shared fishery its own strategy.

How spatial allocation works

Effort moves a little each step

A fleet does not solve for its best map of effort. Each step it moves some effort out of patches that scored below average on its objective and into patches that scored above average (responsiveness sets how much), then fishes, and the fish are caught, grow, recruit and move. The map of effort in any run is the product of many rounds of this loop, not of one decision. So a strategy’s behaviour depends on how its objective responds to effort over many steps, and effort takes several steps, often a few years, to settle after a disturbance such as the start of a run or an MPA closure.

The update rule, step by step

For each fleet, with total effort EtotalE_{\text{total}} already set by the fleet model:

  1. Flat check. If the range of the objective across open patches is less than 0.1% of its median (flatness_tol), effort is spread evenly and the remaining steps are skipped.
  2. Standardise. Each patch’s objective becomes a zz-score, zp=(Op−median(O))/scale(O)z_p = (O_p - \text{median}(O)) / \text{scale}(O), using the median absolute deviation (floored at 1% of the median), clipped to [−c,c][-c, c] with c=5c = 5. Closed patches get zp=0z_p = 0. So do patches where a per-unit-effort objective is undefined because the fleet did not fish there; they are left out of the median and scale.
  3. Centre. vp=zp−z‾openv_p = z_p - \bar{z}_{\text{open}}. Patches with vp>0v_p > 0 gain a share of effort, and those with vp<0v_p < 0 lose one.
  4. Multiplicative update. epnew=epexp⁡(ηvp)×Etotal/∑pepexp⁡(ηvp)e_p^{\text{new}} = e_p \exp(\eta \, v_p) \times E_{\text{total}} / \sum_p e_p \exp(\eta \, v_p), where η\eta is responsiveness (default 0.025). Closed patches get zero.
  5. Exploration mixing (optional). With eps_mix =ϵ= \epsilon, epfinal=(1−ϵ)epnew+ϵEtotal/Nopene_p^{\text{final}} = (1 - \epsilon) \, e_p^{\text{new}} + \epsilon \, E_{\text{total}} / N_{\text{open}}.

Properties of the update

  • Total effort is conserved exactly, so only the fleet model changes how much effort there is.
  • Moves are gradual and do not depend on units. A patch’s push depends on how it compares with the other patches, not on the currency. In one step, the effort in one patch can change relative to another by at most a factor of exp⁡(2cη)\exp(2c\eta), about 1.28 at the defaults.
  • There is no target map. The allocator never computes where effort should end up; it only moves effort toward higher-scoring patches, step after step.
  • A patch at zero effort stays at zero, because the update multiplies. A small eps_mix (0.01–0.05) keeps every open patch above zero, so the fleet can return when a patch improves. This matters most for the objectives with little or no crowding within a step (the totals and fishable_revenue), which concentrate effort hardest. A per-unit-effort objective also loses sight of a patch at zero effort; fishable_revenue does not, but its recovery is just as slow.

How the objectives differ

The objectives differ in when they are measured, how they respond to effort piling into a patch, and how they treat other fleets.

Realised vs prospective objectives

The realised objectives (rpue, revenue, ppue, profit, cpue, catch) are records of fishing that has already happened: the catch, revenue and cost each fleet earned in each patch last step. A fleet placing effort at step tt is responding to step t−1t-1.

The prospective objectives are calculated from the biomass the coming step will fish. The marginal objectives (marginal_profit, marginal_revenue) ask what one more unit of effort in each patch would add, at the fleet’s current map of effort; calc_marginal_value() answers this with finite differences on go_fish(). fishable_revenue is the value of the fish the fleet could catch in each patch: biomass at age × vulnerability at age × price, summed over species (calc_fishable_revenue()).

The one-step lag rarely matters when the distribution of fish changes more slowly than the time step. When it shifts on about the time step, as with seasonal migration, a fleet on a realised objective always arrives where the fish were, and raising responsiveness only makes it chase harder (Scenario 2). Where habitat is stable, a higher responsiveness helps effort settle faster.

What stops effort piling into one patch

Crowding, within a step. Each extra unit of effort in a patch catches less than the one before, because the catch equation removes fish as fishing goes on and other fleets take a share. Per-unit-effort and marginal objectives therefore fall as effort rises, and push back straight away, though only gently if one step removes little of a patch’s fish. Total catch and revenue still rise with effort, so within a step they reward concentration. profit is in between, because its costs rise faster than effort, and fishable_revenue ignores effort altogether.

Depletion, across steps. Fishing a patch leaves less biomass there for later steps, which lowers every objective in the patch, the totals and fishable_revenue included. How hard this bites depends on how fast fish refill the patch, by moving in or by growth and recruitment.

So every strategy is penalised for concentrating, but on different timescales, and the totals and fishable_revenue rely on depletion alone (configuration A of the use case shows how slow that can be).

How the objectives account for other fleets

Fleets fishing the same patch add up their fishing mortality and split the catch in proportion to it. So other fleets lower your catch within the step, and deplete the patch for later steps.

  • Realised objectives already contain the other fleets’ effort, because they record what each fleet earned when all of them fished together. They make no prediction, but acting on last step’s outcome is an implicit bet that the next step, including where the other fleets fish, will look the same.
  • Marginal objectives make that bet explicitly. calc_marginal_value() raises one fleet’s effort while holding the other fleets’ maps fixed, and measures the change in that fleet’s own return. Each fleet ignores the catch its extra effort takes from the others, so several marginal fleets together put more effort into shared patches than a single owner of all of them would.
  • fishable_revenue ignores other fleets within the step; they matter only through the fish they remove.

No strategy anticipates how the other fleets will respond. All of them react to the current state at once, and again in the next step.

Optional: smoothing the objective over time (memory_halflife)

Rebuilding the objective from scratch each step can produce a period-2 sawtooth when a fleet is tightly coupled to the biomass it fishes, most often with the marginal objectives, or with cost-aware ones after an MPA closure: the signal points at a patch, fishing drains it, and the next signal points away. memory_halflife (in years) damps this by replacing OpO_p with an exponentially weighted average of its past values, updated only in open patches:

Õp(t)=αOp(t)+(1−α)Õp(t−1),α=1−0.51/hsteps\tilde{O}_p^{(t)} = \alpha \, O_p^{(t)} + (1 - \alpha) \, \tilde{O}_p^{(t-1)}, \qquad \alpha = 1 - 0.5^{1 / h_{\text{steps}}}

Smoothing adds a lag of roughly 1.44×hyears1.44 \times h_{\text{years}} years, so a long half-life swaps a fast sawtooth for slow overshoot, and on a prospective objective it puts back some of the lag that objective avoids. If you need it, 0.5–1.5 years suits most fisheries; if a run is already smooth, leave it at 0. The spatial-allocation-memory article has a worked example.

Where effort ends up

Resting states and Nash equilibrium

Resting states. The update stops moving a fleet’s effort only when its objective is equal in every patch holding effort, and no open patch left unfished scores higher. What that means depends on the objective:

  • Per-unit-effort objectives: average returns are equal across the patches being fished. This is the ideal free distribution (IFD), and it fits most cleanly with a story of competing vessels, each going where its own catch rate is best.
  • Marginal objectives: the return on the last unit of effort is equal across fished patches. For a fleet fishing alone, that maximises its profit (or revenue) within the step, a story of one owner coordinating every vessel, or of one vessel dividing its fishing time. Average returns are generally not equal.
  • Total objectives and fishable_revenue: every fished patch yields the same long-run total, or holds the same fishable revenue, reached only through depletion. The totals fit most cleanly with a story of a single owner piling effort where the returns are. Because the push-back is slow, their maps can stay patchy for a long time.

Nash equilibrium. In a Nash equilibrium, no player can do better by changing what it does, given what the others do. “Better” is judged by an objective, and in marlin that is whatever spatial_allocation says: a profit fleet, for example, is in equilibrium when no patch offers it more total profit than the ones it is fishing. A resting state is exactly that condition, with no gradient left to chase, so a fleet that comes to rest is at a Nash equilibrium for its objective. With several fleets, the joint resting state is one in which no fleet has a gradient left. (The update is a discrete-time version of the replicator dynamics of evolutionary game theory, for which this is a standard result.) No fleet solves for the equilibrium; repeated steps are how the game is played out.

When repeated play gets there. It reaches the equilibrium, or comes close to it, when:

  1. the environment is stable on the timescale effort takes to settle, so habitat neither shifts as fast as the time step (Scenario 2) nor keeps drifting;
  2. concentrating effort eventually lowers the objective, through crowding or depletion (the push-back is weakest for totals on stocks that refill a patch fast);
  3. responsiveness is small enough not to overshoot, although small steps are slow: at the default of 0.025, the cost-aware fleets in the use case took 15–20 years to get close;
  4. an abandoned open patch that improves can draw effort back, which a small eps_mix ensures. Closed patches such as MPAs are simply not part of the game; the equilibrium is conditional on them.

Even then, the result is Nash in spirit rather than in precise definition. Effort gets close and then wobbles around the equilibrium (see “How close repeated play gets to equilibrium” in the use case), and eps_mix leaves a small floor of effort everywhere. It is also a one-step, myopic equilibrium, as the next section explains.

Adaptive fleets, not forward-looking optimisers

What marlin does not implement is the economist’s optimisation benchmark: fleets that maximise the discounted value of their profit over the whole future, knowing exactly how today’s fishing changes the stock and how the other fleets will respond. For a single owner, the long-run target of that problem is MEY, the effort that maximises long-run profit (at a zero discount rate; with discounting, the owner fishes harder). With several fleets, it is a dynamic game.

marlin’s fleets are adaptive instead, for two reasons. Solving the forward-looking problem for a spatial, age-structured, multispecies model is out of computational reach for routine simulation. And real fleets mostly learn from recent returns rather than plan with perfect knowledge, so an adaptive fleet is a different, often more plausible, model of behaviour, not a flawed version of the optimiser.

The cost is that marlin does not give you the fully informed optimum as a benchmark. Repetition makes fleets feel depletion, but nothing makes them weigh it: no fleet asks whether fishing less now would pay off later. As a result:

  • the equilibria reached are one-step equilibria, at whatever biomass the run settles to (the usual sense of the IFD in fisheries models);
  • maximising marginal profit does not automatically reach MEY: marginal_profit and fleet_model = "sole_owner" act as a myopic sole owner, settling below open-access effort but above MEY effort, and closer to MEY the less one step’s fishing changes the future stock;
  • several fleets on marginal objectives reach, at best, a one-step Nash equilibrium.

If a question needs the fully informed benchmark, such as the effort that achieves MEY, compute it separately, for example from a grid of constant-effort runs.

The objectives at a glance

Realised objectives use the previous step’s fishing, prospective ones use the biomass about to be fished, and manual and uniform use no objective.

spatial_allocation Objective OpO_p Computed from Behaviour
rpue Revenue per unit effort Last step’s revenue ÷ effort Falls as effort is added, so concentration limits itself; tends toward the IFD on gross returns. No costs.
revenue Total revenue Last step’s revenue Rises with effort within a step, so effort reinforces itself until depletion pushes back. No costs.
ppue Profit per unit effort Last step’s (revenue − cost) ÷ effort As rpue, net of operating and travel costs; tends toward the IFD on profit.
profit Total profit Last step’s revenue − cost As revenue, net of costs; costs rising faster than effort add some brake within a step.
cpue Catch per unit effort Last step’s catch ÷ effort As rpue, by weight.
catch Total catch Last step’s catch As revenue, by weight.
marginal_profit Marginal profit Profit from one more unit of effort (finite difference), on the biomass about to be fished A refinement of profit: tends toward equal marginal profit, maximising the fleet’s profit within each step (a myopic sole owner), taking other fleets’ effort as given. Slower to compute (see Runtime).
marginal_revenue Marginal revenue As above, for revenue As marginal_profit, without costs.
fishable_revenue Fishable revenue Biomass at age × vulnerability × price, on the biomass about to be fished Tracks standing stock. Cheap and defined everywhere, but with no crowding, costs or other fleets within a step. Pair it with a small eps_mix (see “Properties of the update”).
manual User weights fishing_grounds$fishing_ground Held in proportion to the weights.
uniform None — Equal effort in every open patch.

The shared two-species, two-fleet system

Scenario 1 and the use case share a 20 × 20 grid with two species, bigeye and skipjack tuna, that prefer different parts of the grid, and two fleets, longline and handline, that sail from ports at (1, 1) and (8, 9). The far corner is a long way from either port, which matters for any strategy that counts costs.

library(marlin)
library(ggplot2)
library(dplyr)
library(tidyr)

theme_set(marlin::theme_marlin(base_size = 12) +
            theme(legend.position = "top"))

resolution <- c(20, 20)
patches <- prod(resolution)
years <- 20
seasons <- 2
time_step <- 1 / seasons

# Two ports, both toward the lower left of the grid
ports <- data.frame(x = c(1, 8), y = c(1, 9))

Habitat

sim_habitat() generates spatially correlated habitat. A negative correlation between the species makes them prefer different areas, so no patch is best for both.

# Negative correlation: species prefer different areas
critter_correlations <- matrix(c(1, -0.5,
                                 -0.5, 1), nrow = 2)

habitats <- sim_habitat(
  critters = c("bigeye", "skipjack"),
  kp = 0.1,
  critter_correlations = critter_correlations,
  resolution = resolution,
  patch_area = 1,
  output = "list"
)

# Extract the habitat matrices
bigeye_habitat <- habitats$critter_distributions$bigeye
skipjack_habitat <- habitats$critter_distributions$skipjack

# Quick look at the generated habitats
habitat_df <- expand_grid(critter = c("bigeye", "skipjack"),
                          x = 1:resolution[1],
                          y = 1:resolution[2]) |>
  arrange(critter, x, y) |>
  mutate(
    habitat = c(as.vector(bigeye_habitat),
                as.vector(skipjack_habitat))
  )

ggplot(habitat_df) +
  geom_tile(aes(x, y, fill = habitat)) +
  geom_point(data = ports, aes(x, y),
             color = "red", size = 3, shape = 17) +
  scale_fill_viridis_c(option = "D") +
  facet_wrap(~critter) +
  coord_equal() +
  labs(title = "Underlying Habitat Quality",
       subtitle = "Red triangles = port locations",
       fill = "Habitat")

Fauna

Bigeye start more depleted than skipjack (spawning biomass at 50% of unfished, against 60%) and have the larger adult home range (5 against 3).

fauna <-
  list(
    "bigeye" = create_critter(
      common_name = "bigeye tuna",
      habitat = list(bigeye_habitat),
      season_blocks = list(1:seasons),
      adult_home_range = 5,
      recruit_home_range = 10,
      density_dependence = "local_habitat",
      seasons = seasons,
      depletion = 0.5,
      init_explt = 0.2,
      explt_type = "f",
      resolution = resolution,
      steepness = 0.6,
      ssb0 = 1000
    ),
    "skipjack" = create_critter(
      scientific_name = "Katsuwonus pelamis",
      habitat = list(skipjack_habitat),
      season_blocks = list(1:seasons),
      adult_home_range = 3,
      recruit_home_range = 8,
      density_dependence = "local_habitat",
      seasons = seasons,
      depletion = 0.6,
      init_explt = 0.15,
      explt_type = "f",
      resolution = resolution,
      steepness = 0.7,
      ssb0 = 800
    )
  )

Fleets

build_fleets() builds both fleets for any pair of spatial_allocation settings. By default the longline fleet is open access and the handline fleet has constant effort. The fleets also differ in several other ways:

  • Travel costs: travel makes up 70% of longline costs and 50% of handline costs (travel_fraction).
  • Species: the longline fleet takes the larger share of bigeye fishing mortality, and the handline fleet the larger share of skipjack (p_explt).
  • Prices: the handline fleet gets 8 for skipjack against 5 for longline; both get 10 for bigeye.
  • Selectivity: it differs for both species.

So their maps would differ even under the same strategy.

build_fleets <- function(longline_allocation, handline_allocation,
                         longline_fleet_model = "open_access",
                         handline_fleet_model = "constant_effort") {

  fleets <- list(
    "longline" = create_fleet(
      list(
        "bigeye" = Metier$new(
          critter = fauna$bigeye,
          price = 10,
          sel_form = "logistic",
          sel_start = 1,
          sel_delta = 0.01,
          catchability = 0,
          p_explt = 2
        ),
        "skipjack" = Metier$new(
          critter = fauna$skipjack,
          price = 5,
          sel_form = "logistic",
          sel_start = 0.8,
          sel_delta = 0.1,
          catchability = 0,
          p_explt = 1
        )
      ),
      ports = ports,
      base_effort = patches,
      resolution = resolution,
      spatial_allocation = longline_allocation,
      fleet_model = longline_fleet_model,
      cr_ratio = 1,
      travel_fraction = 0.7
    ),
    "handline" = create_fleet(
      list(
        "bigeye" = Metier$new(
          critter = fauna$bigeye,
          price = 10,
          sel_form = "logistic",
          sel_start = 1.2,
          sel_delta = 0.1,
          catchability = 0,
          p_explt = 1
        ),
        "skipjack" = Metier$new(
          critter = fauna$skipjack,
          price = 8,
          sel_form = "logistic",
          sel_start = 0.5,
          sel_delta = 0.2,
          catchability = 0,
          p_explt = 2
        )
      ),
      ports = ports,
      base_effort = patches,
      resolution = resolution,
      spatial_allocation = handline_allocation,
      fleet_model = handline_fleet_model,
      cr_ratio = 1,
      travel_fraction = 0.5
    )
  )

  fleets <- tune_fleets(fauna, fleets, tune_type = "depletion")

  fleets
}

Scenario 1: open access vs sole owner

Both fleets allocate on ppue in both runs; only the fleet model differs. Open access adds effort until average profit is zero. fleet_model = "sole_owner" uses the same entry and exit machinery, but responds to the effort-weighted mean of patch-level marginal profit from calc_marginal_value(), so it stops adding effort when the next unit no longer pays within the step. That makes it a myopic sole owner (see “Adaptive fleets, not forward-looking optimisers”): it settles below open-access effort with a positive profit, but in general above MEY effort. The two fleet sets are built separately, because fleet objects share state, and run for 50 years so that both can settle.

fleets_oa <- build_fleets("ppue", "ppue",
                          longline_fleet_model = "open_access",
                          handline_fleet_model = "open_access")

fleets_so <- build_fleets("ppue", "ppue",
                          longline_fleet_model = "sole_owner",
                          handline_fleet_model = "sole_owner")
years_long <- 50

time_oa <- system.time({
  sim_oa <- simmar(fauna = fauna, fleets = fleets_oa, years = years_long)
})

time_so <- system.time({
  sim_so <- simmar(fauna = fauna, fleets = fleets_so, years = years_long)
})

proc_oa <- process_marlin(sim_oa, time_step = time_step)
proc_so <- process_marlin(sim_so, time_step = time_step)
effort_ts <- bind_rows(
  proc_oa$fleets |>
    group_by(step, fleet) |>
    summarise(effort = sum(effort), .groups = "drop") |>
    mutate(model = "open_access"),
  proc_so$fleets |>
    group_by(step, fleet) |>
    summarise(effort = sum(effort), .groups = "drop") |>
    mutate(model = "sole_owner")
)

ggplot(effort_ts, aes(step * time_step, effort, color = model)) +
  geom_line() +
  facet_wrap(~fleet) +
  scale_x_continuous(name = "Year") +
  scale_y_continuous(name = "Total Effort", limits = c(0, NA)) +
  labs(title = "Total Effort: Open Access vs Sole Owner",
       color = "Fleet Model")

The sole owner settles at lower effort than open access in both fleets. process_marlin() does not report profit, so it is read from the raw output: each species’ prof_p_fl is that species’ revenue minus its share of the fleet’s costs, so a fleet’s profit is the sum over species and patches.

fleet_profit <- function(sim, model_label) {
  step_profits <- lapply(names(sim), function(step_name) {
    profit_p_fl <- Reduce(`+`, lapply(names(fauna), function(critter) {
      sim[[step_name]][[critter]]$prof_p_fl
    }))
    tibble(
      step_name = step_name,
      fleet = colnames(profit_p_fl),
      profit = colSums(profit_p_fl)
    )
  })

  # simmar() names each step "year_season"
  bind_rows(step_profits) |>
    separate_wider_delim(step_name, delim = "_", names = c("year", "season")) |>
    mutate(
      step = as.numeric(year) + (as.numeric(season) - 1) * time_step,
      model = model_label
    )
}

profit_ts <- bind_rows(
  fleet_profit(sim_oa, "open_access"),
  fleet_profit(sim_so, "sole_owner")
)

ggplot(profit_ts, aes(step, profit, color = model)) +
  geom_hline(yintercept = 0, color = "grey70") +
  geom_line() +
  facet_wrap(~fleet) +
  scale_x_continuous(name = "Year") +
  labs(title = "Profit: Open Access vs Sole Owner",
       color = "Fleet Model",
       y = "Profit")

Open-access profit falls to about zero, while the sole owner keeps a positive profit and, because it fishes less, leaves more biomass in the water:

bind_rows(
  proc_oa$fauna |>
    filter(step == max(step)) |>
    mutate(model = "open_access"),
  proc_so$fauna |>
    filter(step == max(step)) |>
    mutate(model = "sole_owner")
) |>
  group_by(model, critter, x, y) |>
  summarise(ssb = sum(ssb, na.rm = TRUE), .groups = "drop") |>
  ggplot() +
  geom_tile(aes(x, y, fill = ssb)) +
  geom_point(data = ports, aes(x, y),
             color = "red", size = 1.5, shape = 17) +
  scale_fill_viridis_c(option = "B") +
  facet_grid(model ~ critter) +
  coord_equal() +
  labs(
    title = "Final SSB: open access vs sole owner",
    subtitle = "Red triangles = port locations",
    fill = "SSB"
  )

Scenario 2: fish that move every season

Scenario 1 and the use case have static habitat. Here the habitat flips quickly, which is where the one-step lag of the realised objectives matters. On a 5 × 10 grid with four seasons a year, the fish prefer one end of the grid in seasons 1–2 (habitat A) and the mirror-image end in seasons 3–4, so each habitat lasts two steps. Recruitment habitat is flat, so the only spatial pattern in the fish is the seasonal one.

flip_resolution <- c(5, 10)
flip_seasons <- 4
flip_years <- 25

# marlin reads a habitat matrix with row 1 = the top of the map, while
# expand_grid() runs y from the bottom up, hence arrange(desc(y)) before reshaping
to_habitat_matrix <- function(habitat_df) {
  habitat_df |>
    arrange(desc(y)) |>
    pivot_wider(names_from = x, values_from = habitat) |>
    select(-y) |>
    as.matrix()
}

habitat_a <- expand_grid(x = 1:flip_resolution[1], y = 1:flip_resolution[2]) |>
  mutate(
    habitat = dnorm(x^2 + y^2, 300, 100),
    habitat = habitat / max(habitat) * 5
  ) |>
  to_habitat_matrix()

# the exact mirror image of habitat A
habitat_b <- (max(habitat_a) + min(habitat_a)) - habitat_a

flat_recruit_habitat <- habitat_a
flat_recruit_habitat[!is.na(flat_recruit_habitat)] <- 1

habitat_to_df <- function(habitat_matrix, label) {
  as.data.frame(habitat_matrix) |>
    mutate(y = n():1) |>
    pivot_longer(-y, names_to = "x", values_to = "habitat") |>
    mutate(
      x = match(x, unique(x)),
      habitat_name = label
    )
}
yellowfin <- create_critter(
  query_fishlife = FALSE,
  scientific_name = "Thunnus albacares",
  m = 0.3,
  linf = 100,
  vbk = 0.2,
  t0 = -0.5,
  max_age = 15,
  age_mature = 3,
  weight_a = 4e-5,
  weight_b = 2.86,
  steepness = 0.9,
  habitat = list(habitat_a, habitat_b),
  season_blocks = list(c(1, 2), c(3, 4)),
  recruit_habitat = flat_recruit_habitat,
  adult_home_range = 10,
  recruit_home_range = 10,
  depletion = 0.4,
  density_dependence = "pre_dispersal",
  seasons = flip_seasons,
  init_explt = 0.2,
  explt_type = "f"
)

One longline fleet fishes this stock under six settings: rpue at the default responsiveness (0.025) and at 0.4 (16 times the default, so effort can move a long way within a two-step habitat), with and without eps_mix = 0.05; fishable_revenue at 0.4, with and without eps_mix; and marginal_profit at 0.4 with eps_mix.

run_flip <- function(spatial_allocation, responsiveness, eps_mix) {
  flip_fleets <- list(
    "longline" = create_fleet(
      metiers = list(
        "yellowfin" = Metier$new(
          critter = yellowfin,
          price = 10,
          sel_form = "logistic",
          sel_start = 0.1,
          sel_delta = 0.01,
          catchability = 0,
          p_explt = 1
        )
      ),
      base_effort = prod(flip_resolution),
      resolution = flip_resolution,
      spatial_allocation = spatial_allocation,
      responsiveness = responsiveness,
      eps_mix = eps_mix
    )
  )

  flip_fleets <- tune_fleets(fauna = list("yellowfin" = yellowfin), fleets = flip_fleets)

  flip_proc <- process_marlin(
    sim = simmar(
      fauna = list("yellowfin" = yellowfin),
      fleets = flip_fleets,
      years = flip_years
    ),
    time_step = 1 / flip_seasons,
    keep_age = FALSE
  )

  patch_biomass <- flip_proc$fauna |>
    group_by(step, x, y) |>
    summarise(biomass = sum(b), .groups = "drop")

  patch_effort <- flip_proc$fleets |>
    group_by(step, x, y) |>
    summarise(effort = sum(effort), .groups = "drop")

  # biomass at step t is the biomass that step t's effort fishes
  left_join(patch_effort, patch_biomass, by = c("step", "x", "y")) |>
    arrange(x, y, step) |>
    group_by(x, y) |>
    mutate(biomass_last_step = dplyr::lag(biomass)) |>
    ungroup() |>
    mutate(
      spatial_allocation = spatial_allocation,
      responsiveness = responsiveness,
      eps_mix = eps_mix
    )
}

flip_settings <- tibble(
  spatial_allocation = c("rpue", "rpue", "rpue",
                         "fishable_revenue", "fishable_revenue", "marginal_profit"),
  responsiveness = c(0.025, 0.4, 0.4, 0.4, 0.4, 0.4),
  eps_mix = c(0, 0, 0.05, 0, 0.05, 0.05)
)

flip_runs <- bind_rows(
  lapply(seq_len(nrow(flip_settings)), function(i) {
    run_flip(
      spatial_allocation = flip_settings$spatial_allocation[i],
      responsiveness = flip_settings$responsiveness[i],
      eps_mix = flip_settings$eps_mix[i]
    )
  })
) |>
  mutate(
    setting = paste(spatial_allocation, responsiveness, eps_mix, sep = "_"),
    scenario = paste0(
      spatial_allocation, "\nresponsiveness = ", responsiveness,
      ", eps_mix = ", eps_mix
    )
  )

scenario_order <- flip_runs |>
  distinct(spatial_allocation, responsiveness, eps_mix, scenario) |>
  arrange(desc(spatial_allocation), responsiveness, eps_mix) |>
  pull(scenario)

Each line below shows how closely one step’s map of biomass or effort matches habitat A, over the last three years. Biomass swings between strongly positive and strongly negative as the habitat flips, and a fleet tracking the fish should swing with it.

habitat_a_df <- habitat_to_df(habitat_a, "A") |>
  select(x, y, habitat)

flip_runs |>
  filter(step >= flip_years - 3) |>
  pivot_longer(cols = c(effort, biomass), names_to = "map", values_to = "value") |>
  left_join(habitat_a_df, by = c("x", "y")) |>
  group_by(scenario, map, step) |>
  summarise(cor_with_habitat_a = cor(value, habitat), .groups = "drop") |>
  mutate(scenario = factor(scenario, levels = scenario_order)) |>
  ggplot(aes(step, cor_with_habitat_a, color = map)) +
  geom_hline(yintercept = 0, color = "grey70") +
  geom_line(linewidth = 0.8) +
  geom_point(size = 1.2) +
  facet_wrap(~scenario, ncol = 1) +
  scale_color_manual(values = c(biomass = "#1b7837", effort = "#e08214")) +
  labs(
    x = "Year",
    y = "Correlation with habitat A",
    color = NULL
  )


# for the prose below: across patches, how well does each step's effort line up
# with the biomass it fished, and with the biomass the step before fished?
# (averaged over the last two years)
flip_alignment <- flip_runs |>
  filter(step >= flip_years - 2, !is.na(biomass_last_step)) |>
  group_by(setting, step) |>
  summarise(
    this_step = cor(effort, biomass),
    last_step = cor(effort, biomass_last_step),
    .groups = "drop"
  ) |>
  group_by(setting) |>
  summarise(
    this_step = mean(this_step),
    last_step = mean(last_step),
    .groups = "drop"
  )

this_step_cor <- setNames(round(flip_alignment$this_step, 2), flip_alignment$setting)
last_step_cor <- setNames(round(flip_alignment$last_step, 2), flip_alignment$setting)

collapsed_patches <- flip_runs |>
  filter(step == max(step)) |>
  group_by(setting) |>
  summarise(collapsed = sum(effort / sum(effort) < 1e-3), .groups = "drop")
collapsed_patches <- setNames(collapsed_patches$collapsed, collapsed_patches$setting)

Under rpue, effort is a step behind the fish. At the default responsiveness it barely moves; at 0.4 it swings, but toward where the fish were. Averaged over the last two years, the correlation across patches between effort and the biomass it is fishing is -0.27, against 0.39 with the biomass the step before fished. Adding eps_mix does not change that (-0.29 and 0.45), because the problem is when the signal was measured.

Both prospective objectives with eps_mix move with the fish: fishable_revenue at 0.56 against 0.39, and marginal_profit at 0.61 against 0.38. In the step right after a flip, effort is still only partway to the new pattern, because each step the map moves only part of the way toward a current signal. Without eps_mix, fishable_revenue tracks the fish only weakly (0.18 against 0.21). With no crowding, its effort piles into a few patches, and by the final step 7 of the 50 patches hold less than 0.1% of it, too little to recover before the next flip.

So when fish move on about the time step, use a prospective objective with a small eps_mix: marginal_profit if the fleet’s economics matter (at extra computational cost), or fishable_revenue if “effort follows the fish” is enough. If the fleet should behave as competing vessels (the IFD), only the realised objectives give that, lag included, and the lag matters little when habitat moves slowly.

Use case: a different strategy for each fleet

Real applications usually have several fleets, each with its own settings. Here build_fleets() gives the two fleets different strategies in three configurations:

  • A: longline on rpue, handline on revenue;
  • B: longline on ppue, handline on profit;
  • C: longline on marginal_profit, handline on rpue.

These are not controlled comparisons of strategies. The fleets differ in more than their objective (see “Fleets”), so each map reflects the fleet as well as its strategy.

fleets_a <- build_fleets("rpue", "revenue")
fleets_b <- build_fleets("ppue", "profit")
fleets_c <- build_fleets("marginal_profit", "rpue")

time_a <- system.time({
  sim_a <- simmar(fauna = fauna, fleets = fleets_a, years = years)
})
time_b <- system.time({
  sim_b <- simmar(fauna = fauna, fleets = fleets_b, years = years)
})
time_c <- system.time({
  sim_c <- simmar(fauna = fauna, fleets = fleets_c, years = years)
})

proc_a <- process_marlin(sim_a, time_step = time_step)
proc_b <- process_marlin(sim_b, time_step = time_step)
proc_c <- process_marlin(sim_c, time_step = time_step)

Effort and SSB in the three configurations

Final-step effort for each fleet, on a shared colour scale:

extract_effort <- function(sim, fleet_name, label) {
  final_step <- sim[[length(sim)]]
  effort_vec <- final_step[[1]]$e_p_fl[, fleet_name]

  expand_grid(x = 1:resolution[1], y = 1:resolution[2]) |>
    mutate(
      effort = effort_vec,
      strategy = label
    )
}

all_efforts <- bind_rows(
  extract_effort(sim_a, "longline", "A: longline, rpue"),
  extract_effort(sim_a, "handline", "A: handline, revenue"),
  extract_effort(sim_b, "longline", "B: longline, ppue"),
  extract_effort(sim_b, "handline", "B: handline, profit"),
  extract_effort(sim_c, "longline", "C: longline, marginal_profit"),
  extract_effort(sim_c, "handline", "C: handline, rpue")
) |>
  mutate(strategy = factor(strategy, levels = unique(strategy)))

ggplot(all_efforts) +
  geom_tile(aes(x, y, fill = effort)) +
  geom_point(data = ports, aes(x, y),
             color = "red", size = 2, shape = 17) +
  scale_fill_viridis_c(option = "C") +
  facet_wrap(~strategy, ncol = 2) +
  coord_equal() +
  labs(
    title = "Effort by fleet and strategy, configurations A–C",
    subtitle = "Red triangles = port locations",
    fill = "Effort"
  )

And the resulting SSB:

extract_ssb <- function(proc, longline_alloc, handline_alloc) {
  last_step <- max(proc$fauna$step)

  proc$fauna |>
    filter(step == last_step) |>
    group_by(critter, x, y) |>
    summarise(ssb = sum(ssb, na.rm = TRUE), .groups = "drop") |>
    mutate(strategy = paste0(longline_alloc, " / ", handline_alloc))
}

all_ssb <- bind_rows(
  extract_ssb(proc_a, "A: rpue", "revenue"),
  extract_ssb(proc_b, "B: ppue", "profit"),
  extract_ssb(proc_c, "C: marginal_profit", "rpue")
)

ggplot(all_ssb) +
  geom_tile(aes(x, y, fill = ssb)) +
  geom_point(data = ports, aes(x, y),
             color = "red", size = 1.5, shape = 17) +
  scale_fill_viridis_c(option = "B") +
  facet_grid(strategy ~ critter) +
  coord_equal() +
  labs(
    title = "SSB by configuration (longline strategy / handline strategy)",
    subtitle = "Red triangles = port locations",
    fill = "SSB"
  )

  • A. The longline fleet (rpue) spreads its effort widely, while the handline fleet (revenue) piles its effort into scattered single patches. It fishes those patches down (the pockets of low skipjack SSB), but after 20 years that has not evened out its map. Neither objective counts costs, so neither map follows the ports.
  • B. The longline fleet (ppue) clusters around both ports and leaves the far edges lightly fished, where skipjack build up. The handline fleet (profit) responds to distance much more weakly, partly because travel is a smaller share of its costs.
  • C. The longline fleet (marginal_profit) has the smoothest, most port-centred surface, and bigeye are depleted most around the ports. The handline fleet (rpue) fishes the outer ring instead, likely because longline has fished bigeye down near the ports. A fleet’s map depends on the other fleets as well as on its own settings.
patch_returns <- function(sim, step_index, fleet_name) {
  step_out <- sim[[step_index]]
  sum_over_critters <- function(element) {
    Reduce(`+`, lapply(names(fauna), function(critter) {
      step_out[[critter]][[element]][, fleet_name]
    }))
  }
  effort <- step_out[[1]]$e_p_fl[, fleet_name]

  tibble(
    effort_share = effort / sum(effort),
    rpue = sum_over_critters("r_p_fl") / effort,
    ppue = sum_over_critters("prof_p_fl") / effort
  )
}

# marginal values at the biomass and effort of one step, other fleets held fixed
patch_marginals <- function(sim, step_index, fleet_name, fleets) {
  step_out <- sim[[step_index]]
  marginals <- calc_marginal_value(
    e_p_fl = as.matrix(step_out[[1]]$e_p_fl),
    fauna = fauna,
    n_p_a = setNames(lapply(names(fauna), function(critter) {
      step_out[[critter]]$n_p_a
    }), names(fauna)),
    fleets = fleets
  )

  tibble(
    marginal_revenue = marginals$mr_p_fl[, fleet_name],
    marginal_profit = marginals$mp_p_fl[, fleet_name]
  )
}

# deviation of each patch from the fleet's effort-weighted average, as a
# fraction of the fleet's effort-weighted gross return on the same footing
relative_deviation <- function(value, gross, effort_share) {
  (value - sum(effort_share * value)) / sum(effort_share * gross)
}

final_index <- length(sim_a)

rpue_fleet <- patch_returns(sim_a, final_index, "longline")
ppue_fleet <- patch_returns(sim_b, final_index, "longline")
marginal_fleet <- bind_cols(
  patch_returns(sim_c, final_index, "longline"),
  patch_marginals(sim_c, final_index, "longline", fleets_c)
)

equalised <- bind_rows(
  rpue_fleet |>
    mutate(
      panel = "A: longline on rpue\nrevenue per unit effort",
      deviation = relative_deviation(rpue, rpue, effort_share)
    ),
  ppue_fleet |>
    mutate(
      panel = "B: longline on ppue\nprofit per unit effort",
      deviation = relative_deviation(ppue, rpue, effort_share)
    ),
  marginal_fleet |>
    mutate(
      panel = "C: longline on marginal_profit\nmarginal profit",
      deviation = relative_deviation(marginal_profit, marginal_revenue, effort_share)
    ),
  marginal_fleet |>
    mutate(
      panel = "C: longline on marginal_profit\nprofit per unit effort",
      deviation = relative_deviation(ppue, rpue, effort_share)
    )
) |>
  mutate(panel = factor(panel, levels = unique(panel)))

How close repeated play gets to equilibrium

The next figure tracks, through time, the most an average unit of a fleet’s effort could gain by moving to the fleet’s best patch, as a percentage of its gross return per unit effort; zero would be an exact equilibrium. Configuration C’s handline fleet (rpue) is included, so both players of a two-fleet game are shown.

# the most an average unit of effort could gain by moving to the best patch,
# as a fraction of the fleet's gross return per unit effort
nash_gap <- function(value, gross, effort_share) {
  (max(value, na.rm = TRUE) - sum(effort_share * value)) / sum(effort_share * gross)
}

# simmar() names each step "year_season"
step_years <- sapply(strsplit(names(sim_a), "_"), function(year_season) {
  as.numeric(year_season[1]) + (as.numeric(year_season[2]) - 1) * time_step
})

gap_paths <- bind_rows(lapply(seq_along(sim_a), function(step_index) {
  s1_longline <- patch_returns(sim_a, step_index, "longline")
  s2_longline <- patch_returns(sim_b, step_index, "longline")
  s3_longline <- bind_cols(
    patch_returns(sim_c, step_index, "longline"),
    patch_marginals(sim_c, step_index, "longline", fleets_c)
  )
  s3_handline <- patch_returns(sim_c, step_index, "handline")

  tibble(
    year = step_years[step_index],
    fleet = c(
      "A: longline (rpue)",
      "B: longline (ppue)",
      "C: longline (marginal_profit)",
      "C: handline (rpue)"
    ),
    gap = c(
      nash_gap(s1_longline$rpue, s1_longline$rpue, s1_longline$effort_share),
      nash_gap(s2_longline$ppue, s2_longline$rpue, s2_longline$effort_share),
      nash_gap(s3_longline$marginal_profit, s3_longline$marginal_revenue,
               s3_longline$effort_share),
      nash_gap(s3_handline$rpue, s3_handline$rpue, s3_handline$effort_share)
    )
  )
}))

final_gaps <- gap_paths |>
  filter(year == max(year)) |>
  pull(gap)

# the same measure over the 50-year open-access run of Scenario 1 (both fleets on ppue),
# to see whether the gap keeps shrinking or levels off
long_run_years <- sapply(strsplit(names(sim_oa), "_"), function(year_season) {
  as.numeric(year_season[1]) + (as.numeric(year_season[2]) - 1) * time_step
})
long_run_gaps <- unlist(lapply(which(long_run_years >= 20), function(step_index) {
  sapply(c("longline", "handline"), function(fleet_name) {
    fleet_returns <- patch_returns(sim_oa, step_index, fleet_name)
    nash_gap(fleet_returns$ppue, fleet_returns$rpue, fleet_returns$effort_share)
  })
}))

ggplot(gap_paths, aes(year, 100 * gap, color = fleet)) +
  geom_line(linewidth = 0.8) +
  scale_y_continuous() +
  guides(color = guide_legend(ncol = 2)) +
  labs(
    title = "Distance from equilibrium",
    x = "Year",
    y = "Gain available from moving\n(% of gross return per unit effort)",
    color = NULL
  )

All four gaps shrink, from as much as 38% in the first year to between 0.8% and 1.7% by year 20, but not to zero. In the 50-year open-access run of Scenario 1 (both fleets on ppue), the gap stays between 0.3% and 2.2% from year 20 to year 50: play gets close to the equilibrium and then wobbles around it. The best remaining patches are fished ones slightly above average, not abandoned ones, so these are approximate Nash equilibria rather than traps, and in configuration C both fleets get there together. Convergence is slow, though: the cost-aware fleets take 15–20 years at the default responsiveness. All of this is with static habitat.

Runtime

The marginal objectives and sole_owner need calc_marginal_value() every step, which adds at least one extra go_fish() evaluation per fleet, so they run slower:

runtime <- tibble(
  scenario = c("A: rpue / revenue", "B: ppue / profit", "C: marginal_profit / rpue",
               "Scenario 1: open_access (ppue)", "Scenario 1: sole_owner (ppue)"),
  seconds = c(time_a["elapsed"], time_b["elapsed"], time_c["elapsed"],
              time_oa["elapsed"], time_so["elapsed"]),
  years_run = c(rep(years, 3), years_long, years_long)
) |>
  mutate(
    ms_per_year = 1000 * seconds / years_run,
    relative = round(ms_per_year / min(ms_per_year), 1),
    ms_per_year = round(ms_per_year, 1)
  )

knitr::kable(runtime,
             col.names = c("Scenario", "Seconds", "Years", "ms per simulated year", "Relative to fastest"),
             digits = 2,
             caption = "Wall-clock time for simmar() by scenario. Time per simulated year adjusts for the different run lengths.")
Wall-clock time for simmar() by scenario. Time per simulated year adjusts for the different run lengths.
Scenario Seconds Years ms per simulated year Relative to fastest
A: rpue / revenue 0.11 20 5.4 1.1
B: ppue / profit 0.10 20 4.8 1.0
C: marginal_profit / rpue 0.30 20 14.8 3.0
Scenario 1: open_access (ppue) 0.25 50 5.0 1.0
Scenario 1: sole_owner (ppue) 0.81 50 16.3 3.4

Choosing a strategy

None of the options is the realistic one; pick the one whose story best fits the fleet being modelled, or compare several. As a rough guide: total objectives for a fleet that goes where the catch is; per-unit-effort objectives for competing vessels (the IFD); marginal objectives for a fleet run as one business; and fishable_revenue, with a small eps_mix, when effort should simply follow the fish. The fleet model is a separate choice that sets how much effort there is to allocate. See the port-distance and fleet-management vignettes for how these options interact with other marlin features, and spatial-allocation-memory for memory_halflife.