Agent-Based Modeling (ABM) extension for dissmodel,
inspired by TerraME's Agent /
Society API.
This is a standalone, additive package — it does not modify dissmodel's
core. It depends on dissmodel as a regular package dependency, exactly like
dissmodel-ca.
The whole point of this package is that model code never touches
self.gdf directly. Today the substrate is a vector GeoDataFrame
(raster support is planned next), but a model written against
self.society doesn't know or care about that — it only sees agents as
objects:
for agent in self.society:
if agent.energy <= 0:
agent.die()
elif agent.energy >= 15:
agent.reproduce(energy=5.0)instead of:
self.gdf = self.gdf[self.gdf["energy"] > 0].reset_index(drop=True)
mask = self.gdf["energy"] >= 15
children = self.gdf.loc[mask].copy()
children["energy"] = 5.0
self.gdf = gpd.GeoDataFrame(pd.concat([self.gdf, children]), crs=self.gdf.crs)Society owns no data of its own — it reads and writes through the host
model's gdf attribute, so model.gdf and model.society are always
the same data. Map, Chart, ModelExecutor, and any code that still
expects model.gdf keep working unmodified, whether or not a given
model uses self.society internally.
| TerraME (Agent / Society) | dissmodel-abm |
|---|---|
execute(self) |
execute() (Model lifecycle) |
init(self) |
setup() (Model lifecycle) |
Society (collection of Agents) |
self.society — object-oriented view over self.gdf |
Agent |
self.society[idx] — a proxy over one row |
placement / getCell() |
agent.geometry |
enter(cell) |
agent.enter(x, y) — give a location-less agent a position |
leave() |
agent.leave() — remove an agent's position without removing it from its society |
move(cell) / walk() |
agent.move_to(x, y) / agent.walk(step_size, bounds) |
die() |
agent.die() |
reproduce() |
agent.reproduce(**overrides) |
emptyNeighbor / neighborhood |
agent.neighbors(radius) (points) or agent.grid_neighbors() (cells) |
Society:add / Society:remove |
society.add(**attrs) / society.remove(agent_or_idx) |
Society:sample |
society.sample(n) |
forEachAgent |
for agent in society: ... |
Point-agent models (RandomWalkModel, PredatorPreyModel) back
self.society with Point geometry + state columns. One-agent-per-cell
models (SchellingModel) back it with a polygon grid
(dissmodel.geo.vector.vector_grid), matching the design of TerraME's
logo package — "Implements spatial
agent-based models with at most one agent per cell".
A small set of legacy batch/vectorized methods (walk, die_if,
reproduce_if, neighbors_within) is still available directly on
AgentModel for backward compatibility and for cases where vectorized
performance matters more than per-agent readability — see the API
reference below. New models should prefer self.society.
Following TerraME — where an Agent may exist with no placement at
all until Agent:enter() is called — an agent here can exist with
geometry = None:
orphan = self.society.add(energy=4.0) # no position yet
orphan.has_location # False
orphan.enter(10, 10) # give it a position
orphan.has_location # True
orphan.leave() # remove the position, keep the agent
orphan.has_location # FalseCalling a spatial method (walk, move_to excepted — move_to/enter
are exactly how a location-less agent gets a position — neighbors,
distance_to) on an agent with no location raises a clear
RuntimeError rather than failing on None deep inside geopandas.
agent.reproduce() of a location-less agent yields a location-less
child, unless geometry is given in the overrides.
This is useful for agents that aren't always spatially situated — e.g. a population still "off-map" before being placed, or an agent that temporarily leaves the simulated space.
pip install -e .Requires dissmodel>=0.6.0 (installs geopandas, shapely, numpy,
pandas, libpysal as transitive dependencies).
import geopandas as gpd
import numpy as np
from dissmodel.core import Environment
from dissmodel_abm.models import RandomWalkModel
n = 20
bounds = (0, 0, 100, 100)
rng = np.random.default_rng(42)
gdf = gpd.GeoDataFrame({
"geometry": gpd.points_from_xy(
rng.uniform(bounds[0], bounds[2], n),
rng.uniform(bounds[1], bounds[3], n),
)
})
env = Environment(start_time=0, end_time=20)
model = RandomWalkModel(gdf=gdf, step_size=2.0, bounds=bounds)
env.run()from dissmodel_abm.core import AgentModel
class MyModel(AgentModel):
def setup(self, **params):
... # one-time initialization
def execute(self):
for agent in self.society:
agent.walk(step_size=1.0, bounds=(0, 0, 100, 100))
if agent.energy <= 0:
agent.die()
elif agent.energy >= 15:
agent.reproduce(energy=5.0)Society also provides batch helpers when you don't need a Python
for loop over every agent: society.select(predicate),
society.remove_if(predicate), society.count(predicate),
society.sample(n), and society.walk_all(step_size, bounds) (the
vectorized equivalent of looping agent.walk(...) over everyone).
To expose a live chart in Streamlit (or any dissmodel.visualization.Chart),
decorate the class with @track_plot and update the matching attribute
(same name, lowercased) inside execute() — no separate tracker model
needed:
from dissmodel.visualization import track_plot
@track_plot("Population", "tab:blue")
class MyModel(AgentModel):
population: int = 0
def execute(self):
...
self.population = len(self.society)dissmodel-abm/
├── src/dissmodel_abm/
│ ├── core/
│ │ ├── agent_model.py # AgentModel(SpatialModel) — no core changes
│ │ └── society.py # Society / Agent — the protective layer
│ └── models/
│ ├── random_walk.py # minimal example (point agents)
│ ├── predator_prey.py # wolf-sheep, written against self.society
│ └── schelling.py # segregation model, one agent per cell
├── examples/
│ ├── cli/
│ │ ├── abm_random_walk.py
│ │ ├── abm_predator_prey.py
│ │ └── abm_schelling.py
│ └── streamlit/
│ ├── abm_random_walk.py
│ ├── abm_predator_prey.py
│ └── abm_schelling.py
├── tests/
│ └── test_agent_model.py
└── pyproject.toml
| Model | Substrate | Live-plotted attributes | Source | Description |
|---|---|---|---|---|
RandomWalkModel |
Vector (points) | — | — | Independent random walk within a bounding box. |
PredatorPreyModel |
Vector (points) | sheep, wolves |
— | Wolf-sheep dynamics: movement, predation, death, reproduction, optional grazing. |
SchellingModel |
Vector (grid cells) | satisfaction |
TerraME logo: Schelling |
Segregation model: agents move to empty cells when unhappy with same-type neighbor count. |
from dissmodel.core import Environment
from dissmodel.geo.vector import vector_grid
from dissmodel_abm.models import SchellingModel
gdf = vector_grid(dimension=(25, 25), resolution=1)
env = Environment(start_time=0, end_time=30)
model = SchellingModel(gdf=gdf, free_space=0.25, preference=3, seed=0)
env.run()
print(model.fraction_satisfied()) # -> 1.0 once convergedInternally, every agent is reached through self.society —
agent.agent_type, agent.grid_neighbors() — never raw gdf rows.
Parameters match TerraME's defaults (dim=25, freeSpace=25%,
preference=3). agent_type: -1 = empty, 0/1 = the two agent
groups. model.fraction_satisfied() returns the fraction of agents with
at least preference same-type Queen-neighbors.
docs/agent.md — a full Agent reference page,
mirroring the structure of
TerraME's own Agent docs,
comparing every function side-by-side (die, enter, leave, move,
walk, reproduce, ...) with worked examples from PredatorPreyModel
and SchellingModel, plus a list of TerraME functions with no
dissmodel-abm equivalent yet (social networks, state machines).
- Raster substrate: a
Societybacked by a NumPy/raster array instead of aGeoDataFrame, exposing the exact sameAgent/Societyinterface described above, so existing model code (written againstself.society) keeps working unchanged regardless of substrate. Vector support is intentionally being hardened first. - Social networks: TerraME's
addSocialNetwork/message/on_message, likely as a thin layer over a graph library (e.g.networkx) keyed byagent.id. - State machines: TerraME's
State/Jump/Flow, for agents whose behavior depends on a discrete internal state.
python examples/cli/abm_random_walk.py
python examples/cli/abm_predator_prey.py
python examples/cli/abm_schelling.py # also writes PNG frames to ./map_frames/Each model has an interactive Streamlit app under examples/streamlit/,
following the same Map + Chart convention used by
dissmodel-ca and
dissmodel-sysdyn:
sidebar sliders control parameters, a live Map shows the spatial
state, and (where relevant) a live Chart shows tracked variables over
time. Chart requires no per-app wiring — it reads whatever the model
exposes via @track_plot (PredatorPreyModel.sheep / .wolves,
SchellingModel.satisfaction), the exact convention used by
dissmodel-sysdyn's SIR model.
pip install -e ".[viz]"
streamlit run examples/streamlit/abm_random_walk.py
streamlit run examples/streamlit/abm_predator_prey.py # Map + population Chart
streamlit run examples/streamlit/abm_schelling.py # Map + satisfaction Chartpip install -e ".[dev]"
pytestself.society (a Society, available on any AgentModel):
len(society)— number of agents.for agent in society: ...— iterate over all agents.society[idx]/idx in society— get an agent by id / check existence.society.add(geometry=None, **attrs)— create a new agent; returns it.society.remove(agent_or_idx)— remove one agent.society.remove_if(predicate)— remove every agent matchingpredicate(agent); returns the count removed.society.select(predicate)— return the list of agents matchingpredicate(agent).society.sample(n)—nagents chosen uniformly at random.society.count(predicate=None)— count agents, optionally matchingpredicate.society.walk_all(step_size, bounds=None)— vectorized random walk for every agent (use when you don't need a per-agent loop).society.neighbors_within(idx, radius)— agents withinradiusof agentidx(point agents).society.grid_neighbors_of(idx)— topological neighbors of cellidx(requirescreate_neighborhood(...)).
Agent (what you get from society[idx] or by iterating society):
agent.id— the agent's index in the underlying data.agent.<column>/agent.<column> = value— read/write any attribute; reflects immediately inmodel.gdf.agent.geometry— the agent's position; anyshapelygeometry (Point, Polygon, ...), orNoneif it has no location.agent.has_location— whether this agent currently has a position.agent.enter(x, y)/agent.enter(geometry)— give a location-less agent a position for the first time; accepts coordinates or any geometry directly.agent.leave()— remove this agent's position, without removing it from its society.agent.move_to(x, y)/agent.move_to(geometry)— move to an absolute position; accepts coordinates (aPointshortcut) or any geometry directly (the equivalent of TerraME'sAgent:move(cell)).agent.walk(step_size, bounds=None)— move by a random offset around the geometry's centroid; always produces aPoint(requires a location).agent.distance_to(other_agent)— Euclidean distance (requires both agents to have a location).agent.neighbors(radius)— nearby agents (point agents), asAgentobjects (requires a location).agent.grid_neighbors()— topological cell neighbors, asAgentobjects.agent.die()— remove this agent from its society.agent.reproduce(**overrides)— create a child at the same position, copying attributes and applyingoverrides.
Do not hold onto Agent objects across a step that may remove or add
agents elsewhere — re-obtain them via society[idx] or by iterating
society again within the current step.
Kept for backward compatibility; equivalent functionality is available
through self.society above.
walk(step_size, bounds=None, mask=None)— random displacement in x/y for every agent.move_to(idx, x, y)— move a single agent to an absolute position.die_if(condition)— remove agents for whichcondition(row)is True (resets the GeoDataFrame index).reproduce_if(condition, child_fn=None)— duplicate agents for whichcondition(row)is True.neighbors_within(idx, radius)— indices of agents withinradiusof agentidx.all_neighbors_within(radius)—{idx: [neighbor_idx, ...]}for every agent.
For one-agent-per-cell models (e.g. SchellingModel), use the
inherited SpatialModel.create_neighborhood(strategy=Queen, use_index=True)
and neighs_id(idx) (or agent.grid_neighbors()) — these operate on the
grid topology rather than on point distances.
All other SpatialModel / Model functionality (self.env,
create_neighborhood, pre_execute / post_execute, plot tracking, the
ModelExecutor / ExperimentRecord pipeline, dissmodel.io,
dissmodel.visualization) is inherited unchanged and works exactly as in
dissmodel-ca.
MIT