Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dissmodel/visualization/__init__.py
Original file line number Diff line number Diff line change
@@ -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
143 changes: 142 additions & 1 deletion docs/api/core.md
Original file line number Diff line number Diff line change
@@ -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
206 changes: 206 additions & 0 deletions docs/api/geo/index.md
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading