Chapter 16 Simple Model Building Tutorial: Schooling & Landward Movement

⬇ Download this chapter

This tutorial introduces function integration by combining two submodels — schooling and landward movement — into a single model time step. It is intentionally simple: movement is represented as a generic forward step (fd speed) with no hydrodynamic resistance, routing cost fields, or energy tracking. The goal is to show how these two functions talk to each other and in what order they should be called, before adding the complexity of real hydrology or site-specific data.

16.1 Module Integration

Schooling and landward movement interact because they share the same agents and operate within the same time step, but serve different purposes. movement drives each agent toward an upstream destination by turning to face home-patch and stepping forward; schooling adjusts heading and speed so that agents moving in the same direction stay cohesive as a group. When both functions are active together, movement sets the gross direction of travel and schooling refines it. Within this simple example, agents move toward their destination while also maintaining spacing and alignment with nearby con-specifics.

Temporal structure. Each tick represents one discrete time step. Every agent does the following, in order:

  1. Check whether movement has started
  2. If migrating: face home-patch and move forward by speed
  3. If schoolmates are present: apply separation, cohesion, and alignment rules
  4. Check arrival at destination

Spatial scale. The environment is a simple abstract grid of water patches. There are no real coordinates, tidal cycles, or input data files. Agents have a home-patch (upstream destination) and a migration-patch (downstream entry point), which are set at initialization.

Coupling logic. movement faces the agent toward home-patch and calls fd speed; schooling then adjusts heading and speed after movement, modifying the next time step’s trajectory. This ordering means movement drives direction and schooling refines it. Because adjust-speed can slow an agent below its set speed, the school naturally converges on an emergent group speed — this is intentional behavior representing the cohesion trade-off.

16.2 Function Dependencies

Function Required Inputs Output Variables Dependent On
migrate-landward speed, home-patch xcor, ycor, patch-here movement state flags
find-schoolmates breed, start-migration?, landward-migration? schoolmates movement state flags
find-nearest-neighbor schoolmates nearest-neighbor find-schoolmates
separate nearest-neighbor, minimum-separation, speed, min-speed heading, speed find-nearest-neighbor
cohere schoolmates, cohere-coefficient heading find-nearest-neighbor
align schoolmates, align-coefficient heading find-nearest-neighbor
adjust-speed schoolmates, speed, min-speed speed find-nearest-neighbor

Key dependency: Schooling requires start-migration? and landward-migration? to be true before find-schoolmates will return any agents. An agent that has not yet begun migrating will have an empty schoolmates agent set and skip the schooling block entirely.

16.4 Implementation in R

The R version represents the same logic using a data frame of agents updated in a loop. Movement is a simple position increment toward home_x — no routing, no velocity, no energy — and schooling adjusts heading and speed using the same BOIDS rules. A complete working implementation is available at Simple Model R Implementation.

16.4.1 Agent data structure

library(dplyr)

n_agents <- 20
n_ticks  <- 150

agents <- tibble(
  id                   = 1:n_agents,
  x                    = runif(n_agents, 0, 5),   # random start positions
  y                    = runif(n_agents, -5, 5),
  home_x               = 50,                      # upstream destination
  start_migration      = TRUE,
  landward_migration   = TRUE,
  at_destination       = FALSE,
  speed                = runif(n_agents, 1, 2),
  min_speed            = 0.5,
  heading              = 0,                       # degrees, 0 = facing upstream
  minimum_separation   = 1.5,
  cohere_coefficient   = 0.25,
  align_coefficient    = 0.25,
  separate_coefficient = 0.25
)

16.4.2 Submodel functions

# ── Landward Movement ────────────────────────────────────────────────────────
# Turns agent toward home_x and steps forward by speed.
# No velocity, cost field, or energy — just direction and distance.
migrate_landward <- function(x, y, speed, home_x) {
  angle <- atan2(0, home_x - x)        # heading toward home (y held at 0)
  new_x <- x + speed * cos(angle)
  new_x <- pmin(new_x, home_x)         # cap at destination
  list(x = new_x, y = y, heading = angle * 180 / pi)
}

# ── Find schoolmates ──────────────────────────────────────────────────────────
find_schoolmates <- function(agent_id, agents) {
  focal <- agents[agents$id == agent_id, ]
  agents[agents$id != agent_id &
           agents$start_migration &
           agents$landward_migration == focal$landward_migration, ]
}

# ── Schooling (BOIDS rules) ───────────────────────────────────────────────────
school_agent <- function(agent_id, agents) {
  focal <- agents[agents$id == agent_id, ]
  mates <- find_schoolmates(agent_id, agents)

  if (nrow(mates) == 0) {
    return(list(heading = focal$heading, speed = focal$speed))
  }

  # Nearest neighbor
  dists   <- sqrt((mates$x - focal$x)^2 + (mates$y - focal$y)^2)
  nn_dist <- min(dists)
  nn_x    <- mates$x[which.min(dists)]

  new_heading <- focal$heading
  new_speed   <- focal$speed

  if (nn_dist < focal$minimum_separation) {
    # Separate: slow down if neighbor is ahead, turn away otherwise
    if (nn_x > focal$x) {
      new_speed <- max(focal$min_speed, focal$speed - 0.1)
    } else {
      new_heading <- focal$heading +
        (focal$separate_coefficient / 100) * sign(focal$x - nn_x) * 10
    }
  } else {
    # Cohere: turn toward centroid of schoolmates
    centroid_x  <- mean(mates$x)
    new_heading <- focal$heading +
      (focal$cohere_coefficient / 100) * sign(centroid_x - focal$x) * 10

    # Align: match mean heading of schoolmates
    mean_heading <- mean(mates$heading)
    new_heading  <- new_heading +
      (focal$align_coefficient / 100) * (mean_heading - new_heading)
  }

  # Adjust speed: speed up if a schoolmate is faster, otherwise slow down
  if (max(mates$speed) > focal$speed + 0.1) {
    new_speed <- focal$speed * 1.1
  } else if (focal$speed > focal$min_speed) {
    new_speed <- focal$speed * 0.9
  }
  new_speed <- max(new_speed, focal$min_speed)

  list(heading = new_heading %% 360, speed = new_speed)
}

16.4.3 Simulation loop

log_ticks <- vector("list", n_ticks)

for (tick in 1:n_ticks) {

  # Step 1: migrate — face home and step forward
  migrating <- agents$start_migration & agents$landward_migration &
               !agents$at_destination

  move_results <- lapply(which(migrating), function(i) {
    migrate_landward(agents$x[i], agents$y[i],
                     agents$speed[i], agents$home_x[i])
  })

  agents$x[migrating]       <- sapply(move_results, `[[`, "x")
  agents$y[migrating]       <- sapply(move_results, `[[`, "y")
  agents$heading[migrating] <- sapply(move_results, `[[`, "heading")

  # Step 2: school — separation, cohesion, alignment, speed adjustment
  school_results <- lapply(agents$id, function(aid) {
    school_agent(aid, agents)
  })
  agents$heading <- sapply(school_results, `[[`, "heading")
  agents$speed   <- sapply(school_results, `[[`, "speed")

  # Step 3: check arrival
  agents <- agents |>
    mutate(
      at_destination     = x >= home_x,
      landward_migration = !at_destination
    )

  # Log summary statistics
  log_ticks[[tick]] <- tibble(
    tick       = tick,
    mean_x     = mean(agents$x),
    mean_speed = mean(agents$speed),
    n_arrived  = sum(agents$at_destination)
  )
}

results <- bind_rows(log_ticks)

16.4.4 Visualizing outputs

library(ggplot2)

# Migration progress
ggplot(results, aes(x = tick, y = mean_x)) +
  geom_line(color = "#2c7bb6", linewidth = 1) +
  labs(title = "Mean agent position over time",
       x = "Tick", y = "Mean upstream position") +
  theme_minimal()

# Speed convergence from schooling
ggplot(results, aes(x = tick, y = mean_speed)) +
  geom_line(color = "#d7191c", linewidth = 1) +
  labs(title = "Mean agent speed over time",
       x = "Tick", y = "Mean speed") +
  theme_minimal()

# Cumulative arrivals
ggplot(results, aes(x = tick, y = n_arrived)) +
  geom_step(color = "#1a9641", linewidth = 1) +
  labs(title = "Cumulative arrivals at home patch",
       x = "Tick", y = "Number of agents arrived") +
  theme_minimal()

16.5 Implementation in Python

The Python version follows the same structure as the R implementation — a data frame of agents updated in a loop, with movement as a simple step toward home_x and schooling applied per agent. The pandas library handles the agent table; schooling is applied with a row-wise loop since each agent needs to read the full table. A complete working implementation is available at Simple Model Python Implementation.

16.5.1 Agent data structure

import numpy as np
import pandas as pd

n_agents = 20
n_ticks  = 150
rng      = np.random.default_rng(seed=42)

agents = pd.DataFrame({
    "id"                  : np.arange(n_agents),
    "x"                   : rng.uniform(0, 5, n_agents),   # random start positions
    "y"                   : rng.uniform(-5, 5, n_agents),
    "home_x"              : 50,                            # upstream destination
    "start_migration"     : True,
    "landward_migration"  : True,
    "at_destination"      : False,
    "speed"               : rng.uniform(1, 2, n_agents),
    "min_speed"           : 0.5,
    "heading"             : 0.0,                           # degrees, 0 = upstream
    "minimum_separation"  : 1.5,
    "cohere_coefficient"  : 0.25,
    "align_coefficient"   : 0.25,
    "separate_coefficient": 0.25,
})

16.5.2 Submodel functions

# ── Landward Movement ────────────────────────────────────────────────────────
# Turns agent toward home_x and steps forward by speed.
# No velocity, cost field, or energy — just direction and distance.

def migrate_landward(x, y, speed, home_x):
    angle = np.arctan2(0, home_x - x)        # heading toward home (y held at 0)
    new_x = x + speed * np.cos(angle)
    new_x = min(new_x, home_x)               # cap at destination
    heading = np.degrees(angle)
    return new_x, y, heading


# ── Find schoolmates ──────────────────────────────────────────────────────────

def find_schoolmates(agent_id, agents):
    focal = agents.loc[agents["id"] == agent_id].iloc[0]
    mates = agents[
        (agents["id"] != agent_id) &
        (agents["start_migration"]) &
        (agents["landward_migration"] == focal["landward_migration"])
    ]
    return mates


# ── Schooling (BOIDS rules) ───────────────────────────────────────────────────

def school_agent(agent_id, agents):
    focal = agents.loc[agents["id"] == agent_id].iloc[0]
    mates = find_schoolmates(agent_id, agents)

    if mates.empty:
        return focal["heading"], focal["speed"]

    # Nearest neighbor
    dists   = np.sqrt((mates["x"] - focal["x"])**2 +
                      (mates["y"] - focal["y"])**2)
    nn_dist = dists.min()
    nn_x    = mates.loc[dists.idxmin(), "x"]

    new_heading = focal["heading"]
    new_speed   = focal["speed"]

    if nn_dist < focal["minimum_separation"]:
        # Separate: slow down if neighbor is ahead, turn away otherwise
        if nn_x > focal["x"]:
            new_speed = max(focal["min_speed"], focal["speed"] - 0.1)
        else:
            new_heading = focal["heading"] + (
                focal["separate_coefficient"] / 100
            ) * np.sign(focal["x"] - nn_x) * 10

    else:
        # Cohere: turn toward centroid of schoolmates
        centroid_x  = mates["x"].mean()
        new_heading = focal["heading"] + (
            focal["cohere_coefficient"] / 100
        ) * np.sign(centroid_x - focal["x"]) * 10

        # Align: match mean heading of schoolmates
        mean_heading = mates["heading"].mean()
        new_heading  = new_heading + (
            focal["align_coefficient"] / 100
        ) * (mean_heading - new_heading)

    # Adjust speed: speed up if a schoolmate is faster, otherwise slow down
    if mates["speed"].max() > focal["speed"] + 0.1:
        new_speed = focal["speed"] * 1.1
    elif focal["speed"] > focal["min_speed"]:
        new_speed = focal["speed"] * 0.9
    new_speed = max(new_speed, focal["min_speed"])

    return new_heading % 360, new_speed

16.5.3 Simulation loop

log_ticks = []

for tick in range(1, n_ticks + 1):

    # Step 1: migrate — face home and step forward
    migrating = (
        agents["start_migration"] &
        agents["landward_migration"] &
        ~agents["at_destination"]
    )

    for i in agents.index[migrating]:
        row = agents.loc[i]
        new_x, new_y, new_heading = migrate_landward(
            row["x"], row["y"], row["speed"], row["home_x"]
        )
        agents.at[i, "x"]       = new_x
        agents.at[i, "y"]       = new_y
        agents.at[i, "heading"] = new_heading

    # Step 2: school — separation, cohesion, alignment, speed adjustment
    school_results = [school_agent(aid, agents) for aid in agents["id"]]
    agents["heading"] = [r[0] for r in school_results]
    agents["speed"]   = [r[1] for r in school_results]

    # Step 3: check arrival
    agents["at_destination"]    = agents["x"] >= agents["home_x"]
    agents["landward_migration"] = ~agents["at_destination"]

    # Log summary statistics
    log_ticks.append({
        "tick"      : tick,
        "mean_x"    : agents["x"].mean(),
        "mean_speed": agents["speed"].mean(),
        "n_arrived" : agents["at_destination"].sum(),
    })

results = pd.DataFrame(log_ticks)

16.5.4 Visualizing outputs

import matplotlib.pyplot as plt

fig, axes = plt.subplots(1, 3, figsize=(12, 4))

# Migration progress
axes[0].plot(results["tick"], results["mean_x"], color="#2c7bb6", linewidth=1.5)
axes[0].set_title("Mean agent position over time")
axes[0].set_xlabel("Tick")
axes[0].set_ylabel("Mean upstream position")

# Speed convergence from schooling
axes[1].plot(results["tick"], results["mean_speed"], color="#d7191c", linewidth=1.5)
axes[1].set_title("Mean agent speed over time")
axes[1].set_xlabel("Tick")
axes[1].set_ylabel("Mean speed")

# Cumulative arrivals
axes[2].step(results["tick"], results["n_arrived"],
             color="#1a9641", linewidth=1.5, where="post")
axes[2].set_title("Cumulative arrivals at home patch")
axes[2].set_xlabel("Tick")
axes[2].set_ylabel("Number of agents arrived")

plt.tight_layout()
plt.show()