Chapter 22: Building Models with DisSModel¶
Part IV — DisSModel: Core and Paradigms
Learning Objectives¶
By the end of this chapter you will be able to:
- Install DisSModel and an extension package
- Run a model three different ways — CLI, Streamlit, and notebook
- Understand the minimal structure of a model project
- Build a complete
ModelExecutoraround a real model, end to end
# Standard imports — add chapter-specific imports below
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
Chapter 21's Ticker ran entirely inside this notebook, one lifecycle hook at a time. This chapter does what Chapter 21 deliberately deferred: install the framework for real, meet a model with actual behavior instead of a toy counter, and build the reproducibility contract Chapter 21 only described in the abstract.
Installation¶
dissmodel itself is published on PyPI:
pip install dissmodel
Extension packages such as dissmodel-ca are not published on PyPI — install them straight from GitHub, either the development branch or a released tag:
pip install "git+https://github.com/DisSModel/dissmodel-ca.git"
If you're working from a local clone of an extension repo instead — to run its bundled examples/, or to modify it — install it in editable mode from inside the repo:
git clone https://github.com/DisSModel/dissmodel-ca.git
cd dissmodel-ca
pip install -e .
Both patterns slot directly into the venv workflow Chapter 5 already established — nothing about DisSModel changes how you'd manage a Python environment.
Running a First Model, Three Ways¶
dissmodel-ca ships the same Game of Life model — the exact GameOfLife class Chapter 21 previewed conceptually — in three runnable forms, all built on the identical class: a plain CLI script, a Streamlit dashboard, and a didactic notebook. Each targets a different moment in a model's life.
CLI (examples/cli/ca_game_of_life.py) is the fastest way to confirm a model runs at all — a linear script, no framework beyond dissmodel itself:
from dissmodel.core import Environment
from dissmodel.geo import vector_grid
from dissmodel_ca.models import GameOfLife
from dissmodel.visualization import Map
from matplotlib.colors import ListedColormap
gdf = vector_grid(dimension=(20, 20), resolution=1, attrs={"state": 0}, crs="EPSG:31983")
env = Environment(start_time=0, end_time=10)
gol = GameOfLife(gdf=gdf)
gol.initialize() # seeds the starting alive/dead pattern
cmap = ListedColormap(["white", "black"])
Map(gdf=gdf, plot_params={"column": "state", "cmap": cmap, "ec": "gray"})
env.run()
print("Final alive cells:", int(gdf["state"].sum()))
Final alive cells: 59
As a standalone file, this same script runs from a terminal exactly like Chapter 21's ticker_model.py:
python examples/cli/ca_game_of_life.py
Streamlit (examples/streamlit/ca_all.py) goes a step further and turns every CellularAutomaton subclass in dissmodel_ca.models into an interactive app, without listing them by hand — it discovers models via inspect.getmembers(ca_models, inspect.isclass), filtered down to concrete CellularAutomaton subclasses, then renders sidebar controls (model choice, step count, grid size, colormap) automatically:
streamlit run examples/streamlit/ca_all.py
This works because every model in the package follows the same conventions Chapter 21 introduced: annotated setup() parameters get picked up and rendered as widgets, and execute() applies the same rule()-per-cell logic Chapter 24 opens up properly.
Notebooks (examples/notebooks/) are the didactic entry point — the package's own README calls them "the best way to learn about each model," and ca_game_of_life.ipynb specifically walks through Conway's rule table and a library of seed patterns (blinker, glider, pulsar, and others) independently of the GameOfLife class itself.
Minimal Project Structure¶
The CLI/Streamlit/notebook split above is not incidental — it's a convention every package in the DisSModel ecosystem follows:
dissmodel-ca/
src/dissmodel_ca/models/ # the "science" layer — model classes only
examples/
cli/ # fastest possible confirmation that a model runs
streamlit/ # interactive parameter exploration
notebooks/ # didactic, step-by-step walkthroughs
src/<package>/models/ holds only model classes — subclasses of CellularAutomaton, SpatialModel, or RasterModel, with no CLI parsing, no argparse, no I/O — exactly the "no I/O in the science layer" principle Chapter 21's ModelExecutor section already argued for. examples/ is where that separation pays off: three different ways to use a model, none of which require touching the model's own source.
The Reproducible Way: ModelExecutor¶
The Game of Life script above is the fastest way to confirm a model runs — but it isn't reproducible: no input file is recorded, no output is saved, no checksum proves the result came from that exact input. Chapter 21 introduced the ModelExecutor contract (validate → load → run → save) in the abstract; here it is filled in, end to end, for dissmodel-ca's FireModel:
from dissmodel.executor import ExperimentRecord, ModelExecutor, DataSource
from dissmodel.io import load_dataset, save_dataset
class ForestFireExecutor(ModelExecutor):
name = "forest_fire"
def load(self, record: ExperimentRecord):
gdf, checksum = load_dataset(record.source.uri)
record.source.checksum = checksum
return gdf
def run(self, data, record: ExperimentRecord):
from dissmodel.core import Environment
from dissmodel_ca.models import FireModel
env = Environment(start_time=0, end_time=record.parameters.get("end_time", 20))
model = FireModel(
gdf=data,
initial_fire_density=record.parameters.get("initial_fire_density", 0.05),
seed=record.parameters.get("seed", 42),
)
env.run() # model.setup() already ran at construction — env.run() just advances time
return data
def save(self, result, record: ExperimentRecord) -> ExperimentRecord:
uri = record.output_path or "output.gpkg"
output_checksum = save_dataset(result, uri)
record.output_path = uri
record.output_sha256 = output_checksum
record.status = "completed"
return record
Unlike the CLI script above, a ModelExecutor doesn't start from an in-memory vector_grid() — load() reads from record.source.uri, so there needs to be an actual file on disk first. Generate one the same way Chapter 21's vector_grid() did, then save it:
from dissmodel.geo import vector_grid
from dissmodel.io import save_dataset
gdf = vector_grid(dimension=(15, 15), resolution=1, attrs={"state": 0}, crs="EPSG:31983")
save_dataset(gdf, "forest.gpkg")
print("Sample input written.")
Sample input written.
With an input file in hand, build an ExperimentRecord — the object every phase of the executor reads from and writes back to — and run all three phases in sequence:
record = ExperimentRecord(
experiment_id="fire_demo_001",
model_name="forest_fire",
source=DataSource(type="file", uri="forest.gpkg"),
parameters={"initial_fire_density": 0.1, "seed": 7, "end_time": 10},
)
executor = ForestFireExecutor()
data = executor.load(record)
result = executor.run(data, record)
record = executor.save(result, record)
print("Status:", record.status)
print("Output path:", record.output_path)
print("Source checksum: ", record.source.checksum[:16], "...")
print("Output checksum: ", record.output_sha256[:16], "...")
/home/sergio/develop/github/lambdageo/ebooks/geospatial-modeling-python/.venv/lib/python3.12/site-packages/pyogrio/raw.py:200: RuntimeWarning: File /vsimem/pyogrio_5afea8913f9e40cab4cacf4434073457 has GPKG application_id, but non conformant file extension return ogr_read(
Running from 0 to 10 (duration: 10) Status: completed Output path: output.gpkg Source checksum: ffa93eb6b7e9758f ... Output checksum: 62f24881108e59b1 ...
Compare this to the plain CLI script from Running a First Model: load()/save() do the I/O the CLI script never bothered with, computing a checksum on the way in and on the way out — provenance that survives independent of anyone's memory of what ran. run() stays exactly as free of I/O as Chapter 21 argued it should — it receives already-loaded data, returns a result, and never once touches a filesystem path directly.
The command-line entry point comes almost for free. Add a run_cli call at the bottom of the same file, and the executor gains a terminal interface without writing any argument-parsing code by hand:
# forest_fire_executor.py — same ForestFireExecutor class, plus:
if __name__ == "__main__":
from dissmodel.executor.cli import run_cli
run_cli(ForestFireExecutor)
python forest_fire_executor.py run --input forest.gpkg --param initial_fire_density=0.1 --param seed=7
The --param flags on the command line land directly in record.parameters — the same dictionary run() already reads initial_fire_density, seed, and end_time from above. (One honest caveat: output_sha256 isn't a field ExperimentRecord declares in its schema — it works as a plain Python attribute, and, more importantly, it's the exact name Chapter 28's test harness checks for, which matters more here than schema purity does.)
When to Use Which Pattern¶
Two patterns, two different jobs — not a beginner version and an advanced version of the same thing:
- The
vector_grid()+Environment+ model +env.run()pattern (Running a First Model) is for exploring a model interactively: a notebook cell, a Streamlit app, a quick sanity check. Nothing about it is saved or checksummed, and that's fine — that isn't what it's for. - The
ModelExecutorpattern (this section) is for anything that needs to be run again later and trusted: a batch of experiments, a result cited in a paper, a job submitted to the platform Chapter 30 introduces.disslucc-continuous,disslucc-discrete, andbrmangue-dissmodel— Chapters 26 and 27's domain models — are built entirely on this second pattern;ForestFireExecutorhere is the same contract wrapped around the smallest model that makes it concrete.
Exercises¶
- Install and confirm. In a fresh virtual environment, install
dissmodelfrom PyPI anddissmodel-cafrom GitHub. Run the Running a First Model CLI cell and confirm the Game of Life pattern evolves over 10 steps. - A second parameter. Modify
ForestFireExecutor.run()so a new parameter,wind_direction, is read fromrecord.parameters(even ifFireModelitself ignores it for now). Confirm it round-trips correctly by printingrecord.parametersafter construction. - Break the checksum, on purpose. After running the full
ForestFireExecutorexample, openforest.gpkgin a text or GIS editor, change one value, save it, and reload it withload_dataset(). Doesrecord.source.checksumchange? What would that tell a collaborator re-running your experiment later? - CLI vs ModelExecutor, in your own words. Without looking back at When to Use Which Pattern, write two sentences: one describing when you'd reach for the plain CLI pattern, one for
ModelExecutor. Compare against the section afterward.
# Your code here
Summary¶
Key concepts introduced¶
- Installing DisSModel from PyPI and extension packages from GitHub, either as a release or in editable mode, within the
venvworkflow Chapter 5 established - Three ways to run the same model — CLI (fastest confirmation), Streamlit (interactive exploration, via automatic model discovery), notebooks (didactic) — all driving identical model classes
- The
src/<package>/models/(science) versusexamples/(usage) split as the minimal shape of a DisSModel project - A complete
ModelExecutor,ForestFireExecutor, built and run end to end —load()andsave()carrying real, verified checksums,run()staying free of I/O exactly as Chapter 21 argued it should run_cli()turning anyModelExecutorinto a command-line tool without hand-written argument parsing
Chapters 23 through 25 return to this same Model/ModelExecutor foundation for each simulation paradigm in turn — system dynamics, cellular automata in depth, and agent-based modeling.
Further Reading¶
- dissmodel-ca on GitHub, including its README on the didactic notebooks: https://github.com/DisSModel/dissmodel-ca
- DisSModel documentation and API reference: https://dissmodel.github.io/dissmodel/
- Streamlit documentation, Get started: https://docs.streamlit.io/get-started