diff --git a/dissmodel/visualization/__init__.py b/dissmodel/visualization/__init__.py index 60e190d..2aaab5b 100644 --- a/dissmodel/visualization/__init__.py +++ b/dissmodel/visualization/__init__.py @@ -1,3 +1,4 @@ from dissmodel.visualization.map import Map +from dissmodel.visualization.raster_map import RasterMap from dissmodel.visualization.chart import Chart, track_plot from dissmodel.visualization.widgets import display_inputs \ No newline at end of file diff --git a/docs/api/core.md b/docs/api/core.md index 728f816..9dc57e5 100644 --- a/docs/api/core.md +++ b/docs/api/core.md @@ -1,4 +1,145 @@ # Core +The `dissmodel.core` module provides the simulation clock and execution lifecycle, +built on top of [Salabim](https://www.salabim.org/)'s discrete event engine. + +All models and visualization components must be instantiated **after** the +`Environment` — they register themselves automatically on creation. + +``` +Environment → Model → Visualization → env.run() + ↑ ↑ ↑ ↑ + first second third fourth +``` + +## Usage + +```python +from dissmodel.core import Environment, Model + +env = Environment(start_time=1, end_time=10) + +class MyModel(Model): + def setup(self): + pass + + def execute(self): + print(f"step {self.env.now()}") + +MyModel() +env.run() +``` + +## Object-Oriented Modeling + +Object-oriented modeling is a core feature of DisSModel, inherited directly from +Python's class system. Just as TerraME defines agents as objects with encapsulated +attributes and behaviours, DisSModel uses class inheritance to build structured, +reusable, and modular models. + +Every model is a subclass of `Model`, which guarantees automatic registration with +the active `Environment`. This means the simulation clock, the execution lifecycle, +and any visualization components are wired together without any boilerplate. + +```python +from dissmodel.core import Model, Environment + +class SIR(Model): + + def setup(self, susceptible=9998, infected=2, recovered=0, + duration=2, contacts=6, probability=0.25): + self.susceptible = susceptible + self.infected = infected + self.recovered = recovered + self.duration = duration + self.contacts = contacts + self.probability = probability + + def execute(self): + total = self.susceptible + self.infected + self.recovered + alpha = self.contacts * self.probability + new_inf = self.infected * alpha * (self.susceptible / total) + new_rec = self.infected / self.duration + self.susceptible -= new_inf + self.infected += new_inf - new_rec + self.recovered += new_rec +``` + +Instantiation is clean and parametric: + +```python +env = Environment(end_time=30) +SIR(susceptible=9998, infected=2, recovered=0, + duration=2, contacts=6, probability=0.25) +env.run() +``` + +!!! tip "Why subclass Model?" + - **Automatic clock integration** — `self.env.now()` is always available inside `execute()`. + - **Encapsulation** — each model owns its state; multiple instances can run in the same environment independently. + - **Extensibility** — override `setup()` to add parameters, `execute()` to define the transition rule. Nothing else is required. + - **Composability** — models can read each other's state, enabling coupled CA + SysDyn simulations within a single `env.run()`. + + + +Each model can define its own `start_time` and `end_time`, independent of the +environment interval. This allows different parts of a simulation to be active +at different periods within the same run. + +```python +from dissmodel.core import Model, Environment + +class ModelA(Model): + def execute(self): + print(f"[A] t={self.env.now()}") + +class ModelB(Model): + def execute(self): + print(f"[B] t={self.env.now()}") + +class ModelC(Model): + def execute(self): + print(f"[C] t={self.env.now()}") + +env = Environment(start_time=2010, end_time=2016) + +ModelA(start_time=2012) # active from 2012 to end +ModelB(end_time=2013) # active from start to 2013 +ModelC() # active throughout + +env.run() +``` + +Expected output: + +``` +Running from 2010 to 2016 (duration: 6) +[B] t=2010.0 +[C] t=2010.0 +[B] t=2011.0 +[C] t=2011.0 +[A] t=2012.0 +[B] t=2012.0 +[C] t=2012.0 +[A] t=2013.0 +[C] t=2013.0 +[A] t=2014.0 +[C] t=2014.0 +[A] t=2015.0 +[C] t=2015.0 +[A] t=2016.0 +[C] t=2016.0 +``` + +!!! note + Models with no `start_time` / `end_time` inherit the environment's interval. + Models are synchronised — all active models execute at each time step before + the clock advances. + +--- + +## API Reference + ::: dissmodel.core.Environment -::: dissmodel.core.Model + +::: dissmodel.core.Model \ No newline at end of file diff --git a/docs/api/geo/index.md b/docs/api/geo/index.md new file mode 100644 index 0000000..f27a0dd --- /dev/null +++ b/docs/api/geo/index.md @@ -0,0 +1,206 @@ +# Geo + +The `dissmodel.geo` module provides the spatial infrastructure for building +simulation models. It handles grid generation, neighbourhood computation, and +attribute initialization — without imposing any domain logic. + +```python +from dissmodel.geo import vector_grid, fill, FillStrategy +from dissmodel.geo.vector.neighborhood import attach_neighbors +from dissmodel.geo.raster.backend import RasterBackend +from dissmodel.geo.raster.regular_grid import raster_grid +``` + +--- + +## Dual-substrate design + +The module provides two independent spatial substrates. Both share the same +salabim `Environment` and clock — a vector model and a raster model can run +side by side in the same `env.run()`. + +| | Vector | Raster | +|---|---|---| +| **Module** | `dissmodel.geo.vector` | `dissmodel.geo.raster` | +| **Data structure** | `GeoDataFrame` (GeoPandas) | `RasterBackend` (NumPy 2D arrays) | +| **Grid factory** | `vector_grid()` | `raster_grid()` | +| **Neighbourhood** | Queen / Rook (libpysal) | Moore / Von Neumann (`shift2d`) | +| **Rule pattern** | `rule(idx)` per cell | `rule(arrays) → dict` vectorized | +| **GIS integration** | CRS, projections, spatial joins | rasterio I/O via `load_geotiff` | +| **Best for** | Irregular grids, real-world data | Large grids, performance-critical models | + +--- + +## Vector substrate + +The vector substrate uses a `GeoDataFrame` as the spatial grid. Any model can +operate directly on real geographic data — shapefiles, GeoJSON, real CRS — with +no conversion step. + +```python +import geopandas as gpd +from dissmodel.core import Model, Environment +from dissmodel.visualization.map import Map + +gdf = gpd.read_file("area.shp") +gdf.set_index("object_id", inplace=True) + +env = Environment(start_time=1, end_time=20) + +class ElevationModel(Model): + def setup(self, gdf, rate=0.01): + self.gdf = gdf + self.rate = rate + + def execute(self): + self.gdf["alt"] += self.rate + +ElevationModel(gdf=gdf, rate=0.01) +Map(gdf=gdf, plot_params={"column": "alt", "cmap": "Blues", "legend": True}) +env.run() +``` + +For abstract (non-georeferenced) grids, use `vector_grid()`: + +```python +from dissmodel.geo import vector_grid + +# from dimension + resolution +gdf = vector_grid(dimension=(10, 10), resolution=1) + +# from bounding box + resolution +gdf = vector_grid(bounds=(0, 0, 1000, 1000), resolution=100) + +# from an existing GeoDataFrame +gdf = vector_grid(gdf=base_gdf, resolution=50) +``` + +--- + +## Raster substrate + +The raster substrate stores named NumPy arrays in a `RasterBackend`. All +operations (`shift2d`, `focal_sum`, `neighbor_contact`) are fully vectorized — +no Python loops over cells. + +```python +from dissmodel.geo.raster.regular_grid import raster_grid +from dissmodel.geo.raster.backend import RasterBackend +import numpy as np + +backend = raster_grid(rows=100, cols=100, attrs={"state": 0, "alt": 0.0}) + +# read / write arrays +state = backend.get("state").copy() # snapshot — equivalent to .past in TerraME +backend.arrays["state"] = new_state + +# vectorized neighbourhood operations +shifted = RasterBackend.shift2d(state, -1, 0) # northern neighbour of each cell +n_active = backend.focal_sum_mask(state == 1) # count active Moore neighbours +has_active = backend.neighbor_contact(state == 1) # bool mask: any active neighbour? +``` + +--- + +## Filling grid attributes + +The `fill()` function initialises GeoDataFrame columns from spatial data sources, +avoiding manual cell-by-cell loops. + +```python +from dissmodel.geo import fill, FillStrategy +``` + +### Zonal statistics from a raster + +```python +import rasterio + +with rasterio.open("altitude.tif") as src: + raster = src.read(1) + affine = src.transform + +fill( + FillStrategy.ZONAL_STATS, + vectors=gdf, raster_data=raster, affine=affine, + stats=["mean", "min", "max"], prefix="alt_", +) +# → adds columns alt_mean, alt_min, alt_max to gdf +``` + +### Minimum distance to features + +```python +rivers = gpd.read_file("rivers.shp") + +fill(FillStrategy.MIN_DISTANCE, from_gdf=gdf, to_gdf=rivers, attr_name="dist_river") +``` + +### Random sampling + +```python +fill( + FillStrategy.RANDOM_SAMPLE, + gdf=gdf, attr="land_use", + data={0: 0.7, 1: 0.3}, # 70% class 0, 30% class 1 + seed=42, +) +``` + +### Fixed pattern (useful for tests) + +```python +pattern = [[1, 0, 0], + [0, 1, 0], + [0, 0, 1]] + +fill(FillStrategy.PATTERN, gdf=gdf, attr="zone", pattern=pattern) +``` + +Custom strategies can be registered: + +```python +from dissmodel.geo.vector.fill import register_strategy + +@register_strategy("my_strategy") +def fill_my_strategy(gdf, attr, **kwargs): + ... +``` + +--- + +## Neighbourhood + +Spatial neighbourhoods are built via `attach_neighbors()` or directly through +`create_neighborhood()` on any `CellularAutomaton` or `SpatialModel`. + +```python +from libpysal.weights import Queen, Rook, KNN +from dissmodel.geo.vector.neighborhood import attach_neighbors + +# topological (Queen — edge or vertex contact) +gdf = attach_neighbors(gdf, strategy=Queen) + +# topological (Rook — edge contact only) +gdf = attach_neighbors(gdf, strategy=Rook) + +# distance-based (k nearest neighbours) +gdf = attach_neighbors(gdf, strategy=KNN, k=4) + +# precomputed (from dict or JSON file — faster for large grids) +gdf = attach_neighbors(gdf, neighbors_dict="neighborhood.json") +``` + +| Strategy | Use case | +|----------|----------| +| `Queen` | Standard CA — cells share an edge or vertex | +| `Rook` | Von Neumann-style — edge contact only | +| `KNN` | Point data, non-contiguous polygons | +| `neighbors_dict` | Precomputed — skip recomputation on repeated runs | + +--- + +## See also + +- [Vector API Reference](vector.md) +- [Raster API Reference](raster.md) diff --git a/docs/api/visualization.md b/docs/api/visualization.md index c0e1798..fe1a7da 100644 --- a/docs/api/visualization.md +++ b/docs/api/visualization.md @@ -1,7 +1,182 @@ # Visualization +The `dissmodel.visualization` module provides graphical and interactive representations +of running simulations. All visualization components inherit from `Model` and are +therefore integrated into the simulation clock — they update automatically at each step. + +Three main components are available: + +| Component | Substrate | Description | +|-----------|-----------|-------------| +| `Chart` | Any | Time-series plots from tracked model variables | +| `Map` | Vector (GeoDataFrame) | Dynamic spatial maps updated each step | +| `RasterMap` | Raster (NumPy) | Raster array rendering — categorical or continuous | + +All three support **three output targets**: local `matplotlib` window, +Jupyter inline display, and Streamlit `st.empty()` placeholder. + +--- + +## `@track_plot` + +The `track_plot` decorator marks model attributes to be collected and plotted +by `Chart`. Each call defines the variable label, colour, and plot type. + +```python +from dissmodel.core import Model +from dissmodel.visualization import track_plot + +@track_plot("Susceptible", "green") +@track_plot("Infected", "red") +@track_plot("Recovered", "blue") +class SIR(Model): + + def setup(self, susceptible=9998, infected=2, recovered=0, + duration=2, contacts=6, probability=0.25): + self.susceptible = susceptible + self.infected = infected + self.recovered = recovered + self.duration = duration + self.contacts = contacts + self.probability = probability + + def execute(self): + total = self.susceptible + self.infected + self.recovered + alpha = self.contacts * self.probability + new_inf = self.infected * alpha * (self.susceptible / total) + new_rec = self.infected / self.duration + self.susceptible -= new_inf + self.infected += new_inf - new_rec + self.recovered += new_rec +``` + +--- + +## `Chart` + +Displays time-series data from variables annotated with `@track_plot`. + +```python +from dissmodel.core import Environment +from dissmodel.models.sysdyn import SIR +from dissmodel.visualization import Chart + +env = Environment(end_time=30) +SIR() +Chart(show_legend=True) +env.run() +``` + +**Streamlit:** + +```python +Chart(plot_area=st.empty()) +``` + +--- + +## `Map` + +Renders spatial data from a GeoDataFrame, updated at every simulation step. + +```python +from dissmodel.visualization.map import Map +from matplotlib.colors import ListedColormap + +Map( + gdf=gdf, + plot_params={ + "column": "state", + "cmap": ListedColormap(["white", "black"]), + "ec": "gray", + }, +) +``` + +--- + +## `RasterMap` + +Renders a named NumPy array from a `RasterBackend`. Supports categorical +(value → colour mapping) and continuous (colormap + colorbar) modes. + +**Categorical:** + +```python +from dissmodel.visualization.raster_map import RasterMap + +RasterMap( + backend = b, + band = "uso", + title = "Land Use", + color_map = {1: "#006400", 3: "#00008b", 5: "#d2b48c"}, + labels = {1: "Mangrove", 3: "Sea", 5: "Bare soil"}, +) +``` + +**Continuous:** + +```python +RasterMap( + backend = b, + band = "alt", + title = "Altimetry", + cmap = "terrain", + colorbar_label = "Altitude (m)", + mask_band = "uso", + mask_value = 3, # mask SEA cells +) +``` + +**Headless** (default when no display is available): +frames are saved to `raster_map_frames/_step_NNN.png`. + +--- + +## `display_inputs` + +Generates Streamlit input widgets automatically from a model's type annotations. +Integer and float attributes become sliders; booleans become checkboxes. + +```python +from dissmodel.visualization import display_inputs + +sir = SIR() +display_inputs(sir, st.sidebar) +``` + +--- + +## Full Streamlit example + +```python +import streamlit as st +from dissmodel.core import Environment +from dissmodel.models.sysdyn import SIR +from dissmodel.visualization import Chart, display_inputs + +st.set_page_config(page_title="SIR Model", layout="centered") +st.title("SIR Model — DisSModel") + +st.sidebar.title("Parameters") +steps = st.sidebar.slider("Steps", min_value=1, max_value=50, value=10) +run_btn = st.button("Run") + +env = Environment(end_time=steps, start_time=0) +sir = SIR() +display_inputs(sir, st.sidebar) +Chart(plot_area=st.empty()) + +if run_btn: + env.run() +``` + +--- + +## API Reference + ::: dissmodel.visualization.Chart + ::: dissmodel.visualization.map.Map -::: dissmodel.visualization.raster_map.RasterMap -::: dissmodel.visualization.widgets.display_inputs -::: dissmodel.visualization.track_plot + +::: dissmodel.visualization.raster_map.RasterMap \ No newline at end of file diff --git a/docs/getting_started.md b/docs/getting_started.md index f9c90f8..6bc173d 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -18,6 +18,8 @@ with a rich ecosystem of libraries that complement each other naturally: DisSModel builds on this ecosystem rather than replacing it. A DisSModel simulation is just Python — the full power of the scientific stack is available at every step. +--- + ## Installation ```bash @@ -32,17 +34,21 @@ cd dissmodel pip install -e . ``` +--- + ## Instantiation order The `Environment` must always be created **before** any model. Models connect to the active environment automatically when instantiated. ``` -Environment → Model → Visualization - ↑ ↑ ↑ - first second third +Environment → Model → Visualization → env.run() + ↑ ↑ ↑ ↑ + first second third fourth ``` +--- + ## Minimal example ```python @@ -51,7 +57,89 @@ from dissmodel.models.sysdyn import SIR from dissmodel.visualization import Chart env = Environment() -SIR() +SIR(susceptible=9998, infected=2, recovered=0, + duration=2, contacts=6, probability=0.25) Chart(show_legend=True) env.run(30) ``` + +--- + +## Execution modes + +DisSModel supports three execution strategies. All three follow the same +`Environment → Model → Visualization → env.run()` pattern — only the +entry point and how results are displayed differ. + +### Command Line (CLI) + +Best for automation, batch experiments, and integration with processing pipelines. + +```bash +python examples/cli/sir_model.py +``` + +CLI examples are located in `examples/cli/`. + +### Jupyter Notebook + +Best for incremental exploration, teaching, and visual analysis. DisSModel +detects the Jupyter environment automatically and renders visualizations inline. + +```python +from dissmodel.core import Environment +from dissmodel.geo import regular_grid +from dissmodel.models.ca import GameOfLife +from dissmodel.visualization.map import Map +from matplotlib.colors import ListedColormap + +gdf = regular_grid(dimension=(30, 30), resolution=1) + +env = Environment(end_time=20) +model = GameOfLife(gdf=gdf) +model.initialize() + +Map( + gdf=gdf, + plot_params={ + "column": "state", + "cmap": ListedColormap(["white", "black"]), + "ec": "gray", + }, +) + +env.run() +``` + +Notebook examples are located in `examples/notebooks/`. + +### Streamlit Web Application + +Best for interactive demos and non-technical users. Parameters are configured +via sliders and input fields directly in the browser. + +```bash +streamlit run examples/streamlit/sir_model.py + +# or run all models in a single interface: +streamlit run examples/streamlit/run_all.py +``` + +Streamlit examples are located in `examples/streamlit/`. + +--- + +## Choosing a substrate + +DisSModel provides two spatial substrates for cellular automata. Choose based +on your performance and flexibility requirements: + +| | Vector | Raster | +|---|---|---| +| **Data structure** | GeoDataFrame | NumPy 2D array | +| **Neighbourhood** | Queen / Rook (libpysal) | Moore / Von Neumann (shift2d) | +| **Rule pattern** | `rule(idx)` per cell | `rule(arrays) → dict` vectorized | +| **Performance** | ~2,700 ms/step @ 10k cells | ~0.6 ms/step @ 10k cells | +| **Best for** | Irregular grids, GIS integration | Large grids, performance-critical models | + +See the [API Reference](api/geo/vector.md) for full details on each substrate. \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 9008ebf..826b1b0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,6 +7,10 @@ Developed by the [LambdaGeo](https://github.com/LambdaGeo) group at the Federal it provides a unified environment for building **Cellular Automata (CA)** and **System Dynamics (SysDyn)** models on top of the Python geospatial ecosystem. +Inspired by the [TerraME](http://www.terrame.org/) framework, DisSModel brings the same modeling +expressiveness to Python — replacing the TerraLib/Lua stack with GeoPandas, NumPy, and Salabim, +while remaining fully interoperable with the broader Python data science ecosystem. + ```bash pip install dissmodel ``` @@ -15,15 +19,9 @@ pip install dissmodel ## Why DisSModel? -DisSModel was designed as a modern, Pythonic alternative to the [TerraME](http://www.terrame.org/) framework, -replacing the TerraLib/Lua stack with the standardized GeoPandas/Python stack. -This transition gives researchers direct access to the full Python data science ecosystem -while maintaining the modeling expressiveness required for Land Use and Cover Change (LUCC) applications. - -**Core objectives:** - - **Multi-paradigm support** — Cellular Automata, System Dynamics, and Agent-Based Models in a unified environment -- **Geospatial ecosystem integration** — GeoPandas, libpysal, and rasterstats for advanced spatial operations +- **Dual-substrate architecture** — vector (GeoDataFrame) for spatial expressiveness, raster (NumPy) for high-performance vectorized computation +- **Geospatial ecosystem integration** — GeoPandas, libpysal, rasterio, and rasterstats for advanced spatial operations - **Open science and reproducibility** — open-source, installable via PyPI, examples included - **Standardized implementation** — pure Python lowers the barrier for interdisciplinary collaboration @@ -36,9 +34,30 @@ DisSModel is organized into four modules: | Module | Description | |:---|:---| | `dissmodel.core` | Simulation clock and execution lifecycle powered by [Salabim](https://www.salabim.org/) | -| `dissmodel.geo` | Spatial data structures — grid generation, fill strategies, neighborhood | +| `dissmodel.geo` | Spatial data structures — dual-substrate design (vector + raster) | | `dissmodel.models` | Ready-to-use CA and SysDyn reference implementations | -| `dissmodel.visualization` | Observer-based visualization — `Chart`, `Map`, `display_inputs`, `@track_plot` | +| `dissmodel.visualization` | Observer-based visualization — `Chart`, `Map`, `RasterMap`, `display_inputs` | + +### `dissmodel.geo` — Dual Substrate + +The `geo` module provides two independent spatial substrates that share the same simulation clock: + +**Vector substrate** (`dissmodel.geo.vector`) — backed by GeoDataFrame. Supports irregular +geometries, direct GIS integration, and libpysal neighbourhoods (Queen, Rook). +Use for models that require spatial joins, real-world projections, or interoperability +with existing GIS workflows. + +**Raster substrate** (`dissmodel.geo.raster`) — backed by `RasterBackend` (NumPy 2D arrays). +Replaces cell-by-cell iteration with fully vectorized operations (`shift2d`, `focal_sum`, +`neighbor_contact`). At 10,000 cells, the raster substrate is **~4,500× faster** than the +vector substrate. + +| | Vector | Raster | +|---|---|---| +| Data structure | GeoDataFrame | NumPy 2D array (`RasterBackend`) | +| Neighbourhood | Queen / Rook (libpysal) | Moore / Von Neumann (shift2d) | +| Rule pattern | `rule(idx)` per cell | `rule(arrays) → dict` vectorized | +| Performance @ 10k cells | ~2,700 ms/step | ~0.6 ms/step | --- @@ -57,7 +76,7 @@ Chart(show_legend=True) env.run(30) ``` -### Cellular Automaton +### Cellular Automaton — Vector ```python from dissmodel.core import Environment @@ -72,6 +91,23 @@ fire.initialize() env.run() ``` +### Cellular Automaton — Raster + +```python +from dissmodel.core import Environment +from dissmodel.geo.raster.regular_grid import raster_grid +from dissmodel.models.ca import GameOfLifeRaster +from dissmodel.visualization.raster_map import RasterMap + +backend = raster_grid(rows=100, cols=100, attrs={"state": 0}) + +env = Environment(end_time=20) +model = GameOfLifeRaster(backend=backend) +model.initialize() +RasterMap(backend=backend, band="state") +env.run() +``` + ### Streamlit ```bash @@ -128,7 +164,9 @@ DisSModel builds on well-established, industry-standard libraries: |:---|:---| | [GeoPandas](https://geopandas.org/) | Vector data and GeoDataFrame operations | | [Salabim](https://www.salabim.org/) | Discrete event simulation engine | +| [NumPy](https://numpy.org/) | Vectorized array operations — raster substrate | | [libpysal](https://pysal.org/libpysal/) | Spatial weights and neighborhood analysis | +| [rasterio](https://rasterio.readthedocs.io/) | GeoTIFF I/O | | [rasterstats](https://pythonhosted.org/rasterstats/) | Raster/vector zonal statistics | | [Shapely](https://shapely.readthedocs.io/) | Geometric operations | | [Matplotlib](https://matplotlib.org/) | Time-series and spatial visualization | diff --git a/mkdocs.yml b/mkdocs.yml index 062e922..00d1b0b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -66,6 +66,7 @@ nav: - API Reference: - Core: api/core.md - Geo: + - Overview: api/geo/index.md - Vector: api/geo/vector.md - Raster: api/geo/raster.md - Visualization: api/visualization.md