diff --git a/CHANGELOG.md b/CHANGELOG.md
index aadc511..91ecc6e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
+## [0.3.0] - 2026-04
+
+### Added
+
+#### Sync models
+- `SyncSpatialModel` — `SpatialModel` with automatic `_past` snapshot semantics,
+ equivalent to TerraME's `cs:synchronize()`. Declare `self.land_use_types` in
+ `setup()` and `
_past` columns are managed automatically.
+- `SyncRasterModel` — raster analogue of `SyncSpatialModel`. Copies each array in
+ `land_use_types` to `_past` in the `RasterBackend` before and after each step.
+- Both expose a public `synchronize()` method that can also be called manually.
+
+#### I/O — `dissmodel.geo.raster.io`
+- `shapefile_to_raster_backend` — loads any GeoPandas-supported vector format
+ (Shapefile, GeoJSON, GeoPackage, ZIP) and rasterizes attribute columns into a
+ `RasterBackend`. Adds a `"mask"` band marking valid cells. Supports
+ `nodata_value` to distinguish out-of-extent cells from valid zero values.
+- `save_raster_backend` — convenience wrapper that writes all (or selected) arrays
+ to a GeoTIFF without requiring a `band_spec`.
+- `load_geotiff` and `save_geotiff` updated with full docstrings and support for
+ `.zip` archives containing a single GeoTIFF.
+
+#### `RasterMap`
+- `scheme` parameter: `"manual"` (default), `"equal_interval"`, `"quantiles"`.
+- `k` — number of colour classes for `equal_interval`. Default: `5`.
+- `legend` — show or hide the colorbar. Default: `True`.
+- `save_frames` — force PNG output even in interactive mode.
+- `auto_mask` — automatically applies the `"mask"` band from the backend so
+ out-of-extent cells are transparent. Default: `True`.
+
+#### `Map`
+- `figsize`, `interval`, `save_frames` parameters.
+- Headless fallback: saves PNGs to `map_frames/` when no display is available.
+
+### Changed
+- `RasterMap` no longer calls `matplotlib.use()` at import time, preventing side
+ effects when imported alongside other visualization components.
+- `make_raster_grid()` renamed to `raster_grid()`. Old name kept as a
+ `DeprecationWarning` alias and will be removed in v0.4.0.
+
+### Fixed
+- `Map`: fixed `AttributeError: 'Map' object has no attribute 'fig'` when used
+ with Streamlit (`plot_area=st.empty()`).
+- `Map`: no longer raises `RuntimeError` when no display is available — falls back
+ to saving PNGs to `map_frames/`.
+- `RasterMap`: fixed `plt.close("all")` closing all figures when multiple
+ `RasterMap` instances are active. Each instance now maintains its own persistent
+ figure and updates independently.
+
+---
+
## [0.2.1] - 2026-03
### Changed
diff --git a/dissmodel/geo/__init__.py b/dissmodel/geo/__init__.py
index 3d01942..16d4d24 100644
--- a/dissmodel/geo/__init__.py
+++ b/dissmodel/geo/__init__.py
@@ -15,4 +15,7 @@
from .raster.band_spec import BandSpec # se tiver classe exportável
# raster io — opcional, não importa por padrão (requer rasterio)
-# from .raster.io import load_geotiff, save_geotiff
\ No newline at end of file
+# from .raster.io import load_geotiff, save_geotiff
+
+from .vector.sync_model import SyncSpatialModel
+from .raster.sync_model import SyncRasterModel
\ No newline at end of file
diff --git a/dissmodel/geo/raster/backend.py b/dissmodel/geo/raster/backend.py
index f9436b1..7d5c89d 100644
--- a/dissmodel/geo/raster/backend.py
+++ b/dissmodel/geo/raster/backend.py
@@ -33,7 +33,6 @@
# Moore neighbourhood (8 directions) — framework constant, not domain-specific.
-# Models import from here; projects do not need to redefine it.
DIRS_MOORE: list[tuple[int, int]] = [
(-1, -1), (-1, 0), (-1, 1),
( 0, -1), ( 0, 1),
@@ -64,6 +63,11 @@ class RasterBackend:
----------
shape : tuple[int, int]
Grid shape as ``(rows, cols)``.
+ nodata_value : float | int | None
+ Sentinel value used to mark cells outside the study extent.
+ When provided, ``nodata_mask`` derives the extent mask automatically,
+ so ``RasterMap`` renders those cells as transparent without any extra
+ configuration. Default: ``None``.
Examples
--------
@@ -71,11 +75,44 @@ class RasterBackend:
>>> b.set("state", np.zeros((10, 10), dtype=np.int8))
>>> b.get("state").shape
(10, 10)
+
+ >>> b = RasterBackend(shape=(10, 10), nodata_value=-1)
+ >>> b.nodata_mask # True = valid cell, False = outside extent
"""
- def __init__(self, shape: tuple[int, int]) -> None:
- self.shape = shape # (rows, cols)
+ def __init__(
+ self,
+ shape: tuple[int, int],
+ nodata_value: float | int | None = None,
+ ) -> None:
+ self.shape = shape
self.arrays: dict[str, np.ndarray] = {}
+ self.nodata_value = nodata_value # sentinel for out-of-extent cells
+
+ # ── extent mask ───────────────────────────────────────────────────────────
+
+ @property
+ def nodata_mask(self) -> np.ndarray | None:
+ """
+ Boolean mask: ``True`` = valid cell, ``False`` = outside extent / nodata.
+
+ Derived in priority order:
+ 1. ``arrays["mask"]`` — explicit mask band (dissluc / coastal convention:
+ non-zero = valid).
+ 2. ``nodata_value`` — applied over the first available array.
+ 3. ``None`` — no information; ``RasterMap`` skips auto-masking.
+
+ Used by ``RasterMap`` (``auto_mask=True``) to render out-of-extent pixels
+ as transparent without any per-project configuration.
+ """
+ if "mask" in self.arrays:
+ return self.arrays["mask"] != 0
+
+ if self.nodata_value is not None and self.arrays:
+ first = next(iter(self.arrays.values()))
+ return first != self.nodata_value
+
+ return None
# ── read / write ──────────────────────────────────────────────────────────
@@ -120,14 +157,9 @@ def shift2d(arr: np.ndarray, dr: int, dc: int) -> np.ndarray:
Shift ``arr`` by ``(dr, dc)`` rows/columns without wrap-around.
Edges are filled with zero.
- Equivalent to reading the neighbour in direction ``(dr, dc)`` for every
- cell simultaneously — replaces ``forEachNeighbor`` with a vectorized
- operation.
-
Parameters
----------
arr : np.ndarray
- 2D source array.
dr : int
Row offset (positive = down, negative = up).
dc : int
@@ -137,11 +169,6 @@ def shift2d(arr: np.ndarray, dr: int, dc: int) -> np.ndarray:
-------
np.ndarray
Shifted array of the same shape as ``arr``.
-
- Examples
- --------
- >>> shift2d(alt, -1, 0) # altitude of the northern neighbour of each cell
- >>> shift2d(alt, 1, 1) # altitude of the south-eastern neighbour
"""
rows, cols = arr.shape
out = np.zeros_like(arr)
@@ -164,26 +191,16 @@ def neighbor_contact(
Parameters
----------
condition : np.ndarray
- Boolean array marking the source cells.
neighborhood : list[tuple[int, int]] | None
- Directions to check. ``None`` uses Moore neighbourhood via
- ``binary_dilation`` with a 3×3 structuring element (includes the
- cell itself). Pass ``DIRS_VON_NEUMANN`` or a custom list for other
- neighbourhoods.
+ ``None`` uses Moore neighbourhood via ``binary_dilation``.
Returns
-------
np.ndarray
- Boolean array; ``True`` where a cell neighbours at least one
- ``True`` cell in ``condition``.
-
- Notes
- -----
- Equivalent to ``forEachNeighbor`` checking membership in a set.
+ Boolean array.
"""
if neighborhood is None:
return binary_dilation(condition.astype(bool), structure=np.ones((3, 3)))
- # custom neighbourhood via manual shifts
result = np.zeros_like(condition, dtype=bool)
for dr, dc in neighborhood:
result |= RasterBackend.shift2d(condition.astype(np.int8), dr, dc) > 0
@@ -198,19 +215,15 @@ def focal_sum(
Focal sum: for each cell, sum the values of ``name`` across its neighbours.
The cell itself is not included.
- Useful for counting neighbours in a given state, computing gradients, etc.
-
Parameters
----------
name : str
- Name of the array to aggregate.
neighborhood : list[tuple[int, int]]
- Directions to include. Default: ``DIRS_MOORE``.
+ Default: ``DIRS_MOORE``.
Returns
-------
np.ndarray
- Float array with per-cell neighbour sums.
"""
arr = self.arrays[name]
result = np.zeros_like(arr, dtype=float)
@@ -229,9 +242,8 @@ def focal_sum_mask(
Parameters
----------
mask : np.ndarray
- Boolean array marking the cells to count.
neighborhood : list[tuple[int, int]]
- Directions to include. Default: ``DIRS_MOORE``.
+ Default: ``DIRS_MOORE``.
Returns
-------
@@ -250,4 +262,4 @@ def __repr__(self) -> str:
bands = ", ".join(
f"{k}:{v.dtype}[{v.shape}]" for k, v in self.arrays.items()
)
- return f"RasterBackend(shape={self.shape}, arrays=[{bands}])"
\ No newline at end of file
+ return f"RasterBackend(shape={self.shape}, arrays=[{bands}])"
diff --git a/dissmodel/geo/raster/io.py b/dissmodel/geo/raster/io.py
index 8bc6af0..36dbdd9 100644
--- a/dissmodel/geo/raster/io.py
+++ b/dissmodel/geo/raster/io.py
@@ -1,120 +1,445 @@
"""
-dissmodel.geo.raster_io
-=======================
-
-Generic GeoTIFF read/write utilities for RasterBackend.
-
-No domain knowledge is included here.
-
-The meaning of bands is defined by band_spec.
-
-band_spec
----------
-list of tuples:
-
- (name, dtype, nodata)
-
-example:
- [
- ("landuse", "int8", -1),
- ("elevation", "float32", -9999),
- ]
+dissmodel/geo/raster/io.py
+===========================
+I/O utilities for RasterBackend — load from vector files and GeoTIFFs,
+save back to GeoTIFF.
+
+Public API
+----------
+shapefile_to_raster_backend(path, resolution, attrs, ...)
+ Rasterize a vector file (Shapefile, GeoJSON, GPKG, …) into a
+ RasterBackend. Each attribute column becomes a named array.
+
+load_geotiff(path, band_spec)
+ Read a GeoTIFF (plain or zipped) into a RasterBackend.
+
+save_geotiff(backend, path, band_spec, crs, transform)
+ Write selected RasterBackend arrays to a multi-band GeoTIFF.
+
+save_raster_backend(backend, path, bands, crs, transform)
+ Convenience wrapper — writes all (or selected) arrays without
+ requiring a band_spec.
+
+Notes
+-----
+All functions are domain-agnostic: no land-use classes, no CRS
+assumptions, no project-specific constants.
"""
-
from __future__ import annotations
import pathlib
+import zipfile
+from typing import Any
+
import numpy as np
from dissmodel.geo.raster.backend import RasterBackend
try:
import rasterio
+ import rasterio.features
+ import rasterio.transform
HAS_RASTERIO = True
except ImportError:
HAS_RASTERIO = False
+try:
+ import geopandas as gpd
+ HAS_GEOPANDAS = True
+except ImportError:
+ HAS_GEOPANDAS = False
+
+
+# ── vector → RasterBackend ────────────────────────────────────────────────────
+
+def shapefile_to_raster_backend(
+ path: str | pathlib.Path,
+ resolution: float,
+ attrs: list[str] | dict[str, Any],
+ crs: str | int | None = None,
+ all_touched: bool = False,
+ nodata: int | float = 0,
+ nodata_value: int | float | None = None,
+ add_mask: bool = True,
+) -> RasterBackend:
+ """
+ Load a vector file and rasterize attribute columns into a RasterBackend.
+
+ Reads any format supported by GeoPandas (Shapefile, GeoJSON, GPKG, …),
+ optionally reprojects to ``crs``, derives the grid shape from the bounding
+ box and ``resolution``, and rasterizes each requested attribute column
+ with ``rasterio.features.rasterize``.
+
+ Cells not covered by any geometry receive the ``nodata`` fill value.
+ When ``add_mask=True`` (default), a ``"mask"`` band is added to the
+ backend — ``1`` where a cell is covered by at least one geometry, ``0``
+ elsewhere. Models use this band to skip cells outside the study area.
+
+ Parameters
+ ----------
+ path : str or Path
+ Vector file path. Accepts Shapefile, GeoJSON, GeoPackage, or a
+ ``.zip`` archive containing any of these formats.
+ resolution : float
+ Cell size in the units of the CRS (metres for metric CRS).
+ attrs : list[str] or dict[str, Any]
+ Columns to rasterize.
+
+ - ``list[str]`` — rasterize each column with ``nodata`` as fill.
+ - ``dict[str, Any]`` — keys are column names, values are per-column
+ fill defaults for cells outside the geometries.
+ crs : str, int, or None
+ Target CRS for reprojection before rasterization (e.g. ``"EPSG:31984"``).
+ If ``None``, the file's native CRS is used.
+ all_touched : bool
+ If ``True``, burn all cells touched by a geometry.
+ If ``False`` (default), burn only cells whose centre falls inside.
+ nodata : int or float
+ Default fill value for cells outside geometries. Default: ``0``.
+ nodata_value : int or float or None
+ When provided, cells outside geometries are set to this sentinel
+ value instead of ``nodata``. Useful to distinguish "outside extent"
+ from "valid zero" (e.g. ``nodata_value=-1`` for proportion arrays
+ where ``0.0`` is a legitimate value). Default: ``None``.
+ add_mask : bool
+ If ``True`` (default), adds a ``"mask"`` band (``float32``, values
+ ``0.0`` / ``1.0``) marking valid cells.
+
+ Returns
+ -------
+ RasterBackend
+ Backend with one array per requested attribute, plus an optional
+ ``"mask"`` band. Shape is ``(rows, cols)`` derived from the bounding
+ box and ``resolution``.
+
+ Raises
+ ------
+ ImportError
+ If ``geopandas`` or ``rasterio`` are not installed.
+ FileNotFoundError
+ If ``path`` does not exist.
+ ValueError
+ If ``attrs`` is empty or a requested column is not in the file.
+
+ Examples
+ --------
+ >>> b = shapefile_to_raster_backend(
+ ... path = "data/mangue_grid.shp",
+ ... resolution = 100,
+ ... attrs = ["uso", "alt", "solo"],
+ ... crs = "EPSG:31984",
+ ... )
+ >>> b.shape
+ (947, 1003)
+ >>> b.get("mask").sum()
+ 94704
+ """
+ if not HAS_RASTERIO:
+ raise ImportError("rasterio is required — pip install rasterio")
+ if not HAS_GEOPANDAS:
+ raise ImportError("geopandas is required — pip install geopandas")
+
+ path = pathlib.Path(path) if not str(path).startswith("zip://") else path
+ if isinstance(path, pathlib.Path) and not path.exists():
+ raise FileNotFoundError(f"File not found: {path}")
+
+ # ── load and optionally reproject ─────────────────────────────────────────
+ gdf = gpd.read_file(str(path))
+ if crs is not None:
+ gdf = gdf.to_crs(crs)
+
+ # ── resolve attrs → {column: fill_default} ────────────────────────────────
+ if isinstance(attrs, list):
+ attr_defaults: dict[str, Any] = {col: nodata for col in attrs}
+ else:
+ attr_defaults = dict(attrs)
+
+ if not attr_defaults:
+ raise ValueError("attrs must not be empty")
+
+ missing = [col for col in attr_defaults if col not in gdf.columns]
+ if missing:
+ raise ValueError(f"Columns not found in file: {missing}")
+
+ # ── compute grid shape from bounding box ──────────────────────────────────
+ xmin, ymin, xmax, ymax = gdf.total_bounds
+ n_cols = int(np.ceil((xmax - xmin) / resolution))
+ n_rows = int(np.ceil((ymax - ymin) / resolution))
+
+ transform = rasterio.transform.from_bounds(
+ xmin, ymin, xmax, ymax, n_cols, n_rows
+ )
+
+ backend = RasterBackend(
+ shape=(n_rows, n_cols),
+ nodata_value=nodata_value, # stored for nodata_mask property
+ )
+
+ # ── rasterize geometry coverage → "mask" band ─────────────────────────────
+ valid_geoms = [geom for geom in gdf.geometry if geom is not None]
+ coverage = rasterio.features.rasterize(
+ shapes=((geom, 1) for geom in valid_geoms),
+ out_shape=(n_rows, n_cols),
+ transform=transform,
+ fill=0,
+ all_touched=all_touched,
+ dtype=np.uint8,
+ )
+ mask = coverage.astype(bool) # True = cell covered by at least one polygon
+
+ if add_mask:
+ backend.set("mask", mask.astype(np.float32))
+
+ # ── rasterize each attribute column ───────────────────────────────────────
+ for col, default in attr_defaults.items():
+ values = gdf[col]
+
+ # preserve integer dtypes when possible
+ if np.issubdtype(values.dtype, np.integer):
+ dtype = np.int32
+ else:
+ dtype = np.float32
+
+ arr = rasterio.features.rasterize(
+ shapes=(
+ (geom, float(val))
+ for geom, val in zip(gdf.geometry, values)
+ if geom is not None
+ ),
+ out_shape=(n_rows, n_cols),
+ transform=transform,
+ fill=float(default),
+ all_touched=all_touched,
+ dtype=dtype,
+ )
+
+ # apply sentinel for out-of-extent cells if requested
+ sentinel = nodata_value if nodata_value is not None else default
+ arr = np.where(mask, arr, sentinel).astype(dtype)
+
+ backend.set(col, arr)
+
+ n_valid = int(mask.sum())
+ n_total = n_rows * n_cols
+ print(
+ f" rasterized: {n_valid:,} valid cells"
+ f" / {n_total:,} total"
+ f" ({100 * n_valid / n_total:.1f}% coverage)"
+ )
+
+ return backend
+
+
+# ── GeoTIFF → RasterBackend ───────────────────────────────────────────────────
def load_geotiff(
path: str | pathlib.Path,
band_spec: list[tuple[str, str, float]],
) -> tuple[RasterBackend, dict]:
-
+ """
+ Read a GeoTIFF into a RasterBackend.
+
+ Supports plain ``.tif`` files and ``.zip`` archives containing a single
+ GeoTIFF. Bands are mapped to named arrays in the backend using
+ ``band_spec``.
+
+ Parameters
+ ----------
+ path : str or Path
+ Path to a ``.tif`` or ``.zip`` file.
+ band_spec : list of (name, dtype, nodata)
+ Mapping from band index (1-based) to array name and dtype.
+ Bands whose data equals ``nodata`` everywhere are skipped.
+
+ Example::
+
+ [
+ ("uso", "int8", -1),
+ ("alt", "float32", -9999.0),
+ ("solo", "int8", -1),
+ ]
+
+ Returns
+ -------
+ (RasterBackend, dict)
+ The backend with one array per non-empty band, and a metadata dict
+ with keys ``"transform"``, ``"crs"``, and ``"tags"``.
+
+ Raises
+ ------
+ ImportError
+ If ``rasterio`` is not installed.
+ """
if not HAS_RASTERIO:
- raise ImportError("rasterio is required")
+ raise ImportError("rasterio is required — pip install rasterio")
- path = str(path)
+ path_str = str(path)
- if path.endswith(".zip"):
- import zipfile
- with zipfile.ZipFile(path) as z:
- tif = next(f for f in z.namelist() if f.endswith(".tif"))
- path = f"zip://{path}!{tif}"
-
-
- with rasterio.open(path) as ds:
+ # unwrap zip archives
+ if path_str.endswith(".zip"):
+ with zipfile.ZipFile(path_str) as z:
+ tif_name = next(f for f in z.namelist() if f.endswith(".tif"))
+ path_str = f"zip://{path_str}!{tif_name}"
+ with rasterio.open(path_str) as ds:
rows, cols = ds.height, ds.width
- backend = RasterBackend((rows, cols))
+ backend = RasterBackend(shape=(rows, cols))
for i, (name, dtype, nodata) in enumerate(band_spec, start=1):
-
if i > ds.count:
break
arr = ds.read(i).astype(dtype)
+ # skip bands that are entirely nodata (e.g. uninitialised saves)
if np.all(arr == nodata):
continue
backend.arrays[name] = arr
- meta = dict(
- transform=ds.transform,
- crs=ds.crs,
- tags=ds.tags(),
- )
+ meta = {
+ "transform": ds.transform,
+ "crs": ds.crs,
+ "tags": ds.tags(),
+ }
return backend, meta
+# ── RasterBackend → GeoTIFF ───────────────────────────────────────────────────
+
def save_geotiff(
backend: RasterBackend,
path: str | pathlib.Path,
band_spec: list[tuple[str, str, float]],
- crs: str,
- transform,
+ crs: str | None = None,
+ transform=None,
compress: str = "deflate",
-):
-
+) -> None:
+ """
+ Write selected RasterBackend arrays to a multi-band GeoTIFF.
+
+ Parameters
+ ----------
+ backend : RasterBackend
+ Source backend.
+ path : str or Path
+ Output file path. Parent directories are created if needed.
+ band_spec : list of (name, dtype, nodata)
+ Bands to write, in order. Arrays missing from the backend are
+ written as constant ``nodata`` fills.
+
+ Example::
+
+ [
+ ("uso", "int8", -1),
+ ("alt", "float32", -9999.0),
+ ]
+
+ crs : str or None
+ CRS string for the output file (e.g. ``"EPSG:31984"``).
+ If ``None``, the GeoTIFF is written without a CRS.
+ transform : Affine or None
+ Affine geotransform. If ``None``, a pixel-coordinate identity
+ transform is used.
+ compress : str
+ Compression algorithm. Default: ``"deflate"``.
+
+ Raises
+ ------
+ ImportError
+ If ``rasterio`` is not installed.
+ """
if not HAS_RASTERIO:
- raise ImportError("rasterio is required")
+ raise ImportError("rasterio is required — pip install rasterio")
+
+ path = pathlib.Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
rows, cols = backend.shape
+ # build arrays — fill missing bands with nodata
arrays = []
-
for name, dtype, nodata in band_spec:
-
arr = backend.arrays.get(
name,
- np.full((rows, cols), nodata, dtype=dtype)
+ np.full((rows, cols), nodata, dtype=dtype),
)
-
arrays.append(arr.astype(dtype))
+ if transform is None:
+ transform = rasterio.transform.from_bounds(0, 0, cols, rows, cols, rows)
+
with rasterio.open(
- path,
- "w",
- driver="GTiff",
- height=rows,
- width=cols,
- count=len(arrays),
- dtype=str(arrays[0].dtype),
- crs=crs,
- transform=transform,
- compress=compress,
+ path, "w",
+ driver = "GTiff",
+ height = rows,
+ width = cols,
+ count = len(arrays),
+ dtype = str(arrays[0].dtype),
+ crs = crs,
+ transform= transform,
+ compress = compress,
) as dst:
+ for i, (arr, (name, _, _)) in enumerate(zip(arrays, band_spec), start=1):
+ dst.write(arr, i)
+ dst.update_tags(i, name=name)
- for i, arr in enumerate(arrays, start=1):
- dst.write(arr, i)
\ No newline at end of file
+
+def save_raster_backend(
+ backend: RasterBackend,
+ path: str | pathlib.Path,
+ bands: list[str] | None = None,
+ crs: str | int | None = None,
+ transform=None,
+) -> None:
+ """
+ Convenience wrapper: write all (or selected) arrays to a GeoTIFF
+ without requiring a band_spec.
+
+ dtype and nodata are inferred from each array. Use ``save_geotiff``
+ when you need explicit dtype control or nodata sentinels.
+
+ Parameters
+ ----------
+ backend : RasterBackend
+ path : str or Path
+ bands : list[str] or None
+ Arrays to write. If ``None``, all arrays in the backend are written
+ in insertion order.
+ crs : str, int, or None
+ transform : Affine or None
+
+ Raises
+ ------
+ ImportError
+ If ``rasterio`` is not installed.
+ """
+ if not HAS_RASTERIO:
+ raise ImportError("rasterio is required — pip install rasterio")
+
+ path = pathlib.Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+
+ bands = bands or list(backend.arrays.keys())
+ rows, cols = backend.shape
+
+ arrays = [backend.get(b) for b in bands]
+ dtype = arrays[0].dtype
+
+ if transform is None:
+ transform = rasterio.transform.from_bounds(0, 0, cols, rows, cols, rows)
+
+ with rasterio.open(
+ path, "w",
+ driver = "GTiff",
+ height = rows,
+ width = cols,
+ count = len(arrays),
+ dtype = dtype,
+ crs = crs,
+ transform= transform,
+ ) as dst:
+ for i, (name, arr) in enumerate(zip(bands, arrays), start=1):
+ dst.write(arr.astype(dtype), i)
+ dst.update_tags(i, name=name)
diff --git a/dissmodel/geo/raster/sync_model.py b/dissmodel/geo/raster/sync_model.py
new file mode 100644
index 0000000..865c116
--- /dev/null
+++ b/dissmodel/geo/raster/sync_model.py
@@ -0,0 +1,126 @@
+"""
+dissmodel/geo/raster/sync_model.py
+=====================================
+SyncRasterModel — RasterModel with automatic _past snapshot semantics.
+
+This module provides the snapshot mechanism equivalent to TerraME's
+``cs:synchronize()``, as a reusable base class for any raster model that
+needs per-step state history (LUCC, fire spread, epidemic models, etc.).
+
+How it works
+------------
+At the start of each step, ``synchronize()`` copies the current NumPy array
+for every name in ``land_use_types`` to a ``_past`` array in the
+RasterBackend. Models can call ``self.backend.get("f_past")`` to access the
+state at the beginning of the current step, regardless of changes made
+during execution.
+
+Usage
+-----
+Subclass ``SyncRasterModel`` instead of ``RasterModel`` and declare
+``self.land_use_types`` in ``setup()``:
+
+ class MyRasterModel(SyncRasterModel):
+ def setup(self, backend, ...):
+ super().setup(backend) # RasterModel setup
+ self.land_use_types = ["f", "d"] # arrays to snapshot
+
+ def execute(self):
+ f_past = self.backend.get("f_past") # state at step start
+ ...
+
+The ``synchronize()`` method is called automatically:
+ - once before the first ``execute()`` → snapshot of the initial state
+ - once after each ``execute()`` → snapshot for the next step
+
+It can also be called manually when needed.
+
+Relationship to domain libraries
+----------------------------------
+``dissluc`` uses this class as the base for its raster LUCC components,
+exposing it under the domain-specific alias ``LUCRasterModel``:
+
+ # dissluc/raster/core.py
+ from dissmodel.geo.raster.sync_model import SyncRasterModel as LUCRasterModel
+"""
+from __future__ import annotations
+
+import numpy as np
+
+from dissmodel.geo import RasterModel
+
+
+class SyncRasterModel(RasterModel):
+ """
+ ``RasterModel`` with automatic ``_past`` snapshot semantics.
+
+ Extends :class:`~dissmodel.geo.raster.model.RasterModel` with a
+ ``synchronize()`` method that copies each array listed in
+ ``self.land_use_types`` to a ``_past`` array in the
+ :class:`~dissmodel.geo.raster.backend.RasterBackend` before and after
+ every simulation step.
+
+ This is the raster analogue of
+ :class:`~dissmodel.geo.vector.sync_model.SyncSpatialModel` and the
+ Python equivalent of TerraME's ``cs:synchronize()``.
+
+ Subclass contract
+ -----------------
+ Declare ``self.land_use_types`` (list of array names) in ``setup()``.
+ ``SyncRasterModel`` will manage all ``_past`` arrays automatically.
+ Subclasses must **not** create or update ``_past`` arrays manually.
+
+ Parameters
+ ----------
+ backend : RasterBackend
+ Passed through to :class:`~dissmodel.geo.raster.model.RasterModel`.
+ **kwargs
+ Any additional keyword arguments accepted by the parent class.
+
+ Examples
+ --------
+ >>> class ForestRaster(SyncRasterModel):
+ ... def setup(self, backend, rate=0.01):
+ ... super().setup(backend)
+ ... self.land_use_types = ["forest", "defor"]
+ ... self.rate = rate
+ ...
+ ... def execute(self):
+ ... forest_past = self.backend.get("forest_past")
+ ... gain = forest_past * self.rate
+ ... self.backend.arrays["forest"] = forest_past + gain
+ """
+
+ def process(self) -> None:
+ """
+ Simulation loop with automatic snapshot management.
+
+ Overrides :meth:`~dissmodel.core.Model.process` to insert
+ :meth:`synchronize` calls before the first step and after each step.
+ """
+ if self.env.now() < self.start_time:
+ self.hold(self.start_time - self.env.now())
+
+ # initial snapshot — captures state at t=0 before any execution
+ self.synchronize()
+
+ while self.env.now() < self.end_time:
+ self.execute()
+ self.synchronize() # update snapshot for the next step
+ self.hold(self._step)
+
+ def synchronize(self) -> None:
+ """
+ Copy each array in ``land_use_types`` to ``_past`` in the backend.
+
+ Equivalent to ``cs:synchronize()`` in TerraME. Called automatically
+ before the first step and after each ``execute()``. Can also be
+ called manually when an explicit mid-step snapshot is needed.
+
+ Does nothing if ``land_use_types`` has not been set yet (safe to
+ call before ``setup()`` completes).
+ """
+ if not hasattr(self, "land_use_types"):
+ return
+ for name in self.land_use_types:
+ self.backend.set(name + "_past", self.backend.get(name).copy())
diff --git a/dissmodel/geo/vector/sync_model.py b/dissmodel/geo/vector/sync_model.py
new file mode 100644
index 0000000..9539271
--- /dev/null
+++ b/dissmodel/geo/vector/sync_model.py
@@ -0,0 +1,123 @@
+"""
+dissmodel/geo/vector/sync_model.py
+=====================================
+SyncSpatialModel — SpatialModel with automatic _past snapshot semantics.
+
+This module provides the snapshot mechanism equivalent to TerraME's
+``cs:synchronize()``, as a reusable base class for any vector model that
+needs per-step state history (LUCC, fire spread, epidemic models, etc.).
+
+How it works
+------------
+At the start of each step, ``synchronize()`` copies the current value of
+every column in ``land_use_types`` to a ``
_past`` column in the
+GeoDataFrame. Models can read ``gdf["f_past"]`` to access the state at the
+beginning of the current step, regardless of changes made during execution.
+
+Usage
+-----
+Subclass ``SyncSpatialModel`` instead of ``SpatialModel`` and declare
+``self.land_use_types`` in ``setup()``:
+
+ class MyModel(SyncSpatialModel):
+ def setup(self, gdf, ...):
+ super().setup(gdf) # SpatialModel setup
+ self.land_use_types = ["f", "d"] # columns to snapshot
+
+ def execute(self):
+ past_f = self.gdf["f_past"] # state at step start
+ ...
+
+The ``synchronize()`` method is called automatically:
+ - once before the first ``execute()`` → snapshot of the initial state
+ - once after each ``execute()`` → snapshot for the next step
+
+It can also be called manually when needed (e.g. mid-step resets).
+
+Relationship to domain libraries
+----------------------------------
+``dissluc`` uses this class as the base for its LUCC components,
+exposing it under the domain-specific alias ``LUCSpatialModel``:
+
+ # dissluc/core.py
+ from dissmodel.geo.vector.sync_model import SyncSpatialModel as LUCSpatialModel
+"""
+from __future__ import annotations
+
+from dissmodel.geo.vector.spatial_model import SpatialModel
+
+
+class SyncSpatialModel(SpatialModel):
+ """
+ ``SpatialModel`` with automatic ``_past`` snapshot semantics.
+
+ Extends :class:`~dissmodel.geo.vector.spatial_model.SpatialModel` with
+ a ``synchronize()`` method that copies each column listed in
+ ``self.land_use_types`` to a ``
_past`` column before and after
+ every simulation step.
+
+ This is the Python equivalent of TerraME's ``cs:synchronize()`` — it
+ ensures that every model reads a consistent snapshot of the state at
+ the beginning of the current step, even when multiple models share the
+ same GeoDataFrame.
+
+ Subclass contract
+ -----------------
+ Declare ``self.land_use_types`` (list of column names) in ``setup()``.
+ ``SyncSpatialModel`` will manage all ``
_past`` columns automatically.
+ Subclasses must **not** create or update ``_past`` columns manually.
+
+ Parameters
+ ----------
+ gdf : geopandas.GeoDataFrame
+ Passed through to :class:`~dissmodel.geo.vector.spatial_model.SpatialModel`.
+ **kwargs
+ Any additional keyword arguments accepted by the parent class.
+
+ Examples
+ --------
+ >>> class ForestCA(SyncSpatialModel):
+ ... def setup(self, gdf, rate=0.01):
+ ... super().setup(gdf)
+ ... self.land_use_types = ["forest", "defor"]
+ ... self.rate = rate
+ ...
+ ... def execute(self):
+ ... # forest_past holds the state at the start of this step
+ ... gain = self.gdf["forest_past"] * self.rate
+ ... self.gdf["forest"] = self.gdf["forest_past"] + gain
+ """
+
+ def process(self) -> None:
+ """
+ Simulation loop with automatic snapshot management.
+
+ Overrides :meth:`~dissmodel.core.Model.process` to insert
+ :meth:`synchronize` calls before the first step and after each step.
+ """
+ if self.env.now() < self.start_time:
+ self.hold(self.start_time - self.env.now())
+
+ # initial snapshot — captures state at t=0 before any execution
+ self.synchronize()
+
+ while self.env.now() < self.end_time:
+ self.execute()
+ self.synchronize() # update snapshot for the next step
+ self.hold(self._step)
+
+ def synchronize(self) -> None:
+ """
+ Copy each column in ``land_use_types`` to ``
_past``.
+
+ Equivalent to ``cs:synchronize()`` in TerraME. Called automatically
+ before the first step and after each ``execute()``. Can also be
+ called manually when an explicit mid-step snapshot is needed.
+
+ Does nothing if ``land_use_types`` has not been set yet (safe to
+ call before ``setup()`` completes).
+ """
+ if not hasattr(self, "land_use_types"):
+ return
+ for col in self.land_use_types:
+ self.gdf[col + "_past"] = self.gdf[col].copy()
diff --git a/dissmodel/visualization/__init__.py b/dissmodel/visualization/__init__.py
index 2aaab5b..645ca53 100644
--- a/dissmodel/visualization/__init__.py
+++ b/dissmodel/visualization/__init__.py
@@ -1,4 +1,6 @@
+from dissmodel.visualization.chart import Chart, track_plot
+from dissmodel.visualization.widgets import display_inputs
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
+
+__all__ = ["Chart", "track_plot", "display_inputs", "Map", "RasterMap"]
\ No newline at end of file
diff --git a/dissmodel/visualization/map.py b/dissmodel/visualization/map.py
index 3901913..babca7e 100644
--- a/dissmodel/visualization/map.py
+++ b/dissmodel/visualization/map.py
@@ -1,7 +1,22 @@
+"""
+dissmodel/visualization/map.py
+================================
+Visualization component for GeoDataFrame — choropleth map rendered at
+every simulation step.
+
+Supported render targets
+------------------------
+1. **Streamlit** — ``plot_area=st.empty()``
+2. **Jupyter** — detected automatically
+3. **Interactive** — matplotlib window (TkAgg / Qt)
+4. **Headless** — saves PNGs to ``map_frames/`` (default fallback)
+"""
from __future__ import annotations
+import pathlib
from typing import Any
+import matplotlib
import matplotlib.pyplot as plt
import matplotlib.figure
import matplotlib.axes
@@ -13,27 +28,24 @@
class Map(Model):
"""
- Simulation model that renders a live choropleth map.
-
- Extends :class:`~dissmodel.core.Model` and redraws the map at every
- time step. Supports three rendering targets:
-
- - **Streamlit** — pass a ``plot_area`` (``st.empty()``).
- - **Jupyter** — detected automatically via :func:`is_notebook`.
- - **Matplotlib window** — fallback for plain Python scripts.
+ Simulation model that renders a live choropleth map of a GeoDataFrame.
Parameters
----------
gdf : geopandas.GeoDataFrame
GeoDataFrame to render.
plot_params : dict
- Keyword arguments forwarded to :meth:`GeoDataFrame.plot`
- (e.g. ``column``, ``cmap``, ``legend``).
- pause : bool, optional
- If ``True``, call ``plt.pause()`` after each update, by default
- ``True``. Required for live updates outside notebooks.
- plot_area : any, optional
- Streamlit ``st.empty()`` placeholder, by default ``None``.
+ Keyword arguments forwarded to :meth:`GeoDataFrame.plot`.
+ figsize : tuple[int, int]
+ Figure size in inches. Default: ``(10, 6)``.
+ pause : bool
+ Call ``plt.pause()`` after each update in interactive mode.
+ interval : float
+ Seconds passed to ``plt.pause()``. Default: ``0.01``.
+ plot_area : st.empty() | None
+ Streamlit placeholder. Default: ``None``.
+ save_frames : bool
+ Save one PNG per step to ``map_frames/``. Default: ``False``.
Examples
--------
@@ -42,95 +54,86 @@ class Map(Model):
>>> env.run()
"""
- fig: matplotlib.figure.Figure
- ax: matplotlib.axes.Axes
- gdf: gpd.GeoDataFrame
- plot_params: dict[str, Any]
- pause: bool
- plot_area: Any
-
def setup(
self,
- gdf: gpd.GeoDataFrame,
+ gdf: gpd.GeoDataFrame,
plot_params: dict[str, Any],
- pause: bool = True,
- plot_area: Any = None,
+ figsize: tuple[int, int] = (10, 6),
+ pause: bool = True,
+ interval: float = 0.01,
+ plot_area: Any = None,
+ save_frames: bool = False,
) -> None:
- """
- Configure the map.
-
- Called automatically by salabim during component initialisation.
-
- Parameters
- ----------
- gdf : geopandas.GeoDataFrame
- GeoDataFrame to render.
- plot_params : dict
- Keyword arguments forwarded to :meth:`GeoDataFrame.plot`
- (e.g. ``column``, ``cmap``, ``legend``).
- pause : bool, optional
- If ``True``, call ``plt.pause()`` after each update,
- by default ``True``.
- plot_area : any, optional
- Streamlit ``st.empty()`` placeholder, by default ``None``.
- """
- self.gdf = gdf
+ self.gdf = gdf
self.plot_params = plot_params
- self.pause = pause
- self.plot_area = plot_area
-
- if not is_notebook():
- self.fig, self.ax = plt.subplots(1, 1, figsize=(10, 6))
-
- def update(self, year: float, gdf: gpd.GeoDataFrame) -> None:
- """
- Redraw the map for a given simulation time.
-
- Parameters
- ----------
- year : float
- Current simulation time, displayed in the map title.
- gdf : geopandas.GeoDataFrame
- GeoDataFrame snapshot to render.
-
- Raises
- ------
- RuntimeError
- If no interactive matplotlib backend is detected and the code is
- not running in a notebook or Streamlit context.
- """
- if is_notebook():
- from IPython.display import clear_output, display
- clear_output(wait=True)
- self.fig, self.ax = plt.subplots(1, 1, figsize=(10, 6))
+ self.figsize = figsize
+ self.pause = pause
+ self.interval = interval
+ self.plot_area = plot_area
+ self.save_frames = save_frames
+
+ # always create fig so _render() can always call self.fig.clf()
+ self.fig, self.ax = plt.subplots(1, 1, figsize=self.figsize)
+
+ # close immediately if not needed for interactive mode
+ if is_notebook() or plot_area is not None:
+ plt.close(self.fig)
+
+ # ── rendering ─────────────────────────────────────────────────────────────
+
+ def _render(self, step: float) -> matplotlib.figure.Figure:
+ if is_notebook() or self.plot_area is not None:
+ # create a fresh figure every step
+ self.fig, self.ax = plt.subplots(1, 1, figsize=self.figsize)
else:
+ # reuse existing figure — clear and redraw
self.fig.clf()
self.ax = self.fig.add_subplot(1, 1, 1)
- gdf.plot(ax=self.ax, **self.plot_params)
- self.ax.set_title(f"Map — Step {year}")
+ self.gdf.plot(ax=self.ax, **self.plot_params)
+ self.ax.set_title(f"Map — Step {int(step)}")
plt.tight_layout()
plt.draw()
+ return self.fig
+
+ def _save_frame(self, fig: matplotlib.figure.Figure, step: float) -> None:
+ col = self.plot_params.get("column", "map")
+ out_dir = pathlib.Path("map_frames")
+ out_dir.mkdir(exist_ok=True)
+ fname = out_dir / f"{col}_step_{int(step):03d}.png"
+ fig.savefig(fname, dpi=100, bbox_inches="tight",
+ facecolor=fig.get_facecolor())
+ plt.close(fig)
+ end_time = getattr(self.env, "end_time", step)
+ if int(step) % 10 == 0 or step == end_time:
+ print(f" Map [{col}] step {int(step):3d} → {fname}")
+
+ # ── execute ───────────────────────────────────────────────────────────────
+
+ def execute(self) -> None:
+ step = self.env.now()
+ fig = self._render(step)
if self.plot_area is not None:
- self.plot_area.pyplot(self.fig)
+ # Streamlit
+ self.plot_area.pyplot(fig)
+ plt.close(fig)
+
elif is_notebook():
- from IPython.display import display
- display(self.fig)
- plt.close(self.fig)
- elif self.pause:
- if is_interactive_backend():
- plt.pause(0.01)
- if self.env.now() == self.env.end_time:
- plt.show()
- else:
- raise RuntimeError(
- "No interactive matplotlib backend detected. "
- "On Linux, install tkinter:\n\n"
- " sudo apt install python3-tk\n"
- )
+ # Jupyter
+ from IPython.display import clear_output, display
+ clear_output(wait=True)
+ display(fig)
+ plt.close(fig)
+ elif self.save_frames or not is_interactive_backend():
+ # headless / CI
+ self._save_frame(fig, step)
- def execute(self) -> None:
- """Redraw the map for the current simulation time step."""
- self.update(year=self.env.now(), gdf=self.gdf)
+ else:
+ # interactive window
+ if self.pause:
+ plt.pause(self.interval)
+ end_time = getattr(self.env, "end_time", step)
+ if step == end_time:
+ plt.show()
diff --git a/dissmodel/visualization/raster_map.py b/dissmodel/visualization/raster_map.py
index f065bab..8e6207c 100644
--- a/dissmodel/visualization/raster_map.py
+++ b/dissmodel/visualization/raster_map.py
@@ -4,80 +4,92 @@
Visualization component for RasterBackend — the raster analogue of the
vector ``Map`` component in DisSModel.
-Responsibility
---------------
-Render any named array from a ``RasterBackend`` without any domain
-knowledge — no land-use constants, no CRS, no hard-coded colours.
-
-Visual definitions (colours, labels, colormaps) are injected by the
-project at instantiation time.
-
Supported render targets
------------------------
1. **Streamlit** — ``plot_area=st.empty()``
2. **Jupyter** — detected automatically
-3. **Interactive** — ``RASTER_MAP_INTERACTIVE=1`` (TkAgg / Qt)
-4. **Headless** — saves PNGs to ``raster_map_frames/`` (default)
-
-Minimal usage (headless / no colour map)
------------------------------------------
- from dissmodel.geo.raster.backend import RasterBackend
- from dissmodel.visualization.raster_map import RasterMap
- from dissmodel.core import Environment
-
- env = Environment(start_time=1, end_time=10)
- YourModel(backend=b)
- RasterMap(backend=b, band="state")
- env.run()
- # → raster_map_frames/state_step_001.png … state_step_010.png
-
-Categorical usage (domain colour map)
----------------------------------------
- from myproject.constants import LAND_USE_COLORS, LAND_USE_LABELS
-
- RasterMap(
- backend = b,
- band = "uso",
- title = "Land Use",
- color_map = LAND_USE_COLORS, # dict[int, str] value → hex colour
- labels = LAND_USE_LABELS, # dict[int, str] value → legend label
- )
-
-Continuous usage (altimetry, temperature, …)
----------------------------------------------
- RasterMap(
- backend = b,
- band = "alt",
- title = "Altimetry",
- cmap = "terrain",
- colorbar_label = "Altitude (m)",
- mask_band = "uso", # optional: mask cells where uso == mask_value
- mask_value = 3, # e.g. SEA = 3
- )
+3. **Interactive** — matplotlib window (TkAgg / Qt), used when a display
+ is available
+4. **Headless** — saves PNGs to ``raster_map_frames/`` when no display
+ is detected or ``save_frames=True``
+
+The component does NOT call ``matplotlib.use()`` at import time — the
+backend is left to matplotlib's own detection. This prevents side effects
+when ``RasterMap`` is imported alongside other visualization components
+(e.g. via ``dissmodel.visualization.__init__``).
+
+Usage examples
+--------------
+ # categorical
+ RasterMap(backend=b, band="uso", color_map=COLORS, labels=LABELS)
+
+ # continuous — paridade com Map vetorial
+ # auto_mask=True (default) aplica o extent mask do backend automaticamente
+ RasterMap(backend=b, band="f", cmap="Greens",
+ scheme="equal_interval", k=5, legend=True)
+
+ # continuous — vmin/vmax explícitos + mask de domínio (ex: mar na altimetria)
+ RasterMap(backend=b, band="alt", cmap="terrain",
+ vmin=0.0, vmax=100.0, colorbar_label="Altitude (m)",
+ mask_band="uso", mask_value=3)
+
+ # desligar auto_mask se quiser o comportamento pré-v2
+ RasterMap(backend=b, band="f", auto_mask=False)
+
+Extent mask automático
+-----------------------
+Quando ``auto_mask=True`` (default), o RasterMap consulta
+``backend.nodata_mask`` antes de renderizar. Células fora do extent
+ficam transparentes sem nenhuma configuração adicional.
+
+O ``backend.nodata_mask`` é derivado automaticamente pelo RasterBackend:
+ - se existe a banda ``"mask"``: True onde mask != 0
+ - senão, usa ``nodata_value`` do backend sobre o primeiro array disponível
"""
from __future__ import annotations
-import os
import pathlib
from typing import Any
import matplotlib
-if os.environ.get("RASTER_MAP_INTERACTIVE", "0") == "1":
- pass # let matplotlib choose TkAgg / Qt
-else:
- matplotlib.use("Agg") # headless — no display window
-
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
import matplotlib.patches
from dissmodel.core import Model
-from dissmodel.visualization._utils import is_notebook
+from dissmodel.visualization._utils import is_notebook, is_interactive_backend
+
+def _get_nodata_mask(backend) -> np.ndarray | None:
+ """
+ Deriva a extent mask do backend sem exigir que o backend implemente
+ um atributo específico — funciona com qualquer RasterBackend existente.
+
+ Prioridade:
+ 1. backend.nodata_mask — se o backend já expõe a propriedade
+ 2. arrays["mask"] — convenção dissluc (mask != 0 = válido)
+ 3. nodata_value — aplica sobre o primeiro array disponível
+ """
+ # 1. propriedade nativa (futura)
+ if hasattr(backend, "nodata_mask"):
+ mask = backend.nodata_mask
+ if mask is not None:
+ return mask
+
+ arrays = getattr(backend, "arrays", {})
-def _is_interactive() -> bool:
- return matplotlib.get_backend().lower() not in ("agg", "cairo", "svg", "pdf", "ps")
+ # 2. banda "mask" — convenção dissluc
+ if "mask" in arrays:
+ return arrays["mask"] != 0
+
+ # 3. nodata_value sobre o primeiro array
+ nodata = getattr(backend, "nodata_value", None)
+ if nodata is not None and arrays:
+ first = next(iter(arrays.values()))
+ return first != nodata
+
+ return None
class RasterMap(Model):
@@ -87,93 +99,117 @@ class RasterMap(Model):
Parameters
----------
backend : RasterBackend
- Backend shared with the simulation models.
band : str
- Name of the array to visualize.
+ Array to visualize.
title : str
Figure title prefix. Default: ``"RasterMap"``.
figsize : tuple[int, int]
- Figure size in inches. Default: ``(7, 7)``.
+ Default: ``(7, 7)``.
pause : bool
Use ``plt.pause()`` in interactive mode. Default: ``True``.
interval : float
Seconds between steps in interactive mode. Default: ``0.5``.
plot_area : st.empty() | None
- Streamlit placeholder. Default: ``None``.
-
- Categorical mode (``color_map`` provided)
- ------------------------------------------
- color_map : dict[int, str] | None
- Value-to-hex-colour mapping. Example: ``{1: "#006400", 3: "#00008b"}``.
- When provided, renders with a ``ListedColormap`` and a legend.
- labels : dict[int, str] | None
- Value-to-label mapping for the legend.
- If ``None`` and ``color_map`` is provided, uses ``str(value)``.
-
- Continuous mode (``color_map`` absent)
+ Streamlit placeholder.
+ auto_mask : bool
+ Apply the backend's extent mask automatically so pixels outside
+ the study area are transparent. Default: ``True``.
+ Set to ``False`` to restore the pre-v2 behaviour.
+
+ Categorical mode (``color_map`` provided)
+ -------------------------------------------
+ color_map : dict[int, str]
+ ``{value: "#rrggbb"}``
+ labels : dict[int, str]
+ ``{value: "label"}`` — used in the legend.
+
+ Continuous mode (``color_map`` absent)
----------------------------------------
cmap : str
Matplotlib colormap name. Default: ``"viridis"``.
- vmin : float | None
- Minimum value for the colour scale. Default: array minimum.
- vmax : float | None
- Maximum value for the colour scale. Default: array maximum.
+ scheme : str
+ ``"manual"`` — use ``vmin`` / ``vmax`` (default).
+ ``"equal_interval"`` — divide [min, max] of valid data into ``k`` classes.
+ ``"quantiles"`` — p2–p98 of valid data, robust to outliers.
+ k : int
+ Number of colour classes for ``scheme="equal_interval"``. Default: ``5``.
+ vmin, vmax : float | None
+ Bounds for ``scheme="manual"``.
+ legend : bool
+ Show the colorbar. Default: ``True``.
colorbar_label : str
- Colorbar label. Default: value of ``band``.
+ Colorbar label. Default: ``band``.
mask_band : str | None
- Name of another array used to mask cells.
+ Additional domain mask (e.g. mask sea cells for altimetry).
+ Applied on top of the automatic extent mask.
mask_value : int | float | None
- Value in ``mask_band`` to mask (e.g. ``SEA=3`` for altimetry).
-
- Examples
- --------
- >>> env = Environment(start_time=1, end_time=10)
- >>> RasterMap(backend=b, band="state")
- >>> env.run()
+ Value in ``mask_band`` to mask.
"""
def setup(
self,
backend,
- band: str = "state",
- title: str = "RasterMap",
- figsize: tuple[int, int] = (7, 7),
- pause: bool = True,
- interval: float = 0.5,
- plot_area: Any = None,
- # categorical mode
+ band: str = "state",
+ title: str = "RasterMap",
+ figsize: tuple[int, int] = (7, 7),
+ pause: bool = True,
+ interval: float = 0.5,
+ plot_area: Any = None,
+ auto_mask: bool = True,
+ save_frames: bool = False,
+ # categorical
color_map: dict[int, str] | None = None,
labels: dict[int, str] | None = None,
- # continuous mode
- cmap: str = "viridis",
- vmin: float | None = None,
- vmax: float | None = None,
- colorbar_label: str | None = None,
- mask_band: str | None = None,
+ # continuous
+ cmap: str = "viridis",
+ scheme: str = "manual",
+ k: int = 5,
+ vmin: float | None = None,
+ vmax: float | None = None,
+ legend: bool = True,
+ colorbar_label: str | None = None,
+ mask_band: str | None = None,
mask_value: int | float | None = None,
) -> None:
- self.backend = backend
- self.band = band
- self.title = title
- self.figsize = figsize
- self.pause = pause
- self.interval = interval
- self.plot_area = plot_area
- self.color_map = color_map
- self.labels = labels or {}
- self.cmap = cmap
- self.vmin = vmin
- self.vmax = vmax
- self.colorbar_label = colorbar_label or band
- self.mask_band = mask_band
- self.mask_value = mask_value
+ self.backend = backend
+ self.band = band
+ self.title = title
+ self.figsize = figsize
+ self.pause = pause
+ self.interval = interval
+ self.plot_area = plot_area
+ self.auto_mask = auto_mask
+ self.save_frames = save_frames
+ self.color_map = color_map
+ self.labels = labels or {}
+ self.cmap = cmap
+ self.scheme = scheme
+ self.k = k
+ self.vmin = vmin
+ self.vmax = vmax
+ self.legend = legend
+ self.colorbar_label = colorbar_label or band
+ self.mask_band = mask_band
+ self.mask_value = mask_value
+
+ # resolve extent mask once at setup — not on every frame
+ self._extent_mask: np.ndarray | None = (
+ _get_nodata_mask(backend) if auto_mask else None
+ )
# ── rendering ─────────────────────────────────────────────────────────────
def _render(self, step: float) -> matplotlib.figure.Figure:
- plt.close("all")
- fig, ax = plt.subplots(figsize=self.figsize)
- self.fig, self.ax = fig, ax
+ # each RasterMap owns its own figure — reuse across steps
+ # so multiple instances show as separate windows simultaneously
+ if not hasattr(self, "_fig") or self._fig is None \
+ or not plt.fignum_exists(self._fig.number):
+ self._fig, self._ax = plt.subplots(figsize=self.figsize)
+ else:
+ self._fig.clf()
+ self._ax = self._fig.add_subplot(1, 1, 1)
+
+ fig, ax = self._fig, self._ax
arr = self.backend.arrays.get(self.band)
if arr is None:
@@ -187,84 +223,123 @@ def _render(self, step: float) -> matplotlib.figure.Figure:
else:
self._render_continuous(ax, arr)
- ax.set_xticks([]); ax.set_yticks([])
+ ax.set_xticks([])
+ ax.set_yticks([])
ax.set_title(f"{self.title} [{self.band}] — Step {int(step)}")
plt.tight_layout()
return fig
+ def _apply_masks(self, data: np.ndarray) -> np.ma.MaskedArray:
+ """
+ Aplica todas as máscaras em ordem e retorna um MaskedArray:
+ 1. extent mask automático (auto_mask=True)
+ 2. mask_band / mask_value (domínio, ex: mar para altimetria)
+ 3. NaN / Inf residuais
+ """
+ # 1. extent mask — pixels fora do estudo
+ if self._extent_mask is not None:
+ data = np.where(self._extent_mask, data, np.nan)
+
+ # 2. domain mask — ex: mascarar mar na visualização de altimetria
+ if self.mask_band is not None and self.mask_value is not None:
+ mask_arr = self.backend.arrays.get(self.mask_band)
+ if mask_arr is not None:
+ data = np.where(mask_arr == self.mask_value, np.nan, data)
+
+ # 3. cobre NaN / Inf (inclui os inseridos pelos passos acima)
+ return np.ma.masked_invalid(data)
+
def _render_categorical(self, ax, arr: np.ndarray) -> None:
- """Render integer arrays using a ListedColormap with value-to-colour mapping."""
vals = sorted(self.color_map)
- cmap = mcolors.ListedColormap([self.color_map[k] for k in vals])
+ cmap = mcolors.ListedColormap([self.color_map[v] for v in vals])
norm = mcolors.BoundaryNorm(
[v - 0.5 for v in vals] + [vals[-1] + 0.5], cmap.N
)
- ax.imshow(arr, cmap=cmap, norm=norm, aspect="equal", interpolation="nearest")
+ cmap.set_bad(color="white", alpha=0)
+ data = self._apply_masks(arr.astype(float))
+
+ ax.imshow(data, cmap=cmap, norm=norm, aspect="equal", interpolation="nearest")
- # legend shows only values present in the current array
- present = set(np.unique(arr))
+ present = set(np.unique(arr[~np.isnan(arr.astype(float))]))
patches = [
- matplotlib.patches.Patch(
- color=self.color_map[k],
- label=self.labels.get(k, str(k)),
- )
- for k in vals if k in present
+ matplotlib.patches.Patch(color=self.color_map[v],
+ label=self.labels.get(v, str(v)))
+ for v in vals if v in present
]
if patches:
ax.legend(handles=patches, loc="lower right", fontsize=7, framealpha=0.7)
def _render_continuous(self, ax, arr: np.ndarray) -> None:
- """Render continuous arrays with a colorbar and optional mask."""
- data = arr.astype(float)
+ data = self._apply_masks(arr.astype(float))
+ cmap = plt.get_cmap(self.cmap).copy()
+ cmap.set_bad(color="white", alpha=0)
+
+ valid = data.compressed()
+
+ if len(valid) == 0:
+ vmin, vmax = 0.0, 1.0
+
+ elif self.scheme == "equal_interval":
+ vmin = float(valid.min())
+ vmax = float(valid.max())
+ if vmin != vmax and self.k > 1:
+ bounds = np.linspace(vmin, vmax, self.k + 1)
+ norm = mcolors.BoundaryNorm(bounds, plt.get_cmap(self.cmap).N)
+ im = ax.imshow(data, cmap=cmap, norm=norm, aspect="equal",
+ interpolation="nearest")
+ if self.legend:
+ plt.colorbar(im, ax=ax, label=self.colorbar_label,
+ fraction=0.03, pad=0.02)
+ return
+
+ elif self.scheme == "quantiles":
+ vmin = float(np.percentile(valid, 2))
+ vmax = float(np.percentile(valid, 98))
+
+ else: # "manual"
+ vmin = self.vmin if self.vmin is not None else float(valid.min())
+ vmax = self.vmax if self.vmax is not None else float(valid.max())
- if self.mask_band is not None and self.mask_value is not None:
- mask_arr = self.backend.arrays.get(self.mask_band)
- if mask_arr is not None:
- data = np.ma.masked_where(mask_arr == self.mask_value, data)
-
- vmin = self.vmin if self.vmin is not None else float(np.nanmin(data))
- vmax = self.vmax if self.vmax is not None else float(np.nanmax(data))
if vmin == vmax:
vmax = vmin + 1.0
- im = ax.imshow(data, cmap=self.cmap, aspect="equal",
+ im = ax.imshow(data, cmap=cmap, aspect="equal",
interpolation="nearest", vmin=vmin, vmax=vmax)
- plt.colorbar(im, ax=ax, label=self.colorbar_label, fraction=0.03, pad=0.02)
+ if self.legend:
+ plt.colorbar(im, ax=ax, label=self.colorbar_label,
+ fraction=0.03, pad=0.02)
# ── execute ───────────────────────────────────────────────────────────────
def execute(self) -> None:
- """Render the current array state and dispatch to the active output target."""
step = self.env.now()
fig = self._render(step)
plt.draw()
if self.plot_area is not None:
- # Streamlit
self.plot_area.pyplot(fig)
plt.close(fig)
elif is_notebook():
- # Jupyter
from IPython.display import clear_output, display
clear_output(wait=True)
display(fig)
plt.close(fig)
- elif self.pause and _is_interactive():
- # interactive window (TkAgg / Qt)
- plt.pause(self.interval)
- if step == getattr(self.env, "end_time", step):
- input("Simulation complete — press Enter to close...")
- plt.close("all")
-
- else:
- # headless — save PNG to raster_map_frames/
+ elif self.save_frames or not is_interactive_backend():
out_dir = pathlib.Path("raster_map_frames")
out_dir.mkdir(exist_ok=True)
fname = out_dir / f"{self.band}_step_{int(step):03d}.png"
fig.savefig(fname, dpi=100, bbox_inches="tight",
facecolor=fig.get_facecolor())
plt.close(fig)
- if int(step) % 10 == 0 or step == getattr(self.env, "end_time", step):
- print(f" RasterMap [{self.band}] step {int(step):3d} → {fname}")
\ No newline at end of file
+ end_time = getattr(self.env, "end_time", step)
+ if int(step) % 10 == 0 or step == end_time:
+ print(f" RasterMap [{self.band}] step {int(step):3d} → {fname}")
+
+ else:
+ if self.pause:
+ plt.pause(self.interval)
+ end_time = getattr(self.env, "end_time", step)
+ if step == end_time:
+ plt.show()
\ No newline at end of file
diff --git a/docs/api/'' b/docs/api/''
new file mode 100644
index 0000000..e69de29
diff --git a/docs/api/geo/index.md b/docs/api/geo/index.md
index f27a0dd..c1d701b 100644
--- a/docs/api/geo/index.md
+++ b/docs/api/geo/index.md
@@ -40,7 +40,7 @@ no conversion step.
```python
import geopandas as gpd
from dissmodel.core import Model, Environment
-from dissmodel.visualization.map import Map
+from dissmodel.visualization.map import Map
gdf = gpd.read_file("area.shp")
gdf.set_index("object_id", inplace=True)
diff --git a/docs/api/visualization.md b/docs/api/visualization.md
index fe1a7da..90dcb7e 100644
--- a/docs/api/visualization.md
+++ b/docs/api/visualization.md
@@ -80,7 +80,7 @@ Chart(plot_area=st.empty())
Renders spatial data from a GeoDataFrame, updated at every simulation step.
```python
-from dissmodel.visualization.map import Map
+from dissmodel.visualization.map import Map
from matplotlib.colors import ListedColormap
Map(
@@ -175,7 +175,7 @@ if run_btn:
## API Reference
-::: dissmodel.visualization.Chart
+::: dissmodel.visualization.chart.Chart
::: dissmodel.visualization.map.Map
diff --git a/docs/examples/notebooks/ca_game_of_life.ipynb b/docs/examples/notebooks/ca_game_of_life.ipynb
index 25de857..3c96951 100644
--- a/docs/examples/notebooks/ca_game_of_life.ipynb
+++ b/docs/examples/notebooks/ca_game_of_life.ipynb
@@ -47,7 +47,7 @@
"from dissmodel.geo import vector_grid\n",
"from dissmodel.models.ca import GameOfLife\n",
"from dissmodel.models.ca.game_of_life import PATTERNS\n",
- "from dissmodel.visualization.map import Map"
+ "from dissmodel.visualization.map import Map"
]
},
{
diff --git a/docs/getting_started.md b/docs/getting_started.md
index 6bc173d..b7f0ff3 100644
--- a/docs/getting_started.md
+++ b/docs/getting_started.md
@@ -90,7 +90,7 @@ detects the Jupyter environment automatically and renders visualizations inline.
from dissmodel.core import Environment
from dissmodel.geo import regular_grid
from dissmodel.models.ca import GameOfLife
-from dissmodel.visualization.map import Map
+from dissmodel.visualization.map import Map
from matplotlib.colors import ListedColormap
gdf = regular_grid(dimension=(30, 30), resolution=1)
diff --git a/docs/models/ca/fire_model.md b/docs/models/ca/fire_model.md
index 1ef8076..54ec84f 100644
--- a/docs/models/ca/fire_model.md
+++ b/docs/models/ca/fire_model.md
@@ -15,7 +15,7 @@ from dissmodel.core import Environment
from dissmodel.geo import vector_grid
from dissmodel.models.ca import FireModel
from dissmodel.models.ca.fire_model import FireState
-from dissmodel.visualization.map import Map
+from dissmodel.visualization.map import Map
gdf = vector_grid(dimension=(30, 30), resolution=1, attrs={"state": FireState.FOREST})
diff --git a/docs/models/ca/game_of_life.md b/docs/models/ca/game_of_life.md
index 6e21264..7c22b7c 100644
--- a/docs/models/ca/game_of_life.md
+++ b/docs/models/ca/game_of_life.md
@@ -13,7 +13,7 @@ from dissmodel.geo import vector_grid
from dissmodel.models.ca import GameOfLife
from matplotlib.colors import ListedColormap
-from dissmodel.visualization.map import Map
+from dissmodel.visualization.map import Map
gdf = vector_grid(dimension=(30, 30), resolution=1)
env = Environment(end_time=20)
diff --git a/docs/models/ca/index.md b/docs/models/ca/index.md
index 0af396c..153449c 100644
--- a/docs/models/ca/index.md
+++ b/docs/models/ca/index.md
@@ -25,7 +25,7 @@ before running the simulation.
from dissmodel.core import Environment
from dissmodel.geo import vector_grid
from dissmodel.models.ca import GameOfLife
-from dissmodel.visualization.map import Map
+from dissmodel.visualization.map import Map
gdf = vector_grid(dimension=(30, 30), resolution=1)
@@ -43,7 +43,7 @@ Subclass `CellularAutomaton` and implement the `rule(idx)` method:
```python
from dissmodel.core import Environment
from dissmodel.geo import vector_grid, fill, FillStrategy
-from dissmodel.visualization.map import Map
+from dissmodel.visualization.map import Map
from libpysal.weights import Queen
from dissmodel.geo.vector.cellular_automaton import CellularAutomaton
diff --git a/docs/why_dissmodel.md b/docs/why_dissmodel.md
index 005cdf0..4ad86ed 100644
--- a/docs/why_dissmodel.md
+++ b/docs/why_dissmodel.md
@@ -78,7 +78,7 @@ This works. But the researcher is responsible for:
from dissmodel.core import Environment
from dissmodel.geo import vector_grid
from dissmodel.geo.vector.model import SpatialModel
-from dissmodel.visualization.map import Map
+from dissmodel.visualization.map import Map
from libpysal.weights import Queen
gdf = vector_grid(dimension=(100, 100), resolution=100,
diff --git a/examples/cli/ca/ca_game_of_life.py b/examples/cli/ca/ca_game_of_life.py
index 4aaa2a1..5719612 100644
--- a/examples/cli/ca/ca_game_of_life.py
+++ b/examples/cli/ca/ca_game_of_life.py
@@ -21,7 +21,7 @@
from dissmodel.core import Environment
from dissmodel.geo import vector_grid
from dissmodel.models.ca import GameOfLife
-from dissmodel.visualization.map import Map
+from dissmodel.visualization import Map
# ---------------------------------------------------------------------------
# Setup
diff --git a/examples/notebooks/ca_game_of_life.ipynb b/examples/notebooks/ca_game_of_life.ipynb
index 25de857..f30d25e 100644
--- a/examples/notebooks/ca_game_of_life.ipynb
+++ b/examples/notebooks/ca_game_of_life.ipynb
@@ -37,7 +37,7 @@
},
{
"cell_type": "code",
- "execution_count": 32,
+ "execution_count": 1,
"metadata": {},
"outputs": [],
"source": [
@@ -69,7 +69,7 @@
},
{
"cell_type": "code",
- "execution_count": 33,
+ "execution_count": 2,
"metadata": {},
"outputs": [],
"source": [
@@ -88,7 +88,7 @@
},
{
"cell_type": "code",
- "execution_count": 34,
+ "execution_count": 3,
"metadata": {},
"outputs": [
{
@@ -161,7 +161,7 @@
"4-0 POLYGON ((1 4, 1 5, 0 5, 0 4, 1 4)) 0"
]
},
- "execution_count": 34,
+ "execution_count": 3,
"metadata": {},
"output_type": "execute_result"
}
@@ -186,7 +186,7 @@
},
{
"cell_type": "code",
- "execution_count": 35,
+ "execution_count": 4,
"metadata": {},
"outputs": [
{
@@ -224,7 +224,7 @@
},
{
"cell_type": "code",
- "execution_count": 36,
+ "execution_count": 5,
"metadata": {},
"outputs": [],
"source": [
@@ -248,7 +248,7 @@
},
{
"cell_type": "code",
- "execution_count": 37,
+ "execution_count": 6,
"metadata": {},
"outputs": [
{
@@ -257,7 +257,7 @@
"Map (map.0)"
]
},
- "execution_count": 37,
+ "execution_count": 6,
"metadata": {},
"output_type": "execute_result"
}
@@ -280,12 +280,12 @@
},
{
"cell_type": "code",
- "execution_count": 38,
+ "execution_count": 7,
"metadata": {},
"outputs": [
{
"data": {
- "image/png": "iVBORw0KGgoAAAANSUhEUgAAAkgAAAJOCAYAAABMR/iyAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQAARdBJREFUeJzt3Xt8VPWd//H3ACEhmgwpAZJoAqgRVBCUNjR4wQgVqFXAO2qBeOu6YStNrTauykV3ab1rQXT7ELCmVMVVbLXVYhyilosOyCq2pSQLBJZJDGwyQ0ACP3J+f2wzNd9cyIFzMpzh9Xw85vFwzpzzzuecM1PePTOT+CzLsgQAAICobrEeAAAA4HhDQQIAADBQkAAAAAwUJAAAAAMFCQAAwEBBAgAAMFCQAAAADBQkAAAAAwUJAADAQEECAAAwUJCAOLB06VL5fD75fD599NFHrR63LEvZ2dny+Xz63ve+F4MJ3bNt2zYVFhbq9NNPV1JSkjIyMnTxxRdr9uzZLdZ79tlntXTp0tgM+Xf/9m//piuvvFL9+/eXz+fTnDlz2lxvzpw50fP59VtSUlKnf9bq1at14YUXKjk5WRkZGfrhD3+ohoYGh/YEiH89Yj0AAOckJSVp2bJluvDCC1ssLy8v186dO5WYmBijydxRUVGhb33rW+rVq5duueUWDRw4UKFQSBs2bNDPf/5zzZ07N7rus88+q/T0dM2YMSNm895///3KyMjQeeedp3ffffeI6y9atEgnn3xy9H737t079XM2btyosWPH6qyzztITTzyhnTt36rHHHtOWLVv0hz/84ajnB04kFCQgjnz3u9/V8uXL9cwzz6hHj3+8vJctW6aRI0dq9+7dMZzOeU8++aQaGhq0ceNGDRgwoMVjX375ZYymat/WrVs1cOBA7d69W3379j3i+tdcc43S09Nt/5z77rtPaWlpWrVqlVJTUyVJAwcO1O23364//vGPuuyyy2xnAica3mID4sjUqVO1Z88erVy5Mrrs4MGDeu2113TjjTe2uc1jjz2m0aNHq0+fPurVq5dGjhyp1157rdV6Pp9PM2fO1K9//WsNHjxYSUlJGjlypD744APX9udIKisrdeqpp7YqR5LUr1+/6H8PHDhQX3zxhcrLy6NvV11yySXRx+vr6zVr1ixlZ2crMTFRZ5xxhn7+85+rqakpus62bdvk8/n02GOP6cknn9SAAQPUq1cvjRkzRps2berUvAMHDrS1f5ZlKRKJyLKsTm8TiUS0cuVK3XzzzdFyJEnTpk3TySefrFdffdXWDMCJioIExJGBAwcqPz9fv/nNb6LL/vCHPygcDuuGG25oc5unn35a5513nubNm6d///d/V48ePXTttdfq7bffbrVueXm5Zs2apZtvvlnz5s3Tnj17NGHChE4XBKcNGDBAO3bs0Pvvv9/hek899ZROPfVUDRkyRC+99JJeeukl/eu//qskaf/+/RozZoxKS0s1bdo0PfPMM7rgggtUUlKi4uLiVlm/+tWv9Mwzz6ioqEglJSXatGmTLr30UtXU1Di+f6eddpr8fr9SUlJ08803d+pnfP755/p//+//6Zvf/GaL5T179tSIESP06aefOj4nEJcsAJ63ZMkSS5L1ySefWAsWLLBSUlKs/fv3W5ZlWddee61VUFBgWZZlDRgwwLr88stbbNu8XrODBw9aQ4cOtS699NIWyyVZkqxgMBhdtn37dispKcmaMmWKG7t1RJs2bbJ69eplSbJGjBhh3XXXXdaKFSusffv2tVr3nHPOscaMGdNq+UMPPWSddNJJ1t/+9rcWy3/6059a3bt3t6qqqizLsqytW7dakqxevXpZO3fujK63bt06S5L1ox/9qNNz19bWWpKs2bNnt/n4U089Zc2cOdP69a9/bb322mvWXXfdZfXo0cPKzc21wuFwh9nLly+3JFkffPBBq8euvfZaKyMjo9NzAicyriABcea6667TV199pbfeekt79+7VW2+91e7ba5LUq1ev6H/X1dUpHA7roosu0oYNG1qtm5+fr5EjR0bv5+TkaNKkSXr33Xd1+PBhZ3ekE8455xxt3LhRN998s7Zt26ann35akydPVv/+/fXLX/6yUxnLly/XRRddpLS0NO3evTt6GzdunA4fPtzqLcTJkyfrlFNOid7Py8vTqFGj9Pvf/96x/brrrrv0i1/8QjfeeKOuvvpqPfXUU3rxxRe1ZcsWPfvssx1u+9VXX0lSmx/IT0pKij4OoGN8SBuIM3379tW4ceO0bNky7d+/X4cPH9Y111zT7vpvvfWWHn74YW3cuFGNjY3R5T6fr9W6ubm5rZadeeaZ2r9/v2pra5WRkdHmz6iurj6KPfk/7WV+/ee/9NJLOnz4sP785z/rrbfe0iOPPKI77rhDgwYN0rhx4zrcfsuWLfrss8/a/dC0+WHv9o6B25/tufHGG/XjH/9Y7733nn7605+2u15z4f36uWx24MCBFoUYQPsoSEAcuvHGG3X77berurpaEydOVO/evdtc78MPP9SVV16piy++WM8++6wyMzOVkJCgJUuWaNmyZY7Nk5mZedTbWp38gHL37t01bNgwDRs2TPn5+SooKNCvf/3rIxakpqYmfec739E999zT5uNnnnmm7Zndkp2drf/93//tcJ3mYx0KhVo9FgqFlJWV5cpsQLyhIAFxaMqUKfrBD36gtWvX6pVXXml3vf/8z/9UUlKS3n333RZvySxZsqTN9bds2dJq2d/+9jclJyd3+LX1r3+rris0f0D56yWhrStiknT66aeroaHhiEWqWXvHwO431OyyLEvbtm3Teeed1+F6Q4cOVY8ePRQMBnXddddFlx88eFAbN25ssQxA+yhIQBw6+eSTtWjRIm3btk1XXHFFu+t1795dPp+vxeeHtm3bphUrVrS5/po1a7Rhwwadf/75kqQdO3bozTff1IQJEzr8JYadLR92ffjhh/r2t7+thISEFsubPw80ePDg6LKTTjpJ9fX1rTKuu+46zZkzR++++67Gjx/f4rH6+nqdfPLJLX6n1IoVK/Q///M/0c8hffzxx1q3bp1mzZrl0F5JtbW1rQrnokWLVFtbqwkTJrRY/te//lXJycnKycmRJPn9fo0bN06lpaV64IEHlJKSIkl66aWX1NDQoGuvvdaxOYF45rM6e/0awHFr6dKlKiws1CeffNLq691fN3DgQA0dOlRvvfWWJOn999/X2LFjddFFF+nGG2/Ul19+qYULFyojI0OfffZZi7e3fD6fhg4dqurqav3whz9UYmKinn32WdXU1GjdunU699xzXd9P0/e+9z2tX79eV111VfTnb9iwQb/61a+UnJysYDCoQYMGSZKKioq0aNEizZs3T2eccYb69eunSy+9VPv379dFF12kzz77TDNmzNDIkSO1b98+ff7553rttde0bds2paena9u2bRo0aJCGDRumvXv36s4771RjY6Oeeuop+Xw+ff7550d8K/Gll17S9u3btX//fs2fP18FBQW69NJLJUnf//73o7/PKTk5Wddff72GDRumpKQkffTRR3r55Zc1fPhw/elPf1JycnI00+fzacyYMVq1alV02YYNGzR6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74m0rxL7G89OpH2V3NvfI105asaHtAEAAAwUJAAAAAMFqRMSExM1e/ZsJSYmxnqULnEi7e+JtK/SibW/J9K+SuxvPDuR9lU6fvY3Lj6kDQAA4CSuIAEAABgoSAAAAAYKEgAAgIGCBAAAYKAg/d3ChQs1cOBAJSUladSoUfr44487XH/58uUaMmSIkpKSNGzYMP3+97/vokmPzfz58/Wtb31LKSkp6tevnyZPnqzNmzd3uM3SpUvl8/la3JKSkrpo4qM3Z86cVnMPGTKkw228el4laeDAga321+fzqaioqM31vXZeP/jgA11xxRXKysqSz+fTihUrWjxuWZYefPBBZWZmqlevXho3bpy2bNlyxFy7r/2u0NG+Hjp0SPfee6+GDRumk046SVlZWZo2bZp27drVYebRvB66ypHO7YwZM1rNPmHChCPmeu3cSmrzNezz+fToo4+2m3m8ntvO/Htz4MABFRUVqU+fPjr55JN19dVXq6ampsPco32t20VBkvTKK6+ouLhYs2fP1oYNGzR8+HCNHz9eX375ZZvrr169WlOnTtWtt96qTz/9VJMnT9bkyZO1adOmLp7cvvLychUVFWnt2rVauXKlDh06pMsuu0z79u3rcLvU1FSFQqHobfv27V008bE555xzWsz90Ucftbuul8+rJH3yySct9nXlypWSpGuvvbbdbbx0Xvft26fhw4dr4cKFbT7+yCOP6JlnntFzzz2ndevW6aSTTtL48eN14MCBdjPtvva7Skf7un//fm3YsEEPPPCANmzYoNdff12bN2/WlVdeecRcO6+HrnSkcytJEyZMaDH7b37zmw4zvXhuJbXYx1AopMWLF8vn8+nqq6/uMPd4PLed+ffmRz/6kX73u99p+fLlKi8v165du3TVVVd1mHs0r/WjYsHKy8uzioqKovcPHz5sZWVlWfPnz29z/euuu866/PLLWywbNWqU9YMf/MDVOd3w5ZdfWpKs8vLydtdZsmSJ5ff7u24oh8yePdsaPnx4p9ePp/NqWZZ11113WaeffrrV1NTU5uNePa+WZVmSrDfeeCN6v6mpycrIyLAeffTR6LL6+norMTHR+s1vftNujt3XfiyY+9qWjz/+2JJkbd++vd117L4eYqWt/Z0+fbo1adIkWznxcm4nTZpkXXrppR2u45Vza/57U19fbyUkJFjLly+PrvOXv/zF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",
+ "image/png": "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JkzQT3/6U61fv17vvvtus+v+8z//U3Fxcfr0008bfEfSwoULm1y/bdu2Rrf99a9/VXx8fIsfWz/xU3Vt4Xvf+54kye/3h25r6oyYJF144YU6ePDgSYtUveYeg3A/oRYuy7K0Y8cOXXbZZY7+HOBsQUECzkLnnnuu5s+frx07duj6669vdl379u3l8XgavH9ox44dWr58eZPr161bp02bNunyyy+XJO3atUsffvihxowZ0+KXGLa2fITriy++0Pe//33FxMQ0uL3+/UB9+/YN3XbOOeeopqamUcatt96qmTNn6tNPP9Xo0aMb3FdTU6Nzzz23wXdKLV++XH/7299C70P68ssvtWHDBk2fPt2mvZKqqqoaFc758+erqqpKY8aMse3nAGczChJwlpo8efJJ11x33XV6/vnnNWbMGN1xxx3au3ev5s2bpz59+ujrr79utL5///4aPXq0/vVf/1WxsbGhNwyf+I3Vbenpp5/Wxo0bdeONN2rgwIGSpE2bNunNN9/Ueeed16C0DBkyRPPnz9dTTz2lPn36qFu3brrmmmv00EMP6be//a1+/OMfa8qUKRoyZIgOHTqkb775Ru+//7527NjR4Msa+/Tpo6uuukr333+/amtr9eKLL6pLly7NvkR3orfeeks7d+7U4cOHJUmrV6/WU089JUn6yU9+Evo+p549e+q2227TgAEDFBcXpzVr1mjJkiUaPHiwfvrTn9r18AFnt0h/jA6A8078mH9LmvqY/+uvv25lZWVZsbGxVr9+/ayFCxdaM2bMsMz/+ZBk5efnW0VFRaH1l112meX1eu3enVb7wx/+YOXn51v9+/e3kpKSrJiYGCszM9OaMmWKVVZW1mBtRUWFdd1111kJCQmWpAYf+T9w4IBVWFho9enTx+rYsaOVkpJiDRs2zHruueeso0ePWpb1j4/5P/vss9avfvUrKyMjw4qNjbWuvvpq67//+79bNe/w4cNDX5dgXk58HO+55x7rkksusRISEqyYmBirT58+1iOPPGIFg8HTfswA/B+PZfEVrABOn8fjUX5+vubOnRvpUSJix44d6t27t5599lk9+OCDkR4HwGnie5AAAAAMFCQAAAADBQkAAMDAe5AAAAAMnEECAAAwUJAAAAAMUfFFkXV1ddqzZ48SEhKa/XMBAADg7GZZlg4cOKD09HS1a9fyOaKoKEh79uxR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XL927VpNnDhRU6dO1VdffaXx48dr/Pjx2rJlSxtPHr6SkhLl5+dr/fr1WrlypY4dO6Zrr71Whw4danG7xMRE+f3+0GXnzp1tNPHpufTSSxvMvWbNmmbXuvm4StIf//jHBvu6cuVKSdItt9zS7DZuOq6HDh3SoEGDNG/evCbvf+aZZ/Tyyy/r1Vdf1YYNG3TOOedo9OjROnLkSLOZ4T7320pL+3r48GFt2rRJjz/+uDZt2qQPPvhAW7du1Q033HDS3HCeD23pZMdWksaMGdNg9nfeeafFTDceW0kN9tHv92vBggXyeDy66aabWsw9E49ta37f/OxnP9Pvfvc7LV26VCUlJdqzZ49uvPHGFnNP5bl+SixY2dnZVn5+fuj68ePHrfT0dGvOnDlNrr/11lut6667rsFtQ4cOtX760586OqcT9u7da0mySkpKml2zcOFCKykpqe2GssmMGTOsQYMGtXp9NB1Xy7KsBx54wLrwwguturq6Ju9363G1LMuSZC1btix0va6uzkpNTbWeffbZ0G01NTVWbGys9c477zSbE+5zPxLMfW3Kl19+aUmydu7c2eyacJ8PkdLU/k6ePNkaN25cWDnRcmzHjRtnXXPNNS2uccuxNX/f1NTUWDExMdbSpUtDa/785z9bkqx169Y1mXGqz/VTcdafQTp69Kg2btyoUaNGhW5r166dRo0apXXr1jW5zbp16xqsl6TRo0c3u/5MFggEJEnnnXdei+sOHjyonj17KiMjQ+PGjdO3337bFuOdtm3btik9PV0XXHCB7rzzTpWXlze7NpqO69GjR1VUVKS77767xT/g7Nbjatq+fbsqKioaHL+kpCQNHTq02eN3Ks/9M1UgEJDH41Hnzp1bXBfO8+FMs2rVKnXr1k19+/bV/fffr/379ze7NlqObWVlpT7++GNNnTr1pGvdcGzN3zcbN27UsWPHGhynfv36KTMzs9njdCrP9VN11hekffv26fjx4+revXuD27t3766Kioomt6moqAhr/Zmqrq5O06dP15VXXqn+/fs3u65v375asGCBPvzwQxUVFamurk7Dhg3T7t2723Da8A0dOlSLFi3SihUrNH/+fG3fvl1XX321Dhw40OT6aDmukrR8+XLV1NRoypQpza5x63FtSv0xCuf4ncpz/0x05MgRPfLII5o4cWKLf9gz3OfDmWTMmDF68803VVxcrKefflolJSUaO3asjh8/3uT6aDm2b7zxhhISEk76kpMbjm1Tv28qKirUsWPHRsX+ZL9/69e0dptT1cHWNLhKfn6+tmzZctLXqnNycpSTkxO6PmzYMF188cV67bXX9OSTTzo95ikbO3Zs6L8HDhyooUOHqmfPnnrvvfda9f/I3Oz111/X2LFjlZ6e3uwatx5X/MOxY8d06623yrIszZ8/v8W1bn4+3H777aH/HjBggAYOHKgLL7xQq1at0siRIyM4mbMWLFigO++886QfnnDDsW3t75szyVl/BiklJUXt27dv9K75yspKpaamNrlNampqWOvPRNOmTdNHH30kr9erHj16hLVtTEyMLrvsMpWWljo0nTM6d+6siy66qNm5o+G4StLOnTv12Wef6Z577glrO7ceV0mhYxTO8TuV5/6ZpL4c7dy5UytXrmzx7FFTTvZ8OJNdcMEFSklJaXZ2tx9bSfriiy+0devWsJ/H0pl3bJv7fZOamqqjR4+qpqamwfqT/f6tX9PabU7VWV+QOnbsqCFDhqi4uDh0W11dnYqLixv8v+sT5eTkNFgvSStXrmx2/ZnEsixNmzZNy5Yt0+eff67evXuHnXH8+HF98803SktLc2BC5xw8eFBlZWXNzu3m43qihQsXqlu3brruuuvC2s6tx1WSevfurdTU1AbHLxgMasOGDc0ev1N57p8p6svRtm3b9Nlnn6lLly5hZ5zs+XAm2717t/bv39/s7G4+tvVef/11DRkyRIMGDQp72zPl2J7s982QIUMUExPT4Dht3bpV5eXlzR6nU3mun84OnPWWLFlixcbGWosWLbL+9Kc/Wffdd5/VuXNnq6KiwrIsy/rJT35iPfroo6H1f/jDH6wOHTpYzz33nPXnP//ZmjFjhhUTE2N98803kdqFVrv//vutpKQka9WqVZbf7w9dDh8+HFpj7u+sWbOsTz/91CorK7M2btxo3X777VZcXJz17bffRmIXWu3nP/+5tWrVKmv79u3WH/7wB2vUqFFWSkqKtXfvXsuyouu41jt+/LiVmZlpPfLII43uc/txPXDggPXVV19ZX331lSXJev75562vvvoq9MmtX/7yl1bnzp2tDz/80Pr666+tcePGWb1797a+++67UMY111xj/frXvw5dP9lzP1Ja2tejR49aN9xwg9WjRw9r8+bNDZ7HtbW1oQxzX0/2fIiklvb3wIED1oMPPmitW7fO2r59u/XZZ59Zl19+uZWVlWUdOXIklBENx7ZeIBCw4uPjrfnz5zeZ4ZZj25rfN//0T/9kZWZmWp9//rnl8/msnJwcKycnp0FO3759rQ8++CB0vTXPdTtQkP7u17/+tZWZmWl17NjRys7OttavXx+6b/jw4dbkyZMbrH/vvfesiy66yOrYsaN16aWXWh9//HEbT3xqJDV5WbhwYWiNub/Tp08PPTbdu3e3fvSjH1mbNm1q++HDdNttt1lpaWlWx44drfPPP9+67bbbrNLS0tD90XRc63366aeWJGvr1q2N7nP7cfV6vU3+263fp7q6Ouvxxx+3unfvbsXGxlojR45s9Dj07NnTmjFjRoPbWnruR0pL+7p9+/Zmn8derzeUYe7ryZ4PkdTS/h4+fNi69tprra5du1oxMTFWz549rXvvvbdR0YmGY1vvtddeszp16mTV1NQ0meGWY9ua3zffffed9c///M9WcnKyFR8fb02YMMHy+/2Nck7cpjXPdTt4/v7DAQAA8Hdn/XuQAAAATBQkAAAAAwUJAADAQEECAAAwUJAAAAAMFCQAAAADBQkAAMBAQQIAADBQkAAAAAwUJAAAAAMFCQAAwEBBAgAAMPx/Af75AVCE8EkAAAAASUVORK5CYII=",
"text/plain": [
""
]
@@ -315,13 +315,21 @@
],
"metadata": {
"kernelspec": {
- "display_name": "Python 3",
+ "display_name": ".venv (3.12.3)",
"language": "python",
"name": "python3"
},
"language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
"name": "python",
- "version": "3.10.0"
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.12.3"
}
},
"nbformat": 4,
diff --git a/examples/streamlit/ca_all.py b/examples/streamlit/ca_all.py
index b2e994b..29486f4 100644
--- a/examples/streamlit/ca_all.py
+++ b/examples/streamlit/ca_all.py
@@ -25,7 +25,7 @@
import dissmodel.models.ca as ca_models
from dissmodel.core import Environment
from dissmodel.geo import CellularAutomaton, vector_grid
-from dissmodel.visualization.map import Map
+from dissmodel.visualization import Map
from dissmodel.visualization.widgets import display_inputs
# ---------------------------------------------------------------------------
diff --git a/examples/streamlit/ca_fire_model.py b/examples/streamlit/ca_fire_model.py
index a038e2e..ad12f57 100644
--- a/examples/streamlit/ca_fire_model.py
+++ b/examples/streamlit/ca_fire_model.py
@@ -23,7 +23,7 @@
from dissmodel.models.ca import FireModel
from dissmodel.models.ca.fire_model import FireState
from dissmodel.visualization import display_inputs
-from dissmodel.visualization.map import Map
+from dissmodel.visualization import Map
# ---------------------------------------------------------------------------
# Page config
diff --git a/examples/streamlit/ca_fire_model_prob.py b/examples/streamlit/ca_fire_model_prob.py
index 2ab97f0..d9fc93b 100644
--- a/examples/streamlit/ca_fire_model_prob.py
+++ b/examples/streamlit/ca_fire_model_prob.py
@@ -26,7 +26,7 @@
from dissmodel.models.ca import FireModelProb
from dissmodel.models.ca.fire_model import FireState
from dissmodel.visualization import display_inputs
-from dissmodel.visualization.map import Map
+from dissmodel.visualization import Map
# ---------------------------------------------------------------------------
# Page config
diff --git a/examples/streamlit/ca_game_of_life.py b/examples/streamlit/ca_game_of_life.py
index 92c7453..f152771 100644
--- a/examples/streamlit/ca_game_of_life.py
+++ b/examples/streamlit/ca_game_of_life.py
@@ -22,7 +22,7 @@
from dissmodel.geo import vector_grid
from dissmodel.models.ca import GameOfLife
from dissmodel.models.ca.game_of_life import PATTERNS
-from dissmodel.visualization.map import Map
+from dissmodel.visualization import Map
# ---------------------------------------------------------------------------
# Page config
diff --git a/examples/streamlit/ca_snow.py b/examples/streamlit/ca_snow.py
index d5d5306..3b9d367 100644
--- a/examples/streamlit/ca_snow.py
+++ b/examples/streamlit/ca_snow.py
@@ -25,7 +25,7 @@
from dissmodel.core import Environment
from dissmodel.geo import vector_grid
from dissmodel.models.ca.snow import Snow, SnowState
-from dissmodel.visualization.map import Map
+from dissmodel.visualization import Map
from dissmodel.visualization.widgets import display_inputs
# ---------------------------------------------------------------------------
diff --git a/mkdocs.yml b/mkdocs.yml
index c65c9da..5d0a115 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -8,6 +8,7 @@ plugins:
- mkdocstrings:
handlers:
python:
+ paths: ["."]
options:
docstring_style: numpy
show_source: true
diff --git a/pyproject.toml b/pyproject.toml
index 90ece03..35a1d2f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "dissmodel"
-version = "0.2.1"
+version = "0.3.0"
description = "Discrete Spatial Modeling framework for raster and vector simulations"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.10"
diff --git a/shapefile_to_raster.md b/shapefile_to_raster.md
new file mode 100644
index 0000000..6bdc781
--- /dev/null
+++ b/shapefile_to_raster.md
@@ -0,0 +1,141 @@
+# Loading Shapefiles into the Raster Substrate
+
+DisSModel can load any vector file (Shapefile, GeoJSON, GeoPackage) and
+convert it directly into a `RasterBackend`, making it possible to run
+high-performance raster models on real geographic data without any
+intermediate GIS steps.
+
+## How it works
+
+```
+Shapefile → GeoDataFrame → rasterize → NumPy arrays → RasterBackend → raster model
+```
+
+The rasterization step (powered by `rasterio.features.rasterize`) is fast
+and happens once — after that, the model runs entirely in NumPy at full
+vectorized speed.
+
+!!! note "Grid regularity"
+ This workflow is most accurate when the input shapefile already contains
+ a **regular grid** of equal-area polygons (e.g. 100×100m cells), which is
+ the typical output of spatial homogenization tools. For irregular polygons
+ (municipalities, watersheds), cell values are burned by centroid or by
+ touch — inspect the result before running long simulations.
+
+---
+
+## `shapefile_to_raster_backend`
+
+```python
+from dissmodel.geo.raster.io import shapefile_to_raster_backend
+
+b = shapefile_to_raster_backend(
+ path = "data/mangue_grid.shp",
+ resolution = 100, # 100m cells
+ attrs = ["uso", "alt", "solo"],
+ crs = "EPSG:31984", # reproject if needed
+)
+
+print(b.shape) # (rows, cols) derived from bounding box + resolution
+print(b.get("uso").dtype) # int32
+```
+
+### Parameters
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `path` | `str` or `Path` | Path to the vector file |
+| `resolution` | `float` | Cell size in CRS units (metres for metric CRS) |
+| `attrs` | `list[str]` or `dict[str, default]` | Columns to rasterize |
+| `crs` | `str`, `int`, or `None` | Target CRS — reprojects if needed |
+| `all_touched` | `bool` | Burn all touched cells (default: centre only) |
+| `nodata` | `int` or `float` | Fill value for uncovered cells (default: `0`) |
+
+To set per-column defaults for uncovered cells, pass a dict:
+
+```python
+b = shapefile_to_raster_backend(
+ path = "data/mangue_grid.shp",
+ resolution = 100,
+ attrs = {"uso": 5, "alt": 0.0, "solo": 1},
+ crs = "EPSG:31984",
+)
+```
+
+---
+
+## Full example — flood model from shapefile
+
+```python
+from dissmodel.core import Environment
+from dissmodel.geo.raster.io import shapefile_to_raster_backend
+from dissmodel.visualization.raster_map import RasterMap
+
+# from coastal_dynamics.raster import FloodRasterModel
+# (or your own RasterModel subclass)
+from myproject.flood import FloodRasterModel
+
+# 1. load shapefile → RasterBackend
+b = shapefile_to_raster_backend(
+ path = "data/mangue_grid.shp",
+ resolution = 100,
+ attrs = {"uso": 5, "alt": 0.0, "solo": 1},
+ crs = "EPSG:31984",
+)
+
+print(f"Grid: {b.shape[0]} rows × {b.shape[1]} cols = {b.shape[0]*b.shape[1]:,} cells")
+
+# 2. run the raster model
+env = Environment(start_time=2012, end_time=2100)
+FloodRasterModel(backend=b, taxa=0.011)
+RasterMap(backend=b, band="uso", title="Land Use")
+env.run()
+```
+
+---
+
+## Saving results back to GeoTIFF
+
+```python
+from dissmodel.geo.raster.io import save_raster_backend
+
+save_raster_backend(
+ backend = b,
+ path = "output/flood_result.tif",
+ bands = ["uso", "alt"],
+ crs = "EPSG:31984",
+ transform = transform, # rasterio Affine from original bounds
+)
+```
+
+---
+
+## Comparison: vector vs raster workflow from the same shapefile
+
+```python
+# ── vector workflow ───────────────────────────────────────────────────────────
+import geopandas as gpd
+from dissmodel.geo.vector.model import SpatialModel
+
+gdf = gpd.read_file("data/mangue_grid.shp").to_crs("EPSG:31984")
+# → GeoDataFrame with real geometries, Queen neighbourhood, ~2 min/step @ 94k cells
+
+# ── raster workflow ───────────────────────────────────────────────────────────
+from dissmodel.geo.raster.io import shapefile_to_raster_backend
+
+b = shapefile_to_raster_backend(
+ "data/mangue_grid.shp", resolution=100,
+ attrs=["uso", "alt", "solo"], crs="EPSG:31984"
+)
+# → RasterBackend, same data, ~8 ms/step @ 94k cells (≈ 4,500× faster)
+```
+
+The data source is the same shapefile. The only difference is the substrate.
+
+---
+
+## API Reference
+
+::: dissmodel.geo.raster.io.shapefile_to_raster_backend
+
+::: dissmodel.geo.raster.io.save_raster_backend
diff --git a/tests/raster/test_raster_map.py b/tests/raster/test_raster_map.py
index febcfc9..e8120ef 100644
--- a/tests/raster/test_raster_map.py
+++ b/tests/raster/test_raster_map.py
@@ -43,18 +43,18 @@ class TestInstantiation:
def test_categorical_mode(self, backend):
env = Environment(start_time=1, end_time=1)
color_map = {i: f"#{i:02x}{i:02x}{i:02x}" for i in range(1, 10)}
- m = RasterMap(backend=backend, band="uso", color_map=color_map)
+ m = RasterMap(backend=backend, band="uso", save_frames=True, color_map=color_map)
assert m is not None
def test_continuous_mode(self, backend):
env = Environment(start_time=1, end_time=1)
- m = RasterMap(backend=backend, band="alt", cmap="terrain",
+ m = RasterMap(backend=backend, band="alt", save_frames=True, cmap="terrain",
vmin=0.0, vmax=1.0)
assert m is not None
def test_default_band(self, backend):
env = Environment(start_time=1, end_time=1)
- m = RasterMap(backend=backend, band="uso")
+ m = RasterMap(backend=backend, save_frames=True, band="uso")
assert m.band == "uso"
@@ -68,12 +68,12 @@ def test_runs_without_display(self, backend):
"""RasterMap must not raise when no display is available."""
env = Environment(start_time=1, end_time=3)
color_map = {i: "blue" for i in range(1, 10)}
- RasterMap(backend=backend, band="uso", color_map=color_map)
+ RasterMap(backend=backend, band="uso", save_frames=True,color_map=color_map)
env.run() # must complete without error
def test_continuous_runs_without_display(self, backend):
env = Environment(start_time=1, end_time=3)
- RasterMap(backend=backend, band="alt", cmap="viridis",
+ RasterMap(backend=backend, band="alt",save_frames=True, cmap="viridis",
vmin=0.0, vmax=1.0)
env.run()
@@ -94,7 +94,7 @@ def execute(self): self.backend.arrays["uso"] += 1
env = Environment(start_time=1, end_time=3)
IncModel(backend=backend)
- RasterMap(backend=backend, band="uso")
+ RasterMap(backend=backend,save_frames=True, band="uso")
env.run()
# model ran 3 steps — original values 1..9, now 4..12
@@ -104,6 +104,6 @@ def test_map_does_not_mutate_backend(self, backend):
"""RasterMap must only read arrays, never write to them."""
original = backend.get("uso").copy()
env = Environment(start_time=1, end_time=3)
- RasterMap(backend=backend, band="uso")
+ RasterMap(backend=backend,save_frames=True, band="uso")
env.run()
np.testing.assert_array_equal(backend.get("uso"), original)