Chapter 25: Agent-Based Modeling with DisSModel¶
Part IV — DisSModel: Core and Paradigms
Implemented by the dissmodel-abm package.
Learning Objectives¶
By the end of this chapter you will be able to:
- Understand the
Society/Agentprotective layer over the vector substrate - Translate
Agent/Societyconcepts from TerraME todissmodel-abm - Write an agent-based model without touching
self.gdfdirectly - Know which models ship today, and what's explicitly still missing
# Standard imports
import numpy as np
import pandas as pd
import geopandas as gpd
import matplotlib.pyplot as plt
Chapter 24's cellular automata all shared one constraint: a fixed grid, one rule applied identically to every cell, cells that neither move nor disappear. Real agents don't cooperate with that constraint — they walk, they compete for space, they're born and they die mid-run. This chapter is where DisSModel stops pretending every spatial actor is a stationary cell.
Society and Agent: A Protective Layer¶
The whole point of dissmodel-abm is that model code never touches self.gdf directly. Killing an agent whose energy has run out, in raw GeoDataFrame terms, looks like this:
self.gdf = self.gdf[self.gdf["energy"] > 0].reset_index(drop=True)
Through self.society, the same rule reads as an object-oriented loop:
for agent in self.society:
if agent.energy <= 0:
agent.die()
Society owns no data of its own — it reads and writes through the host model's gdf attribute, so model.gdf and model.society are always views onto the same rows. Agent is a thin proxy over one row: agent.energy = 5 writes the underlying cell directly, no separate copy to keep synchronized. Map, Chart, and every ModelExecutor from Chapter 22 keep working completely unmodified underneath, whether or not a given model's execute() ever mentions self.society — AgentModel is a SpatialModel subclass with a lazily-created society property, not a parallel class hierarchy competing with Chapter 22's lifecycle.
import geopandas as gpd
import numpy as np
from dissmodel.core import Environment, Model
from dissmodel_abm.core import AgentModel
n = 20
bounds = (0, 0, 100, 100)
rng = np.random.default_rng(42)
gdf = gpd.GeoDataFrame({
"energy": rng.uniform(5, 12, n),
"geometry": gpd.points_from_xy(
rng.uniform(bounds[0], bounds[2], n),
rng.uniform(bounds[1], bounds[3], n),
),
})
class EnergyDrain(AgentModel):
def execute(self):
for agent in self.society:
agent.energy -= 2.0
if agent.energy <= 0:
agent.die()
env = Environment(start_time=0, end_time=3)
model = EnergyDrain(gdf=gdf)
print("Agents before:", len(model.gdf))
env.run()
print("Agents after: ", len(model.gdf))
Agents before: 20 Running from 0 to 3 (duration: 3) Agents after: 15
Agents without a location. Following TerraME — where an agent may exist with no placement until it's explicitly given one — an agent here can exist with geometry = None: society.add(energy=4.0) creates one, agent.has_location reports False, and agent.enter(x, y) gives it a position later. Calling a spatial method (walk, neighbors, distance_to) on a location-less agent raises a clear RuntimeError rather than failing deep inside geopandas on a None geometry.
One structural difference is worth naming explicitly: TerraME agents are autonomous objects that carry their own behavior, each with its own execute. dissmodel-abm agents are data with a uniform interface — behavior lives once, in the owning model's execute(), applied identically to every agent via for agent in self.society. That trades TerraME's per-agent heterogeneous behavior for staying close to a vectorizable substrate, the identical trade-off Chapter 24 already made for CellularAutomaton.rule(idx).
Concept Mapping: TerraME to dissmodel-abm¶
TerraME (Agent/Society) |
dissmodel-abm |
|---|---|
execute(self) |
execute() (Model lifecycle, Chapter 22) |
init(self) |
setup() (Model lifecycle, Chapter 22) |
Society (collection of Agents) |
self.society — object-oriented view over self.gdf |
Agent |
self.society[idx] — a proxy over one row |
placement / getCell() |
agent.geometry |
enter(cell) |
agent.enter(x, y) |
leave() |
agent.leave() |
move(cell) / walk() |
agent.move_to(x, y) / agent.walk(step_size, bounds) |
die() |
agent.die() |
reproduce() |
agent.reproduce(**overrides) |
| neighborhood | agent.neighbors(radius) (points) or agent.grid_neighbors() (cells) |
Society:add / Society:remove |
society.add(**attrs) / society.remove(agent_or_idx) |
forEachAgent |
for agent in society: ... |
addSocialNetwork / message |
not provided yet |
State / Jump / Flow |
not provided yet |
Two agent layouts recur across the shipped models. Point-agent models (RandomWalkModel, PredatorPreyModel) back self.society with Point geometry plus ordinary state columns — agents move continuously through space. One-agent-per-cell models (SchellingModel) back it with a polygon grid instead, vector_grid() from Chapter 7 — agents occupy discrete cells and can only move to another empty one.
The dissmodel-abm Package¶
Like every other extension package since Chapter 22, dissmodel-abm has no PyPI release yet:
git clone https://github.com/DisSModel/dissmodel-abm.git
cd dissmodel-abm
pip install -e .
Five models live in src/dissmodel_abm/models/ — a much smaller library than dissmodel-sysdyn's fourteen or dissmodel-ca's sixteen, because agent-based modeling is where the DisSModel port of TerraME's model libraries is least far along:
dissmodel_abm.models |
TerraME logo (lua/) |
What it models |
|---|---|---|
predator_prey.PredatorPreyModel |
PredatorPrey.lua |
Wolf-sheep predation on continuous space |
schelling.SchellingModel |
Schelling.lua |
Segregation from mild same-type preference |
labyrinth.LabyrinthModel |
Labyrinth.lua |
Random-walk maze escape |
ants.AntsModel |
Ants.lua |
Pheromone-trail foraging |
random_walk.RandomWalkModel |
(loosely, SingleAgent.lua) |
Unconstrained random walk, continuous space |
Seven more of TerraME's own logo models have no DisSModel port yet: Disease (SIR-style contagion between agents), GrowingSociety, Heatbugs, LifeCycle, Overpopulation, SpatialPD (spatial Prisoner's Dilemma), and Sugarscape — genuinely more of a gap than either Chapter 23 or 24 had to report.
This chapter works through two of the five in real depth — PredatorPreyModel and SchellingModel below — chosen because they're the two TerraME models this port was checked against directly. LabyrinthModel and AntsModel get a shorter comparative treatment further down; RandomWalkModel, next, is the simplest of the five and needs no TerraME comparison at all — it has no direct original, just the minimal shape every other model in the package builds on.
from dissmodel_abm.models import RandomWalkModel
env = Environment(start_time=0, end_time=20)
walk_model = RandomWalkModel(gdf=gdf.copy(), step_size=2.0, bounds=bounds)
env.run()
print("Sample agent final position:", walk_model.gdf.geometry.iloc[0])
Running from 0 to 20 (duration: 20) Sample agent final position: POINT (77.07597468885255 38.414162547327884)
Theory: Bottom-Up Modeling¶
Agent-based modeling appears under several names across the literature — ABM, multi-agent systems, individual-based modeling — spanning economics, sociology, ecology, and political science. What unifies them is a bottom-up approach: complex system behavior emerges from the interaction of discrete agents, rather than being specified as an aggregate equation the way Chapter 23's system dynamics models are. An agent is any actor able to affect itself, its environment, and other agents.
Helen Couclelis's classification of ABM applications, along two axes — natural versus artificial agent, natural versus artificial environment — places most of the models in this book in the same quadrant:
| Natural environment | Artificial environment | |
|---|---|---|
| Natural agent | Behavioral experiments | Descriptive model |
| Artificial agent | Engineering applications | e-science |
PredatorPreyModel, coming up next, sits squarely in "descriptive model" — artificial agents standing in for real animals, inside a deliberately simplified artificial environment.
Nigel Gilbert's case for why ABM is worth its extra complexity, relative to Chapter 23's aggregate models, comes down to three things a bottom-up model represents directly instead of assuming: structure (it emerges from agent interaction rather than being imposed from outside), agency (agents have goals and beliefs that drive their actions), and dynamics (agents move, learn, and change position — spatially and socially — over the course of a run). ABM also handles qualitative and relational data System Dynamics' continuous aggregate quantities simply can't represent — who is a given type, who is adjacent to whom.
Case Study: Predator-Prey, From Equation to Individual¶
Chapter 23 modeled predator and prey as two continuous stocks under Lotka-Volterra. This section rebuilds the same phenomenon bottom-up, translating each ODE parameter into an individual agent rule:
| ODE parameter | Agent rule |
|---|---|
r — prey growth |
eating pasture raises energy; above a threshold, reproduce (energy halved) |
m — predator mortality |
dies at energy ≤ 0 (applies to both kinds) |
a — predation |
a predator within eat_radius of prey kills it |
b — growth from predation |
predator gains energy from the kill; above a threshold, reproduces |
TerraME's own logo/PredatorPrey.lua is the direct source for this architecture — rabbits, wolves, and a regrowing pasture, all sharing one CellularSpace:
-- PredatorPrey.lua (TerraME logo) -- rabbit agent shown; wolf follows the same shape
model.rabbit = Agent{
energy = 40,
name = "rabbit",
eat = function(self)
if self:getCell().state == "pasture" then
self:getCell().state = "soil"
self.energy = self.energy + 20
end
end,
execute = function(self)
local cell = self:getCell():getNeighborhood():sample()
if cell:isEmpty() then self:move(cell) end
self.energy = self.energy - 1
self:eat()
cell = self:getCell():getNeighborhood():sample()
if self.energy >= 30 and cell:isEmpty() then
local child = self:reproduce()
child:move(cell)
self.energy = self.energy / 2
child.energy = self.energy
end
if self.energy < 0 then self:die() end
end
}
-- ...pasture cells regrow from "soil" back to "pasture" after 4 ticks;
-- wolves follow the identical shape, reproduce >= 50, gain 20% of prey's
-- energy on a kill -- see the "Watch out" box below for what changed
PredatorPreyModel runs on continuous space instead — Point geometry, an eat_radius search — and splits this logic into five explicit phases each tick: movement, metabolism, predation, death, reproduction. The DisSModel translation, defined here and actually run in this kernel — not just described:
from dissmodel_abm.core import AgentModel
class PredatorPreyModel(AgentModel):
sheep: int = 0
wolves: int = 0
def setup(self, step_size=1.0, bounds=(0, 0, 100, 100), eat_radius=1.0,
energy_loss=1.0, energy_gain=5.0, reproduce_threshold=15.0,
graze_gain=0.0, verbose=False):
self.step_size = step_size
self.bounds = bounds
self.eat_radius = eat_radius
self.energy_loss = energy_loss
self.energy_gain = energy_gain
self.reproduce_threshold = reproduce_threshold
self.graze_gain = graze_gain
self.verbose = verbose
if "energy" not in self.gdf.columns:
self.gdf["energy"] = 10.0
if "kind" not in self.gdf.columns:
self.gdf["kind"] = "sheep"
def execute(self):
society = self.society
if len(society) == 0:
self.sheep = self.wolves = 0
return
# 1. Movement
for agent in society:
agent.walk(step_size=self.step_size, bounds=self.bounds)
# 2. Metabolism (sheep graze, everyone loses energy)
for agent in society:
if self.graze_gain and agent.kind == "sheep":
agent.energy += self.graze_gain
agent.energy -= self.energy_loss
# 3. Predation: wolves eat nearby sheep
wolves = society.select(lambda a: a.kind == "wolf")
eaten = set()
for wolf in wolves:
sheep_nearby = [p for p in wolf.neighbors(self.eat_radius)
if p.id not in eaten and p.kind == "sheep"]
if sheep_nearby:
eaten.add(sheep_nearby[0].id)
wolf.energy += self.energy_gain
for prey_id in eaten:
society.remove(prey_id)
# 4. Death
society.remove_if(lambda agent: agent.energy <= 0)
# 5. Reproduction
for agent in society.select(lambda a: a.energy >= self.reproduce_threshold):
agent.reproduce(energy=self.reproduce_threshold / 2.0)
self.sheep = society.count(lambda a: a.kind == "sheep")
self.wolves = society.count(lambda a: a.kind == "wolf")
Execution — the same starting population, run against the maintained package:
from dissmodel_abm.models import PredatorPreyModel
rng = np.random.default_rng(0)
n_sheep, n_wolves = 30, 8
kinds = ["sheep"] * n_sheep + ["wolf"] * n_wolves # must match the model's own label, "wolf"
xs = rng.uniform(bounds[0], bounds[2], n_sheep + n_wolves)
ys = rng.uniform(bounds[1], bounds[3], n_sheep + n_wolves)
pp_gdf = gpd.GeoDataFrame({
"kind": kinds,
"energy": [10.0] * (n_sheep + n_wolves),
"geometry": gpd.points_from_xy(xs, ys),
})
env = Environment(start_time=0, end_time=20)
pp_model = PredatorPreyModel(
gdf=pp_gdf, bounds=bounds, eat_radius=3.0,
energy_loss=1.0, energy_gain=5.0,
reproduce_threshold=15.0, graze_gain=1.5,
)
env.run()
print("Final population:", pp_model.gdf["kind"].value_counts().to_dict())
Running from 0 to 20 (duration: 20)
Final population: {'sheep': 377}
With these particular starting numbers, the wolves do hunt (the flock ends 13 sheep smaller than it would with no predators at all), but not fast enough to feed themselves: sheep out-reproduce the wolves' hunting rate and the wolf population collapses to zero — a legitimate outcome of the parameters chosen, not a bug, and exactly the sort of imbalance Exercise 2 asks you to correct by tuning eat_radius and energy_gain until both populations persist, the way Chapter 23's phase-plane plot showed the continuous version doing.
Watch out
This is a 1:1 architectural port of TerraME's logo/PredatorPrey.lua, not a 1:1 numerical one. Three parameters differ from the original by design: TerraME used per-species reproduction thresholds (rabbits ≥ 30, wolves ≥ 50) where this model has one shared reproduce_threshold; TerraME's predation gain was 20% of the prey's own energy at capture, where this model uses a fixed energy_gain; and TerraME's pasture→soil→pasture regrowth cycle has no equivalent here — graze_gain is a flat, unconditional gain instead. Reproducing the original course's exact numbers means setting these explicitly, not trusting the defaults.
SchellingModel, the third shipped model, applies the same self.society discipline to the one-agent-per-cell layout instead — ported directly from TerraME's own logo package as a validated reference point:
-- Schelling.lua (TerraME)
Schelling = Model{
finalTime = Choice{min = 10, default = 500},
freeSpace = Choice{min = 0.05, max = 0.20, step = 0.05},
dim = Choice{min = 25, max = 400, step = 25},
preference = Choice{min = 3, max = 6, step = 1},
random = true,
init = function (model)
model.cell = Cell{
state = function(cell)
local agent = cell:getAgent()
if agent then return agent.state else return "free" end
end
}
model.cs = CellularSpace{xdim = model.dim, instance = model.cell}
model.cs:createNeighborhood{wrap = true}
model.agent = Agent{
state = Random{"brazil", "germany"},
isUnhappy = function(agent)
local mycell = agent:getCell()
local likeme = 0
forEachNeighbor(mycell, function(neigh)
local other = neigh:getAgent()
if other and other.state == agent.state then
likeme = likeme + 1
end
end)
return likeme < model.preference
end
}
-- ...Society of unhappy-agent lookup, step() moves one unhappy
-- agent per tick to a random empty cell -- see "Execution" below
end
}
The DisSModel translation, defined here and actually run in this kernel — not just described:
from libpysal.weights import Queen
import numpy as np
from dissmodel_abm.core import AgentModel
EMPTY = -1
class SchellingModel(AgentModel):
def setup(self, free_space=0.25, preference=3, seed=None):
self.free_space = free_space
self.preference = preference
self.create_neighborhood(strategy=Queen, use_index=True)
rng = np.random.default_rng(seed)
n = len(self.society)
n_empty = int(round(n * free_space))
n_occupied = n - n_empty
n_red = n_occupied // 2
n_blue = n_occupied - n_red
types = np.array([0] * n_red + [1] * n_blue + [EMPTY] * n_empty)
rng.shuffle(types)
self.gdf["agent_type"] = 0
for agent, t in zip(self.society, types):
agent.agent_type = int(t)
def execute(self):
society = self.society
type_map = {agent.id: agent.agent_type for agent in society}
empty_idx = [idx for idx, t in type_map.items() if t == EMPTY]
if not empty_idx:
self.satisfaction = self.fraction_satisfied()
return
rng = np.random.default_rng()
order = list(type_map.keys())
rng.shuffle(order)
for idx in order:
t = type_map[idx]
if t == EMPTY:
continue
neighs = self.neighs_id(idx)
same = sum(1 for nb in neighs if type_map.get(nb, EMPTY) == t)
if same < self.preference and empty_idx:
target = empty_idx.pop(rng.integers(len(empty_idx)))
type_map[idx], type_map[target] = EMPTY, t
empty_idx.append(idx)
for agent in society:
agent.agent_type = type_map[agent.id]
self.satisfaction = self.fraction_satisfied()
Execution — same defaults as TerraME's dim=25 (via its Choice's implicit minimum) and 25% free space:
from dissmodel.geo.vector import vector_grid
from dissmodel_abm.models import SchellingModel
schelling_gdf = vector_grid(dimension=(25, 25), resolution=1)
env = Environment(start_time=0, end_time=30)
schelling = SchellingModel(gdf=schelling_gdf, free_space=0.25, preference=3, seed=0)
env.run()
print("Fraction satisfied:", schelling.fraction_satisfied())
Running from 0 to 30 (duration: 30)
Fraction satisfied: 1.0
A fraction_satisfied() of 1.0 means the model converged — every agent ended up with at least preference same-type neighbors, Schelling's classic segregation result emerging from nothing more than individually mild, locally-applied preferences.
Two More, in Brief: Labyrinth and Ants¶
PredatorPreyModel and SchellingModel got the full treatment — defined and run live above; LabyrinthModel and AntsModel get the shorter comparative version instead: real Lua next to real Python, definition only, no live execution.
Labyrinth drops quantity agents into a maze and lets each wander to a random empty neighbor until it finds the exit:
-- Labyrinth.lua (TerraME)
Labyrinth = Model{
quantity = 1,
finalTime = 1000,
init = function(model)
model.cs = getLabyrinth(model.labyrinth) -- loads a maze pattern
model.cs:createNeighborhood()
model.agent = Agent{
execute = function(agent)
local empty = {}
local exit
forEachNeighbor(agent:getCell(), function(neigh)
if neigh.state == "exit" then exit = neigh
elseif neigh.state == "empty" then table.insert(empty, neigh)
end
end)
if exit then
exit.state = "found"
agent:leave()
agent.execute = function() end
else
agent:move(Random(empty):sample())
end
end
}
-- ...Society of `quantity` agents, placed randomly; Map/Timer wired below...
end
}
# labyrinth.py (dissmodel-abm)
def setup(self, n_walkers: int = 1, seed: int | None = None) -> None:
self.create_neighborhood(strategy=Queen, use_index=True)
# ...place n_walkers agents on random empty cells...
def execute(self) -> None:
for walker in self.society.select(lambda a: a.kind == "walker"):
cell_id = walker.cell_id
exit_id, empty_ids = None, []
for neighbor_id in self.neighs_id(cell_id):
state = self.gdf.at[neighbor_id, "state"]
if state == "exit":
exit_id = neighbor_id
elif state == "empty":
empty_ids.append(neighbor_id)
if exit_id is not None:
self.gdf.at[exit_id, "state"] = "found"
walker.die()
self.found += 1
elif empty_ids:
new_cell_id = empty_ids[rng.integers(len(empty_ids))]
walker.cell_id = new_cell_id
walker.move_to(self.gdf.at[new_cell_id, "geometry"].centroid)
Same shape as the Lua original — check neighbors for the exit, otherwise move to a random empty one — with agent:leave() becoming walker.die(), TerraME's implicit per-agent execute override (agent.execute = function() end, disabling itself once found) becoming an explicit kind == "walker" filter instead.
Ants is TerraME's most elaborate logo model — full pheromone-trail foraging, cells that evaporate chemical deposits over time, ants that switch between searching and returning-to-nest behavior. dissmodel_abm's port keeps the same two-state ant behavior but factors it into private helpers (_search_step, _bring_step) rather than one long inline function:
-- Ants.lua (TerraME) -- the searching-ant decision, extracted from a longer file
-- (chemical evaporation and nest-finding logic omitted here)
if ant_state == "searching" then
if cell.food > 0 then
cell.food = cell.food - 1
ant_state = "bringing"
-- ...deposit initial chemical...
else
-- ...move toward the neighbor with the most chemical, or a random one...
end
end
# ants.py (dissmodel-abm)
def execute(self) -> None:
for ant in self.society.select(lambda a: a.kind == "ant"):
if ant.state == "searching":
self._search_step(ant, rng)
else:
self._bring_step(ant, rng)
# Evaporation, vectorized over every cell at once
cells_mask = self.gdf["kind"] == "cell"
pheromone = self.gdf.loc[cells_mask, "pheromone"] * (1 - self.evaporation_rate)
pheromone[pheromone < 0.01] = 0.0
self.gdf.loc[cells_mask, "pheromone"] = pheromone
def _search_step(self, ant, rng) -> None:
cell_id = ant.cell_id
if self.gdf.at[cell_id, "food"] > 0:
self.gdf.at[cell_id, "food"] -= 1
ant.state = "bringing"
self._deposit(cell_id)
return
neighbor_ids = list(self.neighs_id(cell_id))
rng.shuffle(neighbor_ids)
best = max(neighbor_ids, key=lambda nb: self.gdf.at[nb, "pheromone"])
target = best if self.gdf.at[best, "pheromone"] > 0 else neighbor_ids[0]
self._move_ant(ant, target)
The evaporation step is the one place execute() reaches past self.society into self.gdf directly — a deliberate exception, not an oversight: evaporation touches every cell uniformly each tick, exactly the kind of bulk, agent-free update pandas vectorizes and a for cell in ... loop would not.
What's Not There Yet¶
Stated directly in the package's own roadmap, not implied by omission: raster substrate (a Society backed by a NumPy array instead of a GeoDataFrame, so model code written against self.society would keep working unchanged regardless of substrate — vector support is being hardened first, deliberately); social networks (TerraME's message-passing between agents, likely as a thin layer over a graph library keyed by agent ID); and state machines (TerraME's State/Jump/Flow, for agents whose behavior depends on a discrete internal mode). The package table above already flags seven of TerraME's logo models with no DisSModel port at all (Disease, GrowingSociety, Heatbugs, LifeCycle, Overpopulation, SpatialPD, Sugarscape); Chapter 32's migration guide comes back to that gap directly — not every TerraME agent model can be migrated today without a gap, and checking which gap applies before assuming a straightforward port is worth the five minutes it takes.
Exercises¶
- Why no snapshot?
Agentneeds no__init__-time copy of its data. What makesagent.energy = 5immediately visible inmodel.gdf, without an explicit sync step? - Balance the ecosystem. Starting from the Case Study parameters, adjust
eat_radius,energy_gain, andgraze_gainuntil bothsheepandwolvessurvive past tick 20 without either population collapsing to zero. Report the parameters you landed on. - Point agents vs grid agents. Using the concept-mapping table, explain why
agent.grid_neighbors()only makes sense forSchellingModelandagent.neighbors(radius)only forPredatorPreyModel— what's structurally different about how each model's agents occupy space? - A gap that matters to you. Pick one item from What's Not There Yet (raster substrate, social networks, state machines) and describe, in a sentence, a model you'd want to build that needs it specifically.
# Your code here
Summary¶
Key concepts introduced¶
Society/Agentas a protective, substrate-agnostic layer overself.gdf— agents read and written as objects, never as raw DataFrame masks, whileMap,Chart, andModelExecutorkeep working underneath- The TerraME-to-
dissmodel-abmconcept mapping, and the point-agent versus one-agent-per-cell distinction it implies dissmodel-abm's five-model library, mapped against TerraME's ownlogopackage — the least complete port in the book so far, with sevenlogomodels not yet carried overPredatorPreyModelandSchellingModelin full depth — defined as real, running classes in this notebook, not just described, then executed against the maintained package;LabyrinthModelandAntsModelas shorter Lua/Python comparisons with no live execution;RandomWalkModelneeding no comparison at all, since it has no direct TerraME original- Predator-Prey rebuilt bottom-up from Chapter 23's Lotka-Volterra ODE, with three explicitly documented parameter gaps between the architectural port and the original's exact numbers
- An honest list of what
dissmodel-abmdoesn't do yet — raster substrate, social networks, state machines — that Chapter 32's migration guide checks against directly
Chapter 26 leaves the general-purpose paradigm chapters behind and turns to a specific domain: land use and cover change, built on the same Model/AgentModel foundation this Part has spent five chapters establishing.
Further Reading¶
- Gilbert, N. (2008). Agent-Based Models. Sage Publications — a concise case for bottom-up modeling over aggregate equations
- Couclelis, H. (2001). "Why I no longer work with agents." In Agent-Based Models of Land-Use and Land-Cover Change
- TerraME's
logopackage documentation, the source ofSchellingModel's validated defaults: https://www.terrame.org/package/logo/models/ - TerraME/logo on GitHub — source for every Lua model compared in this chapter: https://github.com/TerraME/logo
- dissmodel-abm on GitHub: https://github.com/DisSModel/dissmodel-abm