Chapter 21: Introducing DisSModel¶
Part IV — DisSModel: Core and Paradigms
Learning Objectives¶
By the end of this chapter you will be able to:
- Explain why DisSModel exists and what problem it solves
- Describe the Model lifecycle and how Environment drives it
- Understand the ModelExecutor contract and why science and infrastructure are kept separate
# Standard imports — add chapter-specific imports below
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
Every technique since Chapter 17 has been building a spatial model largely by hand — a raw NumPy grid, a hand-written neighbor loop, a salabim event queue assembled one piece at a time. This chapter introduces the framework that exists to stop that from being necessary — not by showing you code to run yet, but by explaining the two ideas everything else in Part IV is built on: a shared lifecycle every model follows, and a contract that makes a model's results trustworthy months after it ran. Chapter 22 puts both to work with real, running code.
Why DisSModel¶
DisSModel is the Python-native successor to TerraME/LuccME, the modeling framework this book's own research group has developed since 2024. TerraME, written in Lua, was a capable and influential platform for two decades of spatial modeling research in Brazil — but Lua sits outside the Python scientific ecosystem this book has spent thirteen chapters building fluency in. Every model written in TerraME had to either stay isolated from pandas, geopandas, and the rest of that world, or be translated by hand.
DisSModel's founding bet is that a modeling framework should live inside that ecosystem rather than beside it. Three principles follow directly from that bet, and reappear throughout Part IV:
- Open — source available, openly licensed, built to be read and extended, not just called.
- Interoperable — a model's state is a
GeoDataFrameor a NumPy-backed raster, the same objects Part II already made fluent, not a framework-specific data type requiring its own import/export step. - Reproducible by construction — not an afterthought bolted on later, but a contract (this chapter's second half) that every model satisfies from the start.
Chapter 33 returns to this lineage directly, with a concept-by-concept migration guide for anyone bringing an existing TerraME or LuccME model across.
The Model Lifecycle¶
Every DisSModel model, regardless of what it simulates, descends from a common Model base class with a four-hook lifecycle:
| Hook | Called | Purpose |
|---|---|---|
setup(**kwargs) |
once, right after construction | one-time setup — build a neighborhood, initialize state |
pre_execute() |
once per tick, before execute() |
snapshot state before the transition rule runs |
execute() |
once per tick | the transition rule itself — the only required override |
post_execute() |
once per tick, after execute() |
cleanup or logging after the transition rule |
The fastest way to make that table concrete is to build the smallest possible model, one hook at a time, the same way DisSModel's own tutorial notebooks do — a few cells, each adding one piece, rather than one finished class dropped in all at once.
Step 1 — a substrate, and nothing else. Every model needs something to hold its state. A vector_grid(), exactly like Chapter 7's GeoDataFrame-as-grid, is the smallest substrate available:
from dissmodel.core import Model, Environment
from dissmodel.geo import vector_grid
gdf = vector_grid(dimension=(3, 3), resolution=1, attrs={"age": 0})
gdf[["age"]]
| age | |
|---|---|
| id | |
| 0-0 | 0 |
| 1-0 | 0 |
| 2-0 | 0 |
| 0-1 | 0 |
| 1-1 | 0 |
| 2-1 | 0 |
| 0-2 | 0 |
| 1-2 | 0 |
| 2-2 | 0 |
Step 2 — an Environment, then setup() and nothing else. A Model needs an active Environment to register itself with the moment it's constructed — even one that never gets run() yet. Build that Environment first, then a minimal Model subclass that only overrides setup(): it fires once, at construction time, and does exactly one thing here — store whatever it was constructed with.
env = Environment(start_time=0, end_time=3)
class Ticker(Model):
def setup(self, gdf):
self.gdf = gdf
ticker = Ticker(gdf=gdf)
print("Constructed. age is still:", ticker.gdf["age"].tolist())
Constructed. age is still: [0, 0, 0, 0, 0, 0, 0, 0, 0]
Step 3 — add execute(). This is the hook that actually runs every tick. Here, it does the simplest possible thing: add one to every cell's age:
class Ticker(Model):
def setup(self, gdf):
self.gdf = gdf
def execute(self):
self.gdf["age"] = self.gdf["age"] + 1
Step 4 — construct an Environment, then run. Notice ticker below is never explicitly handed to env — construction alone was enough to register it, exactly as the lifecycle table promised:
gdf = vector_grid(dimension=(3, 3), resolution=1, attrs={"age": 0})
env = Environment(start_time=0, end_time=3)
ticker = Ticker(gdf=gdf)
print("Before run:", gdf["age"].tolist())
env.run()
print("After run: ", gdf["age"].tolist())
Before run: [0, 0, 0, 0, 0, 0, 0, 0, 0] Running from 0 to 3 (duration: 3) After run: [4, 4, 4, 4, 4, 4, 4, 4, 4]
Watch out
age ends at 4, not 3 — end_time=3 with start_time=0 runs ticks at 0, 1, 2, and 3, four ticks in total, not three. end_time is inclusive. This is exactly the kind of off-by-one that's worth confirming with a print statement the first time you use a new Environment, not assuming.
The Same Model, as a Script¶
Every cell above ran in this notebook — fine for building intuition one hook at a time, but not how a model actually gets run in practice. A real model lives in its own .py file, callable from a terminal, following the same minimal pattern every example in the DisSModel ecosystem uses: build a substrate, construct an Environment and the model, call env.run().
# ticker_model.py
from dissmodel.core import Model, Environment
from dissmodel.geo import vector_grid
class Ticker(Model):
def setup(self, gdf):
self.gdf = gdf
def execute(self):
self.gdf["age"] = self.gdf["age"] + 1
if __name__ == "__main__":
gdf = vector_grid(dimension=(3, 3), resolution=1, attrs={"age": 0})
env = Environment(start_time=0, end_time=3)
ticker = Ticker(gdf=gdf)
env.run()
print(gdf["age"].tolist())
python ticker_model.py
Nothing about Ticker changed — only where it lives, and the if __name__ == "__main__": guard Chapter 5 already introduced, keeping the "build it and run it" part separate from the class definition so another script could import Ticker without triggering a run. Chapter 22 uses this exact script shape as the starting point for a real fire-spread model, then layers ModelExecutor on top of it.
ModelExecutor: The Reproducibility Contract¶
A model that runs correctly once, in one notebook, on one machine, is not yet a model anyone else can trust. ModelExecutor is DisSModel's answer to that gap: a second, stricter contract, layered on top of Model, with four phases instead of four hooks:
| Phase | Responsibility |
|---|---|
validate |
reject a malformed configuration before any computation starts |
load |
read the input data, and record a checksum of exactly what was read |
run |
the model itself — no I/O, only computation |
save |
write the result, and record a checksum of exactly what was written |
The separation between run and everything else is the whole point. load and save are where a file gets read from or written to disk; run is not allowed to touch a filesystem at all — it receives data, returns data, and nothing about its behavior depends on where that data came from. That separation is what makes an executor's output auditable after the fact: record.source.checksum and record.output_sha256 prove, independent of anyone's memory of what happened, exactly which input produced exactly which output.
This is also, deliberately, the same split Chapter 5's analysis.py drew between exploratory code and tested, reusable functions — ModelExecutor is that discipline, applied specifically to a model that needs to be re-run, cited, or audited later rather than looked at once and discarded. Chapter 22 builds a complete ModelExecutor from scratch, end to end; Chapter 28 comes back to the provenance side of this contract in depth.
Substrates, Briefly¶
One more thing worth knowing before Chapter 22's first real model: a Model's state is deliberately not tied to one data structure. A SpatialModel keeps its state in self.gdf, a GeoDataFrame exactly like the ones Chapter 7 built; a RasterModel keeps its state in self.backend, wrapping NumPy arrays exactly like Chapter 8's elevation grid. Both extend the same Model lifecycle from above — setup, execute, and the rest work identically either way.
This isn't a foundational design principle so much as a practical convenience DisSModel makes available: some processes fit a GeoDataFrame's exact geometry more naturally (Chapter 7's substrate-choice guidance still applies directly), others fit a regular raster grid better, and a few — Chapter 27's coastal case study among them — are worth implementing on both, as a correctness check against each other. Chapter 24 picks this distinction back up properly, once there's a real cellular automaton to run on either substrate.
Exercises¶
- Trace the lifecycle. For a hypothetical model that runs for 5 ticks, list every lifecycle hook call in order, from construction through the end of
env.run(). - Why not skip
load/save? Sketch, in a sentence or two, what could go wrong if aModelExecutor'srun()phase were allowed to read its own input file directly, instead of receiving already-loaded data fromload(). - Checksums, by hand. Using Python's
hashlib, compute the SHA-256 checksum of a small text file you create. What would change about that checksum if you changed a single character in the file? What does that tell you about what a checksum can and can't prove? - Pick a substrate, conceptually. Without writing any code, decide whether a
SpatialModelor aRasterModelfits each of the following better, and why: (a) a model of protected-area boundaries expanding over time, (b) a model of heat diffusing across a uniform grid.
# Your code here
Summary¶
Key concepts introduced¶
- Why DisSModel exists: a Python-native successor to TerraME/LuccME, built to live inside the scientific Python ecosystem rather than beside it
- The
Modellifecycle —setup → pre_execute → execute → post_execute— driven automatically byEnvironment.run(), with no manual event registration ModelExecutor's four-phase contract (validate → load → run → save) and why keepingrun()free of I/O is what makes a model's output auditable later- Substrates as a practical convenience, not a foundational principle:
self.gdforself.backend, both driven by the identical lifecycle
Chapter 22 makes every one of these ideas concrete: installing DisSModel, running a first model three different ways, and building a complete ModelExecutor around a real fire-spread model.
Further Reading¶
- DisSModel on GitHub: https://github.com/DisSModel/dissmodel
- TerraME on GitHub, including the course this book's Part III drew several exercises from: https://github.com/TerraME/terrame
- The dissmodel-book's own account of this same material, for a different framing of the same ideas: https://dissmodel.github.io/dissmodel-book/