import matplotlib
if not hasattr(matplotlib.RcParams, "_get"):
    matplotlib.RcParams._get = dict.get

3.1: Household washing coordination#

In this hands-on tutorial, you will build a complete agent-based model in Mesa to simulate household washing-machine use. The model shows how individual decisions about when to wash can affect electricity demand and pricing. To work through the code, select the rocket icon at the top of this page and choose Live Code.

What you will learn

  • How to create basic Mesa agents and models

  • How to give agents behaviour and step a model through time

  • How to add a grid and neighbourhood interactions

  • How to collect data and visualise results

  • How to manage agent groups, run batch experiments, and analyse output

Scenario

Imagine a neighbourhood where electricity prices change with demand. Households decide when to run their washing machines by balancing urgency, price sensitivity, and neighbour influence. When many households wash simultaneously, the price rises and creates a feedback loop between individual decisions and system-wide costs.

Tutorial structure

The tutorial is built in eight short sections, each adding one Mesa idea:

  1. Setting up the model: the Agent and Model classes, activation, and random seeds

  2. Agent and model behaviour: household decisions and dynamic pricing

  3. Adding space and neighbouring agents: placing agents on a grid

  4. Collecting data: recording metrics with a DataCollector

  5. Visualisation: animating the model over time

  6. Agent management with AgentSet: grouping agents by type

  7. Batch runs: sweeping parameters across many runs

  8. Accessing data with pandas: analysing the collected results

Important

For live code, launch Thebe by clicking on the in the toolbar. Your code edits are automatically saved in your browser. Use the button to export your progress to a file, to import a previously saved session, or to reset to the original code. For a fuller guide to the interactive features and how to get the most out of this book, see How to use this book.

# Install the packages used in this tutorial.
# %pip is a Jupyter command: it installs into the Python environment behind this notebook.
# Pinning Mesa to 3.5.0 keeps the API in the code below consistent for every learner.
%pip install mesa==3.5.0 seaborn networkx
# Import the libraries used throughout the tutorial.
# Importing makes code from an installed package available in this notebook.

# --- Plotting and data-analysis tools ---
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns

# --- Mesa itself, plus the two base classes used to build the first model ---
import mesa
from mesa import Agent, Model

# An f-string inserts the value inside {...} into a piece of text.
print(f"You are using Mesa version: {mesa.__version__}")

3.1.1: Setting up the model#

Start with the smallest complete Mesa skeleton before adding domain detail. Creating one agent class, one model class, and one step method gives you a runnable baseline whose registration and activation can be checked immediately. Building outwards from that baseline makes later errors easier to localise: when a new behaviour breaks the model, you know which addition introduced the problem. This incremental structure follows the Mesa documentation, but the reasoning applies to any ABM framework.

In a nutshell

A Mesa model is two classes, each with one job. The Agent class knows how a single household behaves. The Model class owns all the agents, the clock, and the shared world they live in. Everything else you add in this chapter, from decisions to space to data collection, hangs off these two classes.

3.1.1.1: Start building the model#

We will build a model where households decide when to run their washing machines based on urgency, price sensitivity, and neighbour influence. Electricity prices increase with grid usage, creating feedback loops between individual decisions and system-wide costs.

Rather than writing the whole thing at once, you will grow it in small, runnable pieces. We start with the simplest possible agent: a household with a single urgency attribute that starts at 0. Step by step you will add price sensitivity, neighbour influence, and the decision logic that ties them together, running the model after each addition so you always understand what you just changed.

3.1.1.2: Create the agent#

The first piece is the agent. Keep it deliberately bare for now; every later section adds one more capability to it.

An agent is one of the individual entities that act in the model, and here each agent is a household deciding whether to run its washing machine. When you create an agent, Mesa automatically gives it an integer unique_id, so you never have to number them yourself.

You define an agent by writing a new class that extends mesa.Agent, which makes HouseholdAgent a subclass of Mesa’s Agent. That inheritance is what gives your household all of Mesa’s built-in machinery for free; if you are curious what it inherits, the Agent source lives in the Mesa repository. For now the household stores just its urgency.

The HouseholdAgent class is created with the following code. Run it.

# Define the smallest useful Mesa agent.

# --- 1. Define a new kind of object ---
# A class is a blueprint. `(Agent)` means HouseholdAgent inherits Mesa's agent machinery.
class HouseholdAgent(Agent):
    """
    A household that needs to schedule washing machine usage.
    Each household has urgency to wash and makes scheduling decisions.
    """
    # --- 2. Describe the starting state of every household object ---
    # Python calls __init__ automatically when a new HouseholdAgent is created.
    # `self` is that particular household; `model` is the WashingModel that owns it.
    def __init__(self, model):
        # Let the parent Agent class register this household and assign its unique_id.
        super().__init__(model)

        # Attributes stored on self belong to this household and may differ across agents.
        self.urgency = 0

The activity below presents a half-written Mesa agent class with two blanks: the base class it must inherit from, and the first line of its __init__. These two ingredients are exactly what allow Mesa to recognise a new agent and register it in the model. Before you complete them, consider which line is responsible for that registration.

Learning Activity: Assemble a Mesa agent
assemble a Mesa agent from its building blocks.
A half-written Mesa agent class with two blanks, paired with a live model registry that reacts to whichever lines you choose.
Click a pill to fill each blank, then press Check to score your answer out of 2 and watch the model registry react.
class HouseholdAgent(…):
    def __init__(self, model):
        …
        self.urgency = 0
Blank 1 · the base class you inherit from
Blank 2 · the first line inside __init__
Model registry
self.agents starts empty. Run the check to see what your code registers.
model.agents → { }
agent.unique_id → ?
Bonus: press the create_agents button to spawn 5 agents and watch Mesa number them for you.

3.1.1.3: Create the model#

Next comes the model, the second of the two classes every Mesa ABM needs. With the agent and the model in place, you have the full skeleton of a simulation.

Think of the model as the container that holds all the agents. It creates them, keeps track of them, and activates them in discrete time steps, one tick of the clock at a time. Where the agent describes one household, the model describes the world all the households share.

You define it by writing a new class that extends mesa.Model and calls super().__init__() so Mesa can set up its internal bookkeeping. This one is named WashingModel. Run this cell as well.

# Define the model that owns the household population and shared electricity state.

# A WashingModel object will be the container for one complete simulation run.
class WashingModel(Model):
    """
    A model with some number of households.
    This class represents the overall model/environment in which our households operate.
    """

    # N is an input to the constructor: it says how many households to create.
    def __init__(self, N):
        # Initialise the parent Model before adding our own attributes.
        # Mesa creates the clock, random-number generator, and self.agents AgentSet here.
        super().__init__()

        # Store N on this model object so other methods can use it later.
        self.num_households = N

        # --- Shared state: one value for the whole simulated neighbourhood ---
        self.base_price = 0.70
        self.current_price = self.base_price
        self.previous_usage = 0

        # --- Population creation ---
        # create_agents constructs N separate HouseholdAgent objects.
        # Passing model=self connects every new household to this WashingModel.
        HouseholdAgent.create_agents(model=self, n=self.num_households)

The activity below lists a set of responsibilities from the washing model and asks which class should own each one. It rests on a distinction that runs through every Mesa model: an agent manages only itself, while the model owns and drives the whole population. Before you begin, predict where the boundary between the two classes falls.

Learning Activity: Agent or model: whose job?
decide which responsibilities belong to the agent and which to the model.
Nine responsibilities from a Mesa model, each belonging either to a single mesa.Agent that manages only itself or to the mesa.Model that owns and drives the whole population.
For each responsibility choose Agent or Model, then press Check to score your answers.
Holds its own self.urgency
Owns self.agents (the AgentSet)
Decides to start washing (wash_decision)
Creates all households with create_agents
Has a unique_id
Sets the shared current_price
Calls super().__init__(model)
Advances time in step() and calls shuffle_do
Calls super().__init__() with no model argument
An Agent knows only itself: its own self.urgency, its unique_id, and its own wash decision. A Model knows everyone: it builds the households with create_agents, keeps them in self.agents, sets the shared current_price, and drives time in step() with shuffle_do. A single household cannot create the neighbourhood or set the price, and the model never does one household's laundry. Watch the two super().__init__ calls that differ: the agent passes model so it can find its way home, while the model passes nothing.

3.1.1.4: Activate the agents#

A model with agents that never act is just a list of objects. To bring it to life, you tell Mesa to activate the agents each step so they can do something.

Mesa handles this through the AgentSet, the collection of all the model’s agents. Calling self.agents.shuffle_do("method_name") reshuffles the agents into a fresh random order and then runs that method on each one in turn, so every agent acts exactly once per step but never in a fixed order. You will see in a moment why that reshuffling matters.

To keep this first version easy to follow, each household simply prints its unique_id when activated, which is enough to show that it took its turn. The code below defines that say_hi behaviour and the model’s step; explore the activity that follows to see what shuffle_do is really doing internally, then run the model in the next part.

# Add one visible action so we can verify that Mesa activates every agent.

class HouseholdAgent(Agent):
    """
    A household that needs to schedule washing machine usage.
    """

    def __init__(self, model):
        super().__init__(model)
        self.urgency = 0

    # A method is a function attached to a class; it acts on one object through self.
    def say_hi(self):
        """
        Define an action the agent takes in one step.
        Here, the agent will simply print its unique identifier.
        """
        # unique_id was created by Agent.__init__; str converts the number to text.
        print("Hi, I am household " + str(self.unique_id) + ".")


class WashingModel(Model):
    """
    A model with some number of households.
    """

    def __init__(self, N):
        super().__init__()
        self.num_households = N

        HouseholdAgent.create_agents(self, n=self.num_households)

    # Mesa treats step() as the model's instructions for one tick of simulated time.
    def step(self):
        """
        Advance the model by one step.
        Each step, every household's 'say_hi' method will be called.
        """
        # self.agents is an AgentSet: Mesa's collection of all registered households.
        # shuffle_do randomises the order, then calls say_hi once on every agent.
        self.agents.shuffle_do("say_hi")

The call self.agents.shuffle_do("say_hi") reshuffles the activation order and then runs say_hi() once for every household. First run the unseeded model below and observe that the order changes. The random-seed subsection then shows how to make the same sequence reproducible.

3.1.1.5: Run the model#

We now have the pieces of a basic model. Creating a model object runs its initialisation once: it creates the households, registers them with the model, and establishes the starting state. Calling step() then advances that same object by one tick. The agents retain their state between calls, so repeated steps create one evolving simulation rather than a collection of unrelated snapshots. Here the agents print their unique_id, which makes their activation visible and gives us a simple first check that every registered household acts exactly once.

Create the model object, and run it for one step:

# Create one model instance, then advance its clock by one step.

# Calling the class like a function runs __init__ and returns a new model object.
starter_model = WashingModel(10)

# Dot notation calls the step method belonging to this particular object.
starter_model.step()

Run the above code more times and see what happens.

3.1.1.6: Random seeds and reproducibility#

Each new unseeded model produces a different activation order. For a reproducible experiment, pass a random seed when you create the model. The same seed with the same code reproduces the same stream of random choices, step after step.

Use the activity below to compare two runs with and without a fixed seed. Then add seed support to the model in the following code cell.

Learning Activity: Inside a single time step
make both runs reproduce the same activation order by fixing the random seed.
Two separate runs of the same ten-household model, Simulation 1 and Simulation 2, whose activation orders coincide only when they share a random seed.
Press Step both to advance the two runs and compare their activation orders, then switch the Seed on and step again to make them identical.
Seed off
Step: 0
Simulation 1
Simulation 2
# Add a seed parameter so Mesa's random choices can be reproduced.

class WashingModel(Model):
    """
    A model with some number of households.
    """

    # seed=None keeps the input optional; an integer makes the random stream repeatable.
    def __init__(self, N, seed=None):
        # Mesa routes all model randomness through self.random, initialised from rng.
        super().__init__(rng=seed)
        self.num_households = N

        HouseholdAgent.create_agents(self, n=self.num_households)

    def step(self):
        """
        Advance the model by one step.
        Each step, every household's 'say_hi' method will be called.
        """
        self.agents.shuffle_do("say_hi")

Let’s create a model with a random seed and run the simulation for one step. Run it several times and see that the output is the same each time.

# Recreating a model with seed 123 starts the same random sequence from the beginning.

starter_model = WashingModel(10, seed=123)

starter_model.step()

Also in a new cell, with the same code, the output will be the same.

# A second fresh model with the same seed produces the same first-step order.

starter_model = WashingModel(10, seed=123)

starter_model.step()

The result we see is for the first step. Try to run another step of the same model and see that the results change:

# Advancing the existing model consumes the next values in its random sequence.

starter_model.step()

So, by setting a seed, we ensure that the random processes in the model are repeatable. You always get the same results at the same step.

Test the reproducibility claim before carrying it into the next model version.

Learning Activity: Check seeds and reproducibility
predict what a fixed seed guarantees when the same model is run again.
A single-choice question about two freshly created Mesa models that use the same code and seed.
Choose the result you expect, press Show answer, and use Try again if your reasoning needs revision.

Key takeaways

  • An ABM in Mesa is built from two classes: an Agent (how one household behaves) and a Model (which owns the agents, the world, and the clock).

  • create_agents builds the whole population in one call, and Mesa gives every agent a unique_id automatically.

  • self.agents.shuffle_do("method") activates every agent once per step in a freshly reshuffled order, so no household is permanently first.

  • Setting seed= fixes the random stream, which makes a run reproducible: the same seed and the same code give the same results, step after step.

3.1.2: Agent and model behaviour#

In a nutshell

Two forces pull on every household each step: rising urgency (I need to wash) and the price of electricity (but not now, it is expensive). A household washes only when urgency wins and the price is acceptable. Because everyone washing at once pushes the price up, the crowd ends up regulating itself.

This section turns the empty agent and model classes into a feedback system. The household owns the decision rule and updates its own urgency, while the model calculates the shared electricity price and determines when agents act. Keeping those responsibilities separate makes the causal loop readable: individual decisions create aggregate demand, demand changes price, and the new price influences the next round of individual decisions.

3.1.2.1: Household washing decisions#

Now the model starts to come alive. Instead of just announcing themselves, households build up urgency over time and decide when to wash, weighing several factors at once: how badly they need to wash, how expensive electricity is right now, and how sensitive they are to their own urgency.

This is where the agent’s real behaviour lives: a wash_decision method that runs for each household every step. Concretely, each household is described by:

  • Urgency accumulation: Increases by 1 each step when not washing (starts at 0 to 3)

  • Personal thresholds: Each household has an urgency threshold (3 to 5) that must be reached

  • Price sensitivity: Initial willingness to pay ($0.10 to $0.30 per kWh) that adapts with urgency

  • Adaptive willingness: The price threshold rises by (urgency × sensitivity), where sensitivity ranges $0.05 to $0.15, so a household grows more willing to pay the longer it waits

  • Washing cycle: Washing lasts one step, then urgency resets to 0 and the thresholds reset

Decision logic: A household starts washing when both conditions hold:

  1. urgency >= urgency_threshold (it needs to wash badly enough), AND

  2. model.current_price <= price_threshold (electricity is cheap enough)

Price dynamics: The electricity price is not fixed. Each step the model computes a target price from how many households washed last step, then smooths the price toward it:

  • target_price = base_price * (1 + beta * fraction_washing), with base_price = $0.70 and beta = 0.5

  • current_price = (1 - alpha) * current_price + alpha * target_price, with alpha = 0.2

The smoothing (alpha below 1) makes the price glide toward its target rather than jumping, which stops the model from oscillating wildly. You will feel exactly what beta and alpha do in the pricing simulator further down.

The agent’s wash_decision method, called for every household each step, puts this whole multi-factor process into code.

# Replace the greeting with the household's actual urgency-and-price decision rule.

class HouseholdAgent_2(Agent):
    """A household that makes washing decisions based on urgency and price."""

    # --- Constructor: give each household its own starting state ---
    def __init__(self, model):
        super().__init__(model)

        # Use model.random so the model's seed controls every random draw.
        # randint includes both endpoints, so urgency starts at 0, 1, 2, or 3.
        self.urgency = model.random.randint(0, 3)

        # A Boolean stores one of two states: washing (True) or not washing (False).
        self.is_washing = False

        # Each household receives its own willingness-to-pay and urgency settings.
        self.initial_price_threshold = model.random.uniform(0.10, 0.30)
        self.price_threshold = self.initial_price_threshold
        self.urgency_threshold = model.random.randint(3, 5)
        self.sensitivity = model.random.uniform(0.05, 0.15)

    # --- Behaviour: make one washing decision for this household ---
    def wash_decision(self):
        # The if/else separates a waiting tick from the tick after washing.
        # A waiting household becomes more urgent and more willing to pay.
        if not self.is_washing:
            self.urgency += 1

            # Recalculate willingness to pay from the baseline, urgency, and sensitivity.
            self.price_threshold = self.initial_price_threshold + (self.urgency * self.sensitivity)


            # `and` means both conditions must be true. self.model reaches shared model state.
            if self.urgency >= self.urgency_threshold and self.model.current_price <= self.price_threshold:
                self.is_washing = True

        else:
            # Washing occupies one tick; afterwards reset this agent for a new cycle.
            self.is_washing = False
            self.urgency = 0
            self.urgency_threshold = self.model.random.randint(3, 5)
            self.price_threshold = self.initial_price_threshold
# Couple the agents through a price that responds to the previous step's demand.

class WashingModel_2(Model):
    """
    A model with households that make washing decisions based on urgency and price.
    """

    # --- Constructor: create shared state and the population ---
    def __init__(self, N, seed=None):
        super().__init__(rng=seed)
        self.num_households = N

        self.base_price = 0.70
        self.current_price = self.base_price
        self.previous_usage = 0

        # The created agents are collected automatically in self.agents.
        HouseholdAgent_2.create_agents(self, n=self.num_households)

    # --- Helper method: calculate one new shared electricity price ---
    def update_price(self):
        """Update electricity price based on previous step's usage."""

        # Convert the household count to a 0-to-1 demand fraction.
        usage_frac = self.previous_usage / self.num_households

        # beta controls the price response; alpha controls how quickly the price moves.
        beta = 0.5
        alpha = 0.2

        # Demand sets a target; alpha smooths the move instead of jumping instantly.
        target_price = self.base_price * (1 + beta * usage_frac)
        self.current_price = (1 - alpha) * self.current_price + alpha * target_price

    # --- Tick method: run the three operations in their intended order ---
    def step(self):
        """
        Advance the model by one step.
        """
        # Price reacts to the previous tick before households make today's decision.
        self.update_price()

        # Every household then decides once, in a fresh random order.
        self.agents.shuffle_do("wash_decision")

        # Save today's demand so the next tick can feed it back into the price.
        self.previous_usage = sum(1 for agent in self.agents if agent.is_washing)

The activity below shows the three lines of this model’s step() method in scrambled order. Because a step() runs once per tick, the sequence of its lines determines the model’s logic. Work out which order Mesa should run them in each tick.

Learning Activity: Order the step() method

put the steps of a Mesa step() method in the right order.
Three scrambled lines from a Mesa model's step() method, whose order is the logic the model runs each tick.
Use the ▲ and ▼ arrows to put the three lines into the sequence Mesa should run each tick, then press Check.

def step(self):

Foreshadowing: in the data collection section you will add a fourth line, self.datacollector.collect(self), and it always comes LAST, after every household has acted and been counted.

The step() sequence establishes when price, household decisions, and demand are updated. Before running the complete model, isolate the price calculation in the interactive activity below and explore how its two parameters shape the shared price signal.

Learning Activity: Explore the pricing feedback loop
explain how demand, reaction strength, and smoothing combine to determine the electricity price.
An interactive version of the model's price-update equation. Household demand sets a target price, while beta controls the size of the response and alpha controls how gradually the charged price approaches that target.
Drag all three sliders and compare the target and settled prices. First vary beta while holding the other controls steady, then vary alpha and observe the shape of the blue response curve. Use Reset to defaults to start again.
40%
0.50
0.20
Usage fraction
0.40
Target price
$0.840 /kWh
Settled price
$0.840 /kWh
current = (1 - alpha) × current + alpha × target, iterated for 25 steps starting from $0.700. The dashed line is the target; the blue curve is the charged price easing in.
A balancing loop: a higher price is the very thing that pulls demand back down.
Why two knobs? Beta sets how hard the price reacts to load: at beta = 0.5 a fully loaded grid lifts the price by 50% above base. Alpha sets how smoothly it gets there: with alpha below 1 the charged price glides towards its target one fraction of the gap at a time instead of snapping, and that gentle easing is the damping that keeps the price from overshooting and oscillating wildly.

Once you can distinguish reaction strength from smoothing, you are ready to run the full household model and watch this feedback operate over successive ticks.

3.1.2.2: Run the first model#

Now let’s create a model with 5 households and run it for 10 steps.

# Run ten ticks; the model updates price and activates every household on each tick.

# Create one five-household simulation.
model2 = WashingModel_2(5, seed=123)

# range(10) produces ten loop iterations; each iteration advances one model tick.
for i in range(10):
    model2.step()

Before moving on, feel what that decision rule does at the level of the whole street: in the activity below every household runs the same urgency-versus-price rule you just coded, and you can watch independent choices settle into a single, self-organised electricity load.

Learning Activity: Time your machine
watch simple local washing rules add up into a coordinated electricity load.
A grid of households whose laundry slowly piles up until its urgency passes a threshold, coupled through an electricity price that climbs whenever many wash at the same moment.
Set the Urgency threshold to change how full a basket must get before a wash begins, then raise Price sensitivity to make households hold back at a price peak and watch the load curve flatten.
fresh laundryurgentwashing now
Households (colour = urgency, cyan = washing now)
Washing now
0 / 24
Peak this run
0
Price now
$0.70
Total load each tick
3
off
Nobody schedules the washing. Every household follows the same simple local rule, run the machine when the laundry is urgent enough and electricity is not too dear, yet a shared signal, the price that rises when many wash at once, leads them to spread their runs out on their own. That is the washing-coordination lesson of this chapter in miniature: simple local decisions, coupled through one shared quantity, produce a coordinated macro-level pattern that none of the agents planned. Following that individual rule upward to the system pattern is the micro-to-macro link at the heart of agent-based modelling.

The lesson to carry forward is that the coordinated timetable is never programmed anywhere; it emerges from many households each following one simple local rule, coupled only through the shared price they all react to.

# Ask Jupyter to display Matplotlib figures directly below their code cells.

%matplotlib inline

Let’s visualise how much the households are washing in per run of 20 steps:

# Count how often each household washes during one reproducible run.

# --- 1. Prepare one experiment and one counter per agent ---
model2 = WashingModel_2(10, seed=123)

# A dictionary gives every household its own cumulative counter.
wash_counts = {a.unique_id: 0 for a in model2.agents}

# --- 2. Run the model and update the counters after every tick ---
steps = 20
for t in range(steps):
    model2.step()
    for a in model2.agents:
        if a.is_washing:
            wash_counts[a.unique_id] += 1

# --- 3. Convert the counters to plotting inputs ---
# values() discards the IDs because a histogram needs only the wash counts.
vals = list(wash_counts.values())
bins = range(0, (max(vals) if vals else 0) + 2)

# --- 4. Build and label the histogram ---
plt.figure(figsize=(6,4))
plt.hist(vals, bins=bins, align='left', rwidth=0.9)
plt.title(f'Distribution of Cumulative Washes per Household ({steps} steps)')
plt.xlabel('Cumulative washes per household')
plt.ylabel('Number of households')
plt.xticks(bins)
plt.grid(True, axis='y', alpha=0.3)
plt.show()

3.1.2.3: Examine model behaviour#

Looking at the results above, there is not much we can conclude about the model’s behaviour over time from a single run. Random activation and random initial conditions mean that another run with the same parameters may follow a different path. One trajectory is useful for checking the mechanism, but it cannot show which outcomes are typical, how variable they are, or whether an apparent pattern was an unusual draw.

To describe the model’s behaviour, repeat the same experiment with multiple random seeds and compare the resulting distribution. Keep the parameter values and run length fixed so that the random seed is the only intended difference. The nested loop below performs those replications and retains a comparable measurement from each run; later sections introduce Mesa’s batch tools for doing the same work more systematically.

# Repeat the experiment with a different seed per run to measure variation, not one anecdote.

# --- 1. Accumulate results from many independent model objects ---
all_wash_totals = []

# r identifies the repetition; changing the seed gives a new reproducible random stream.
for r in range(100):
    model2 = WashingModel_2(10, seed=123 + r)

    wash_counts = {a.unique_id: 0 for a in model2.agents}

    for t in range(steps):
        model2.step()
        for a in model2.agents:
            if a.is_washing:
                wash_counts[a.unique_id] += 1

    # extend adds every household's count to one combined list.
    all_wash_totals.extend(wash_counts.values())

# --- 2. Plot the distribution across all 100 x 10 household observations ---
max_val = max(all_wash_totals) if all_wash_totals else 0
bins = range(0, max_val + 2)

plt.figure(figsize=(6,4))
plt.hist(all_wash_totals, bins=bins, align='left', rwidth=0.9)
plt.title(f'Distribution of Cumulative Washes per Household\n(100 runs, {steps} steps each)')
plt.xlabel('Cumulative washes per household')
plt.ylabel('Number of households (across all runs)')
plt.xticks(bins)
plt.grid(True, axis='y', alpha=0.3)
plt.tight_layout()
plt.show()

Key takeaways

  • Urgency accumulates while a household waits, and its willingness to pay rises with it, so patience and price interact.

  • The washing decision is a two-part AND: enough urgency and a low enough price. Neither on its own is sufficient.

  • The price is endogenous: the households set it collectively through their own washing, which feeds back into every household’s next decision.

  • One run is an anecdote. Running the model many times (as in the loop above) reveals the distribution of behaviour, which is what you should actually reason about.

3.1.3: Add space and neighbouring agents#

In a nutshell

Give the model a map. Each household now sits in a grid cell and can feel its 8 surrounding neighbours (a Moore neighbourhood). When enough of those neighbours are already washing, social pressure nudges a household to wash too, so washing spreads in clusters instead of at random.

So far every household has been faceless: it had no location and no neighbours. Real households sit somewhere, and where they sit shapes who influences them. Mesa offers several kinds of space; here you use a discrete grid, placing each household in a cell. The households do not move (houses stay put), but their position now matters, because it decides which other households count as their neighbours.

Adding space changes the model’s causal structure, not just its display. Neighbour influence is now local: two households with identical attributes can make different decisions because they occupy different parts of the grid and observe different neighbours. The grid shape, neighbourhood definition, edge behaviour, and cell capacity therefore become modelling assumptions that must be stated and tested. In this tutorial they are kept simple so that the effect of local social influence remains visible.

3.1.3.1: What this section adds#

New imports and base classes:

  • CellAgent: Base class for agents that live on grid cells

  • OrthogonalMooreGrid: Grid with a Moore neighbourhood (the 8 surrounding cells, including diagonals)

New agent attributes:

  • cell: The household’s location on the grid (inherited from CellAgent)

  • Enhanced wash_decision(): now also considers neighbour influence

New model attributes:

  • grid: the 2D spatial grid the households are placed on

  • Grid parameters: width, height for the grid dimensions

  • torus=True: the grid wraps around its edges, like the surface of a donut, so there are no edges

  • capacity=1: at most one household per cell

Enhanced behaviour:

  • Neighbour influence: each household checks how many of its immediate neighbours are washing

  • Social pressure: if more than 3 neighbours are washing (that is, 4 or more), the household becomes more likely to wash too, because its effective urgency threshold drops by 1

  • Spatial coordination: this creates clustering, where washing behaviour spreads through neighbourhoods rather than appearing everywhere at once

# Add a discrete grid so each household can respond to a local Moore neighbourhood.

# CellAgent knows how to live in a cell; OrthogonalMooreGrid creates square cells
# whose neighbourhoods include horizontal, vertical, and diagonal neighbours.
from mesa.discrete_space import CellAgent, OrthogonalMooreGrid

# --- Agent: extend the earlier household with a location ---
class HouseholdAgent_3(CellAgent):
    """A household agent that lives on a grid cell."""

    # cell is the Cell object assigned to this particular household.
    def __init__(self, model, cell):
        super().__init__(model)
        # Keeping the cell on self lets the agent find its own neighbours later.
        self.cell = cell
        self.urgency = model.random.randint(0,3)
        self.is_washing = False
        self.initial_price_threshold = model.random.uniform(0.10, 0.30)
        self.price_threshold = self.initial_price_threshold
        self.urgency_threshold = model.random.randint(3, 5)
        self.sensitivity = model.random.uniform(0.05, 0.15)


    # --- Behaviour: observe neighbours first, then make the household decision ---
    def wash_decision(self):
        # Count washing agents in the eight surrounding Moore-neighbourhood cells.
        neighbors_washing = 0
        # The outer loop visits cells; the inner loop visits agents stored in each cell.
        for neighbor_cell in self.cell.neighborhood:
            for neighbor in neighbor_cell.agents:
                if neighbor.is_washing:
                    neighbors_washing += 1

        if not self.is_washing:
            self.urgency += 1

            self.price_threshold = self.initial_price_threshold + (self.urgency * self.sensitivity)

            # A compact conditional lowers the threshold by one when many neighbours wash.
            current_urg_threshold = self.urgency_threshold - (1 if neighbors_washing > 3 else 0)

            # Parentheses let one logical condition continue cleanly across two lines.
            if (self.urgency >= current_urg_threshold and
                self.model.current_price <= self.price_threshold):
                self.is_washing = True
        else:
            self.is_washing = False
            self.urgency = 0
            self.urgency_threshold = self.model.random.randint(3, 5)
            self.price_threshold = self.initial_price_threshold


# --- Model: create the shared grid, agents, and tick sequence ---
class WashingModel_3(Model):
    """A model with households placed on a 2D grid."""

    def __init__(self, N, width, height, seed=None):
        super().__init__(rng=seed)
        self.num_households = N

        self.base_price = 0.70
        self.current_price = self.base_price
        self.previous_usage = 0

        # The grid is an object owned by the model.
        # torus=True joins opposite edges; capacity=1 permits one household per cell.
        self.grid = OrthogonalMooreGrid(
            (width, height), torus=True, capacity=1, random=self.random
        )

        # Sample N distinct Cell objects so two households never start in the same cell.
        # create_agents pairs those cells with the N new agents position by position.
        HouseholdAgent_3.create_agents(
            self,
            self.num_households,
            self.random.sample(list(self.grid.all_cells.cells), k=self.num_households)
        )

    def update_price(self):
        """Update electricity price based on previous step's usage."""

        usage_frac = self.previous_usage / self.num_households
        beta = 0.5
        alpha = 0.2

        target_price = self.base_price * (1 + beta * usage_frac)
        self.current_price = (1 - alpha) * self.current_price + alpha * target_price


    # The tick order is unchanged: price, decisions, then a demand count for next tick.
    def step(self):
        self.update_price()
        self.agents.shuffle_do("wash_decision")
        self.previous_usage = sum(1 for agent in self.agents if agent.is_washing)

The code above gives every household a Moore neighbourhood on a toroidal grid. The activity below makes the difference between a wrapped grid and a hard-edged grid visible.

Learning Activity: Explore a household's neighbourhood on a torus
explain how a torus removes edge effects from a Moore neighbourhood.
A 10 × 10 grid where the focus household senses the eight surrounding Moore-neighbourhood cells, with optional wrapping across opposite edges.
Select a centre, edge, and corner household. For each one, switch the torus off and on, compare the neighbour count, and identify any neighbours that wrap from the opposite edge.
This household has 8 neighbours.
Focus household Neighbour Wrapped neighbour (from the opposite edge)
Why does this matter? On a grid with hard edges, a corner household senses only 3 neighbours and an edge household only 5, giving them a quieter, second-class experience that can distort your results. A torus stitches the top row to the bottom and the left column to the right, like the surface of a doughnut, so the missing neighbours reappear on the far side and every household keeps a full 8 Moore neighbours. That is why the model uses OrthogonalMooreGrid((10,10), torus=True): no edges, no edge effects.

The discrete_space module gives you a small vocabulary of terms for placing agents on a grid. The activity below pairs each term with what it does, then shows how the pieces combine into the two lines you actually write. Before you start, consider which terms build the grid itself and which place agents onto it.

Learning Activity: Match the space API

match each task to the right Mesa space method.
Eight terms from Mesa's discrete_space vocabulary for placing agents on a grid, each to be paired with what it does.
Click a code term, then click its meaning to pair them; you can repair at any time, and once all eight are matched press Check.

Code term

What it means

Paired 0 / 8

Nicely done. These eight terms are not trivia: they click together into the two lines you really write. You lay out the world with

OrthogonalMooreGrid((width, height), torus=True, capacity=1, random=self.random)

where the grid gives each cell up to 8 neighbours, torus=True wraps the edges into a borderless donut, and capacity=1 keeps at most one household per cell. Then you scatter households across the board with

self.random.sample(list(self.grid.all_cells.cells), k=N)

pulling from self.grid.all_cells, every cell as one collection. Each CellAgent then remembers its self.cell, and from that cell you reach cell.coordinate for its (x, y), cell.agents for who stands there, and cell.neighborhood for the ring of cells around it.

Let’s create a model with 100 households on a 10x10 grid and run it for 15 steps:

# Build and advance the spatial model before inspecting its cells.

# These arguments fill N, width, height, and seed in the constructor above.
model3 = WashingModel_3(100, 10, 10, seed=123)

# run_for(15) is Mesa shorthand for calling model3.step() fifteen times.
model3.run_for(15)

Now let’s visualise the grid showing which households are washing:

# Convert agent states into a numeric grid that Matplotlib can display.

# --- 1. Create an empty 2D NumPy array matching the Mesa grid dimensions ---
washing_grid = np.zeros((model3.grid.width, model3.grid.height))

# --- 2. Encode every occupied cell as 0 (low), 1 (urgent), or 2 (washing) ---
for cell in model3.grid.all_cells:
    x, y = cell.coordinate
    if cell.agents:
        # capacity=1 guarantees at most one agent, so take the first item.
        agent = list(cell.agents)[0]
        if agent.is_washing:
            washing_grid[x, y] = 2
        elif agent.urgency > 3:
            washing_grid[x, y] = 1
        else:
            washing_grid[x, y] = 0

# --- 3. Turn the numeric array into a labelled colour map ---
plt.figure(figsize=(6, 6))
# Transpose (.T) aligns NumPy's row/column order with the grid's x/y coordinates.
im = plt.imshow(washing_grid.T, cmap='RdYlGn_r', vmin=0, vmax=2)
plt.title('Household States on Grid')
plt.xlabel('Grid X')
plt.ylabel('Grid Y')
cbar = plt.colorbar(im, ticks=[0, 1, 2])
cbar.ax.set_yticklabels(['Low urgency', 'High urgency', 'Washing'])
plt.show()

Now connect the local threshold rule you coded to the spatial pattern it can produce across the grid.

Learning Activity: Connect neighbour influence to spatial patterns
infer the system-level pattern created by a local neighbour-influence rule.
A single-choice question about a household whose urgency threshold falls when more than three neighbours are already washing.
Choose the model-level behaviour you expect, press Show answer, and use the feedback to connect the local rule to its spatial outcome.

Try it yourself

The empty cell below is your workspace for the exercise above. Try changing the grid size, the number of households, or the washing threshold, and watch how the clustering in the grid plot changes.

Need a hint?

You already met self.cell.neighborhood in the explorer above: it is the set of neighbouring cells. Loop over it, count how many neighbours are washing, and compare that count to a threshold, just as wash_decision does with neighbors_washing > 3. To change the grid, adjust the width, height, and N you pass when you build WashingModel_3.

# Exercise scaffold: compare one controlled spatial-model change at a time.
# 1. Change only one of N, width, or height, keeping the seed fixed.
exercise_model = WashingModel_3(N=60, width=10, height=10, seed=123)

# 2. Give the new model enough ticks for washing clusters to develop.
for _ in range(20):
    exercise_model.step()

# 3. Record simple evidence before repeating with a different single input.
washing_now = sum(agent.is_washing for agent in exercise_model.agents)
mean_urgency = np.mean([agent.urgency for agent in exercise_model.agents])
print({"washing_now": washing_now, "mean_urgency": mean_urgency})

Key takeaways

  • OrthogonalMooreGrid gives each household a location and an eight-cell neighbourhood.

  • torus=True joins opposite edges, while capacity=1 prevents two households occupying one cell.

  • Local neighbour counts can alter an agent’s decision without giving it global information.

  • Keep spatial experiments comparable by changing one input at a time and holding the seed fixed.

3.1.4: Collect data#

In a nutshell

Stop hand-writing loops to pull numbers out of your model. A DataCollector takes two dictionaries, one for whole-model numbers and one for per-household numbers, and quietly snapshots them for you at every step.

Let’s add a DataCollector to track important metrics over time.

Up to now, whenever you wanted results you wrote your own code to dig them out of the model at the end of a run. That has two problems: it is fiddly, and it only gives you the final state. If you wanted each household’s urgency at every step, you would have to weave storage into the step loop by hand. Since producing data for analysis is one of the main points of agent-based modelling, Mesa gives you a class that handles this for you: mesa.DataCollector.

The data collector gathers two categories of data, and telling them apart is the key idea of this section:

  • Model-level variables are computed from the whole model and give one value per step. Here they will be the number of households washing, the average urgency across all households, the current electricity price, and the average price threshold. A model reporter is either a small function that takes the model and returns a number, or the name of a model attribute given as a string.

  • Agent-level variables are read from each agent and give one value per household per step. Here they will be each household’s urgency, washing status, and price threshold.

You set this up by creating a DataCollector with two keyword arguments, model_reporters and agent_reporters. Each is a dictionary whose key is the name the data will be stored under, and whose value is either a function (such as compute_avg_urgency) or an attribute name as a string (such as "current_price"). Then, so the numbers are actually recorded, the model calls self.datacollector.collect(self) once at the end of __init__ (to capture the starting state) and again at the end of each step. The helper functions below compute the two averages, and each new line in the model is annotated with a comment.

# Add reusable reporter functions and let Mesa collect model- and agent-level data.

# --- Reporter functions: turn a complete model state into one summary number ---
# DataCollector will pass the current model object into each function every tick.
def compute_avg_urgency(model):
    """Calculate average urgency across all households."""
    # Guard against division by zero if a model is ever created without agents.
    if len(model.agents) == 0:
        return 0
    total_urgency = sum(agent.urgency for agent in model.agents)
    return total_urgency / len(model.agents)


def compute_avg_price_threshold(model):
    """Calculate average price threshold across all households."""
    if len(model.agents) == 0:
        return 0
    total_threshold = sum(agent.price_threshold for agent in model.agents)
    return total_threshold / len(model.agents)


# --- Agent: the spatial decision rule stays the same ---
# Data collection belongs to the model, so this class needs no collector-specific code.
class HouseholdAgent_4(CellAgent):
    """A household agent with enhanced data tracking."""

    def __init__(self, model, cell):
        super().__init__(model)
        self.cell = cell
        self.urgency = model.random.randint(0,3)
        self.is_washing = False
        self.initial_price_threshold = model.random.uniform(0.10, 0.30)
        self.price_threshold = self.initial_price_threshold
        self.urgency_threshold = model.random.randint(3, 5)
        self.sensitivity = model.random.uniform(0.05, 0.15)

    def wash_decision(self):
        neighbors_washing = 0
        for neighbor_cell in self.cell.neighborhood:
            for neighbor in neighbor_cell.agents:
                if neighbor.is_washing:
                    neighbors_washing += 1

        if not self.is_washing:
            self.urgency += 1
            self.price_threshold = self.initial_price_threshold + (self.urgency * self.sensitivity)

            current_urg_threshold = self.urgency_threshold - (1 if neighbors_washing > 3 else 0)

            if (self.urgency >= current_urg_threshold and
                self.model.current_price <= self.price_threshold):
                self.is_washing = True

        else:
            self.is_washing = False
            self.urgency = 0
            self.urgency_threshold = self.model.random.randint(3, 5)
            self.price_threshold = self.initial_price_threshold


# --- Model: add one DataCollector object while preserving the earlier logic ---
class WashingModel_4(Model):
    """A model with enhanced data collection."""

    def __init__(self, N, width, height, seed=None):
        super().__init__(rng=seed)
        self.num_households = N

        self.base_price = 0.70
        self.current_price = self.base_price
        self.previous_usage = 0

        self.grid = OrthogonalMooreGrid(
            (width, height), torus=True, capacity=1, random=self.random
        )

        HouseholdAgent_4.create_agents(
            self,
            self.num_households,
            self.random.sample(list(self.grid.all_cells.cells), k=self.num_households)
        )

        # DataCollector stores reporter recipes; it does not collect until collect() is called.
        # Model reporters create one value per tick; agent reporters create one value per agent.
        self.datacollector = mesa.DataCollector(
            model_reporters={
                # A lambda is a short unnamed function; m is the model passed by Mesa.
                "Households_Washing": lambda m: sum(1 for a in m.agents if a.is_washing),
                "Average_Urgency": compute_avg_urgency,
                # A string tells DataCollector to read an attribute of that name.
                "Current_Price": "current_price",
                "Average_Price_Threshold": compute_avg_price_threshold
            },
            agent_reporters={
                # These strings are read from every individual household object.
                "Urgency": "urgency",
                "Is_Washing": "is_washing",
                "Price_Threshold": "price_threshold"
            }
        )

        # Collect the initial state (step 0) before any household acts.
        self.datacollector.collect(self)


    def update_price(self):
        """Update electricity price based on previous step's usage."""

        usage_frac = self.previous_usage / self.num_households
        beta = 0.5
        alpha = 0.2

        target_price = self.base_price * (1 + beta * usage_frac)
        self.current_price = (1 - alpha) * self.current_price + alpha * target_price


    def step(self):
        self.update_price()
        self.agents.shuffle_do("wash_decision")
        self.previous_usage = sum(1 for agent in self.agents if agent.is_washing)
        # Snapshot after all updates so each stored row describes a completed tick.
        self.datacollector.collect(self)

Mesa’s DataCollector measures at two levels: model reporters record one value for the whole model each step, while agent reporters record one value per household. The activity below presents a set of reporters and asks which level each belongs to. Before you begin, consider what separates a quantity measured once for the model from one measured for every agent.

Learning Activity: Model reporter or agent reporter?
tell model-level data collection from agent-level.
Seven DataCollector reporters, each belonging in either model_reporters (one value for the whole run each step) or agent_reporters (one value per household).
For each reporter choose Model or Agent, then press Check to score your answers.

Now let’s run the model and analyse the collected data:

# Generate a 50-step time series with the data-enabled model.

# model4 now owns its households, grid, random generator, and DataCollector.
model4 = WashingModel_4(100, 10, 10, seed=123)

# Each call to step inside run_for also adds one new collector snapshot.
model4.run_for(50)
# Retrieve one row per model step and plot the four model-level reporters.

# The collector converts its stored records into a pandas DataFrame (a labelled table).
model4_data = model4.datacollector.get_model_vars_dataframe()

# subplots returns one Figure container and a 2 x 2 array of Axes plotting areas.
fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(15, 10))

# Each Axes object receives one series selected by its DataFrame column name.
ax1.plot(model4_data.index, model4_data['Households_Washing'], 'b-')
ax1.set_title('Number of Households Washing Over Time')
ax1.set_ylabel('Households Washing')

ax2.plot(model4_data.index, model4_data['Average_Urgency'], 'r-')
ax2.set_title('Average Urgency Over Time')
ax2.set_ylabel('Average Urgency')

ax3.plot(model4_data.index, model4_data['Current_Price'], 'g-')
ax3.set_title('Electricity Price Over Time')
ax3.set_ylabel('Price per kWh ($)')
ax3.set_xlabel('Time Step')

# The final panel overlays two series so their relationship can be compared directly.
ax4.plot(model4_data.index, model4_data['Average_Price_Threshold'], 'orange', label='Avg Threshold')
ax4.plot(model4_data.index, model4_data['Current_Price'], 'g-', label='Current Price')
ax4.set_title('Price Dynamics')
ax4.set_ylabel('Price per kWh ($)')
ax4.set_xlabel('Time Step')
ax4.legend()

plt.tight_layout()
plt.show()
# Retrieve agent-level records; the result is indexed by both Step and AgentID.

# One agent contributes one row per collected tick, so two labels are needed to find a row.
agent4_data = model4.datacollector.get_agent_vars_dataframe()
# head() previews the first five rows without printing the entire table.
agent4_data.head()

Let’s look at the final urgency distribution:

# Slice the MultiIndex at the final step, then plot the cross-household distribution.

# Extract all Step labels and find the largest (latest) one.
last_step = agent4_data.index.get_level_values("Step").max()
# xs means cross-section: fix Step and keep the AgentID rows for that moment.
final_data = agent4_data.xs(last_step, level="Step")

plt.hist(final_data['Urgency'], bins=range(8), alpha=0.7)
plt.title('Final Distribution of Household Urgency')
plt.xlabel('Urgency Level')
plt.ylabel('Number of Households')
plt.show()

Before extending the reporters, check why the collector records once before the first model step.

Learning Activity: Explain why data collection starts at step 0
explain why a model records its initial state before any agent acts.
A single-choice question about calling self.datacollector.collect(self) in both __init__ and step().
Choose the purpose of the initial collection call, press Show answer, and compare the feedback with the time-series index you produced.

Try it yourself

Use the empty cell below to extend the data collection: add a new reporter, then re-run and inspect it.

Need a hint?

Decide first whether your new metric is a model number (one value for the whole neighbourhood each step, such as the maximum urgency) or an agent number (one value per household, such as its household type), exactly the distinction you sorted in the activity above. Then add it to the matching dictionary: model_reporters or agent_reporters. A model reporter can be a short function like lambda m: max(a.urgency for a in m.agents); an agent reporter can be an attribute name in quotes.

# Exercise scaffold: add one reporter, rerun the class definition, and verify its output.
# 1. In WashingModel_4's model_reporters dictionary above, add:
#    "Maximum_Urgency": lambda m: max(a.urgency for a in m.agents)
# 2. Rerun that class-definition cell, then run this cell.
exercise_data_model = WashingModel_4(100, 10, 10, seed=123)
exercise_data_model.run_for(20)
exercise_model_data = exercise_data_model.datacollector.get_model_vars_dataframe()

# 3. Confirm that the new reporter appears before analysing it.
print("Collected columns:", list(exercise_model_data.columns))
# Uncomment after Maximum_Urgency appears in the printed list:
# display(exercise_model_data[["Average_Urgency", "Maximum_Urgency"]].tail())

Key takeaways

  • A DataCollector evaluates model reporters once per step and agent reporters once per agent per step.

  • Collecting before the first step preserves the initial condition; collecting after each step builds the time series.

  • Model data has one row per step, while agent data uses a (Step, AgentID) MultiIndex.

  • Reporter names become DataFrame columns, so descriptive names make later analysis easier.

3.1.5: Visualisation#

Let’s create a simple animation to watch the households over time:

Each cell on the grid carries an (x, y) coordinate, and Mesa gives you a handful of expressions for reading what sits where. The activity below pairs every Mesa expression with what it actually returns. Before you begin, consider what each one hands back: a collection of cells, a single coordinate, or a value read from the agent standing there.

Learning Activity: Speak Mesa
match each Mesa term to what it does.
Eight Mesa expressions that the washing visualiser reads each frame, each to be paired with exactly what it returns from the grid.
Hover a code chip to see which slice of the grid it reads, then click an expression and its meaning to pair them; once all eight are paired press Check.
ExpressionReturns
low urgency high urgency washing empty

Cells carry an (x, y) coordinate. Hover a chip on the left to see which slice of the grid that Mesa expression reads.

Tip

The animation below is a real Matplotlib FuncAnimation rendered straight to HTML with to_jshtml, so it plays inside the book with no extra software. Use the play and scrub controls to step through the run frame by frame, and watch clusters of washing (red) form, spread, and fade, exactly the coordination you glimpsed in the very first activity of this chapter.

See also

Visualisation is a whole craft of its own. You will build richer, purpose-built views for the classic models in Chapter 4 and for the larger, empirical models in Chapter 5.

# Animate the spatial model by advancing it once for every displayed frame.

# FuncAnimation repeatedly calls an update function; HTML embeds its controls in Jupyter.
from matplotlib.animation import FuncAnimation
from IPython.display import HTML

# --- 1. Create the model and the reusable plotting objects ---
model5 = WashingModel_4(60, 10, 10, seed=123)

# fig is the whole figure; ax is the single plotting area drawn inside it.
fig, ax = plt.subplots(figsize=(6, 6))
fig.subplots_adjust(bottom=0.2)

# --- 2. Define how one model state should look ---
# This helper receives any compatible model object and redraws its current state.
def plot_grid(model):
    # Clear the previous frame so drawings do not accumulate on top of one another.
    ax.clear()

    washing_grid = np.zeros((model.grid.width, model.grid.height))

    for cell in model.grid.all_cells:
        x, y = cell.coordinate
        if cell.agents:
            agent = list(cell.agents)[0]
            if agent.is_washing:
                washing_grid[x, y] = 2
            elif agent.urgency > 3:
                washing_grid[x, y] = 1
            else:
                washing_grid[x, y] = 0

    im = ax.imshow(washing_grid.T, cmap='RdYlGn_r', vmin=0, vmax=2)
    ax.set_title(f"Household Washing: Step {model.steps}")
    ax.set_xlabel('Grid X')
    ax.set_ylabel('Grid Y')
    ax.set_xticks([])
    ax.set_yticks([])

    # Return the image object so the initial colour bar can use the same colour scale.
    return im

# --- 3. Draw frame zero and add a colour bar once ---
im = plot_grid(model5)
cbar = fig.colorbar(im, ax=ax, ticks=[0, 1, 2])
cbar.ax.set_yticklabels(['Low urgency', 'High urgency', 'Washing'])

# --- 4. Define what changes between frames ---
# FuncAnimation supplies a frame number, but this model only needs one more tick.
def update(frame):
    # FuncAnimation calls this once per frame, so one frame represents one model tick.
    model5.step()
    plot_grid(model5)

# --- 5. Build and display the animation ---
# Store the FuncAnimation object in anim so Python keeps it alive while rendering.
# Thirty frames at 500 ms each represent thirty model ticks.
anim = FuncAnimation(fig, update, frames=30, repeat=False, interval=500)
# Close the static Matplotlib copy; only the interactive HTML version is needed.
plt.close(fig)

# to_jshtml converts the animation to browser-ready HTML and JavaScript controls.
output = HTML(anim.to_jshtml())
output

Key takeaways

  • A grid plot translates each household state into a numeric value and then into a consistent colour.

  • FuncAnimation calls an update function once per frame; here each frame advances the ABM by one tick.

  • Pausing and scrubbing help verify transitions, while quantitative reporters remain the evidence for conclusions.

3.1.6: Manage agents with AgentSet#

Background: Mesa stores the model’s registered agents in an AgentSet, so population-level operations can be expressed as operations on the collection rather than through repeated bookkeeping loops. This becomes especially useful once agents are heterogeneous: the model can select a subgroup, group agents by an attribute, order or shuffle them, call a method on the result, and aggregate measurements without maintaining parallel lists. Here, the three household types provide a concrete reason to use those operations.

3.1.6.1: Group agents#

Let’s create households with different types and see how they behave:

  • Normal households: Standard urgency threshold (3), moderate sensitivity (0.08)

  • Patient households: Higher urgency threshold (6), low sensitivity (0.05) - wait longer before washing

  • Impatient households: Lower urgency threshold (2), high sensitivity (0.15) - wash sooner and adapt quickly to urgency

Definition 3 (AgentSet)

An AgentSet is Mesa’s queryable collection of agents, reached through self.agents. It supports operations such as select (filter by a condition), groupby (split into subgroups by an attribute), shuffle, and do (call a method on every agent). This lets you activate, filter, or measure whole groups of agents in one expressive line, instead of writing manual loops.

You have already used one AgentSet method, shuffle_do. Here you meet another, groupby, which lets households of different kinds behave and be analysed as distinct cohorts.

The washing model has three household types, and groupby splits the population into one cohort per type. The activity below introduces the three types, then asks you to reassemble the grouping loop from WashingModel_6.step() so that the 100 agents fall into colour-coded cohorts. Consider how a single loop can hand you each type together with just its own members.

Meet the household types

Three flavours of household appear later in the chapter, yet all three are the same Mesa agent class. Click a card to flip it and see what actually differs under the hood.

Learning Activity: Assemble the groupby loop

assemble the loop that summarises results by group.
The grouping loop inside WashingModel_6.step(), with two method blanks to fill and a follow-up question on what groupby hands back.
Pick the right method for each blank and answer what groupby returns, then press Check.
for htype, agents in self.agents.____("household_type"):
    agents.____("wash_decision")
# Extend the model with household types, then manage each cohort through AgentSet.

# These reporter functions are repeated so this section can be run independently.
def compute_avg_urgency(model):
    """Calculate average urgency across all households."""
    if len(model.agents) == 0:
        return 0
    total_urgency = sum(agent.urgency for agent in model.agents)
    return total_urgency / len(model.agents)


def compute_avg_price_threshold(model):
    """Calculate average price threshold across all households."""
    if len(model.agents) == 0:
        return 0
    total_threshold = sum(agent.price_threshold for agent in model.agents)
    return total_threshold / len(model.agents)


# --- Agent: add one category attribute while keeping the decision method familiar ---
# AgentSet will later read household_type to divide the population into cohorts.
class HouseholdAgent_6(CellAgent):
    """A household agent with type-specific behaviour and enhanced data tracking."""

    # The default value means callers may omit household_type and receive "normal".
    def __init__(self, model, cell, household_type="normal"):
        super().__init__(model)
        self.cell = cell
        self.urgency = model.random.randint(0,3)
        self.is_washing = False
        # Store the constructor input on the individual agent object.
        self.household_type = household_type
        self.initial_price_threshold = model.random.uniform(0.10, 0.30)
        self.price_threshold = self.initial_price_threshold

        # --- Type-specific parameters ---
        # if/elif/else selects exactly one parameter pair for this household.
        # The decision algorithm below stays identical for all three types.
        if household_type == "patient":
            self.urgency_threshold = 6
            self.sensitivity = 0.05
        elif household_type == "impatient":
            self.urgency_threshold = 2
            self.sensitivity = 0.15
        else:
            self.urgency_threshold = 3
            self.sensitivity = 0.08

    def wash_decision(self):
        neighbors_washing = 0
        for neighbor_cell in self.cell.neighborhood:
            for neighbor in neighbor_cell.agents:
                if neighbor.is_washing:
                    neighbors_washing += 1

        if not self.is_washing:
            self.urgency += 1
            self.price_threshold = self.initial_price_threshold + (self.urgency * self.sensitivity)

            current_urg_threshold = self.urgency_threshold - (1 if neighbors_washing > 3 else 0)

            if (self.urgency >= current_urg_threshold and
                self.model.current_price <= self.price_threshold):
                self.is_washing = True
        else:
            self.is_washing = False
            self.urgency = 0
            self.price_threshold = self.initial_price_threshold


# --- Model: create balanced labels and activate the resulting AgentSet groups ---
class WashingModel_6(Model):
    """A model with enhanced data collection and household types."""

    def __init__(self, N, width, height, seed=None):
        super().__init__(rng=seed)
        self.num_households = N

        self.base_price = 0.70
        self.current_price = self.base_price
        self.previous_usage = 0

        self.grid = OrthogonalMooreGrid(
            (width, height), torus=True, capacity=1, random=self.random
        )

        # Build a plain Python list containing approximately one-third of each label.
        types = (["normal"] * int(N * 0.34) +
                ["patient"] * int(N * 0.33) +
                ["impatient"] * int(N * 0.33))

        # Integer rounding may leave the list short; fill any remainder with normal.
        while len(types) < N:
            types.append("normal")
        types = types[:N]

        # Shuffle through the model RNG so the seed also controls type placement.
        self.random.shuffle(types)

        # create_agents receives two parallel sequences: sampled cells and type labels.
        # Item 0 from each sequence goes to agent 0, item 1 to agent 1, and so on.
        HouseholdAgent_6.create_agents(
            self,
            self.num_households,
            self.random.sample(list(self.grid.all_cells.cells), k=self.num_households),
            types
        )

        # count() and an f-string provide a quick check that the population is balanced.
        print(f"Created {N} households: {types.count('normal')} normal, {types.count('patient')} patient, {types.count('impatient')} impatient.")

        # Household_Type is an agent reporter, so every recorded row keeps its cohort label.
        self.datacollector = mesa.DataCollector(
            model_reporters={
                "Households_Washing": lambda m: sum(1 for a in m.agents if a.is_washing),
                "Average_Urgency": compute_avg_urgency,
                "Current_Price": "current_price",
                "Average_Price_Threshold": compute_avg_price_threshold
            },
            agent_reporters={
                "Urgency": "urgency",
                "Is_Washing": "is_washing",
                "Price_Threshold": "price_threshold",
                "Household_Type": "household_type"
            }
        )

        self.datacollector.collect(self)

    def update_price(self):
        """Update electricity price based on previous step's usage."""

        usage_frac = self.previous_usage / self.num_households
        beta = 0.5
        alpha = 0.2

        target_price = self.base_price * (1 + beta * usage_frac)
        self.current_price = (1 - alpha) * self.current_price + alpha * target_price

    def step(self):
        self.update_price()

        # groupby yields (group label, AgentSet) pairs; each cohort is activated separately.
        for htype, agents in self.agents.groupby("household_type"):
            agents.shuffle_do("wash_decision")

        self.previous_usage = sum(1 for agent in self.agents if agent.is_washing)
        self.datacollector.collect(self)

Run the model and analyse by type:

# Compare the final urgency and total washing behaviour of the three AgentSet groups.

# --- 1. Run one typed-household experiment and retrieve its agent table ---
model6 = WashingModel_6(100, 10, 10, seed=123)

model6.run_for(20)

agent6_data = model6.datacollector.get_agent_vars_dataframe()
# Keep all households but only their records from the last collected tick.
last_step = agent6_data.index.get_level_values("Step").max()
final_data = agent6_data.xs(last_step, level="Step")

# --- 2. Compare final urgency distributions ---
plt.figure(figsize=(12, 5))

plt.subplot(1, 2, 1)
for htype in ["normal", "patient", "impatient"]:
    # A Boolean mask selects only rows whose cohort label matches htype.
    type_data = final_data[final_data['Household_Type'] == htype]
    plt.hist(type_data['Urgency'], alpha=0.6, label=htype, bins=range(10))
plt.title('Urgency Distribution by Household Type At Final Step')
plt.xlabel('Urgency Level')
plt.ylabel('Number of Households')
plt.legend()

# --- 3. Count washing events across every tick, grouped by household type ---
plt.subplot(1, 2, 2)
# reset_index turns Step and AgentID back into columns before grouping.
# sum counts True values because Python treats True as 1 and False as 0.
washes_by_type = (
    agent6_data.reset_index()
    .groupby('Household_Type')['Is_Washing']
    .sum()
    # reindex fixes the display order and supplies zero if a cohort is absent.
    .reindex(['normal','patient','impatient'], fill_value=0)
)
plt.bar(washes_by_type.index, washes_by_type.values)
plt.title('Total Washes Across All Steps (by Type)')
plt.ylabel('Total washes')


plt.tight_layout()
plt.show()

Use the distributions above to check how each household type’s threshold shapes its accumulated urgency.

Learning Activity: Predict household-type urgency
predict how different washing thresholds affect average household urgency.
A single-choice question comparing normal, patient, and impatient household types after repeated washing-and-reset cycles.
Use the threshold definitions and the plots above to choose which type should have the lowest average urgency, then press Show answer.

3.1.6.2: Select and order agents#

groupby splits the households into cohorts, but Mesa’s AgentSet can also activate a chosen subset or a chosen order: select filters the agents by a condition, sort arranges them before they act, and agents_by_type reaches one agent class, tools the rest of this tutorial has not yet needed.

Learning Activity: Only the ones who qualify
activate a chosen subset or ordering of agents with Mesa's AgentSet.
A grid of households, each tile labelled with its urgency, where you activate only a chosen subset or ordering of agents rather than waking every household at once.
Keep Select chosen and drag the urgency threshold, then press Run activation to light up only the qualifying households; switch to Sort and run again to activate every household in order, dirtiest first.
Households (number = urgency, cyan = activated)
5

select, sort and agents_by_type are the AgentSet's tools for choosing who acts and in what order. Where do and shuffle_do activate every agent, model.agents.select(lambda a: …) activates only the subset that meets a condition, model.agents.sort(key, ascending=False) activates them in a chosen order, and model.agents_by_type[Household] reaches just the agents of one class. Conditional and ordered activation like this lets a model prioritise, ration, or stage its agents, washing the dirtiest first, or waking only one kind of agent, without ever writing a manual loop.

The lesson to carry forward is that select, sort, and agents_by_type let you decide exactly which agents act, and in what order, without ever writing a manual for loop.

Key takeaways

  • AgentSet is Mesa’s queryable population container, not just a Python list.

  • groupby creates cohorts, select filters a subset, and sort controls order without manual population loops.

  • Cohort comparisons reveal heterogeneity that a single population average can hide.

3.1.7: Run batch experiments#

In a nutshell

One run tells you what happened once. A batch run re-runs the model across a grid of parameter values, with several random seeds each, so you can separate the signal of a parameter from the noise of randomness.

A batch experiment changes the scale of the question. Instead of following one household trajectory, you compare complete model runs under controlled configurations. The parameter grid defines the configurations, the iterations provide independent repetitions, and the resulting table records both the inputs and the outputs of every run. Designing that grid carefully is essential: each extra parameter value multiplies the total number of simulations and therefore the computation required.

3.1.7.1: Batch-run parameters#

A single simulation is an anecdote: with randomness in the model, the next run could look quite different. To draw conclusions you need many runs, sweeping the parameters you care about and repeating each setting with different seeds. Mesa’s batch_run does exactly this. You hand it the model class (model_cls) and a few arguments, and it runs every combination for you and returns tidy results.

The main arguments are:

parameters

A dictionary of parameter values. A value can be fixed ("height": 10) or an iterable ("N": range(10, 500, 10)). batch_run builds every combination of the iterable values.

iterations / rng

How many repetitions per combination. Use iterations for a count, or pass an explicit list of seeds via rng for reproducible repetitions (this tutorial passes 5 seeds).

number_processes

How many CPU cores to use. None uses all of them and is faster, but harder to debug. Set it to 1 if a sweep throws puzzling errors.

data_collection_period

How often to record data, in steps. 1 collects every step; the default -1 collects only at the end of each run.

max_steps

The step at which each run halts (unless the model stops itself first by setting self.running = False).

display_progress

Whether to show a progress bar while the batch runs. Defaults to True.

Let’s run multiple simulations with different parameters to see how they affect the system:

# Define the data-enabled model that Mesa's batch runner will instantiate repeatedly.

# This cell repeats the complete tested model so the batch section can run independently.
# Comments concentrate on the new batch-run contract rather than reteaching familiar logic.

# The batch runner needs reporter definitions in the same kernel as the model class.
def compute_avg_urgency(model):
    """Calculate average urgency across all households."""
    if len(model.agents) == 0:
        return 0
    total_urgency = sum(agent.urgency for agent in model.agents)
    return total_urgency / len(model.agents)


def compute_avg_price_threshold(model):
    """Calculate average price threshold across all households."""
    if len(model.agents) == 0:
        return 0
    total_threshold = sum(agent.price_threshold for agent in model.agents)
    return total_threshold / len(model.agents)


# --- Agent: keep the tested mechanism fixed so the batch varies parameters, not logic ---
class HouseholdAgent_7(CellAgent):
    """A household agent with enhanced data tracking."""

    def __init__(self, model, cell):
        super().__init__(model)
        self.cell = cell
        self.urgency = model.random.randint(0,3)
        self.is_washing = False
        self.initial_price_threshold = model.random.uniform(0.10, 0.30)
        self.price_threshold = self.initial_price_threshold
        self.urgency_threshold = model.random.randint(3, 5)
        self.sensitivity = model.random.uniform(0.05, 0.15)

    def wash_decision(self):
        neighbors_washing = 0
        for neighbor_cell in self.cell.neighborhood:
            for neighbor in neighbor_cell.agents:
                if neighbor.is_washing:
                    neighbors_washing += 1

        if not self.is_washing:
            self.urgency += 1
            self.price_threshold = self.initial_price_threshold + (self.urgency * self.sensitivity)

            current_urg_threshold = self.urgency_threshold - (1 if neighbors_washing > 3 else 0)

            if (self.urgency >= current_urg_threshold and
                self.model.current_price <= self.price_threshold):
                self.is_washing = True
        else:
            self.is_washing = False
            self.urgency = 0
            self.urgency_threshold = self.model.random.randint(3, 5)
            self.price_threshold = self.initial_price_threshold


# --- Model: expose constructor parameters that batch_run can supply by name ---
# Each run creates a fresh object, so state never leaks from one repetition to another.
class WashingModel_7(Model):
    """A model with enhanced data collection."""

    def __init__(self, N, width, height, seed=None):
        super().__init__(rng=seed)
        self.num_households = N

        self.base_price = 0.70
        self.current_price = self.base_price
        self.previous_usage = 0

        self.grid = OrthogonalMooreGrid(
            (width, height), torus=True, capacity=1, random=self.random
        )

        HouseholdAgent_7.create_agents(
            self,
            self.num_households,
            self.random.sample(list(self.grid.all_cells.cells), k=self.num_households)
        )

        # batch_run exports these reporters together with parameters, RunId, and Step.
        self.datacollector = mesa.DataCollector(
            model_reporters={
                "Households_Washing": lambda m: sum(1 for a in m.agents if a.is_washing),
                "Average_Urgency": compute_avg_urgency,
                "Current_Price": "current_price",
                "Average_Price_Threshold": compute_avg_price_threshold
            },
            agent_reporters={
                "Urgency": "urgency",
                "Is_Washing": "is_washing",
                "Price_Threshold": "price_threshold"
            }
        )

        self.datacollector.collect(self)

    def update_price(self):
        """Update electricity price based on previous step's usage."""

        usage_frac = self.previous_usage / self.num_households
        beta = 0.5
        alpha = 0.2

        target_price = self.base_price * (1 + beta * usage_frac)
        self.current_price = (1 - alpha) * self.current_price + alpha * target_price

    def step(self):
        # Keep the within-tick order identical across every batch run.
        self.update_price()
        self.agents.shuffle_do("wash_decision")
        self.previous_usage = sum(1 for agent in self.agents if agent.is_washing)
        self.datacollector.collect(self)

Before you launch a parameter sweep, it is worth knowing how many model runs it will produce, since that total is the product of every varying parameter value and the number of random seeds; fixed parameters and the step count do not multiply it. The activity below lets you vary each quantity and reports the resulting number of runs. Predict how the count grows as you add one extra value to a swept parameter.

Learning Activity: How many runs will this sweep launch?
work out how many runs a parameter sweep will launch.
A live calculator for a parameter sweep, where only the parameters that actually vary multiply the total run count.
Move the sliders to watch the run count grow, then match the tutorial's settings of 5 values of N, 5 seeds and 40 steps and press Check.
3
3
30
combinations = 3 (values of N) × 1 (width fixed) × 1 (height fixed) = 3
total runs = combinations × seeds = 3 × 3 = 9
rows collected ≈ runs × max_steps = 9 × 30 ≈ 270
Bar length tracks total runs (out of a possible 80).
Challenge: set the sliders to match the tutorial's code (5 values of N, 5 seeds, 40 steps), then press Check.
# Specify the parameter grid and explicit random seeds for a reproducible batch experiment.

import sys

# --- 1. Define which inputs vary and which remain fixed ---
# batch_run sweeps list values; scalar values stay fixed across every run.
params = {
    "N": [20, 40, 60, 80, 100],
    "width": 10,
    "height": 10
}

# --- 2. Generate five explicit seeds for five reproducible repetitions ---
# default_rng creates a NumPy random-generator object independent of the Mesa models.
rng = np.random.default_rng(42)
# sys.maxsize supplies a safe upper bound for platform-sized integer seeds.
rng_values = rng.integers(0, sys.maxsize, size=(5,))

# --- 3. Run 5 population sizes x 5 seeds = 25 fresh model objects ---
# number_processes=1 is slower but gives beginner-friendly, deterministic debugging.
results = mesa.batch_run(
    WashingModel_7,
    parameters=params,
    rng=rng_values.tolist(),
    max_steps=40,
    number_processes=1,
    data_collection_period=1,
    display_progress=True
)
# Convert Mesa's list of run records into a pandas DataFrame for analysis.

# Each dictionary in results becomes one row; dictionary keys become column labels.
results_df = pd.DataFrame(results)
# Keep the final row of each run, aggregate by population size, and compare outcomes.

# --- 1. Compare runs at the same final tick instead of mixing early and late states ---
final_steps = results_df[results_df['Step'] == results_df['Step'].max()]

plt.figure(figsize=(12, 4))

# --- 2. groupby averages across the five seeds for each population size ---
avg_urgency_by_N = final_steps.groupby('N')['Average_Urgency'].mean()
plt.subplot(1, 2, 1)
plt.bar(avg_urgency_by_N.index, avg_urgency_by_N.values)
plt.title('Average Urgency by Population Size')
plt.xlabel('Number of Households')
plt.ylabel('Average Urgency')

# --- 3. Divide the mean count by N to compare proportions, not raw population sizes ---
washing_by_N = final_steps.groupby('N')['Households_Washing'].mean()
plt.subplot(1, 2, 2)
plt.bar(washing_by_N.index, washing_by_N.values/washing_by_N.index)
plt.title('Households Washing Proportionally by Population Size')
plt.xlabel('Number of Households')
plt.ylabel('Proportion of households washing')

plt.tight_layout()
plt.show()

Key takeaways

  • Iterable parameters multiply the number of runs; fixed parameters do not.

  • Total runs = (combinations of iterable parameters) × (repetitions/seeds). This is why sweeps get expensive quickly.

  • data_collection_period chooses whether you record every step or only the final state of each run.

  • batch_run returns a tidy DataFrame, ready for groupby and aggregation, which is exactly what the next section leans on.

3.1.8: Access data with pandas#

In a nutshell

Your agent data comes back with a two-level index, (Step, AgentID). Once you can slice by either level, every question (“how did household 1 evolve?” or “what did everyone look like at step 10?”) becomes a one-liner.

Everything the DataCollector gathered is handed back to you as a pandas DataFrame, so a little fluency with pandas turns your simulation output into answers. You do not need to master the whole library, but a few ideas do most of the work here:

  • DataFrames and Series: the table (many columns) and the single column, the two structures your data arrives in.

  • Indexing and selecting: choosing rows and columns by label or position. For agent data this means slicing a MultiIndex of (Step, AgentID), which you will do just below with .xs.

  • Aggregation and grouping: groupby, pivot_table, and summary methods like mean and describe, for turning per-household rows into per-type or per-step summaries.

If you want to go deeper, these are excellent starting points:

Consider the multi-index DataFrame from our agent data:

# Practise model tables, agent tables, and cross-sections of a (Step, AgentID) MultiIndex.

# Printed divider lines make the two table types easier to distinguish in the output.
print("=" * 50)
print("MODEL-LEVEL DATA ANALYSIS")
print("=" * 50)

# --- Model table: one row per tick, one column per model reporter ---
model6_data = model6.datacollector.get_model_vars_dataframe()
print("Model data structure:")
print(model6_data.head())
print(f"\nModel data shape: {model6_data.shape}")
print(f"Model data columns: {list(model6_data.columns)}")

print("\nModel data summary statistics:")
# describe() computes count, mean, spread, and quartiles for numeric columns.
print(model6_data.describe())

print("\n" + "=" * 50)
print("AGENT-LEVEL DATA ANALYSIS")
print("=" * 50)

# --- Agent table: one row per (Step, AgentID) pair ---
agent6_data = model6.datacollector.get_agent_vars_dataframe()
print("Agent data structure:")
print(agent6_data.head())
print(f"\nAgent data shape: {agent6_data.shape}")
print(f"Agent data columns: {list(agent6_data.columns)}")
print(f"Agent data index levels: {agent6_data.index.names}")

# --- Cross-sections: fix one MultiIndex label and keep the other dimension ---
print("\nData for agent 1:")
# xs (cross-section) fixes one MultiIndex level and leaves the other as the row index.
agent_1_data = agent6_data.xs(1, level="AgentID")
print(agent_1_data.head())

print("\nData for step 10:")
step_10_data = agent6_data.xs(10, level="Step")
print(step_10_data.head())

# --- Final-state summary: compare households at one common moment ---
print("\nAgent data summary statistics (final step):")
final_step = agent6_data.index.get_level_values("Step").max()
final_agent_data = agent6_data.xs(final_step, level="Step")
print(final_agent_data.describe())
Learning Activity: Extract data from a pandas DataFrame
choose pandas expressions that extract the rows or columns needed from agent-level data.
A small agent DataFrame indexed by Step and AgentID. Each expression below returns a different view of the same sixteen records.
Select a goal, then choose the pandas expression that achieves it. Inspect the returned table and explanation, and complete all six goals.
Completed 0 of 6

Consolidate the distinction between one model record per step and one agent record per household per step.

Learning Activity: Distinguish model data from agent data
distinguish the row structure of model-level and agent-level DataFrames.
A single-choice question comparing the outputs of get_model_vars_dataframe() and get_agent_vars_dataframe().
Choose the statement that correctly describes both table shapes, press Show answer, and relate the feedback to the MultiIndex activity above.

Key takeaways

  • Model-level DataFrames answer one question per tick; agent-level DataFrames preserve one record per household per tick.

  • A pandas MultiIndex stores (Step, AgentID) together; .xs, .loc, Boolean masks, and column lists extract different views.

  • groupby, aggregation, and cross-sections turn raw simulation records into comparisons you can explain.

3.1.9: Wrap-up and next steps#

  • Agents & Models: You created HouseholdAgent subclasses and a WashingModel that manages agents, showing how Mesa separates agent behaviour from model orchestration.

  • Agent activation: You used agents.shuffle_do() and custom step methods to control activation order and per-step actions.

  • Spatial containers: You placed agents on an OrthogonalMooreGrid and used cell neighbourhoods to implement local influence and spatial interactions.

  • Data collection: You added mesa.DataCollector for model- and agent-level reporters to gather time-series and panel data automatically.

  • Batch experiments: You ran mesa.batch_run to sweep parameters and collect aggregated results for analysis.

  • Visualisation & debugging: You used Matplotlib (plots, animation) and deterministic seeds (seed=) to inspect, reproduce, and debug runs.

Finish the tutorial by identifying the claims that the model you built actually supports.

Learning Activity: Review the Mesa model you built
identify the Mesa concepts and results that the completed household model demonstrates.
A multiple-selection chapter recap covering activation order, toroidal space, collected agent data, batch-run size, and reproducibility.
Select every statement that is true of the model you built, press Show answer, and revisit any chapter idea named in the feedback.

See also

You now have every Mesa building block: agents and models, activation, space, data collection, visualisation, groups, and batch experiments. In Chapter 4 you will put them to work on classic models such as the simple economy, Game of Life, traffic, and virus spread, and in Chapter 5 on larger, empirically grounded ones.

See also

Optional video: an earlier Mesa tutorial. This 2025 tutorial provides another walk-through of an agent-based model in Mesa. It uses an older Mesa version than this book, so its code and APIs may differ.

Introduction to Agent-Based Modelling using the Mesa library in Python (2025), Omar Kammouh.