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/zFkmStWbOmzYyjfa0fjRP+CtLBgwe1fv16jRs3LrqsW7duGjdunNasWdPmNmvWrGmxviSNHz++3fWPZ+FwWJL0jW98o8P1GhoaNGDAAGVnZ2vSpEn64osvumK8Y7ZlyxZlZWXptNNO00033aSqqqp2142n83rw4EGVlpbqlltu6fAPOHv1vJq2bt2q6urqFufP7/dr1KhR7Z6/o3ntH6/C4bB8Pp969+7d4Xp2Xg/Hm1WrVqlfv34aPHiw7rzzTu3Zs6fddePl3NbU1Ojtt9/WrbfeesR1vXBuzX9v1q9fr0OHDrU4T0OGDFFOTk675+loXutH64QvSLt379bhw4fVv3//Fsv79++v6urqNreprq62tf7xqqmpSbNmzdIFF1ygoUOHtrve4MGDtXjxYr355psqLS1VU1OTRo8erZ07d3bhtPaNGjVKS5cu1TvvvKNFixZp69atuuiii7R3794214+X8ypJK1asUH19vWbMmNHuOl49r21pPkd2zt/RvPaPRwcOHNC9996rqVOndviHPe2+Ho4nEyZM0K9+9SuVlZXp5z//ucrLyzVx4kQdPny4zfXj5dy++OKLSklJOeJbTl44t239e1NdXa2ePXu2KvZH+ve3eZ3ObnO0ejiaBk8pKirSpk2bjvhedX5+vvLz86P3R48erbPOOkvPP/+8HnroIbfHPGoTJ06M/ve5556rUaNGacCAAXr11Vc79f/IvOyFF17QxIkTlZWV1e46Xj2v+IdDhw7puuuuk2VZWrRoUYfrevn1cMMNN0T/e9iwYTr33HN1+umna9WqVRo7dmwMJ3PX4sWLddNNNx3xyxNeOLed/ffmeHLCX0FKT09X9+7dW31qvqamRhkZGW1uk5GRYWv949HMmTP11ltvKRAI6NRTT7W1bUJCgs477zxVVFS4NJ07evfurTPPPLPduePhvErS9u3b9d577+m2226ztZ1Xz6uk6Dmyc/6O5rV/PGkuR9u3b9fKlSs7vHrUliO9Ho5np512mtLT09ud3evnVpI+/PBDbd682fbrWDr+zm17/95kZGTo4MGDqq+vb7H+kf79bV6ns9scrRO+IPXs2VMjR45UWVlZdFlTU5PKyspa/L/rr8vPz2+xviStXLmy3fWPJ5ZlaebMmXrjjTf0/vvva9CgQbYzDh8+rM8//1yZmZkuTOiehoYGVVZWtju3l8/r1y1ZskT9+vXT5Zdfbms7r55XSRo0aJAyMjJanL9IJKJ169a1e/6O5rV/vGguR1u2bNF7772nPn362M440uvheLZz507t2bOn3dm9fG6bvfDCCxo5cqSGDx9ue9vj5dwe6d+bkSNHKiEhocV52rx5s6qqqto9T0fzWj+WHTjhvfzyy1ZiYqK1dOlS689//rN1xx13WL1797aqq6sty7Ks73//+9ZPf/rT6Pp/+tOfrB49eliPPfaY9Ze//MWaPXu2lZCQYH3++eex2oVOu/POOy2/32+tWrXKCoVC0dv+/fuj65j7O3fuXOvdd9+1KisrrfXr11s33HCDlZSUZH3xxRex2IVO+/GPf2ytWrXK2rp1q/WnP/3JGjdunJWenm59+eWXlmXF13ltdvjwYSsnJ8e69957Wz3m9fO6d+9e69NPP7U+/fRTS5L1xBNPWJ9++mn0m1s/+9nPrN69e1tvvvmm9dlnn1mTJk2yBg0aZH311VfRjEsvvdT6xS9+Eb1/pNd+rHS0rwcPHrSuvPJK69RTT7U2btzY4nXc2NgYzTD39Uivh1jqaH/37t1r3X333daaNWusrVu3Wu+99551/vnnW7m5udaBAweiGfFwbpuFw2ErOTnZWrRoUZsZXjm3nfn35p/+6Z+snJwc6/3337eCwaCVn59v5efnt8gZPHiw9frrr0fvd+a17gQK0t/94he/sHJycqyePXtaeXl51tq1a6OPjRkzxpo+fXqL9V999VXrzDPPtHr27Gmdc8451ttvv93FEx8dSW3elixZEl3H3N9Zs2ZFj03//v2t7373u9aGDRu6fnibrr/+eiszM9Pq2bOndcopp1jXX3+9VVFREX08ns5rs3fffdeSZG3evLnVY14/r4FAoM3nbvM+NTU1WQ888IDVv39/KzEx0Ro7dmyr4zBgwABr9uzZLZZ19NqPlY72devWre2+jgOBQDTD3NcjvR5iqaP93b9/v3XZZZdZffv2tRISEqwBAwZYt99+e6uiEw/nttnzzz9v9erVy6qvr28zwyvntjP/3nz11VfWP//zP1tpaWlWcnKyNWXKFCsUCrXK+fo2nXmtO8H39x8OAACAvzvhP4MEAABgoiABAAAYKEgAAAAGChIAAICBggQAAGCgIAEAABgoSAAAAAYKEgAAgIGCBAAAYKAgAQAAGChIAAAABgoSAACA4f8DVq9hQV/uhSYAAAAASUVORK5CYII=", + "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)