Chapter 16 Simple Model Building Tutorial: Schooling & Landward Movement
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:
- Check whether movement has started
- If migrating: face
home-patchand move forward byspeed - If schoolmates are present: apply separation, cohesion, and alignment rules
- 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.3 Implementation in NetLogo
This example uses a minimal environment — all patches are water and agents face and step toward home-patch each time step. There are no GIS data, no CSV inputs, no calendar, and no energy or velocity calculations. A complete working implementation is available at Simple Model NetLogo Implementation.
16.3.1 Variable declarations
patches-own [
patch-terrain ;; "water" or "land"
]
turtles-own [
;; Migration state
start-migration?
landward-migration?
seaward-migration?
at-destination?
;; Destination patches
home-patch
migration-patch
;; Swimming
speed
min-speed
;; Schooling
schoolmates
nearest-neighbor
minimum-separation
align-coefficient
cohere-coefficient
separate-coefficient
]
16.3.2 Setup
to setup
clear-all
ask patches [
set patch-terrain "water"
]
create-turtles 20 [
setxy (min-pxcor + random world-width) (min-pycor + random world-height)
set shape "fish"
set color cyan
;; Migration state — start migrating immediately in this minimal example
set start-migration? true
set landward-migration? true
set seaward-migration? false
set at-destination? false
;; Assign destination and entry patches
set home-patch patch max-pxcor 0 ;; upstream end
set migration-patch patch min-pxcor 0 ;; downstream entry point
;; Swimming parameters
set min-speed 0.5
set speed 1 + random-float 1
;; Schooling parameters (BOIDS coefficients)
set minimum-separation 1.5
set align-coefficient 25
set cohere-coefficient 25
set separate-coefficient 25
]
reset-ticks
end
16.3.3 The go procedure — time sequence
to go
ask turtles [
;; Step 1: move toward home-patch
;; face turns the agent toward its destination; fd moves it forward by speed.
;; No routing cost field or velocity calculation needed here.
if landward-migration? and not at-destination? [
face home-patch
fd speed
]
;; Step 2: apply schooling rules (separation, cohesion, alignment, speed)
;; find-schoolmates uses start-migration? and landward-migration? to form
;; groups only among agents sharing the same movement state.
school
;; Step 3: check arrival
if patch-here = home-patch [
set at-destination? true
set landward-migration? false
]
]
tick
end
16.3.4 What to observe
- School cohesion: Plot
mean [distance nearest-neighbor] of turtles with [any? schoolmates]over time. It should stabilize nearminimum-separation. - Movement progress: Plot
mean [xcor] of turtles— it should increase steadily towardmax-pxcor. - Speed convergence: Plot
mean [speed] of turtles— becauseadjust-speedpulls agents toward the group speed, individual variation should narrow over time.
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_speed16.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()