Skip to content

Commit 5597615

Browse files
committed
Enhance wolf-sheep with continuous energy and reactive behaviors
Update the wolf-sheep predation example to demonstrate mesa_signals and ContinuousObservable capabilities. Energy now depletes continuously over time rather than in discrete steps, and agent behaviors react automatically to energy state changes. Changes: - Convert Animal base class to use ContinuousObservable for energy tracking with configurable metabolic rates - Add computed properties (is_hungry, can_reproduce) that automatically update based on energy levels - Implement threshold-triggered events for death (energy reaches zero) and survival mode (critical hunger) - Enhance decision logic: sheep prioritize grass when hungry, wolves hunt actively when hungry - Add activity-based metabolic rates (movement costs more energy than resting) - Improve GrassPatch to use Observable and react to state changes with signal handlers - Expand data collection to track average energy levels across populations The example now demonstrates key reactive programming patterns: observable state management, computed properties for decision making, threshold-based event triggering, and automatic dependency tracking. This makes the model more realistic (continuous dynamics) while reducing boilerplate code (declarative state management). fix agent imports
1 parent 24191cb commit 5597615

File tree

2 files changed

+165
-57
lines changed

2 files changed

+165
-57
lines changed
Lines changed: 129 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,26 @@
11
from mesa.discrete_space import CellAgent, FixedAgent
2+
from mesa.experimental.mesa_signals import (
3+
Computable,
4+
Computed,
5+
ContinuousObservable,
6+
HasObservables,
7+
Observable,
8+
)
29

310

4-
class Animal(CellAgent):
5-
"""The base animal class."""
11+
class Animal(CellAgent, HasObservables):
12+
"""The base animal class with reactive energy management."""
13+
14+
# Energy depletes continuously over time
15+
energy = ContinuousObservable(
16+
initial_value=8.0, rate_func=lambda value, elapsed, agent: -agent.metabolic_rate
17+
)
18+
19+
# Computed property: animal is hungry when energy is low
20+
is_hungry = Computable()
21+
22+
# Computed property: animal can reproduce when energy is sufficient
23+
can_reproduce = Computable()
624

725
def __init__(
826
self, model, energy=8, p_reproduce=0.04, energy_from_food=4, cell=None
@@ -17,14 +35,40 @@ def __init__(
1735
cell: Cell in which the animal starts
1836
"""
1937
super().__init__(model)
38+
39+
# Set base metabolic rate (energy loss per time unit when idle)
40+
self.metabolic_rate = 0.5
41+
42+
# Initialize energy (triggers continuous depletion)
2043
self.energy = energy
2144
self.p_reproduce = p_reproduce
2245
self.energy_from_food = energy_from_food
2346
self.cell = cell
2447

48+
# Set up computed properties
49+
self.is_hungry = Computed(lambda: self.energy < self.energy_from_food * 2)
50+
self.can_reproduce = Computed(lambda: self.energy > self.energy_from_food * 4)
51+
52+
# Register threshold: die when energy reaches zero
53+
self.add_threshold("energy", 0.0, self._on_energy_depleted)
54+
55+
# Register threshold: become critically hungry at 25% of starting energy
56+
self.add_threshold("energy", energy * 0.25, self._on_critical_hunger)
57+
58+
def _on_energy_depleted(self, signal):
59+
"""Called when energy crosses zero - animal dies."""
60+
if signal.direction == "down": # Only trigger on downward crossing
61+
self.remove()
62+
63+
def _on_critical_hunger(self, signal):
64+
"""Called when energy becomes critically low."""
65+
if signal.direction == "down":
66+
# Increase metabolic efficiency when starving (survival mode)
67+
self.metabolic_rate *= 0.8
68+
2569
def spawn_offspring(self):
2670
"""Create offspring by splitting energy and creating new instance."""
27-
self.energy /= 2
71+
self.energy /= 2 # This updates the continuous observable
2872
self.__class__(
2973
self.model,
3074
self.energy,
@@ -35,33 +79,39 @@ def spawn_offspring(self):
3579

3680
def feed(self):
3781
"""Abstract method to be implemented by subclasses."""
82+
raise NotImplementedError
3883

3984
def step(self):
4085
"""Execute one step of the animal's behavior."""
41-
# Move to random neighboring cell
86+
# Move to neighboring cell (uses more energy than standing still)
87+
self.metabolic_rate = 1.0 # Movement costs more energy
4288
self.move()
4389

44-
self.energy -= 1
45-
4690
# Try to feed
4791
self.feed()
4892

49-
# Handle death and reproduction
50-
if self.energy < 0:
51-
self.remove()
52-
elif self.random.random() < self.p_reproduce:
93+
# Return to resting metabolic rate
94+
self.metabolic_rate = 0.5
95+
96+
# Reproduce if conditions are met (using computed property)
97+
if self.can_reproduce and self.random.random() < self.p_reproduce:
5398
self.spawn_offspring()
5499

55100

56101
class Sheep(Animal):
57-
"""A sheep that walks around, reproduces (asexually) and gets eaten."""
102+
"""A sheep that walks around, reproduces and gets eaten.
103+
104+
Sheep prefer cells with grass and avoid wolves. They gain energy by
105+
eating grass, which continuously depletes over time.
106+
"""
58107

59108
def feed(self):
60109
"""If possible, eat grass at current location."""
61110
grass_patch = next(
62111
obj for obj in self.cell.agents if isinstance(obj, GrassPatch)
63112
)
64113
if grass_patch.fully_grown:
114+
# Eating gives instant energy boost
65115
self.energy += self.energy_from_food
66116
grass_patch.fully_grown = False
67117

@@ -70,64 +120,82 @@ def move(self):
70120
cells_without_wolves = self.cell.neighborhood.select(
71121
lambda cell: not any(isinstance(obj, Wolf) for obj in cell.agents)
72122
)
73-
# If all surrounding cells have wolves, stay put
123+
124+
# If all surrounding cells have wolves, stay put (fear overrides hunger)
74125
if len(cells_without_wolves) == 0:
75126
return
76127

77-
# Among safe cells, prefer those with grown grass
78-
cells_with_grass = cells_without_wolves.select(
79-
lambda cell: any(
80-
isinstance(obj, GrassPatch) and obj.fully_grown for obj in cell.agents
128+
# If critically hungry, prioritize grass over safety
129+
if self.is_hungry: # Using computed property
130+
cells_with_grass = cells_without_wolves.select(
131+
lambda cell: any(
132+
isinstance(obj, GrassPatch) and obj.fully_grown
133+
for obj in cell.agents
134+
)
81135
)
82-
)
83-
# Move to a cell with grass if available, otherwise move to any safe cell
84-
target_cells = (
85-
cells_with_grass if len(cells_with_grass) > 0 else cells_without_wolves
86-
)
136+
# Move to grass if available, otherwise any safe cell
137+
target_cells = (
138+
cells_with_grass if len(cells_with_grass) > 0 else cells_without_wolves
139+
)
140+
else:
141+
# Not hungry - just avoid wolves
142+
target_cells = cells_without_wolves
143+
87144
self.cell = target_cells.select_random_cell()
88145

89146

90147
class Wolf(Animal):
91-
"""A wolf that walks around, reproduces (asexually) and eats sheep."""
148+
"""A wolf that walks around, reproduces and eats sheep.
149+
150+
Wolves are more efficient predators, with higher base energy and
151+
metabolic rate. They actively hunt sheep and gain substantial energy
152+
from successful kills.
153+
"""
154+
155+
def __init__(
156+
self, model, energy=20, p_reproduce=0.05, energy_from_food=20, cell=None
157+
):
158+
"""Initialize a wolf with higher energy needs than sheep."""
159+
super().__init__(model, energy, p_reproduce, energy_from_food, cell)
160+
# Wolves have higher metabolic rate (they're larger predators)
161+
self.metabolic_rate = 1.0
92162

93163
def feed(self):
94164
"""If possible, eat a sheep at current location."""
95165
sheep = [obj for obj in self.cell.agents if isinstance(obj, Sheep)]
96-
if sheep: # If there are any sheep present
166+
if sheep: # Successful hunt
97167
sheep_to_eat = self.random.choice(sheep)
168+
# Eating gives instant energy boost
98169
self.energy += self.energy_from_food
99170
sheep_to_eat.remove()
100171

101172
def move(self):
102173
"""Move to a neighboring cell, preferably one with sheep."""
103-
cells_with_sheep = self.cell.neighborhood.select(
104-
lambda cell: any(isinstance(obj, Sheep) for obj in cell.agents)
105-
)
106-
target_cells = (
107-
cells_with_sheep if len(cells_with_sheep) > 0 else self.cell.neighborhood
108-
)
109-
self.cell = target_cells.select_random_cell()
174+
# When hungry, actively hunt for sheep
175+
if self.is_hungry: # Using computed property
176+
cells_with_sheep = self.cell.neighborhood.select(
177+
lambda cell: any(isinstance(obj, Sheep) for obj in cell.agents)
178+
)
179+
target_cells = (
180+
cells_with_sheep
181+
if len(cells_with_sheep) > 0
182+
else self.cell.neighborhood
183+
)
184+
else:
185+
# When not hungry, wander randomly (conserve energy)
186+
target_cells = self.cell.neighborhood
110187

188+
self.cell = target_cells.select_random_cell()
111189

112-
class GrassPatch(FixedAgent):
113-
"""A patch of grass that grows at a fixed rate and can be eaten by sheep."""
114190

115-
@property
116-
def fully_grown(self):
117-
"""Whether the grass patch is fully grown."""
118-
return self._fully_grown
191+
class GrassPatch(FixedAgent, HasObservables):
192+
"""A patch of grass that grows at a fixed rate and can be eaten by sheep.
119193
120-
@fully_grown.setter
121-
def fully_grown(self, value: bool) -> None:
122-
"""Set grass growth state and schedule regrowth if eaten."""
123-
self._fully_grown = value
194+
Grass growth is modeled as a continuous process with a fixed regrowth time.
195+
"""
124196

125-
if not value: # If grass was just eaten
126-
self.model.simulator.schedule_event_relative(
127-
setattr,
128-
self.grass_regrowth_time,
129-
function_args=[self, "fully_grown", True],
130-
)
197+
# Observable: grass growth state
198+
fully_grown = Observable()
131199

132200
def __init__(self, model, countdown, grass_regrowth_time, cell):
133201
"""Create a new patch of grass.
@@ -139,12 +207,25 @@ def __init__(self, model, countdown, grass_regrowth_time, cell):
139207
cell: Cell to which this grass patch belongs
140208
"""
141209
super().__init__(model)
142-
self._fully_grown = countdown == 0
210+
211+
self.fully_grown = countdown == 0
143212
self.grass_regrowth_time = grass_regrowth_time
144213
self.cell = cell
145214

215+
# Listen for when grass gets eaten, schedule regrowth
216+
self.observe("fully_grown", "change", self._on_growth_change)
217+
146218
# Schedule initial growth if not fully grown
147219
if not self.fully_grown:
220+
self.model.simulator.schedule_event_relative(self._regrow, countdown)
221+
222+
def _on_growth_change(self, signal):
223+
"""React to grass being eaten - schedule regrowth."""
224+
if signal.new is False: # Grass was just eaten
148225
self.model.simulator.schedule_event_relative(
149-
setattr, countdown, function_args=[self, "fully_grown", True]
226+
self._regrow, self.grass_regrowth_time
150227
)
228+
229+
def _regrow(self):
230+
"""Regrow the grass patch."""
231+
self.fully_grown = True

mesa/examples/advanced/wolf_sheep/model.py

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
Wolf-Sheep Predation Model
33
================================
44
5+
Enhanced version with continuous energy depletion and reactive behaviors.
6+
57
Replication of the model found in NetLogo:
68
Wilensky, U. (1997). NetLogo Wolf Sheep Predation model.
79
http://ccl.northwestern.edu/netlogo/models/WolfSheepPredation.
@@ -21,11 +23,16 @@
2123
class WolfSheep(Model):
2224
"""Wolf-Sheep Predation Model.
2325
24-
A model for simulating wolf and sheep (predator-prey) ecosystem modelling.
26+
A model for simulating wolf and sheep (predator-prey) ecosystem with:
27+
- Continuous energy depletion over time
28+
- Reactive behaviors based on hunger levels
29+
- Threshold-triggered events (death, starvation mode)
30+
- Computed properties for decision making
2531
"""
2632

2733
description = (
28-
"A model for simulating wolf and sheep (predator-prey) ecosystem modelling."
34+
"A model for simulating wolf and sheep (predator-prey) ecosystem modelling "
35+
"with continuous energy dynamics and reactive behaviors."
2936
)
3037

3138
def __init__(
@@ -55,12 +62,13 @@ def __init__(
5562
wolf_gain_from_food: Energy a wolf gains from eating a sheep
5663
grass: Whether to have the sheep eat grass for energy
5764
grass_regrowth_time: How long it takes for a grass patch to regrow
58-
once it is eaten
5965
sheep_gain_from_food: Energy sheep gain from grass, if enabled
6066
seed: Random seed
6167
simulator: ABMSimulator instance for event scheduling
6268
"""
6369
super().__init__(seed=seed)
70+
71+
# Initialize time-based simulator for continuous energy dynamics
6472
self.simulator = simulator
6573
self.simulator.setup(self)
6674

@@ -77,19 +85,32 @@ def __init__(
7785
random=self.random,
7886
)
7987

80-
# Set up data collection
88+
# Set up data collection (tracks observable changes automatically)
8189
model_reporters = {
8290
"Wolves": lambda m: len(m.agents_by_type[Wolf]),
8391
"Sheep": lambda m: len(m.agents_by_type[Sheep]),
92+
"Avg Wolf Energy": lambda m: (
93+
sum(w.energy for w in m.agents_by_type[Wolf])
94+
/ len(m.agents_by_type[Wolf])
95+
if len(m.agents_by_type[Wolf]) > 0
96+
else 0
97+
),
98+
"Avg Sheep Energy": lambda m: (
99+
sum(s.energy for s in m.agents_by_type[Sheep])
100+
/ len(m.agents_by_type[Sheep])
101+
if len(m.agents_by_type[Sheep]) > 0
102+
else 0
103+
),
84104
}
105+
85106
if grass:
86107
model_reporters["Grass"] = lambda m: len(
87108
m.agents_by_type[GrassPatch].select(lambda a: a.fully_grown)
88109
)
89110

90111
self.datacollector = DataCollector(model_reporters)
91112

92-
# Create sheep:
113+
# Create sheep with random initial energy
93114
Sheep.create_agents(
94115
self,
95116
initial_sheep,
@@ -98,7 +119,8 @@ def __init__(
98119
energy_from_food=sheep_gain_from_food,
99120
cell=self.random.choices(self.grid.all_cells.cells, k=initial_sheep),
100121
)
101-
# Create Wolves:
122+
123+
# Create wolves with random initial energy
102124
Wolf.create_agents(
103125
self,
104126
initial_wolves,
@@ -123,10 +145,15 @@ def __init__(
123145
self.datacollector.collect(self)
124146

125147
def step(self):
126-
"""Execute one step of the model."""
127-
# First activate all sheep, then all wolves, both in random order
148+
"""Execute one step of the model.
149+
150+
Energy continuously depletes between steps via ContinuousObservable.
151+
This step method only triggers agent decisions and actions.
152+
"""
153+
# Activate all sheep, then all wolves, both in random order
154+
# Their energy has been continuously depleting since last step
128155
self.agents_by_type[Sheep].shuffle_do("step")
129156
self.agents_by_type[Wolf].shuffle_do("step")
130157

131-
# Collect data
158+
# Collect data (automatically captures current energy levels)
132159
self.datacollector.collect(self)

0 commit comments

Comments
 (0)