From a16afbfa1d7c6000c1360549ed506183b0f19f2b Mon Sep 17 00:00:00 2001 From: Sergio Souza Costa Date: Tue, 10 Mar 2026 09:21:41 -0300 Subject: [PATCH 01/12] refactor: extract SpatialModel base class from CellularAutomaton --- dissmodel/geo/celullar_automaton.py | 145 ++++---------------- dissmodel/geo/spatial_model.py | 199 ++++++++++++++++++++++++++++ 2 files changed, 222 insertions(+), 122 deletions(-) create mode 100644 dissmodel/geo/spatial_model.py diff --git a/dissmodel/geo/celullar_automaton.py b/dissmodel/geo/celullar_automaton.py index 18b253b..fd89a6c 100644 --- a/dissmodel/geo/celullar_automaton.py +++ b/dissmodel/geo/celullar_automaton.py @@ -1,27 +1,34 @@ +""" +dissmodel/geo/cellular_automaton.py +===================================== +Base class for spatial cellular automata backed by a GeoDataFrame. + +Extends :class:`~dissmodel.geo.spatial_model.SpatialModel` with a +cell-by-cell transition rule loop. + +For source-oriented (push) models that cannot use rule(idx), inherit +:class:`~dissmodel.geo.spatial_model.SpatialModel` directly and implement +execute() freely. +""" from __future__ import annotations import math -from typing import Any, Optional, Type +from abc import ABC, abstractmethod +from typing import Any, Optional -import numpy as np import geopandas as gpd -from libpysal.weights import Queen, W - -from dissmodel.core import Model -from dissmodel.geo import attach_neighbors - +from libpysal.weights import Queen +from dissmodel.geo.spatial_model import SpatialModel from dissmodel.geo.neighborhood import StrategyType -from abc import ABC, abstractmethod - -class CellularAutomaton(Model, ABC): +class CellularAutomaton(SpatialModel, ABC): """ Base class for spatial cellular automata backed by a GeoDataFrame. - Extends :class:`~dissmodel.core.Model` with neighborhood management and - a cell-by-cell transition rule loop. + Extends :class:`~dissmodel.geo.spatial_model.SpatialModel` with + neighborhood management and a cell-by-cell transition rule loop. Parameters ---------- @@ -40,7 +47,8 @@ class CellularAutomaton(Model, ABC): dim : tuple of int, optional Grid dimensions as ``(n_cols, n_rows)``, by default ``None``. **kwargs : - Extra keyword arguments forwarded to :class:`~dissmodel.core.Model`. + Extra keyword arguments forwarded to + :class:`~dissmodel.geo.spatial_model.SpatialModel`. Examples -------- @@ -60,12 +68,10 @@ def __init__( dim: Optional[int] = None, **kwargs: Any, ) -> None: - self.gdf = gdf self.state_attr = state_attr - self._neighborhood_created: bool = False - self._neighs_cache: dict[Any, list[Any]] = {} - self.dim = dim + self.dim = dim super().__init__( + gdf=gdf, step=step, start_time=start_time, end_time=end_time, @@ -81,111 +87,6 @@ def initialize(self) -> None: """ pass - def create_neighborhood( - self, - strategy: StrategyType = Queen, - neighbors_dict: Optional[dict[Any, list[Any]] | str] = None, - **kwargs: Any, - ) -> None: - """ - Build and attach the neighborhood structure to the GeoDataFrame. - - Parameters - ---------- - strategy : type, optional - Libpysal weight class (e.g. ``Queen``, ``Rook``), - by default ``Queen``. - neighbors_dict : dict or str, optional - Precomputed ``{id: [neighbor_ids]}`` mapping or a path to a JSON - file with the same structure. If provided, ``strategy`` is ignored. - **kwargs : - Extra keyword arguments forwarded to the strategy. - """ - self.gdf = attach_neighbors( - gdf=self.gdf, - strategy=strategy, - neighbors_dict=neighbors_dict, - **kwargs, - ) - self._neighborhood_created = True - self._neighs_cache = self.gdf["_neighs"].to_dict() - - def neighs_id(self, idx: Any) -> list[Any]: - """ - Return the neighbor indices for cell ``idx``. - - Parameters - ---------- - idx : any - Index of the cell in the GeoDataFrame. - - Returns - ------- - list - List of neighbor indices. - """ - if self._neighs_cache: - return self._neighs_cache.get(idx, []) - return self.gdf.loc[idx, "_neighs"] - - def neighs(self, idx: Any) -> gpd.GeoDataFrame: - """ - Return the neighboring cells of ``idx`` as a GeoDataFrame. - - Parameters - ---------- - idx : any - Index of the cell in the GeoDataFrame. - - Returns - ------- - geopandas.GeoDataFrame - GeoDataFrame containing the neighboring rows. - - Raises - ------ - RuntimeError - If the neighborhood has not been created yet. - ValueError - If the ``_neighs`` column is missing from the GeoDataFrame. - - Notes - ----- - Returns a GeoDataFrame slice, which involves Pandas overhead. - For performance-critical rule evaluation inside simulation loops, - prefer :meth:`neighbor_values` which returns a NumPy array directly. - - """ - if not self._neighborhood_created: - raise RuntimeError( - "Neighborhood has not been created yet. " - "Call `.create_neighborhood()` first." - ) - if "_neighs" not in self.gdf.columns: - raise ValueError("Column '_neighs' is not present in the GeoDataFrame.") - - return self.gdf.loc[self.neighs_id(idx)] - - def neighbor_values(self, idx: Any, col: str) -> np.ndarray: - """ - Return the values of ``col`` for all neighbors of cell ``idx``. - - Faster than ``neighs(idx)[col]`` because it skips geometry overhead. - - Parameters - ---------- - idx : any - Index of the cell in the GeoDataFrame. - col : str - Column name to retrieve. - - Returns - ------- - numpy.ndarray - Array of neighbor values. - """ - return self.gdf.loc[self.neighs_id(idx), col].values - @abstractmethod def rule(self, idx: Any) -> Any: """ diff --git a/dissmodel/geo/spatial_model.py b/dissmodel/geo/spatial_model.py new file mode 100644 index 0000000..9d2e225 --- /dev/null +++ b/dissmodel/geo/spatial_model.py @@ -0,0 +1,199 @@ +""" +dissmodel/geo/spatial_model.py +================================ +Classe base para modelos com suporte a GeoDataFrame e vizinhança. + +Responsabilidade +---------------- +Prover infraestrutura espacial — gdf, criação de vizinhança, acesso a +vizinhos — sem impor nenhum contrato de regra de transição. + +Hierarquia +---------- + Model (dissmodel.core) + └── SpatialModel ← este arquivo + ├── CellularAutomaton gdf + rule(idx) por célula (pull) + └── (modelos livres) gdf + execute() orientado a fonte (push) + +Por que separar +--------------- +CellularAutomaton.rule(idx) assume que cada célula calcula seu próprio +novo estado de forma independente (modelo pull). Modelos orientados a +FONTE — como o Hidro do BR-MANGUE — modificam vizinhos a partir da +fonte (modelo push). Eles precisam da infraestrutura espacial mas não +podem usar o contrato de rule(). + +SpatialModel fornece: + - self.gdf GeoDataFrame compartilhado + - create_neighborhood() constrói _neighs via libpysal ou dict + - neighs_id(idx) lista de índices vizinhos (cache) + - neighs(idx) GeoDataFrame dos vizinhos + - neighbor_values(idx, col) array numpy dos valores vizinhos + +Uso +--- + class MeuModelo(SpatialModel): + def __init__(self, gdf, meu_param=1.0, **kwargs): + super().__init__(gdf, **kwargs) + self.meu_param = meu_param + self.create_neighborhood() + + def execute(self): + nivel = self.env.now() * self.meu_param + # lógica livre — orientada a fonte, por grupo, etc. +""" +from __future__ import annotations + +import math +from typing import Any, Optional + +import numpy as np +import geopandas as gpd +from libpysal.weights import Queen + +from dissmodel.core import Model +from dissmodel.geo import attach_neighbors +from dissmodel.geo.neighborhood import StrategyType + + +class SpatialModel(Model): + """ + Model com suporte a GeoDataFrame e vizinhança. + + Subclasse de Model que acrescenta infraestrutura espacial sem impor + contrato de regra de transição. Pode ser herdada diretamente por + modelos com execute() livre (push/fonte) ou indiretamente via + CellularAutomaton (pull/rule). + + Parameters + ---------- + gdf : geopandas.GeoDataFrame + Grade ou polígonos da simulação. + step : float, optional + Time increment per execution step, by default 1. + start_time : float, optional + Simulation start time, by default 0. + end_time : float, optional + Simulation end time, by default ``math.inf``. + name : str, optional + Optional model name, by default ``""``. + **kwargs : + Extra keyword arguments forwarded to :class:`~dissmodel.core.Model`. + """ + + def __init__( + self, + gdf: gpd.GeoDataFrame, + step: float = 1, + start_time: float = 0, + end_time: float = math.inf, + name: str = "", + **kwargs: Any, + ) -> None: + self.gdf: gpd.GeoDataFrame = gdf + self._neighborhood_created: bool = False + self._neighs_cache: dict = {} + super().__init__( + step=step, + start_time=start_time, + end_time=end_time, + name=name, + **kwargs, + ) + + # ── vizinhança ──────────────────────────────────────────────────────────── + + def create_neighborhood( + self, + strategy: StrategyType = Queen, + neighbors_dict: Optional[dict | str] = None, + **kwargs: Any, + ) -> None: + """ + Constrói e anexa a estrutura de vizinhança ao GeoDataFrame. + + Popula ``gdf["_neighs"]`` com a lista de índices vizinhos por célula. + + Parameters + ---------- + strategy : type, optional + Libpysal weight class (e.g. ``Queen``, ``Rook``), + by default ``Queen``. + neighbors_dict : dict or str, optional + Precomputed ``{id: [neighbor_ids]}`` mapping or a path to a JSON + file with the same structure. If provided, ``strategy`` is ignored. + **kwargs : + Extra keyword arguments forwarded to the strategy. + """ + self.gdf = attach_neighbors( + gdf=self.gdf, + strategy=strategy, + neighbors_dict=neighbors_dict, + **kwargs, + ) + self._neighborhood_created = True + self._neighs_cache = self.gdf["_neighs"].to_dict() + + def neighs_id(self, idx: Any) -> list[Any]: + """ + Return the neighbor indices for cell ``idx``. + + Parameters + ---------- + idx : any + Index of the cell in the GeoDataFrame. + + Returns + ------- + list + List of neighbor indices. + """ + if self._neighs_cache: + return self._neighs_cache.get(idx, []) + return self.gdf.loc[idx, "_neighs"] + + def neighs(self, idx: Any) -> gpd.GeoDataFrame: + """ + Return the neighboring cells of ``idx`` as a GeoDataFrame. + + Parameters + ---------- + idx : any + Index of the cell in the GeoDataFrame. + + Returns + ------- + geopandas.GeoDataFrame + GeoDataFrame containing the neighboring rows. + + Raises + ------ + RuntimeError + If the neighborhood has not been created yet. + """ + if not self._neighborhood_created: + raise RuntimeError( + "Neighborhood has not been created yet. " + "Call `.create_neighborhood()` first." + ) + return self.gdf.loc[self.neighs_id(idx)] + + def neighbor_values(self, idx: Any, col: str) -> np.ndarray: + """ + Return the values of ``col`` for all neighbors of cell ``idx``. + + Faster than ``neighs(idx)[col]`` because it skips geometry overhead. + + Parameters + ---------- + idx : any + Index of the cell in the GeoDataFrame. + col : str + Column name to retrieve. + + Returns + ------- + numpy.ndarray + Array of neighbor values. + """ + return self.gdf.loc[self.neighs_id(idx), col].values From 453b7cad6990aaccf15671b7d8a88b96b582f637 Mon Sep 17 00:00:00 2001 From: Sergio Souza Costa Date: Tue, 10 Mar 2026 09:32:51 -0300 Subject: [PATCH 02/12] feat: add RasterBackend, RasterModel and RasterMap --- dissmodel/geo/raster_backend.py | 177 +++++++++++++++ dissmodel/geo/raster_model.py | 62 ++++++ dissmodel/visualization/raster_map.py | 255 ++++++++++++++++++++++ tests/geo/test_raster.py | 302 ++++++++++++++++++++++++++ 4 files changed, 796 insertions(+) create mode 100644 dissmodel/geo/raster_backend.py create mode 100644 dissmodel/geo/raster_model.py create mode 100644 dissmodel/visualization/raster_map.py create mode 100644 tests/geo/test_raster.py diff --git a/dissmodel/geo/raster_backend.py b/dissmodel/geo/raster_backend.py new file mode 100644 index 0000000..4788da3 --- /dev/null +++ b/dissmodel/geo/raster_backend.py @@ -0,0 +1,177 @@ +""" +dissmodel/geo/raster_backend.py +================================ +Motor vetorizado de autômatos celulares sobre grades raster (NumPy 2D). + +Responsabilidade +---------------- +Prover operações espaciais genéricas (shift, dilate, focal_sum, snapshot) +sem qualquer conhecimento de domínio — sem classes de uso do solo, sem CRS, +sem I/O, sem constantes de projeto. + +Os modelos de domínio (FloodRasterModel, MangueRasterModel, …) importam +RasterBackend e operam sobre arrays nomeados armazenados em self.arrays. + +Exemplo mínimo +-------------- + from dissmodel.geo.raster_backend import RasterBackend, DIRS_MOORE + + b = RasterBackend(shape=(100, 100)) + b.set("estado", np.zeros((100, 100), dtype=np.int8)) + + estado = b.get("estado").copy() # equivale a celula.past[attr] + vizinhos = b.neighbor_contact(estado == 1) + for dr, dc in DIRS_MOORE: + viz = RasterBackend.shift2d(estado, dr, dc) + ... + b.arrays["estado"] = estado_novo +""" +from __future__ import annotations + +from typing import Any +import numpy as np +from scipy.ndimage import binary_dilation + + +# Vizinhança de Moore (8 direções) — constante de framework, não de domínio. +# Os modelos importam daqui; projetos não precisam redefinir. +DIRS_MOORE: list[tuple[int, int]] = [ + (-1, -1), (-1, 0), (-1, 1), + ( 0, -1), ( 0, 1), + ( 1, -1), ( 1, 0), ( 1, 1), +] + +# Vizinhança de Von Neumann (4 direções) — disponível para modelos que a usem. +DIRS_VON_NEUMANN: list[tuple[int, int]] = [ + (-1, 0), (0, -1), (0, 1), (1, 0), +] + + +class RasterBackend: + """ + Armazenamento e operações vetorizadas para grades raster 2D. + + Substitui forEachCell / forEachNeighbor do TerraME em código NumPy puro. + O backend é compartilhado entre múltiplos modelos no mesmo Environment — + cada modelo lê e escreve nos arrays nomeados a cada passo. + + Arrays + ------ + Armazenados em self.arrays como np.ndarray de shape (rows, cols). + Nenhum nome é reservado — os modelos de domínio definem os nomes + ("uso", "alt", "solo", "estado", "temperatura", …). + + Parâmetros + ---------- + shape : (rows, cols) da grade. + """ + + def __init__(self, shape: tuple[int, int]) -> None: + self.shape = shape # (rows, cols) + self.arrays: dict[str, np.ndarray] = {} + + # ── leitura / escrita ───────────────────────────────────────────────────── + + def set(self, name: str, array: np.ndarray) -> None: + """Armazena cópia do array com o nome dado.""" + self.arrays[name] = np.asarray(array).copy() + + def get(self, name: str) -> np.ndarray: + """Retorna referência direta ao array (use .copy() para .past).""" + return self.arrays[name] + + def snapshot(self) -> dict[str, np.ndarray]: + """ + Cópia profunda de todos os arrays — equivale ao mecanismo .past do TerraME. + + Uso típico: + past = backend.snapshot() + uso_past = past["uso"] # estado no início do passo + """ + return {k: v.copy() for k, v in self.arrays.items()} + + # ── operações espaciais ─────────────────────────────────────────────────── + + @staticmethod + def shift2d(arr: np.ndarray, dr: int, dc: int) -> np.ndarray: + """ + Desloca o array por (dr, dc) linhas/colunas sem wrap-around. + Bordas preenchidas com zero. + + Equivale a acessar o vizinho na direção (dr, dc) para cada célula + simultaneamente — substitui forEachNeighbor com uma operação vetorial. + + Exemplo: + shift2d(alt, -1, 0) → altitude do vizinho ao norte de cada célula + shift2d(alt, 1, 1) → altitude do vizinho ao sudeste + """ + rows, cols = arr.shape + out = np.zeros_like(arr) + rs = slice(max(0, -dr), min(rows, rows - dr)) + rd = slice(max(0, dr), min(rows, rows + dr)) + cs_ = slice(max(0, -dc), min(cols, cols - dc)) + cd = slice(max(0, dc), min(cols, cols + dc)) + out[rd, cd] = arr[rs, cs_] + return out + + @staticmethod + def neighbor_contact( + condition: np.ndarray, + neighborhood: list[tuple[int, int]] | None = None, + ) -> np.ndarray: + """ + Retorna máscara booleana onde a célula tem pelo menos um vizinho + satisfazendo condition. + + neighborhood=None usa DIRS_MOORE (3×3 incluindo a própria célula via + binary_dilation). Para vizinhança Von Neumann, passe DIRS_VON_NEUMANN + ou construa uma estrutura personalizada. + + Equivale a forEachNeighbor verificando pertencimento a um conjunto. + """ + if neighborhood is None: + return binary_dilation(condition.astype(bool), structure=np.ones((3, 3))) + # vizinhança customizada via shift manual + result = np.zeros_like(condition, dtype=bool) + for dr, dc in neighborhood: + result |= RasterBackend.shift2d(condition.astype(np.int8), dr, dc) > 0 + return result + + def focal_sum(self, name: str, neighborhood: list[tuple[int, int]] = DIRS_MOORE) -> np.ndarray: + """ + Soma focal: para cada célula, soma os valores do array nos vizinhos. + Não inclui a própria célula. + + Útil para contar vizinhos em determinado estado, calcular gradientes, etc. + + Exemplo: + n_vizinhos_agua = backend.focal_sum_mask("uso", condicao_agua) + """ + arr = self.arrays[name] + result = np.zeros_like(arr, dtype=float) + for dr, dc in neighborhood: + result += self.shift2d(arr, dr, dc) + return result + + def focal_sum_mask( + self, + mask: np.ndarray, + neighborhood: list[tuple[int, int]] = DIRS_MOORE, + ) -> np.ndarray: + """ + Conta vizinhos onde mask é True. + Retorna array int com contagem por célula. + """ + result = np.zeros(self.shape, dtype=int) + m = mask.astype(np.int8) + for dr, dc in neighborhood: + result += self.shift2d(m, dr, dc) + return result + + # ── utilitários ─────────────────────────────────────────────────────────── + + 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}])" diff --git a/dissmodel/geo/raster_model.py b/dissmodel/geo/raster_model.py new file mode 100644 index 0000000..16e9ceb --- /dev/null +++ b/dissmodel/geo/raster_model.py @@ -0,0 +1,62 @@ +""" +dissmodel/geo/raster_model.py +============================== +Classe base para modelos com suporte a RasterBackend (NumPy 2D). + +Análogo a SpatialModel para o substrato raster — provê infraestrutura +sem impor contrato de regra de transição. + +Hierarquia +---------- + Model (dissmodel.core) + ├── SpatialModel gdf + vizinhança Queen/Rook (vetor) + └── RasterModel backend + shift2d (raster) ← este arquivo + ├── FloodRasterModel + └── MangueRasterModel + +Uso +--- + class MeuModeloRaster(RasterModel): + def setup(self, backend, meu_param=1.0): + super().setup(backend) + self.meu_param = meu_param + + def execute(self): + uso = self.backend.get("uso").copy() + ... + self.backend.arrays["uso"] = uso_novo +""" +from __future__ import annotations + +import numpy as np + +from dissmodel.core import Model +from dissmodel.geo.raster_backend import RasterBackend, DIRS_MOORE + + +class RasterModel(Model): + """ + Model com suporte a RasterBackend. + + Subclasse de Model que acrescenta infraestrutura raster sem impor + contrato de regra de transição. Pode ser herdada diretamente por + qualquer modelo que opere sobre arrays NumPy 2D. + + Parâmetros (setup) + ------------------ + backend : RasterBackend + Backend compartilhado entre os modelos do mesmo Environment. + + Atributos disponíveis nas subclasses + ------------------------------------- + self.backend : RasterBackend + self.shape : (rows, cols) — atalho para self.backend.shape + self.shift : atalho para RasterBackend.shift2d (método estático) + self.dirs : DIRS_MOORE — as 8 direções da vizinhança de Moore + """ + + def setup(self, backend: RasterBackend, **kwargs) -> None: + self.backend = backend + self.shape = backend.shape + self.shift = RasterBackend.shift2d + self.dirs = DIRS_MOORE diff --git a/dissmodel/visualization/raster_map.py b/dissmodel/visualization/raster_map.py new file mode 100644 index 0000000..d4528d9 --- /dev/null +++ b/dissmodel/visualization/raster_map.py @@ -0,0 +1,255 @@ +""" +dissmodel/visualization/raster_map.py +====================================== +Visualização de RasterBackend — análogo ao Map (geopandas) do DisSModel, +mas para grades NumPy 2D. + +Responsabilidade +---------------- +Renderizar qualquer array nomeado de um RasterBackend sem saber nada do +domínio — sem constantes de uso do solo, sem CRS, sem cores fixas. + +As definições visuais (cores, labels, colormaps) são passadas pelo +projeto no momento da instanciação. + +Destinos de renderização suportados +------------------------------------- + 1. Streamlit — plot_area=st.empty() + 2. Jupyter — detectado automaticamente + 3. Interativo — RASTER_MAP_INTERACTIVE=1 (TkAgg/Qt) + 4. Headless — salva PNGs em raster_map_frames/ (padrão) + +Uso mínimo (headless / sem cores) +---------------------------------- + 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) + SeuModel(backend=b) + RasterMap(backend=b, band="estado") + env.run() + # → raster_map_frames/estado_step_001.png … estado_step_010.png + +Uso com cores de domínio (projeto BR-MANGUE) +--------------------------------------------- + from brmangue.constants import USO_COLORS, USO_LABELS + + RasterMap( + backend = b, + band = "uso", + title = "Uso do Solo", + color_map = USO_COLORS, # dict[int, str hex] + labels = USO_LABELS, # dict[int, str] + ) + +Uso com colormap contínuo (altimetria, temperatura, …) +------------------------------------------------------- + RasterMap( + backend = b, + band = "alt", + title = "Altimetria", + cmap = "terrain", + colorbar_label = "Altitude (m)", + mask_band = "uso", # opcional: mascara células onde uso==mask_value + mask_value = 3, # ex.: MAR=3 + ) +""" +from __future__ import annotations + +import os +import pathlib +from typing import Any + +import matplotlib +if os.environ.get("RASTER_MAP_INTERACTIVE", "0") == "1": + pass # deixa matplotlib escolher TkAgg/Qt +else: + matplotlib.use("Agg") # headless — sem janela + +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 + + +def _is_interactive() -> bool: + return matplotlib.get_backend().lower() not in ("agg", "cairo", "svg", "pdf", "ps") + + +class RasterMap(Model): + """ + Modelo de visualização para RasterBackend. + + Parâmetros + ---------- + backend : RasterBackend + Backend compartilhado com os modelos de simulação. + band : str + Nome do array a visualizar. + title : str + Prefixo do título. Padrão: ``"RasterMap"``. + figsize : tuple[int, int] + Tamanho da figura em polegadas. Padrão: ``(7, 7)``. + pause : bool + Usar plt.pause() em modo interativo. Padrão: ``True``. + plot_area : st.empty() | None + Placeholder Streamlit. Padrão: ``None``. + + -- modo categórico (color_map preenchido) -- + color_map : dict[int, str] | None + Mapeamento valor → cor hex. Ex.: {1: "#006400", 3: "#00008b"}. + Se fornecido, renderiza com ListedColormap + legenda. + labels : dict[int, str] | None + Mapeamento valor → rótulo para a legenda. + Se None e color_map fornecido, usa str(valor). + + -- modo contínuo (color_map ausente) -- + cmap : str + Nome do colormap matplotlib. Padrão: ``"viridis"``. + vmin : float | None + Valor mínimo da escala. Padrão: mínimo do array. + vmax : float | None + Valor máximo da escala. Padrão: máximo do array. + colorbar_label : str + Rótulo da colorbar. Padrão: valor de ``band``. + mask_band : str | None + Nome de outro array usado para mascarar células. + mask_value : int | float | None + Valor em mask_band a mascarar (ex.: MAR=3 para altimetria). + """ + + def setup( + self, + backend, + band: str = "estado", + title: str = "RasterMap", + figsize: tuple[int, int] = (7, 7), + pause: bool = True, + interval: float = 0.5, # segundos entre passos (modo interativo) + plot_area: Any = None, + # modo categórico + color_map: dict[int, str] | None = None, + labels: dict[int, str] | None = None, + # modo contínuo + cmap: str = "viridis", + vmin: float | None = None, + vmax: float | None = None, + 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 + + # ── renderização ────────────────────────────────────────────────────────── + + def _render(self, step: float) -> matplotlib.figure.Figure: + plt.close("all") + fig, ax = plt.subplots(figsize=self.figsize) + self.fig, self.ax = fig, ax + + arr = self.backend.arrays.get(self.band) + if arr is None: + ax.text(0.5, 0.5, f"band '{self.band}' não encontrado", + ha="center", va="center", transform=ax.transAxes) + ax.set_title(f"{self.title} [{self.band}] — Step {int(step)}") + return fig + + if self.color_map: + self._render_categorical(ax, arr) + else: + self._render_continuous(ax, arr) + + ax.set_xticks([]); ax.set_yticks([]) + ax.set_title(f"{self.title} [{self.band}] — Step {int(step)}") + plt.tight_layout() + return fig + + def _render_categorical(self, ax, arr: np.ndarray) -> None: + """ListedColormap para arrays inteiros com mapeamento valor→cor.""" + vals = sorted(self.color_map) + cmap = mcolors.ListedColormap([self.color_map[k] for k 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") + + # legenda apenas com valores presentes no array atual + present = set(np.unique(arr)) + patches = [ + matplotlib.patches.Patch( + color=self.color_map[k], + label=self.labels.get(k, str(k)), + ) + for k in vals if k 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: + """Colormap contínuo com colorbar e máscara opcional.""" + data = arr.astype(float) + + 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", + interpolation="nearest", vmin=vmin, vmax=vmax) + plt.colorbar(im, ax=ax, label=self.colorbar_label, fraction=0.03, pad=0.02) + + # ── execute ─────────────────────────────────────────────────────────────── + + def execute(self) -> None: + step = self.env.now() + fig = self._render(step) + plt.draw() + + if self.plot_area is not None: + self.plot_area.pyplot(fig) + plt.close(fig) + + elif is_notebook(): + from IPython.display import clear_output, display + clear_output(wait=True) + display(fig) + plt.close(fig) + + elif self.pause and _is_interactive(): + plt.pause(self.interval) + if step == getattr(self.env, "end_time", step): + input("Simulação concluída — pressione Enter para fechar...") + plt.close("all") + + else: + 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}") diff --git a/tests/geo/test_raster.py b/tests/geo/test_raster.py new file mode 100644 index 0000000..e9c78ba --- /dev/null +++ b/tests/geo/test_raster.py @@ -0,0 +1,302 @@ +""" +tests/geo/test_raster_backend.py +tests/visualization/test_raster_map.py + +Testes simples para RasterBackend, RasterModel e RasterMap. + +Execução +-------- + pytest tests/ -v + pytest tests/geo/test_raster_backend.py -v +""" +from __future__ import annotations + +import numpy as np +import pytest + +from dissmodel.geo.raster_backend import RasterBackend, DIRS_MOORE, DIRS_VON_NEUMANN + + +# ══════════════════════════════════════════════════════════════════════════════ +# RasterBackend +# ══════════════════════════════════════════════════════════════════════════════ + +class TestRasterBackend: + + def setup_method(self): + self.b = RasterBackend(shape=(5, 5)) + data = np.arange(25, dtype=float).reshape(5, 5) + self.b.set("val", data) + + # ── set / get ───────────────────────────────────────────────────────────── + + def test_set_stores_copy(self): + arr = np.zeros((5, 5)) + self.b.set("x", arr) + arr[0, 0] = 99 + assert self.b.get("x")[0, 0] == 0 # cópia, não referência + + def test_get_returns_reference(self): + ref = self.b.get("val") + ref[0, 0] = 999 + assert self.b.arrays["val"][0, 0] == 999 # referência direta + + def test_shape(self): + assert self.b.shape == (5, 5) + + # ── snapshot ────────────────────────────────────────────────────────────── + + def test_snapshot_is_deep_copy(self): + snap = self.b.snapshot() + self.b.arrays["val"][0, 0] = 999 + assert snap["val"][0, 0] != 999 # independente + + def test_snapshot_has_all_arrays(self): + self.b.set("extra", np.ones((5, 5))) + snap = self.b.snapshot() + assert "val" in snap + assert "extra" in snap + + # ── shift2d ─────────────────────────────────────────────────────────────── + + def test_shift2d_north(self): + arr = np.zeros((3, 3)) + arr[1, 1] = 1.0 + shifted = RasterBackend.shift2d(arr, -1, 0) # norte + assert shifted[0, 1] == 1.0 + assert shifted[1, 1] == 0.0 + + def test_shift2d_south(self): + arr = np.zeros((3, 3)) + arr[1, 1] = 1.0 + shifted = RasterBackend.shift2d(arr, 1, 0) # sul + assert shifted[2, 1] == 1.0 + + def test_shift2d_border_zeros(self): + arr = np.ones((3, 3)) + shifted = RasterBackend.shift2d(arr, 0, 1) # leste + # coluna 0 deve ser zero (borda) + assert np.all(shifted[:, 0] == 0) + + def test_shift2d_identity_zero(self): + arr = np.eye(3) + shifted = RasterBackend.shift2d(arr, 0, 0) + np.testing.assert_array_equal(shifted, arr) + + # ── neighbor_contact ────────────────────────────────────────────────────── + + def test_neighbor_contact_propagates(self): + cond = np.zeros((5, 5), dtype=bool) + cond[2, 2] = True + contact = self.b.neighbor_contact(cond) + # todas as células ao redor de (2,2) devem ser True + for dr in [-1, 0, 1]: + for dc in [-1, 0, 1]: + assert contact[2+dr, 2+dc] + + def test_neighbor_contact_corner(self): + cond = np.zeros((5, 5), dtype=bool) + cond[0, 0] = True + contact = self.b.neighbor_contact(cond) + assert contact[0, 1] + assert contact[1, 0] + assert contact[1, 1] + assert not contact[0, 2] + + # ── focal_sum_mask ──────────────────────────────────────────────────────── + + def test_focal_sum_mask_center(self): + mask = np.zeros((5, 5), dtype=bool) + mask[2, 2] = True + counts = self.b.focal_sum_mask(mask) + # vizinhos de (2,2) devem ter contagem 1 + assert counts[1, 1] == 1 + assert counts[1, 2] == 1 + assert counts[2, 1] == 1 + # (2,2) não conta a si mesmo + assert counts[2, 2] == 0 + + def test_focal_sum_mask_all_true(self): + mask = np.ones((3, 3), dtype=bool) + b = RasterBackend(shape=(3, 3)) + counts = b.focal_sum_mask(mask) + # célula central tem 8 vizinhos True + assert counts[1, 1] == 8 + # canto tem 3 vizinhos True + assert counts[0, 0] == 3 + + # ── dirs ────────────────────────────────────────────────────────────────── + + def test_dirs_moore_count(self): + assert len(DIRS_MOORE) == 8 + + def test_dirs_von_neumann_count(self): + assert len(DIRS_VON_NEUMANN) == 4 + + def test_dirs_von_neumann_no_diagonals(self): + for dr, dc in DIRS_VON_NEUMANN: + assert abs(dr) + abs(dc) == 1 # sem diagonais + + # ── repr ────────────────────────────────────────────────────────────────── + + def test_repr_contains_shape(self): + assert "5, 5" in repr(self.b) + + def test_repr_contains_band_names(self): + assert "val" in repr(self.b) + + +# ══════════════════════════════════════════════════════════════════════════════ +# RasterModel +# ══════════════════════════════════════════════════════════════════════════════ + +class TestRasterModel: + + def test_setup_populates_attrs(self): + from dissmodel.geo.raster_model import RasterModel + from dissmodel.core import Environment + + class DummyModel(RasterModel): + def setup(self, backend): + super().setup(backend) + def execute(self): + pass + + b = RasterBackend(shape=(4, 4)) + env = Environment(start_time=1, end_time=1) + m = DummyModel(backend=b) + + assert m.backend is b + assert m.shape == (4, 4) + assert m.shift is RasterBackend.shift2d + assert m.dirs is DIRS_MOORE + + def test_execute_called_by_environment(self): + from dissmodel.geo.raster_model import RasterModel + from dissmodel.core import Environment + + calls = [] + + class CountModel(RasterModel): + def setup(self, backend): + super().setup(backend) + def execute(self): + calls.append(self.env.now()) + + b = RasterBackend(shape=(3, 3)) + env = Environment(start_time=1, end_time=3) + CountModel(backend=b) + env.run() + + assert calls == [1, 2, 3] + + def test_subclass_can_modify_backend(self): + from dissmodel.geo.raster_model import RasterModel + from dissmodel.core import Environment + + class IncrementModel(RasterModel): + def setup(self, backend): + super().setup(backend) + def execute(self): + self.backend.arrays["v"] += 1 + + b = RasterBackend(shape=(2, 2)) + b.set("v", np.zeros((2, 2))) + + env = Environment(start_time=1, end_time=3) + IncrementModel(backend=b) + env.run() + + np.testing.assert_array_equal(b.get("v"), np.full((2, 2), 3.0)) + + +# ══════════════════════════════════════════════════════════════════════════════ +# RasterMap — testa sem display (headless) +# ══════════════════════════════════════════════════════════════════════════════ + +class TestRasterMap: + + def test_headless_saves_png(self, tmp_path, monkeypatch): + """RasterMap deve salvar PNG em headless sem levantar exceção.""" + import os + from dissmodel.visualization.raster_map import RasterMap + from dissmodel.core import Environment + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("RASTER_MAP_INTERACTIVE", "0") + + b = RasterBackend(shape=(10, 10)) + b.set("estado", np.random.randint(0, 3, (10, 10))) + + env = Environment(start_time=1, end_time=2) + RasterMap(backend=b, band="estado") + env.run() + + frames = list((tmp_path / "raster_map_frames").glob("estado_step_*.png")) + assert len(frames) == 2 + + def test_headless_categorical(self, tmp_path, monkeypatch): + """Modo categórico (color_map) não deve levantar exceção.""" + import os + from dissmodel.visualization.raster_map import RasterMap + from dissmodel.core import Environment + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("RASTER_MAP_INTERACTIVE", "0") + + b = RasterBackend(shape=(8, 8)) + b.set("uso", np.random.choice([1, 2, 3], (8, 8))) + + color_map = {1: "#006400", 2: "#808000", 3: "#00008b"} + labels = {1: "A", 2: "B", 3: "C"} + + env = Environment(start_time=1, end_time=1) + RasterMap(backend=b, band="uso", color_map=color_map, labels=labels) + env.run() + + frames = list((tmp_path / "raster_map_frames").glob("uso_step_*.png")) + assert len(frames) == 1 + + def test_headless_continuous_with_mask(self, tmp_path, monkeypatch): + """Modo contínuo com mask_band não deve levantar exceção.""" + from dissmodel.visualization.raster_map import RasterMap + from dissmodel.core import Environment + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("RASTER_MAP_INTERACTIVE", "0") + + b = RasterBackend(shape=(8, 8)) + alt = np.random.uniform(0, 10, (8, 8)) + uso = np.ones((8, 8), dtype=int) + uso[0, :] = 3 # MAR + b.set("alt", alt) + b.set("uso", uso) + + env = Environment(start_time=1, end_time=1) + RasterMap( + backend = b, + band = "alt", + cmap = "terrain", + colorbar_label = "Altitude (m)", + mask_band = "uso", + mask_value = 3, + ) + env.run() + + frames = list((tmp_path / "raster_map_frames").glob("alt_step_*.png")) + assert len(frames) == 1 + + def test_missing_band_does_not_crash(self, tmp_path, monkeypatch): + """Band inexistente deve renderizar mensagem, não levantar exceção.""" + from dissmodel.visualization.raster_map import RasterMap + from dissmodel.core import Environment + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("RASTER_MAP_INTERACTIVE", "0") + + b = RasterBackend(shape=(5, 5)) + b.set("uso", np.ones((5, 5))) + + env = Environment(start_time=1, end_time=1) + RasterMap(backend=b, band="inexistente") + env.run() # não deve levantar From 771942df5a43315f9bedf0a5a0a8c5111824a90b Mon Sep 17 00:00:00 2001 From: Sergio Souza Costa Date: Tue, 10 Mar 2026 10:27:09 -0300 Subject: [PATCH 03/12] Refactor: use generic GeoTIFF I/O from dissmodel instead of project-specific raster_io --- dissmodel/geo/band_spec.py | 21 +++++++ dissmodel/geo/raster_io.py | 111 +++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 dissmodel/geo/band_spec.py create mode 100644 dissmodel/geo/raster_io.py diff --git a/dissmodel/geo/band_spec.py b/dissmodel/geo/band_spec.py new file mode 100644 index 0000000..fe51e0d --- /dev/null +++ b/dissmodel/geo/band_spec.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass + + +@dataclass +class BandSpec: + """ + Specification of a raster band in a GeoTIFF. + + Attributes + ---------- + name : str + Name used inside RasterBackend (e.g. 'uso', 'alt', 'soil'). + dtype : str + NumPy dtype used to store the band. + nodata : float | int + Value representing missing data. + """ + + name: str + dtype: str + nodata: float | int \ No newline at end of file diff --git a/dissmodel/geo/raster_io.py b/dissmodel/geo/raster_io.py new file mode 100644 index 0000000..3a5746e --- /dev/null +++ b/dissmodel/geo/raster_io.py @@ -0,0 +1,111 @@ +""" +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), + ] +""" + +from __future__ import annotations + +import pathlib +import numpy as np + +from dissmodel.geo.raster_backend import RasterBackend + +try: + import rasterio + HAS_RASTERIO = True +except ImportError: + HAS_RASTERIO = False + + +def load_geotiff( + path: str | pathlib.Path, + band_spec: list[tuple[str, str, float]], +) -> tuple[RasterBackend, dict]: + + if not HAS_RASTERIO: + raise ImportError("rasterio is required") + + with rasterio.open(path) as ds: + + rows, cols = ds.height, ds.width + backend = RasterBackend((rows, cols)) + + for i, (name, dtype, nodata) in enumerate(band_spec, start=1): + + if i > ds.count: + break + + arr = ds.read(i).astype(dtype) + + if np.all(arr == nodata): + continue + + backend.arrays[name] = arr + + meta = dict( + transform=ds.transform, + crs=ds.crs, + tags=ds.tags(), + ) + + return backend, meta + + +def save_geotiff( + backend: RasterBackend, + path: str | pathlib.Path, + band_spec: list[tuple[str, str, float]], + crs: str, + transform, + compress: str = "deflate", +): + + if not HAS_RASTERIO: + raise ImportError("rasterio is required") + + rows, cols = backend.shape + + arrays = [] + + for name, dtype, nodata in band_spec: + + arr = backend.arrays.get( + name, + np.full((rows, cols), nodata, dtype=dtype) + ) + + arrays.append(arr.astype(dtype)) + + 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, + ) as dst: + + for i, arr in enumerate(arrays, start=1): + dst.write(arr, i) \ No newline at end of file From ccfdc435781ec7de96bfccb1e360870daca37d0f Mon Sep 17 00:00:00 2001 From: Sergio Souza Costa Date: Tue, 10 Mar 2026 10:36:55 -0300 Subject: [PATCH 04/12] fix: set use_index=True when building libpysal weights to avoid FutureWarning --- dissmodel/geo/neighborhood.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dissmodel/geo/neighborhood.py b/dissmodel/geo/neighborhood.py index 2bfdd67..2345c2e 100644 --- a/dissmodel/geo/neighborhood.py +++ b/dissmodel/geo/neighborhood.py @@ -138,7 +138,9 @@ def attach_neighbors( if resolved is not None: w = W(resolved) elif strategy is not None: - w = strategy.from_dataframe(gdf, **kwargs) + if "use_index" not in kwargs: + kwargs["use_index"] = True + w = strategy.from_dataframe(gdf, **kwargs) else: raise ValueError("Provide either `strategy` or `neighbors_dict`.") From fc2f63ff13003bec181026709db2c71f4b11d506 Mon Sep 17 00:00:00 2001 From: Sergio Souza Costa Date: Tue, 10 Mar 2026 10:46:25 -0300 Subject: [PATCH 05/12] feat: add support for reading GeoTIFF files inside ZIP archives --- dissmodel/geo/raster_io.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/dissmodel/geo/raster_io.py b/dissmodel/geo/raster_io.py index 3a5746e..e558417 100644 --- a/dissmodel/geo/raster_io.py +++ b/dissmodel/geo/raster_io.py @@ -43,6 +43,15 @@ def load_geotiff( if not HAS_RASTERIO: raise ImportError("rasterio is required") + path = 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: rows, cols = ds.height, ds.width From f4e1552a372e984e9868a6a0a296cec3ec4d8424 Mon Sep 17 00:00:00 2001 From: Sergio Souza Costa Date: Tue, 10 Mar 2026 11:30:40 -0300 Subject: [PATCH 06/12] feat: add coastal dynamics examples for DissModel (raster and vector versions) --- .gitignore | 2 +- examples/cli/coastal_dynamics/__init__.py | 0 .../cli/coastal_dynamics/common/__init__.py | 0 .../cli/coastal_dynamics/common/constants.py | 96 ++++++++++ .../coastal_dynamics/raster/flood_model.py | 77 ++++++++ .../coastal_dynamics/raster/mangrove_model.py | 101 ++++++++++ examples/cli/coastal_dynamics/raster/run.py | 181 ++++++++++++++++++ .../coastal_dynamics/vector/flood_model.py | 140 ++++++++++++++ examples/cli/coastal_dynamics/vector/run.py | 143 ++++++++++++++ 9 files changed, 739 insertions(+), 1 deletion(-) create mode 100644 examples/cli/coastal_dynamics/__init__.py create mode 100644 examples/cli/coastal_dynamics/common/__init__.py create mode 100644 examples/cli/coastal_dynamics/common/constants.py create mode 100644 examples/cli/coastal_dynamics/raster/flood_model.py create mode 100644 examples/cli/coastal_dynamics/raster/mangrove_model.py create mode 100644 examples/cli/coastal_dynamics/raster/run.py create mode 100644 examples/cli/coastal_dynamics/vector/flood_model.py create mode 100644 examples/cli/coastal_dynamics/vector/run.py diff --git a/.gitignore b/.gitignore index 33ce013..7f8347f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ - +raster_map_frames testes # Byte-compiled / optimized / DLL files diff --git a/examples/cli/coastal_dynamics/__init__.py b/examples/cli/coastal_dynamics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/cli/coastal_dynamics/common/__init__.py b/examples/cli/coastal_dynamics/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/cli/coastal_dynamics/common/constants.py b/examples/cli/coastal_dynamics/common/constants.py new file mode 100644 index 0000000..2a9772d --- /dev/null +++ b/examples/cli/coastal_dynamics/common/constants.py @@ -0,0 +1,96 @@ +""" +brmangue/constants.py — Constantes de domínio BR-MANGUE +========================================================= +tabela_usos / tabela_solos alinhadas com o modelo Lua original (Bezerra, 2014). +CRS e parâmetros geográficos da Ilha do Maranhão. + +Nada neste arquivo pertence ao framework DisSModel. +""" +from __future__ import annotations + +# ── tabela_usos ─────────────────────────────────────────────────────────────── +MANGUE = 1 +VEGETACAO_TERRESTRE = 2 +MAR = 3 +AREA_ANTROPIZADA = 4 +SOLO_DESCOBERTO = 5 +SOLO_INUNDADO = 6 +AREA_ANTROPIZADA_INUNDADA = 7 +MANGUE_MIGRADO = 8 +MANGUE_INUNDADO = 9 +VEG_TERRESTRE_INUNDADA = 10 + +USOS_INUNDADOS: list[int] = [ + MAR, SOLO_INUNDADO, AREA_ANTROPIZADA_INUNDADA, + MANGUE_INUNDADO, VEG_TERRESTRE_INUNDADA, +] + +# seco → inundado (Bezerra 2014) +REGRAS_INUNDACAO: dict[int, int] = { + MANGUE: MANGUE_INUNDADO, + MANGUE_MIGRADO: MANGUE_INUNDADO, + VEGETACAO_TERRESTRE: VEG_TERRESTRE_INUNDADA, + AREA_ANTROPIZADA: AREA_ANTROPIZADA_INUNDADA, + SOLO_DESCOBERTO: SOLO_INUNDADO, +} + +USO_LABELS: dict[int, str] = { + MANGUE: "Mangue", + VEGETACAO_TERRESTRE: "Vegetação Terrestre", + MAR: "Mar", + AREA_ANTROPIZADA: "Área Antropizada", + SOLO_DESCOBERTO: "Solo Descoberto", + SOLO_INUNDADO: "Solo Inundado", + AREA_ANTROPIZADA_INUNDADA: "Área Antrop. Inundada", + MANGUE_MIGRADO: "Mangue Migrado", + MANGUE_INUNDADO: "Mangue Inundado", + VEG_TERRESTRE_INUNDADA: "Veg. Terrestre Inundada", +} + +# cores exatas do Lua (tabela_usos RGB → hex) +USO_COLORS: dict[int, str] = { + MANGUE: "#006400", + VEGETACAO_TERRESTRE: "#808000", + MAR: "#00008b", + AREA_ANTROPIZADA: "#ffd700", + SOLO_DESCOBERTO: "#ffdead", + SOLO_INUNDADO: "#000000", + AREA_ANTROPIZADA_INUNDADA: "#323232", + MANGUE_MIGRADO: "#00ff00", + MANGUE_INUNDADO: "#ff0000", + VEG_TERRESTRE_INUNDADA: "#000000", +} + +# ── tabela_solos ────────────────────────────────────────────────────────────── +SOLO_CANAL_FLUVIAL = 0 +SOLO_MANGUE = 3 +SOLO_MANGUE_MIGRADO = 9 +SOLO_OUTROS = 4 + +SOLO_LABELS: dict[int, str] = { + SOLO_CANAL_FLUVIAL: "Canal Fluvial", + SOLO_MANGUE: "Mangue", + SOLO_MANGUE_MIGRADO: "Mangue Migrado", + SOLO_OUTROS: "Outros", +} + +# ── geografia — Ilha do Maranhão ────────────────────────────────────────────── +ORIGIN_X = 500_000.0 # UTM Easting (SIRGAS 2000 / UTM 24S) +ORIGIN_Y = 9_700_000.0 # UTM Northing +CRS = "EPSG:31984" +CELL_SIZE = 100.0 # metros + +# ── GeoTIFF: especificação de bandas (nome, dtype numpy, nodata) ────────────── +TIFF_BANDS: list[tuple[str, str, float]] = [ + ("uso", "int16", 0), + ("alt", "float32", -9999.0), + ("solo", "int16", -1), +] + +# cores da tabela_solos (para RasterMap) +SOLO_COLORS: dict[int, str] = { + SOLO_CANAL_FLUVIAL: "#0000ff", # azul — canal de drenagem + SOLO_MANGUE: "#006400", # verde escuro + SOLO_MANGUE_MIGRADO: "#228b22", # verde floresta + SOLO_OUTROS: "#888888", # cinza +} diff --git a/examples/cli/coastal_dynamics/raster/flood_model.py b/examples/cli/coastal_dynamics/raster/flood_model.py new file mode 100644 index 0000000..13d0f4c --- /dev/null +++ b/examples/cli/coastal_dynamics/raster/flood_model.py @@ -0,0 +1,77 @@ +""" +brmangue/flood_raster_model.py — Modelo Hidro para DisSModel +============================================================= +Tradução fiel do hidro.lua para DisSModel + RasterBackend. +""" +from __future__ import annotations + +import numpy as np +from dissmodel.geo.raster_model import RasterModel +from dissmodel.geo.raster_backend import RasterBackend + +from coastal_dynamics.common.constants import USOS_INUNDADOS, REGRAS_INUNDACAO, MAR + + +class FloodModel(RasterModel): + """ + Hidro (hidro.lua) → DisSModel + RasterBackend. + + Parâmetros + ---------- + backend : RasterBackend com arrays "uso" e "alt" + taxa_elevacao : m/ano — IPCC RCP8.5 = 0.011 + aim_base : AIM base em metros. Padrão: 6.0 + """ + + def setup( + self, + backend: RasterBackend, + taxa_elevacao: float = 0.011, + aim_base: float = 6.0, + ) -> None: + super().setup(backend) + self.taxa_elevacao = taxa_elevacao + self.aim_base = aim_base + + self.celulas_inundadas = 0 + self.novas_inundadas = 0 + self.nivel_mar_atual = 0.0 + + def execute(self) -> None: + nivel_mar = self.env.now() * self.taxa_elevacao + rows, cols = self.shape + uso_past = self.backend.get("uso").copy() + alt_past = self.backend.get("alt").copy() + + eh_fonte = np.isin(uso_past, USOS_INUNDADOS) & (alt_past >= 0) + + viz_baixos = np.ones((rows, cols), dtype=float) + for dr, dc in self.dirs: + viz_baixos += (self.shift(alt_past, dr, dc) <= alt_past).astype(float) + + fluxo = np.where(eh_fonte, self.taxa_elevacao / viz_baixos, 0.0) + delta_alt = fluxo.copy() + uso_novo = uso_past.copy() + + for dr, dc in self.dirs: + fonte_viz = self.shift(eh_fonte.astype(float), dr, dc) > 0 + alt_viz = self.shift(alt_past, dr, dc) + fluxo_viz = self.shift(fluxo, dr, dc) + + # 1. altimetria — condição relativa + delta_alt += np.where( + fonte_viz & (alt_past <= alt_viz), fluxo_viz, 0.0 + ) + # 2. inundação — cota absoluta + for uso_seco, uso_inund in REGRAS_INUNDACAO.items(): + pode = fonte_viz & (uso_past == uso_seco) & (alt_past <= nivel_mar) + uso_novo = np.where(pode, uso_inund, uso_novo) + + self.backend.arrays["alt"] = alt_past + delta_alt + self.backend.arrays["uso"] = uso_novo + + inund = np.isin(uso_novo, USOS_INUNDADOS) & (uso_novo != MAR) + novas = np.isin(uso_novo, USOS_INUNDADOS) & ~np.isin(uso_past, USOS_INUNDADOS) + self.celulas_inundadas = int(np.sum(inund)) + self.novas_inundadas = int(np.sum(novas)) + self.nivel_mar_atual = round(nivel_mar, 4) diff --git a/examples/cli/coastal_dynamics/raster/mangrove_model.py b/examples/cli/coastal_dynamics/raster/mangrove_model.py new file mode 100644 index 0000000..ebc1a5d --- /dev/null +++ b/examples/cli/coastal_dynamics/raster/mangrove_model.py @@ -0,0 +1,101 @@ +""" +brmangue/mangue_raster_model.py — Modelo Mangue para DisSModel +=============================================================== +Tradução fiel do mangue.lua para DisSModel + RasterBackend. +""" +from __future__ import annotations + +import numpy as np +from dissmodel.geo.raster_model import RasterModel +from dissmodel.geo.raster_backend import RasterBackend + +from coastal_dynamics.common.constants import ( + MANGUE, MANGUE_MIGRADO, VEGETACAO_TERRESTRE, SOLO_DESCOBERTO, + USOS_INUNDADOS, + SOLO_MANGUE, SOLO_MANGUE_MIGRADO, SOLO_CANAL_FLUVIAL, +) + + +class MangroveModel(RasterModel): + """ + Mangue (mangue.lua) → DisSModel + RasterBackend. + + Parâmetros + ---------- + backend : RasterBackend com arrays "uso", "alt", "solo" + taxa_elevacao : m/ano — IPCC RCP8.5 = 0.011 + altura_mare : AIM base em metros. Padrão: 6.0 + acrecao_ativa : habilita aplicarAcrecao (Alongi 2008). Padrão: False + """ + + SOLOS_FONTE = [SOLO_MANGUE, SOLO_MANGUE_MIGRADO, SOLO_CANAL_FLUVIAL] + SOLOS_MANGUE = [SOLO_MANGUE, SOLO_MANGUE_MIGRADO] + USOS_FONTE = [MANGUE, MANGUE_MIGRADO] + USOS_ALVO = [VEGETACAO_TERRESTRE, SOLO_DESCOBERTO] + COEF_A, COEF_B = 1.693, 0.939 # Alongi 2008 + + def setup( + self, + backend: RasterBackend, + taxa_elevacao: float = 0.011, + altura_mare: float = 6.0, + acrecao_ativa: bool = False, + ) -> None: + super().setup(backend) + self.taxa_elevacao = taxa_elevacao + self.altura_mare = altura_mare + self.acrecao_ativa = acrecao_ativa + + self.mangue_migrado = 0 + self.solo_migrado = 0 + + def execute(self) -> None: + nivel_mar = self.env.now() * self.taxa_elevacao + zi = self.altura_mare + nivel_mar + taxa_ac = self.COEF_A / 1000.0 + self.COEF_B * nivel_mar + + uso_past = self.backend.get("uso").copy() + alt_past = self.backend.get("alt").copy() + solo_past = self.backend.get("solo").copy() + + # ── migrarSolos ─────────────────────────────────────────────────────── + eh_fonte_solo = np.isin(solo_past, self.SOLOS_FONTE) + solo_novo = solo_past.copy() + + for dr, dc in self.dirs: + fonte_viz = self.shift(eh_fonte_solo.astype(np.int8), dr, dc) > 0 + cond = ( + fonte_viz + & np.isin(uso_past, self.USOS_ALVO) + & (solo_past != SOLO_MANGUE_MIGRADO) + & (alt_past <= zi) + ) + solo_novo = np.where(cond, SOLO_MANGUE_MIGRADO, solo_novo) + + # ── migrarUsos — usa solo_past (fiel ao .past do TerraME) ──────────── + eh_fonte_uso = np.isin(uso_past, self.USOS_FONTE) + uso_novo = uso_past.copy() + + for dr, dc in self.dirs: + fonte_viz = self.shift(eh_fonte_uso.astype(np.int8), dr, dc) > 0 + cond = ( + fonte_viz + & np.isin(uso_past, self.USOS_ALVO) + & np.isin(solo_past, self.SOLOS_MANGUE) # ← solo_past + & (alt_past <= zi) + ) + uso_novo = np.where(cond, MANGUE_MIGRADO, uso_novo) + + # ── aplicarAcrecao (False por padrão) ───────────────────────────────── + if self.acrecao_ativa: + cond_ac = ( + np.isin(solo_past, self.SOLOS_MANGUE) + & ~np.isin(uso_past, USOS_INUNDADOS) + ) + self.backend.arrays["alt"] = np.where(cond_ac, alt_past + taxa_ac, alt_past) + + self.backend.arrays["uso"] = uso_novo + self.backend.arrays["solo"] = solo_novo + + self.mangue_migrado = int(np.sum(uso_novo == MANGUE_MIGRADO)) + self.solo_migrado = int(np.sum(solo_novo == SOLO_MANGUE_MIGRADO)) diff --git a/examples/cli/coastal_dynamics/raster/run.py b/examples/cli/coastal_dynamics/raster/run.py new file mode 100644 index 0000000..3359e93 --- /dev/null +++ b/examples/cli/coastal_dynamics/raster/run.py @@ -0,0 +1,181 @@ +""" +brmangue/run.py — Ponto de entrada BR-MANGUE +============================================= +Acopla FloodRasterModel + MangueRasterModel + RasterMap no mesmo +Environment DisSModel, compartilhando um RasterBackend. + +Ordem de execução por passo (ordem de instanciação): + 1. FloodRasterModel — Hidro: altimetria + inundação + 2. MangueRasterModel — Mangue: migração solo/uso + acreção + 3. RasterMap(uso) — visualização + +Uso +--- + python -m brmangue.run flood_p000_0.000m.tif + + # modo interativo (requer display): + RASTER_MAP_INTERACTIVE=1 python -m brmangue.run flood_p000_0.000m.tif + + # múltiplos mapas: + python -m brmangue.run flood_p000_0.000m.tif --bands uso alt solo + + # sem salvar resultado: + python -m brmangue.run flood_p000_0.000m.tif --no-save +""" +from __future__ import annotations + +import argparse +import pathlib +import sys + +from dissmodel.core import Environment +from dissmodel.visualization.raster_map import RasterMap + +from coastal_dynamics.common.constants import ( + USO_COLORS, USO_LABELS, + SOLO_COLORS, SOLO_LABELS, + MAR,TIFF_BANDS, CRS +) +#from raster_io import carregar_tiff, salvar_tiff + +from dissmodel.geo.raster_io import load_geotiff, save_geotiff + + +from coastal_dynamics.raster.flood_model import FloodModel +from coastal_dynamics.raster.mangrove_model import MangroveModel + + +# ── configuração da simulação ───────────────────────────────────────────────── + +TAXA_ELEVACAO = 0.011 # m/ano — IPCC RCP8.5 +ALTURA_MARE = 6.0 # AIM base em metros +END_TIME = 88 # passos (2012–2100) + +# definição visual por band — passada ao RasterMap genérico do dissmodel +BAND_CONFIG: dict[str, dict] = { + "uso": dict( + color_map = USO_COLORS, + labels = USO_LABELS, + title = "Uso do Solo", + ), + "solo": dict( + color_map = SOLO_COLORS, + labels = SOLO_LABELS, + title = "Solo", + ), + "alt": dict( + cmap = "terrain", + colorbar_label = "Altitude (m)", + mask_band = "uso", + mask_value = MAR, + title = "Altimetria", + ), +} + + +# ── main ────────────────────────────────────────────────────────────────────── + +def run( + tif_path: str | pathlib.Path, + bands: list[str] = ("uso",), + acrecao_ativa: bool = False, + save: bool = True, +) -> None: + tif_path = pathlib.Path(tif_path) + + # ── carrega estado inicial ──────────────────────────────────────────────── + print(f"Carregando {tif_path}...") + backend, meta = load_geotiff( + tif_path, + band_spec=TIFF_BANDS + ) + + tags = meta.get("tags", {}) + + print( + f" shape={backend.shape} " + f"passo={tags.get('passo',0)} " + f"nivel_mar={tags.get('nivel_mar',0)}m " + f"crs={meta['crs']}" + ) + + start = int(tags.get("passo", 0)) + 1 + + env = Environment(start_time=start, end_time=END_TIME) + + # ── modelos — compartilham o mesmo backend ──────────────────────────────── + FloodModel( + backend = backend, + taxa_elevacao = TAXA_ELEVACAO, + aim_base = ALTURA_MARE, + ) + MangroveModel( + backend = backend, + taxa_elevacao = TAXA_ELEVACAO, + altura_mare = ALTURA_MARE, + acrecao_ativa = acrecao_ativa, + ) + + # ── visualização — um RasterMap por band solicitado ─────────────────────── + for band in bands: + if band not in BAND_CONFIG: + print(f" aviso: band '{band}' sem configuração visual — usando viridis") + RasterMap(backend=backend, band=band, **BAND_CONFIG.get(band, {})) + + # ── execução ────────────────────────────────────────────────────────────── + print(f"Executando passos {start} → {END_TIME}...") + env.run() + print("Concluído.") + + # ── salva estado final ──────────────────────────────────────────────────── + if save: + + nivel_mar_final = END_TIME * TAXA_ELEVACAO + + out_path = tif_path.with_name( + tif_path.stem + "_resultado.tif" + ) + + save_geotiff( + backend, + out_path, + band_spec=TIFF_BANDS, + crs=CRS, + transform=meta["transform"], + ) + + print(f"Salvo: {out_path}") + + +# ── CLI ─────────────────────────────────────────────────────────────────────── + +def _parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser( + prog="python -m brmangue.run", + description="Simulação BR-MANGUE via DisSModel", + ) + p.add_argument("tif", help="GeoTIFF de entrada (estado inicial)") + p.add_argument( + "--bands", nargs="+", default=["uso"], + choices=list(BAND_CONFIG), metavar="BAND", + help="Bands a visualizar: uso solo alt (padrão: uso)", + ) + p.add_argument( + "--acrecao", action="store_true", + help="Ativa aplicarAcrecao no MangueRasterModel (Alongi 2008)", + ) + p.add_argument( + "--no-save", dest="save", action="store_false", + help="Não salva GeoTIFF de resultado", + ) + return p.parse_args() + + +if __name__ == "__main__": + args = _parse_args() + run( + tif_path = args.tif, + bands = args.bands, + acrecao_ativa = args.acrecao, + save = args.save, + ) diff --git a/examples/cli/coastal_dynamics/vector/flood_model.py b/examples/cli/coastal_dynamics/vector/flood_model.py new file mode 100644 index 0000000..7ee45ea --- /dev/null +++ b/examples/cli/coastal_dynamics/vector/flood_model.py @@ -0,0 +1,140 @@ +""" +brmangue/flood_vector_model.py — Modelo Hidro (versão GeoDataFrame) +==================================================================== +Versão do FloodRasterModel usando GeoDataFrame + SpatialModel, +para comparação direta com a versão NumPy (flood_raster_model.py). + +Mesma lógica, diferente substrato: + + flood_raster_model.py RasterBackend (NumPy, vetorizado) + flood_vector_model.py ← GeoDataFrame (libpysal, célula a célula) + +Por que NÃO usar CellularAutomaton +----------------------------------- +CellularAutomaton.rule(idx) calcula o novo estado de uma célula com base +em si mesma e nos seus vizinhos (modelo pull). O Hidro é orientado a +FONTE: células inundadas propagam fluxo e inundação para vizinhos — +a lógica é inversa (modelo push). Por isso herdamos SpatialModel +diretamente e implementamos execute() livremente. + +Uso +--- + from dissmodel.core import Environment + from brmangue.flood_vector_model import FloodVectorModel + import geopandas as gpd + + gdf = gpd.read_file("flood_model.shp") + env = Environment(start_time=1, end_time=88) + FloodVectorModel(gdf=gdf, taxa_elevacao=0.011) + env.run() +""" +from __future__ import annotations + +import geopandas as gpd +from libpysal.weights import Queen + +from dissmodel.geo.spatial_model import SpatialModel + +from coastal_dynamics.common.constants import ( + USOS_INUNDADOS, + REGRAS_INUNDACAO, + MAR, +) + + +class FloodVectorModel(SpatialModel): + """ + Hidro (hidro.lua) → DisSModel + GeoDataFrame. + + Equivalência com a versão Raster + --------------------------------- + RasterBackend.shift2d() → neighs_id(idx) / neighbor_values() + np.isin(uso, USOS_INUNDADOS) → uso_past.isin(USOS_INUNDADOS) + loop sobre DIRS_MOORE → loop sobre vizinhos reais do GDF + vetorizado sobre grade inteira → loop célula a célula (mais lento, + mas fiel à geometria real) + + Parâmetros + ---------- + gdf : GeoDataFrame com colunas attr_uso e attr_alt + taxa_elevacao : m/ano — IPCC RCP8.5 = 0.011 + attr_uso : coluna de uso do solo. Padrão: "uso" + attr_alt : coluna de altitude. Padrão: "alt" + """ + + def setup( + self, + taxa_elevacao: float = 0.011, + attr_uso: str = "uso", + attr_alt: str = "alt", + ) -> None: + self.taxa_elevacao = taxa_elevacao + self.attr_uso = attr_uso + self.attr_alt = attr_alt + + # métricas expostas para @track_plot / Chart + self.celulas_inundadas = 0 + self.novas_inundadas = 0 + self.nivel_mar_atual = 0.0 + + # Queen = vizinhança Moore (8 direções) para grade regular + # silence_warnings suprime aviso de ilhas (células sem vizinhos) + self.create_neighborhood(strategy=Queen, silence_warnings=True) + + def execute(self) -> None: + nivel_mar = self.env.now() * self.taxa_elevacao + + # Snapshots — equivale a celula.past[] do TerraME + uso_past = self.gdf[self.attr_uso].copy() + alt_past = self.gdf[self.attr_alt].copy() + + # ── fontes: ehMarOuInundado(uso) and alt >= 0 ───────────────────────── + fontes = set( + uso_past.index[ + uso_past.isin(USOS_INUNDADOS) & (alt_past >= 0) + ] + ) + + # ── A. Altimetria — difusão de fluxo (condição relativa) ────────────── + # Lua: if vizinho.past[alt] <= altAtual: viz[alt] += fluxo + alt_nova = alt_past.copy() + + for idx in fontes: + alt_atual = alt_past[idx] + vizinhos = self.neighs_id(idx) + + viz_baixos = 1 + sum( + 1 for n in vizinhos if alt_past[n] <= alt_atual + ) + fluxo = self.taxa_elevacao / viz_baixos + + alt_nova[idx] += fluxo + for n in vizinhos: + if alt_past[n] <= alt_atual: + alt_nova[n] += fluxo + + self.gdf[self.attr_alt] = alt_nova + + # ── B. Inundação — cota absoluta (BR-MANGUE, Bezerra 2014) ─────────── + # Lua: if vizinho.past[alt] <= nivelMar and not ehMarOuInundado(viz): + # aplicarInundacao(vizinho) + # Usa alt_past — fiel ao .past do TerraME + uso_novo = uso_past.copy() + + for idx in self.gdf.index: + uso_atual = uso_past[idx] + if uso_atual not in REGRAS_INUNDACAO: + continue + if alt_past[idx] > nivel_mar: + continue + if any(n in fontes for n in self.neighs_id(idx)): + uso_novo[idx] = REGRAS_INUNDACAO[uso_atual] + + self.gdf[self.attr_uso] = uso_novo + + # ── métricas ────────────────────────────────────────────────────────── + inund = uso_novo.isin(USOS_INUNDADOS) & (uso_novo != MAR) + novas = uso_novo.isin(USOS_INUNDADOS) & ~uso_past.isin(USOS_INUNDADOS) + self.celulas_inundadas = int(inund.sum()) + self.novas_inundadas = int(novas.sum()) + self.nivel_mar_atual = round(nivel_mar, 4) diff --git a/examples/cli/coastal_dynamics/vector/run.py b/examples/cli/coastal_dynamics/vector/run.py new file mode 100644 index 0000000..bca8f43 --- /dev/null +++ b/examples/cli/coastal_dynamics/vector/run.py @@ -0,0 +1,143 @@ +""" +brmangue/run_vector.py — Ponto de entrada BR-MANGUE (versão GeoDataFrame) +========================================================================= +Versão vetorial para comparação com run.py (RasterBackend). + +Usa FloodVectorModel + GeoDataFrame + Map/Chart do DisSModel. + +Uso +--- + python -m brmangue.run_vector flood_model.shp + python -m brmangue.run_vector flood_model.gpkg --taxa 0.05 + python -m brmangue.run_vector flood_model.shp --chart +""" +from __future__ import annotations + +import argparse +import pathlib +import sys + +import numpy as np +import geopandas as gpd +from matplotlib.colors import ListedColormap, BoundaryNorm + +from dissmodel.core import Environment +from dissmodel.visualization import Map, Chart + +from coastal_dynamics.common.constants import USO_COLORS, USO_LABELS +from examples.cli.coastal_dynamics.vector.flood_model import FloodVectorModel + + +# ── configuração ────────────────────────────────────────────────────────────── + +TAXA_ELEVACAO = 0.011 +END_TIME = 88 + +# ListedColormap alinhado com tabela_usos do Lua +_vals = sorted(USO_COLORS) +USO_CMAP = ListedColormap([USO_COLORS[k] for k in _vals]) +USO_NORM = BoundaryNorm([v - 0.5 for v in _vals] + [_vals[-1] + 0.5], USO_CMAP.N) + + +# ── main ────────────────────────────────────────────────────────────────────── + +def run( + shp_path: str | pathlib.Path, + taxa_elevacao: float = TAXA_ELEVACAO, + attr_uso: str = "uso", + attr_alt: str = "alt", + show_chart: bool = False, + save: bool = True, +) -> None: + shp_path = pathlib.Path(shp_path) + + # ── carrega ─────────────────────────────────────────────────────────────── + print(f"Carregando {shp_path}...") + gdf = gpd.read_file(shp_path) + print(f" features={len(gdf)} crs={gdf.crs}") + + # ── ambiente ────────────────────────────────────────────────────────────── + env = Environment(start_time=1, end_time=END_TIME) + + # ── modelo ──────────────────────────────────────────────────────────────── + FloodVectorModel( + gdf = gdf, + taxa_elevacao = taxa_elevacao, + attr_uso = attr_uso, + attr_alt = attr_alt, + ) + + # ── visualização ────────────────────────────────────────────────────────── + Map( + gdf = gdf, + plot_params = { + "column": attr_uso, + "cmap": USO_CMAP, + "norm": USO_NORM, + "legend": False, # legenda manual seria necessária para labels + }, + ) + Map( + gdf = gdf, + plot_params = { + "column": attr_alt, + "cmap": "terrain", + "legend": True, + }, + ) + if show_chart: + Chart(select={"celulas_inundadas"}) + + # ── execução ────────────────────────────────────────────────────────────── + print(f"Executando passos 1 → {END_TIME}...") + env.run() + print("Concluído.") + + # ── salva ───────────────────────────────────────────────────────────────── + if save: + out_path = shp_path.with_name(shp_path.stem + "_resultado.gpkg") + gdf.to_file(out_path, driver="GPKG", layer="flood_vector") + print(f"Salvo: {out_path}") + + +# ── CLI ─────────────────────────────────────────────────────────────────────── + +def _parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser( + prog="python -m brmangue.run_vector", + description="Simulação BR-MANGUE — versão GeoDataFrame", + ) + p.add_argument("shp", help="Shapefile ou GeoPackage de entrada") + p.add_argument( + "--taxa", type=float, default=TAXA_ELEVACAO, metavar="M/ANO", + help=f"Taxa de elevação do mar em m/ano (padrão: {TAXA_ELEVACAO})", + ) + p.add_argument( + "--attr-uso", default="uso", metavar="COL", + help="Coluna de uso do solo (padrão: uso)", + ) + p.add_argument( + "--attr-alt", default="alt", metavar="COL", + help="Coluna de altitude (padrão: alt)", + ) + p.add_argument( + "--chart", action="store_true", + help="Exibe gráfico de células inundadas por passo", + ) + p.add_argument( + "--no-save", dest="save", action="store_false", + help="Não salva GeoPackage de resultado", + ) + return p.parse_args() + + +if __name__ == "__main__": + args = _parse_args() + run( + shp_path = args.shp, + taxa_elevacao = args.taxa, + attr_uso = args.attr_uso, + attr_alt = args.attr_alt, + show_chart = args.chart, + save = args.save, + ) From 9379d05ea19633a2534d2330c9f2b1afe92a0145 Mon Sep 17 00:00:00 2001 From: Sergio Souza Costa Date: Tue, 10 Mar 2026 11:35:47 -0300 Subject: [PATCH 07/12] feat: add coastal dynamics example data --- examples/data/synthetic_grid_60x60_shp.zip | Bin 0 -> 85974 bytes examples/data/synthetic_grid_60x60_tiff.zip | Bin 0 -> 12748 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 examples/data/synthetic_grid_60x60_shp.zip create mode 100644 examples/data/synthetic_grid_60x60_tiff.zip diff --git a/examples/data/synthetic_grid_60x60_shp.zip b/examples/data/synthetic_grid_60x60_shp.zip new file mode 100644 index 0000000000000000000000000000000000000000..12617c7ed95fd08bd6e4e370132ed409b4f88ee3 GIT binary patch literal 85974 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 literal 0 HcmV?d00001 From 62880be86113d8edcfcb311873dab3317132d2b5 Mon Sep 17 00:00:00 2001 From: Sergio Souza Costa Date: Tue, 10 Mar 2026 12:40:52 -0300 Subject: [PATCH 08/12] feat: add raster vs vector benchmark script --- benchmarks/benchmark_raster_vs_vector.py | 275 ++++++++++++++++++ .../coastal_dynamics/vector/mangue_model.py | 164 +++++++++++ examples/cli/coastal_dynamics/vector/run.py | 78 +++-- 3 files changed, 489 insertions(+), 28 deletions(-) create mode 100644 benchmarks/benchmark_raster_vs_vector.py create mode 100644 examples/cli/coastal_dynamics/vector/mangue_model.py diff --git a/benchmarks/benchmark_raster_vs_vector.py b/benchmarks/benchmark_raster_vs_vector.py new file mode 100644 index 0000000..2eeaedf --- /dev/null +++ b/benchmarks/benchmark_raster_vs_vector.py @@ -0,0 +1,275 @@ +""" +benchmarks/benchmark_raster_vs_vector.py +========================================== +Benchmark: RasterBackend (NumPy) vs GeoDataFrame (libpysal) + +Autocontido — não depende de projetos externos ao dissmodel. +Define um modelo de inundação mínimo inline para isolar a medição +do overhead de cada backend. + +Execução +-------- + python benchmarks/benchmark_raster_vs_vector.py + python benchmarks/benchmark_raster_vs_vector.py --steps 5 --sizes 10 20 50 + python benchmarks/benchmark_raster_vs_vector.py --no-validation +""" +from __future__ import annotations + +import argparse +import time +from dataclasses import dataclass, field + +import numpy as np +import geopandas as gpd +import pandas as pd +from shapely.geometry import box +from libpysal.weights import Queen + +from dissmodel.core import Environment +from dissmodel.geo.raster_backend import RasterBackend, DIRS_MOORE +from dissmodel.geo.raster_model import RasterModel +from dissmodel.geo.spatial_model import SpatialModel + + +# ══════════════════════════════════════════════════════════════════════════════ +# CONSTANTES MÍNIMAS (inline — sem dependência externa) +# ══════════════════════════════════════════════════════════════════════════════ + +MAR = 3 +SOLO_INUNDADO = 6 +AREA_ANTROPIZADA_INUNDADA = 7 +MANGUE_INUNDADO = 9 +VEG_TERRESTRE_INUNDADA = 10 + +USOS_INUNDADOS = [MAR, SOLO_INUNDADO, AREA_ANTROPIZADA_INUNDADA, + MANGUE_INUNDADO, VEG_TERRESTRE_INUNDADA] + +REGRAS_INUNDACAO = { + 1: MANGUE_INUNDADO, # MANGUE + 8: MANGUE_INUNDADO, # MANGUE_MIGRADO + 2: VEG_TERRESTRE_INUNDADA, # VEGETACAO_TERRESTRE + 4: AREA_ANTROPIZADA_INUNDADA,# AREA_ANTROPIZADA + 5: SOLO_INUNDADO, # SOLO_DESCOBERTO +} + +TAXA = 0.011 + + +# ══════════════════════════════════════════════════════════════════════════════ +# MODELOS INLINE (mínimos, sem importar projetos externos) +# ══════════════════════════════════════════════════════════════════════════════ + +class _FloodRaster(RasterModel): + def setup(self, backend): + super().setup(backend) + + def execute(self): + nivel_mar = self.env.now() * TAXA + rows, cols = self.shape + uso_past = self.backend.get("uso").copy() + alt_past = self.backend.get("alt").copy() + + eh_fonte = np.isin(uso_past, USOS_INUNDADOS) & (alt_past >= 0) + viz_baixos = np.ones((rows, cols), dtype=float) + for dr, dc in self.dirs: + viz_baixos += (self.shift(alt_past, dr, dc) <= alt_past).astype(float) + + fluxo = np.where(eh_fonte, TAXA / viz_baixos, 0.0) + delta_alt = fluxo.copy() + uso_novo = uso_past.copy() + + for dr, dc in self.dirs: + fonte_viz = self.shift(eh_fonte.astype(float), dr, dc) > 0 + delta_alt += np.where( + fonte_viz & (alt_past <= self.shift(alt_past, dr, dc)), + self.shift(fluxo, dr, dc), 0.0, + ) + for uso_seco, uso_inund in REGRAS_INUNDACAO.items(): + pode = fonte_viz & (uso_past == uso_seco) & (alt_past <= nivel_mar) + uso_novo = np.where(pode, uso_inund, uso_novo) + + self.backend.arrays["alt"] = alt_past + delta_alt + self.backend.arrays["uso"] = uso_novo + + +class _FloodVector(SpatialModel): + def setup(self): + self.create_neighborhood(strategy=Queen, silence_warnings=True) + + def execute(self): + nivel_mar = self.env.now() * TAXA + uso_past = self.gdf["uso"].copy() + alt_past = self.gdf["alt"].copy() + + fontes = set(uso_past.index[uso_past.isin(USOS_INUNDADOS) & (alt_past >= 0)]) + alt_nova = alt_past.copy() + + for idx in fontes: + alt_atual = alt_past[idx] + vizinhos = self.neighs_id(idx) + viz_baixos = 1 + sum(1 for n in vizinhos if alt_past[n] <= alt_atual) + fluxo = TAXA / viz_baixos + alt_nova[idx] += fluxo + for n in vizinhos: + if alt_past[n] <= alt_atual: + alt_nova[n] += fluxo + + self.gdf["alt"] = alt_nova + uso_novo = uso_past.copy() + + for idx in self.gdf.index: + uso_atual = uso_past[idx] + if uso_atual not in REGRAS_INUNDACAO: + continue + if alt_past[idx] > nivel_mar: + continue + if any(n in fontes for n in self.neighs_id(idx)): + uso_novo[idx] = REGRAS_INUNDACAO[uso_atual] + + self.gdf["uso"] = uso_novo + + +# ══════════════════════════════════════════════════════════════════════════════ +# GERAÇÃO DE GRADE SINTÉTICA +# ══════════════════════════════════════════════════════════════════════════════ + +def _arrays(rows: int, cols: int, seed: int = 42) -> dict[str, np.ndarray]: + rng = np.random.default_rng(seed) + uso = np.full((rows, cols), 2, dtype=np.int16) # VEG_TERRESTRE + alt = np.zeros((rows, cols), dtype=np.float32) + c_mar = int(cols * 0.20) + uso[:, :c_mar] = MAR + for c in range(cols): + alt[:, c] = max(0.0, (c - c_mar) * 0.1) + alt += rng.uniform(0, 0.05, (rows, cols)).astype(np.float32) + alt[:, :c_mar] = 0.0 + return {"uso": uso, "alt": alt} + + +def _make_backend(rows: int, cols: int) -> RasterBackend: + b = RasterBackend(shape=(rows, cols)) + for name, arr in _arrays(rows, cols).items(): + b.set(name, arr) + return b + + +def _make_gdf(rows: int, cols: int, cell_size: float = 100.0) -> gpd.GeoDataFrame: + arrs = _arrays(rows, cols) + geoms = [ + box(c * cell_size, (rows-1-r) * cell_size, + (c+1) * cell_size, (rows-r) * cell_size) + for r in range(rows) for c in range(cols) + ] + return gpd.GeoDataFrame( + {"uso": arrs["uso"].ravel().astype(int), + "alt": arrs["alt"].ravel().astype(float)}, + geometry=geoms, crs="EPSG:31984", + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# RUNNERS +# ══════════════════════════════════════════════════════════════════════════════ + +@dataclass +class Result: + label: str + rows: int + cols: int + steps: int + total_s: float + uso_final: np.ndarray = field(repr=False) + + @property + def ms_per_step(self) -> float: + return self.total_s / self.steps * 1000 + + +def run_raster(n: int, steps: int) -> Result: + backend = _make_backend(n, n) + env = Environment(start_time=1, end_time=steps) + _FloodRaster(backend=backend) + t0 = time.perf_counter() + env.run() + return Result("raster", n, n, steps, + time.perf_counter() - t0, + backend.get("uso").copy()) + + +def run_vector(n: int, steps: int) -> Result: + gdf = _make_gdf(n, n) + env = Environment(start_time=1, end_time=steps) + _FloodVector(gdf=gdf) + t0 = time.perf_counter() + env.run() + return Result("vector", n, n, steps, + time.perf_counter() - t0, + gdf["uso"].values.reshape(n, n).astype(np.int16)) + + +# ══════════════════════════════════════════════════════════════════════════════ +# MAIN +# ══════════════════════════════════════════════════════════════════════════════ + +VECTOR_MAX_CELLS = 10_000 # acima disso vetor é impraticável + +def benchmark(sizes: list[int], steps: int, validate: bool) -> None: + rows_data = [] + + for n in sizes: + cells = n * n + print(f"\n── {n}×{n} ({cells:,} células, {steps} passos) ──") + + print(f" raster ... ", end="", flush=True) + r = run_raster(n, steps) + print(f"{r.total_s:.3f}s ({r.ms_per_step:.1f} ms/passo)") + + row = { + "grid": f"{n}×{n}", + "cells": cells, + "raster_ms": round(r.ms_per_step, 2), + "vector_ms": "—", + "speedup": "—", + "identical": "—", + } + + if cells <= VECTOR_MAX_CELLS: + print(f" vector ... ", end="", flush=True) + v = run_vector(n, steps) + print(f"{v.total_s:.3f}s ({v.ms_per_step:.1f} ms/passo)") + + speedup = v.total_s / r.total_s if r.total_s > 0 else float("inf") + print(f" speedup: {speedup:.0f}×") + + if validate: + match = r.uso_final == v.uso_final + pct = float(match.mean()) * 100 + ok = bool(match.all()) + print(f" validação: {'✅ idêntico' if ok else f'⚠️ {pct:.1f}% iguais'}") + row["identical"] = "yes" if ok else f"{pct:.1f}%" + + row["vector_ms"] = round(v.ms_per_step, 2) + row["speedup"] = f"{speedup:.0f}×" + else: + print(f" vector: skipped (>{VECTOR_MAX_CELLS:,} células)") + + rows_data.append(row) + + print("\n" + "═" * 60) + print("RESUMO") + print("═" * 60) + print(pd.DataFrame(rows_data).to_string(index=False)) + print() + + +def _parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="Benchmark RasterBackend vs GeoDataFrame") + p.add_argument("--sizes", nargs="+", type=int, default=[10, 50, 100, 200, 500]) + p.add_argument("--steps", type=int, default=10) + p.add_argument("--no-validation", dest="validate", action="store_false") + return p.parse_args() + + +if __name__ == "__main__": + args = _parse_args() + benchmark(sizes=args.sizes, steps=args.steps, validate=args.validate) diff --git a/examples/cli/coastal_dynamics/vector/mangue_model.py b/examples/cli/coastal_dynamics/vector/mangue_model.py new file mode 100644 index 0000000..f765020 --- /dev/null +++ b/examples/cli/coastal_dynamics/vector/mangue_model.py @@ -0,0 +1,164 @@ +""" +brmangue/mangue_vector_model.py — Modelo Mangue (versão GeoDataFrame) +====================================================================== +Versão do MangroveModel usando GeoDataFrame + SpatialModel, +para comparação direta com a versão NumPy (mangue_raster_model.py). + +Mesma lógica, diferente substrato: + + mangue_raster_model.py RasterBackend (NumPy, vetorizado) + mangue_vector_model.py ← GeoDataFrame (libpysal, célula a célula) + +Três processos por passo — ordem idêntica ao Lua e à versão raster: + + 1. migrarSolos — propaga substrato de mangue + 2. migrarUsos — propaga uso MANGUE_MIGRADO (usa solo_past) + 3. aplicarAcrecao — eleva altitude (Alongi 2008, False por padrão) + +NOTA CRÍTICA: migrarUsos usa solo_past — fiel ao .past do TerraME. + +Uso +--- + from dissmodel.core import Environment + from brmangue.mangue_vector_model import MangroveVectorModel + import geopandas as gpd + + gdf = gpd.read_file("flood_model.shp") + env = Environment(start_time=1, end_time=88) + MangroveVectorModel(gdf=gdf, taxa_elevacao=0.011) + env.run() +""" +from __future__ import annotations + +import geopandas as gpd +from libpysal.weights import Queen + +from dissmodel.geo.spatial_model import SpatialModel + +from coastal_dynamics.common.constants import ( + MANGUE, + MANGUE_MIGRADO, + VEGETACAO_TERRESTRE, + SOLO_DESCOBERTO, + USOS_INUNDADOS, + SOLO_MANGUE, + SOLO_MANGUE_MIGRADO, + SOLO_CANAL_FLUVIAL, +) + + +class MangroveModel(SpatialModel): + """ + Mangue (mangue.lua) → DisSModel + GeoDataFrame. + + Equivalência com a versão Raster + --------------------------------- + np.isin(solo, SOLOS_FONTE) → solo_past.isin(SOLOS_FONTE) + shift2d loop sobre DIRS_MOORE → loop sobre vizinhos reais do GDF + np.where(cond, novo, atual) → solo_novo[idx] = SOLO_MANGUE_MIGRADO + solo_past (não solo_novo) → solo_past[idx] — mesmo cuidado .past + + Parâmetros + ---------- + gdf : GeoDataFrame com colunas attr_uso, attr_alt, attr_solo + taxa_elevacao : m/ano — IPCC RCP8.5 = 0.011 + altura_mare : AIM base em metros. Padrão: 6.0 + acrecao_ativa : habilita aplicarAcrecao (Alongi 2008). Padrão: False + attr_uso : coluna de uso do solo. Padrão: "uso" + attr_alt : coluna de altitude. Padrão: "alt" + attr_solo : coluna de tipo de solo. Padrão: "solo" + """ + + SOLOS_FONTE = [SOLO_MANGUE, SOLO_MANGUE_MIGRADO, SOLO_CANAL_FLUVIAL] + SOLOS_MANGUE = [SOLO_MANGUE, SOLO_MANGUE_MIGRADO] + USOS_FONTE = [MANGUE, MANGUE_MIGRADO] + USOS_ALVO = [VEGETACAO_TERRESTRE, SOLO_DESCOBERTO] + COEF_A, COEF_B = 1.693, 0.939 # Alongi 2008 + + def setup( + self, + taxa_elevacao: float = 0.011, + altura_mare: float = 6.0, + acrecao_ativa: bool = False, + attr_uso: str = "uso", + attr_alt: str = "alt", + attr_solo: str = "solo", + ) -> None: + self.taxa_elevacao = taxa_elevacao + self.altura_mare = altura_mare + self.acrecao_ativa = acrecao_ativa + self.attr_uso = attr_uso + self.attr_alt = attr_alt + self.attr_solo = attr_solo + + # métricas expostas para @track_plot / Chart + self.mangue_migrado = 0 + self.solo_migrado = 0 + + self.create_neighborhood(strategy=Queen, silence_warnings=True) + + def execute(self) -> None: + nivel_mar = self.env.now() * self.taxa_elevacao + zi = self.altura_mare + nivel_mar + taxa_ac = self.COEF_A / 1000.0 + self.COEF_B * nivel_mar + + # snapshots — equivale a celula.past[] do TerraME + uso_past = self.gdf[self.attr_uso].copy() + alt_past = self.gdf[self.attr_alt].copy() + solo_past = self.gdf[self.attr_solo].copy() + + # ── migrarSolos ─────────────────────────────────────────────────────── + # Fonte: celula.past[solo] in SOLOS_FONTE + # Alvo: viz.uso in USOS_ALVO + # viz.solo != SOLO_MANGUE_MIGRADO + # viz.alt <= zonaInfluencia + fontes_solo = set( + solo_past.index[solo_past.isin(self.SOLOS_FONTE)] + ) + solo_novo = solo_past.copy() + + for idx in self.gdf.index: + if uso_past[idx] not in self.USOS_ALVO: + continue + if solo_past[idx] == SOLO_MANGUE_MIGRADO: + continue + if alt_past[idx] > zi: + continue + if any(n in fontes_solo for n in self.neighs_id(idx)): + solo_novo[idx] = SOLO_MANGUE_MIGRADO + + # ── migrarUsos ──────────────────────────────────────────────────────── + # Fonte: celula.past[uso] in USOS_FONTE + # Alvo: viz.uso in USOS_ALVO + # viz.solo in SOLOS_MANGUE ← solo_PAST, não solo_novo + # viz.alt <= zonaInfluencia + fontes_uso = set( + uso_past.index[uso_past.isin(self.USOS_FONTE)] + ) + uso_novo = uso_past.copy() + + for idx in self.gdf.index: + if uso_past[idx] not in self.USOS_ALVO: + continue + if solo_past[idx] not in self.SOLOS_MANGUE: # ← solo_past + continue + if alt_past[idx] > zi: + continue + if any(n in fontes_uso for n in self.neighs_id(idx)): + uso_novo[idx] = MANGUE_MIGRADO + + # ── aplicarAcrecao (False por padrão — comentada no Lua) ───────────── + if self.acrecao_ativa: + alt_nova = alt_past.copy() + for idx in self.gdf.index: + if solo_past[idx] in self.SOLOS_MANGUE: + if uso_past[idx] not in USOS_INUNDADOS: + alt_nova[idx] += taxa_ac + self.gdf[self.attr_alt] = alt_nova + + self.gdf[self.attr_uso] = uso_novo + self.gdf[self.attr_solo] = solo_novo + + # ── métricas ────────────────────────────────────────────────────────── + self.mangue_migrado = int((uso_novo == MANGUE_MIGRADO).sum()) + self.solo_migrado = int((solo_novo == SOLO_MANGUE_MIGRADO).sum()) diff --git a/examples/cli/coastal_dynamics/vector/run.py b/examples/cli/coastal_dynamics/vector/run.py index bca8f43..b8ed2de 100644 --- a/examples/cli/coastal_dynamics/vector/run.py +++ b/examples/cli/coastal_dynamics/vector/run.py @@ -3,49 +3,59 @@ ========================================================================= Versão vetorial para comparação com run.py (RasterBackend). -Usa FloodVectorModel + GeoDataFrame + Map/Chart do DisSModel. +Usa FloodVectorModel + MangroveVectorModel + GeoDataFrame + Map/Chart. Uso --- python -m brmangue.run_vector flood_model.shp python -m brmangue.run_vector flood_model.gpkg --taxa 0.05 python -m brmangue.run_vector flood_model.shp --chart + python -m brmangue.run_vector flood_model.shp --acrecao --no-save """ from __future__ import annotations import argparse import pathlib -import sys -import numpy as np import geopandas as gpd from matplotlib.colors import ListedColormap, BoundaryNorm from dissmodel.core import Environment from dissmodel.visualization import Map, Chart -from coastal_dynamics.common.constants import USO_COLORS, USO_LABELS +from coastal_dynamics.common.constants import ( + USO_COLORS, USO_LABELS, + SOLO_COLORS, SOLO_LABELS, +) from examples.cli.coastal_dynamics.vector.flood_model import FloodVectorModel +from examples.cli.coastal_dynamics.vector.mangue_model import MangroveModel # ── configuração ────────────────────────────────────────────────────────────── TAXA_ELEVACAO = 0.011 +ALTURA_MARE = 6.0 END_TIME = 88 -# ListedColormap alinhado com tabela_usos do Lua -_vals = sorted(USO_COLORS) +_vals = sorted(USO_COLORS) USO_CMAP = ListedColormap([USO_COLORS[k] for k in _vals]) USO_NORM = BoundaryNorm([v - 0.5 for v in _vals] + [_vals[-1] + 0.5], USO_CMAP.N) +_svals = sorted(SOLO_COLORS) +SOLO_CMAP = ListedColormap([SOLO_COLORS[k] for k in _svals]) +SOLO_NORM = BoundaryNorm([v - 0.5 for v in _svals] + [_svals[-1] + 0.5], SOLO_CMAP.N) + # ── main ────────────────────────────────────────────────────────────────────── def run( shp_path: str | pathlib.Path, taxa_elevacao: float = TAXA_ELEVACAO, + altura_mare: float = ALTURA_MARE, + acrecao_ativa: bool = False, attr_uso: str = "uso", attr_alt: str = "alt", + attr_solo: str = "solo", show_chart: bool = False, save: bool = True, ) -> None: @@ -59,34 +69,31 @@ def run( # ── ambiente ────────────────────────────────────────────────────────────── env = Environment(start_time=1, end_time=END_TIME) - # ── modelo ──────────────────────────────────────────────────────────────── + # ── modelos — compartilham o mesmo gdf ──────────────────────────────────── + # Ordem de instanciação = ordem de execução por passo FloodVectorModel( gdf = gdf, taxa_elevacao = taxa_elevacao, attr_uso = attr_uso, attr_alt = attr_alt, ) + MangroveModel( + gdf = gdf, + taxa_elevacao = taxa_elevacao, + altura_mare = altura_mare, + acrecao_ativa = acrecao_ativa, + attr_uso = attr_uso, + attr_alt = attr_alt, + attr_solo = attr_solo, + ) # ── visualização ────────────────────────────────────────────────────────── - Map( - gdf = gdf, - plot_params = { - "column": attr_uso, - "cmap": USO_CMAP, - "norm": USO_NORM, - "legend": False, # legenda manual seria necessária para labels - }, - ) - Map( - gdf = gdf, - plot_params = { - "column": attr_alt, - "cmap": "terrain", - "legend": True, - }, - ) + Map(gdf=gdf, plot_params={"column": attr_uso, "cmap": USO_CMAP, "norm": USO_NORM, "legend": False}) + Map(gdf=gdf, plot_params={"column": attr_alt, "cmap": "terrain", "legend": True}) + Map(gdf=gdf, plot_params={"column": attr_solo, "cmap": SOLO_CMAP, "norm": SOLO_NORM, "legend": False}) + if show_chart: - Chart(select={"celulas_inundadas"}) + Chart(select={"celulas_inundadas", "mangue_migrado"}) # ── execução ────────────────────────────────────────────────────────────── print(f"Executando passos 1 → {END_TIME}...") @@ -113,16 +120,28 @@ def _parse_args() -> argparse.Namespace: help=f"Taxa de elevação do mar em m/ano (padrão: {TAXA_ELEVACAO})", ) p.add_argument( - "--attr-uso", default="uso", metavar="COL", + "--altura-mare", type=float, default=ALTURA_MARE, metavar="M", + help=f"AIM base em metros (padrão: {ALTURA_MARE})", + ) + p.add_argument( + "--acrecao", action="store_true", + help="Ativa aplicarAcrecao no MangroveVectorModel (Alongi 2008)", + ) + p.add_argument( + "--attr-uso", default="uso", metavar="COL", help="Coluna de uso do solo (padrão: uso)", ) p.add_argument( - "--attr-alt", default="alt", metavar="COL", + "--attr-alt", default="alt", metavar="COL", help="Coluna de altitude (padrão: alt)", ) + p.add_argument( + "--attr-solo", default="solo", metavar="COL", + help="Coluna de tipo de solo (padrão: solo)", + ) p.add_argument( "--chart", action="store_true", - help="Exibe gráfico de células inundadas por passo", + help="Exibe gráfico de métricas por passo", ) p.add_argument( "--no-save", dest="save", action="store_false", @@ -136,8 +155,11 @@ def _parse_args() -> argparse.Namespace: run( shp_path = args.shp, taxa_elevacao = args.taxa, + altura_mare = args.altura_mare, + acrecao_ativa = args.acrecao, attr_uso = args.attr_uso, attr_alt = args.attr_alt, + attr_solo = args.attr_solo, show_chart = args.chart, save = args.save, ) From d7399df1d742b31f365df898bfe66748e6481db9 Mon Sep 17 00:00:00 2001 From: Sergio Souza Costa Date: Tue, 10 Mar 2026 13:33:39 -0300 Subject: [PATCH 09/12] feat: add RasterCellularAutomaton, make_raster_grid and raster examples --- dissmodel/geo/raster_cellular_automaton.py | 156 +++++++++++++++++++ dissmodel/geo/raster_grid.py | 98 ++++++++++++ dissmodel/models/ca/fire_model_raster.py | 171 +++++++++++++++++++++ dissmodel/models/ca/game_of_life_raster.py | 134 ++++++++++++++++ examples/cli/ca_game_of_life_raster.py | 44 ++++++ 5 files changed, 603 insertions(+) create mode 100644 dissmodel/geo/raster_cellular_automaton.py create mode 100644 dissmodel/geo/raster_grid.py create mode 100644 dissmodel/models/ca/fire_model_raster.py create mode 100644 dissmodel/models/ca/game_of_life_raster.py create mode 100644 examples/cli/ca_game_of_life_raster.py diff --git a/dissmodel/geo/raster_cellular_automaton.py b/dissmodel/geo/raster_cellular_automaton.py new file mode 100644 index 0000000..34c0c6d --- /dev/null +++ b/dissmodel/geo/raster_cellular_automaton.py @@ -0,0 +1,156 @@ +""" +dissmodel/geo/raster_cellular_automaton.py +========================================== +Base class for cellular automata backed by RasterBackend (NumPy 2D arrays). + +Analogous to CellularAutomaton (GeoDataFrame), but for the raster substrate. + +Hierarchy +--------- + Model + ├── SpatialModel + │ └── CellularAutomaton rule(idx) → value (vector, pull) + └── RasterModel + └── RasterCellularAutomaton rule(arrays) → arrays (raster, vectorized) + +Why a different rule() contract +-------------------------------- +CellularAutomaton.rule(idx) returns a single value for one cell — it is +called once per cell per step (O(n) Python calls). This is correct for +the vector substrate where neighborhood lookup is the bottleneck. + +For the raster substrate, the bottleneck is the Python loop itself. +RasterCellularAutomaton.rule() receives the full snapshot of all arrays +and returns a dict of updated arrays — one NumPy call covers the entire +grid. This is the natural pattern for NumPy-based CA. + +Comparison +---------- + # vector CA — rule called n times per step + class GameOfLife(CellularAutomaton): + def rule(self, idx): + alive = self.neighbor_values(idx, "state").sum() + ... + return new_state + + # raster CA — rule called once per step + class GameOfLife(RasterCellularAutomaton): + def rule(self, arrays): + state = arrays["state"] + alive = backend.focal_sum_mask(state == 1) + ... + return {"state": new_state} + +Usage +----- + from dissmodel.geo.raster_cellular_automaton import RasterCellularAutomaton + from dissmodel.geo.raster_backend import RasterBackend + from dissmodel.core import Environment + import numpy as np + + class GameOfLife(RasterCellularAutomaton): + def rule(self, arrays): + state = arrays["state"] + neighbors = self.backend.focal_sum_mask(state == 1) + born = (state == 0) & (neighbors == 3) + survive = (state == 1) & np.isin(neighbors, [2, 3]) + return {"state": np.where(born | survive, 1, 0)} + + b = RasterBackend(shape=(50, 50)) + b.set("state", np.random.randint(0, 2, (50, 50))) + + env = Environment(start_time=1, end_time=100) + GameOfLife(backend=b) + env.run() +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +import numpy as np + +from dissmodel.geo.raster_model import RasterModel +from dissmodel.geo.raster_backend import RasterBackend + + +class RasterCellularAutomaton(RasterModel, ABC): + """ + Base class for NumPy-based cellular automata. + + Extends :class:`~dissmodel.geo.raster_model.RasterModel` with a + vectorized transition rule — ``rule()`` receives all arrays as a + snapshot and returns a dict of updated arrays. + + Parameters + ---------- + backend : RasterBackend + Shared backend with the simulation arrays. + state_attr : str, optional + Primary state array name, by default ``"state"``. + Used only for introspection/logging — rule() can update any array. + **kwargs : + Extra keyword arguments forwarded to RasterModel. + + Examples + -------- + >>> class MyCA(RasterCellularAutomaton): + ... def rule(self, arrays): + ... state = arrays["state"] + ... # ... NumPy operations over full grid ... + ... return {"state": new_state} + """ + + def setup( + self, + backend: RasterBackend, + state_attr: str = "state", + **kwargs: Any, + ) -> None: + super().setup(backend) + self.state_attr = state_attr + + @abstractmethod + def rule(self, arrays: dict[str, np.ndarray]) -> dict[str, np.ndarray]: + """ + Vectorized transition rule applied to the full grid. + + Receives a snapshot of all arrays (equivalent to celula.past[] in + TerraME) and returns a dict with the arrays to update. + + Only the arrays present in the returned dict are written back — + arrays not returned are left unchanged. + + Parameters + ---------- + arrays : dict[str, np.ndarray] + Snapshot of backend arrays at the start of the step. + Modifying these arrays does NOT affect the backend — they are + copies (equivalent to .past semantics). + + Returns + ------- + dict[str, np.ndarray] + Dict mapping array name → new array. Partial updates allowed. + + Examples + -------- + >>> def rule(self, arrays): + ... state = arrays["state"] # read from snapshot + ... neighbors = self.backend.focal_sum_mask(state == 1) + ... new_state = np.where(neighbors > 3, 0, state) + ... return {"state": new_state} # write back + """ + raise NotImplementedError("Subclasses must implement rule().") + + def execute(self) -> None: + """ + Execute one simulation step by calling rule() once over the full grid. + + Takes a snapshot of all arrays (past state), passes it to rule(), + and writes the returned arrays back to the backend. + """ + past = self.backend.snapshot() # equivale a celula.past[] + updates = self.rule(past) + for name, arr in updates.items(): + self.backend.arrays[name] = arr diff --git a/dissmodel/geo/raster_grid.py b/dissmodel/geo/raster_grid.py new file mode 100644 index 0000000..448fca3 --- /dev/null +++ b/dissmodel/geo/raster_grid.py @@ -0,0 +1,98 @@ +""" +dissmodel/geo/raster_grid.py +============================= +Utilitário para criar RasterBackend sintético. + +Análogo a regular_grid() (GeoDataFrame), mas para o substrato NumPy. + +Uso +--- + from dissmodel.geo.raster_grid import make_raster_grid + import numpy as np + + # grade vazia com arrays zerados + b = make_raster_grid(rows=50, cols=50, attrs={"state": 0}) + + # grade com array inicial customizado + b = make_raster_grid( + rows=50, cols=50, + attrs={"state": np.random.randint(0, 2, (50, 50))} + ) +""" +from __future__ import annotations + +from typing import Any, Union + +import numpy as np + +from dissmodel.geo.raster_backend import RasterBackend + +# Valor escalar ou array pré-computado +AttrValue = Union[int, float, np.ndarray] + + +def make_raster_grid( + rows: int, + cols: int, + attrs: dict[str, AttrValue] | None = None, + dtype: np.dtype | None = None, +) -> RasterBackend: + """ + Create a RasterBackend with optional pre-filled arrays. + + Analogous to :func:`~dissmodel.geo.regular_grid` for the raster + substrate. Useful for tests, examples, and synthetic benchmarks. + + Parameters + ---------- + rows : int + Number of rows in the grid. + cols : int + Number of columns in the grid. + attrs : dict, optional + Mapping of array name → initial value. + - scalar (int or float): fills the entire grid with that value. + - np.ndarray of shape (rows, cols): used directly (a copy is stored). + If not provided, an empty backend is returned. + dtype : numpy dtype, optional + Default dtype for scalar-initialized arrays. If None, inferred + from the scalar type (int → np.int32, float → np.float64). + + Returns + ------- + RasterBackend + Backend with shape (rows, cols) and the requested arrays. + + Examples + -------- + >>> b = make_raster_grid(10, 10, attrs={"state": 0}) + >>> b.shape + (10, 10) + >>> b.get("state").shape + (10, 10) + + >>> import numpy as np + >>> state = np.random.randint(0, 2, (10, 10)) + >>> b = make_raster_grid(10, 10, attrs={"state": state}) + """ + b = RasterBackend(shape=(rows, cols)) + + for name, value in (attrs or {}).items(): + if isinstance(value, np.ndarray): + if value.shape != (rows, cols): + raise ValueError( + f"Array '{name}' has shape {value.shape}, " + f"expected ({rows}, {cols})." + ) + b.set(name, value) + else: + # scalar → infer dtype + if dtype is not None: + arr_dtype = dtype + elif isinstance(value, float): + arr_dtype = np.float64 + else: + arr_dtype = np.int32 + b.set(name, np.full((rows, cols), value, dtype=arr_dtype)) + + return b diff --git a/dissmodel/models/ca/fire_model_raster.py b/dissmodel/models/ca/fire_model_raster.py new file mode 100644 index 0000000..53d9bb4 --- /dev/null +++ b/dissmodel/models/ca/fire_model_raster.py @@ -0,0 +1,171 @@ +""" +dissmodel/examples/fire_model_raster.py +======================================== +Raster version of FireModel using RasterCellularAutomaton. + +Symmetric counterpart of FireModel (GeoDataFrame + CellularAutomaton). +Same logic, different substrate — rule() operates on the full NumPy +grid in one vectorized call instead of cell-by-cell. + +Comparison +---------- + # vector — rule called once per cell per step + class FireModel(CellularAutomaton): + def rule(self, idx): + state = self.gdf.loc[idx, self.state_attr] + if state == BURNING: return BURNED + if state == FOREST: + if (self.neighbor_values(idx, "state") == BURNING).any(): + return BURNING + return state + + # raster — rule called once per step, covers all cells + class FireModel(RasterCellularAutomaton): + def rule(self, arrays): + state = arrays["state"] + has_burning = self.backend.focal_sum_mask(state == BURNING) > 0 + new_state = np.where(state == BURNING, BURNED, state) + new_state = np.where((state == FOREST) & has_burning, BURNING, new_state) + return {"state": new_state} + +Usage +----- + from dissmodel.core import Environment + from dissmodel.geo.raster_backend import RasterBackend + from dissmodel.examples.fire_model_raster import FireModel + import numpy as np + + b = RasterBackend(shape=(50, 50)) + rng = np.random.default_rng(42) + state = np.where(rng.random((50, 50)) < 0.05, FireState.BURNING, FireState.FOREST) + b.set("state", state.astype(np.int8)) + + env = Environment(start_time=1, end_time=30) + FireModel(backend=b) + env.run() +""" +from __future__ import annotations + +from enum import IntEnum + +import numpy as np + +from dissmodel.geo.raster_backend import RasterBackend, DIRS_VON_NEUMANN +from dissmodel.geo.raster_cellular_automaton import RasterCellularAutomaton + + +class FireState(IntEnum): + """ + Possible states for a cell in :class:`FireModel`. + + Attributes + ---------- + FOREST : int + Healthy tree, can catch fire. + BURNING : int + Actively burning, spreads fire to neighbors. + BURNED : int + Already burned, no longer spreads. + """ + FOREST = 0 + BURNING = 1 + BURNED = 2 + + +class FireModel(RasterCellularAutomaton): + """ + Raster cellular automaton simulating forest fire spread. + + Symmetric counterpart of the vector FireModel — same state machine, + same Rook (Von Neumann, 4-direction) neighborhood, fully vectorized. + + The fire spreads to any FOREST cell that has at least one BURNING + neighbor. BURNING cells become BURNED in the next step. + + Parameters + ---------- + backend : RasterBackend + Shared backend. Must contain a ``"state"`` array (or the name + set via ``state_attr``) with values from :class:`FireState`. + initial_fire_density : float, optional + Proportion of cells that start as BURNING, by default 0.05. + Only used if ``initialize()`` is called. + seed : int, optional + Random seed for initialization, by default 42. + state_attr : str, optional + Name of the state array in the backend, by default ``"state"``. + + Examples + -------- + >>> from dissmodel.core import Environment + >>> from dissmodel.geo.raster_backend import RasterBackend + >>> import numpy as np + >>> b = RasterBackend(shape=(20, 20)) + >>> rng = np.random.default_rng(42) + >>> state = np.where(rng.random((20, 20)) < 0.05, FireState.BURNING, FireState.FOREST) + >>> b.set("state", state.astype(np.int8)) + >>> env = Environment(start_time=1, end_time=20) + >>> FireModel(backend=b) + """ + + def setup( + self, + backend: RasterBackend, + initial_fire_density: float = 0.05, + seed: int = 42, + state_attr: str = "state", + ) -> None: + super().setup(backend, state_attr=state_attr) + self.initial_fire_density = initial_fire_density + self.seed = seed + # Rook = Von Neumann (4 directions) — same as vector FireModel + self.dirs = DIRS_VON_NEUMANN + + def initialize(self) -> None: + """ + Fill the grid with a random initial state. + + Uses :attr:`initial_fire_density` to determine the proportion of + cells that start as BURNING. The remaining cells start as FOREST. + Only needed when the backend array is not already initialized. + """ + rng = np.random.default_rng(self.seed) + state = np.where( + rng.random(self.shape) < self.initial_fire_density, + int(FireState.BURNING), + int(FireState.FOREST), + ).astype(np.int8) + self.backend.set(self.state_attr, state) + + def rule(self, arrays: dict) -> dict: + """ + Vectorized fire spread transition rule. + + Applied once per step over the entire grid: + + - BURNING → BURNED + - FOREST with ≥ 1 BURNING neighbor → BURNING (Rook neighborhood) + - otherwise → unchanged + + Parameters + ---------- + arrays : dict[str, np.ndarray] + Snapshot of backend arrays (past state). + + Returns + ------- + dict[str, np.ndarray] + Updated ``"state"`` array. + """ + state = arrays[self.state_attr] + + # count BURNING neighbors (Von Neumann / Rook — 4 directions) + has_burning = self.backend.focal_sum_mask( + state == int(FireState.BURNING) + ) > 0 + + new_state = state.copy() + new_state = np.where(state == int(FireState.BURNING), int(FireState.BURNED), new_state) + new_state = np.where((state == int(FireState.FOREST)) & has_burning, int(FireState.BURNING), new_state) + + return {self.state_attr: new_state.astype(np.int8)} diff --git a/dissmodel/models/ca/game_of_life_raster.py b/dissmodel/models/ca/game_of_life_raster.py new file mode 100644 index 0000000..4017de4 --- /dev/null +++ b/dissmodel/models/ca/game_of_life_raster.py @@ -0,0 +1,134 @@ +""" +dissmodel/examples/game_of_life_raster.py +========================================== +Raster version of Conway's Game of Life using RasterCellularAutomaton. + +Symmetric counterpart of the vector GameOfLife (GeoDataFrame + CellularAutomaton). +Same rules, fully vectorized — rule() operates on the full NumPy grid in a +single call instead of cell-by-cell. + +Rules (Conway 1970) +------------------- +- Any live cell with 2 or 3 live neighbors survives. +- Any dead cell with exactly 3 live neighbors becomes alive. +- All other cells die or remain dead. + +Comparison +---------- + # vector — rule called once per cell per step + class GameOfLife(CellularAutomaton): + def rule(self, idx): + state = self.gdf.loc[idx, self.state_attr] + neighbors = (self.neighbor_values(idx, "state") == 1).sum() + if state == 1: return 1 if neighbors in (2, 3) else 0 + else: return 1 if neighbors == 3 else 0 + + # raster — rule called once per step, covers all cells + class GameOfLife(RasterCellularAutomaton): + def rule(self, arrays): + state = arrays["state"] + neighbors = self.backend.focal_sum_mask(state == 1) + survive = (state == 1) & np.isin(neighbors, [2, 3]) + born = (state == 0) & (neighbors == 3) + return {"state": np.where(survive | born, 1, 0).astype(np.int8)} + +Usage +----- + from dissmodel.core import Environment + from dissmodel.geo.raster_grid import make_raster_grid + from dissmodel.examples.game_of_life_raster import GameOfLife + import numpy as np + + rng = np.random.default_rng(42) + b = make_raster_grid(50, 50, attrs={"state": rng.integers(0, 2, (50, 50))}) + + env = Environment(start_time=1, end_time=100) + GameOfLife(backend=b) + env.run() +""" +from __future__ import annotations + +import numpy as np + +from dissmodel.geo.raster_backend import RasterBackend +from dissmodel.geo.raster_grid import make_raster_grid +from dissmodel.geo.raster_cellular_automaton import RasterCellularAutomaton + + +class GameOfLife(RasterCellularAutomaton): + """ + Raster cellular automaton implementing Conway's Game of Life. + + Symmetric counterpart of the vector GameOfLife — same rules, + Moore neighborhood (8 directions), fully vectorized. + + Parameters + ---------- + backend : RasterBackend + Shared backend. Must contain a ``"state"`` array (or the name + set via ``state_attr``) with values 0 (dead) or 1 (alive). + density : float, optional + Proportion of cells initially alive, by default 0.3. + Only used if ``initialize()`` is called. + seed : int, optional + Random seed for initialization, by default 42. + state_attr : str, optional + Name of the state array in the backend, by default ``"state"``. + + Examples + -------- + >>> from dissmodel.core import Environment + >>> from dissmodel.geo.raster_grid import make_raster_grid + >>> import numpy as np + >>> rng = np.random.default_rng(0) + >>> b = make_raster_grid(20, 20, attrs={"state": rng.integers(0, 2, (20, 20))}) + >>> env = Environment(start_time=1, end_time=10) + >>> GameOfLife(backend=b) + """ + + def setup( + self, + backend: RasterBackend, + density: float = 0.3, + seed: int = 42, + state_attr: str = "state", + ) -> None: + super().setup(backend, state_attr=state_attr) + self.density = density + self.seed = seed + + def initialize(self) -> None: + """ + Fill the grid with a random initial state. + + Uses :attr:`density` to determine the proportion of live cells. + Only needed when the backend array is not already initialized. + """ + rng = np.random.default_rng(self.seed) + state = (rng.random(self.shape) < self.density).astype(np.int8) + self.backend.set(self.state_attr, state) + + def rule(self, arrays: dict) -> dict: + """ + Vectorized Conway transition rule. + + Applied once per step over the entire grid using Moore neighborhood + (8 directions — all 8 neighbors counted via focal_sum_mask). + + Parameters + ---------- + arrays : dict[str, np.ndarray] + Snapshot of backend arrays (past state). + + Returns + ------- + dict[str, np.ndarray] + Updated ``"state"`` array (0 = dead, 1 = alive). + """ + state = arrays[self.state_attr] + neighbors = self.backend.focal_sum_mask(state == 1) + + survive = (state == 1) & np.isin(neighbors, [2, 3]) + born = (state == 0) & (neighbors == 3) + + return {self.state_attr: np.where(survive | born, 1, 0).astype(np.int8)} diff --git a/examples/cli/ca_game_of_life_raster.py b/examples/cli/ca_game_of_life_raster.py new file mode 100644 index 0000000..ee93f70 --- /dev/null +++ b/examples/cli/ca_game_of_life_raster.py @@ -0,0 +1,44 @@ +""" +Game of Life — CLI example (raster) +===================================== +Raster version of the Game of Life using RasterCellularAutomaton + RasterMap. + +Same rules as the vector version, fully vectorized over NumPy arrays. + +Usage +----- + python examples/cli/ca_game_of_life_raster.py + RASTER_MAP_INTERACTIVE=1 python examples/cli/ca_game_of_life_raster.py +""" +from __future__ import annotations + +from dissmodel.core import Environment +from dissmodel.geo.raster_grid import make_raster_grid +from dissmodel.models.ca.game_of_life_raster import GameOfLife +from dissmodel.visualization.raster_map import RasterMap + +# --------------------------------------------------------------------------- +# Setup +# --------------------------------------------------------------------------- +b = make_raster_grid(rows=1000, cols=1000, attrs={"state": 0}) + +env = Environment(start_time=0, end_time=10) + +gol = GameOfLife(backend=b) +gol.initialize() + +# --------------------------------------------------------------------------- +# Visualization +# --------------------------------------------------------------------------- +RasterMap( + backend = b, + band = "state", + color_map = {0: "#ffffff", 1: "#000000"}, + labels = {0: "dead", 1: "alive"}, + title = "Game of Life", +) + +# --------------------------------------------------------------------------- +# Run +# --------------------------------------------------------------------------- +env.run() From b06343031985b50a5f22d146ec458932b62438e5 Mon Sep 17 00:00:00 2001 From: Sergio Souza Costa Date: Tue, 10 Mar 2026 14:17:53 -0300 Subject: [PATCH 10/12] feat: organized geo module --- benchmarks/benchmark_raster_vs_vector.py | 2 +- benchmarks/ca_game_of_life.py | 2 +- dissmodel/geo/__init__.py | 21 +- dissmodel/geo/raster/__init__.py | 0 .../{raster_backend.py => raster/backend.py} | 0 dissmodel/geo/{ => raster}/band_spec.py | 0 .../cellular_automaton.py} | 4 +- dissmodel/geo/{raster_io.py => raster/io.py} | 2 +- .../geo/{raster_model.py => raster/model.py} | 2 +- .../regular_grid.py} | 2 +- dissmodel/geo/vector/__init__.py | 0 .../cellular_automaton.py} | 4 +- dissmodel/geo/{ => vector}/fill.py | 0 .../geo/{spatial_model.py => vector/model.py} | 2 +- dissmodel/geo/{ => vector}/neighborhood.py | 0 dissmodel/geo/{ => vector}/regular_grid.py | 0 dissmodel/models/ca/game_of_life.py | 2 +- dissmodel/models/ca/game_of_life_raster.py | 6 +- examples/cli/ca_game_of_life_raster.py | 2 +- .../coastal_dynamics/vector/flood_model.py | 2 +- .../coastal_dynamics/vector/mangue_model.py | 2 +- tests/geo/test_cellular_automaton.py | 231 ++++++++++++++++++ tests/geo/{test_raster.py => test_raster_py} | 0 .../tests_fill_pattern_py} | 2 +- tests/test_cellular_automaton.py | 78 ------ 25 files changed, 266 insertions(+), 100 deletions(-) create mode 100644 dissmodel/geo/raster/__init__.py rename dissmodel/geo/{raster_backend.py => raster/backend.py} (100%) rename dissmodel/geo/{ => raster}/band_spec.py (100%) rename dissmodel/geo/{raster_cellular_automaton.py => raster/cellular_automaton.py} (98%) rename dissmodel/geo/{raster_io.py => raster/io.py} (97%) rename dissmodel/geo/{raster_model.py => raster/model.py} (96%) rename dissmodel/geo/{raster_grid.py => raster/regular_grid.py} (98%) create mode 100644 dissmodel/geo/vector/__init__.py rename dissmodel/geo/{celullar_automaton.py => vector/cellular_automaton.py} (97%) rename dissmodel/geo/{ => vector}/fill.py (100%) rename dissmodel/geo/{spatial_model.py => vector/model.py} (99%) rename dissmodel/geo/{ => vector}/neighborhood.py (100%) rename dissmodel/geo/{ => vector}/regular_grid.py (100%) create mode 100644 tests/geo/test_cellular_automaton.py rename tests/geo/{test_raster.py => test_raster_py} (100%) rename tests/{tests_fill_pattern.py => geo/tests_fill_pattern_py} (96%) delete mode 100644 tests/test_cellular_automaton.py diff --git a/benchmarks/benchmark_raster_vs_vector.py b/benchmarks/benchmark_raster_vs_vector.py index 2eeaedf..32dcf28 100644 --- a/benchmarks/benchmark_raster_vs_vector.py +++ b/benchmarks/benchmark_raster_vs_vector.py @@ -28,7 +28,7 @@ from dissmodel.core import Environment from dissmodel.geo.raster_backend import RasterBackend, DIRS_MOORE from dissmodel.geo.raster_model import RasterModel -from dissmodel.geo.spatial_model import SpatialModel +from dissmodel.geo.raster.spatial_model import SpatialModel # ══════════════════════════════════════════════════════════════════════════════ diff --git a/benchmarks/ca_game_of_life.py b/benchmarks/ca_game_of_life.py index 9736d90..a0bf4bd 100644 --- a/benchmarks/ca_game_of_life.py +++ b/benchmarks/ca_game_of_life.py @@ -19,7 +19,7 @@ from dissmodel.core import Environment from dissmodel.geo import FillStrategy, fill, regular_grid -from dissmodel.geo.celullar_automaton import CellularAutomaton +from dissmodel.geo.raster.celullar_automaton import CellularAutomaton # --------------------------------------------------------------------------- diff --git a/dissmodel/geo/__init__.py b/dissmodel/geo/__init__.py index a51c568..b8d78e5 100644 --- a/dissmodel/geo/__init__.py +++ b/dissmodel/geo/__init__.py @@ -1,5 +1,18 @@ +# dissmodel/geo/__init__.py -from .neighborhood import attach_neighbors -from .regular_grid import regular_grid, parse_idx -from .fill import fill, FillStrategy -from .celullar_automaton import CellularAutomaton +# vector substrate +from .vector.neighborhood import attach_neighbors +from .vector.regular_grid import regular_grid, parse_idx +from .vector.fill import fill, FillStrategy +from .vector.cellular_automaton import CellularAutomaton +from .vector.model import SpatialModel + +# raster substrate +from .raster.backend import RasterBackend, DIRS_MOORE, DIRS_VON_NEUMANN +from .raster.model import RasterModel +from .raster.cellular_automaton import RasterCellularAutomaton +from .raster.regular_grid import make_raster_grid +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 diff --git a/dissmodel/geo/raster/__init__.py b/dissmodel/geo/raster/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dissmodel/geo/raster_backend.py b/dissmodel/geo/raster/backend.py similarity index 100% rename from dissmodel/geo/raster_backend.py rename to dissmodel/geo/raster/backend.py diff --git a/dissmodel/geo/band_spec.py b/dissmodel/geo/raster/band_spec.py similarity index 100% rename from dissmodel/geo/band_spec.py rename to dissmodel/geo/raster/band_spec.py diff --git a/dissmodel/geo/raster_cellular_automaton.py b/dissmodel/geo/raster/cellular_automaton.py similarity index 98% rename from dissmodel/geo/raster_cellular_automaton.py rename to dissmodel/geo/raster/cellular_automaton.py index 34c0c6d..0621d4e 100644 --- a/dissmodel/geo/raster_cellular_automaton.py +++ b/dissmodel/geo/raster/cellular_automaton.py @@ -70,8 +70,8 @@ def rule(self, arrays): import numpy as np -from dissmodel.geo.raster_model import RasterModel -from dissmodel.geo.raster_backend import RasterBackend +from dissmodel.geo.raster.model import RasterModel +from dissmodel.geo.raster.backend import RasterBackend class RasterCellularAutomaton(RasterModel, ABC): diff --git a/dissmodel/geo/raster_io.py b/dissmodel/geo/raster/io.py similarity index 97% rename from dissmodel/geo/raster_io.py rename to dissmodel/geo/raster/io.py index e558417..8bc6af0 100644 --- a/dissmodel/geo/raster_io.py +++ b/dissmodel/geo/raster/io.py @@ -26,7 +26,7 @@ import pathlib import numpy as np -from dissmodel.geo.raster_backend import RasterBackend +from dissmodel.geo.raster.backend import RasterBackend try: import rasterio diff --git a/dissmodel/geo/raster_model.py b/dissmodel/geo/raster/model.py similarity index 96% rename from dissmodel/geo/raster_model.py rename to dissmodel/geo/raster/model.py index 16e9ceb..bc75699 100644 --- a/dissmodel/geo/raster_model.py +++ b/dissmodel/geo/raster/model.py @@ -31,7 +31,7 @@ def execute(self): import numpy as np from dissmodel.core import Model -from dissmodel.geo.raster_backend import RasterBackend, DIRS_MOORE +from dissmodel.geo.raster.backend import RasterBackend, DIRS_MOORE class RasterModel(Model): diff --git a/dissmodel/geo/raster_grid.py b/dissmodel/geo/raster/regular_grid.py similarity index 98% rename from dissmodel/geo/raster_grid.py rename to dissmodel/geo/raster/regular_grid.py index 448fca3..88dd3b2 100644 --- a/dissmodel/geo/raster_grid.py +++ b/dissmodel/geo/raster/regular_grid.py @@ -25,7 +25,7 @@ import numpy as np -from dissmodel.geo.raster_backend import RasterBackend +from dissmodel.geo.raster.backend import RasterBackend # Valor escalar ou array pré-computado AttrValue = Union[int, float, np.ndarray] diff --git a/dissmodel/geo/vector/__init__.py b/dissmodel/geo/vector/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dissmodel/geo/celullar_automaton.py b/dissmodel/geo/vector/cellular_automaton.py similarity index 97% rename from dissmodel/geo/celullar_automaton.py rename to dissmodel/geo/vector/cellular_automaton.py index fd89a6c..7a4e890 100644 --- a/dissmodel/geo/celullar_automaton.py +++ b/dissmodel/geo/vector/cellular_automaton.py @@ -19,8 +19,8 @@ import geopandas as gpd from libpysal.weights import Queen -from dissmodel.geo.spatial_model import SpatialModel -from dissmodel.geo.neighborhood import StrategyType +from dissmodel.geo.vector.model import SpatialModel +from dissmodel.geo.vector.neighborhood import StrategyType class CellularAutomaton(SpatialModel, ABC): diff --git a/dissmodel/geo/fill.py b/dissmodel/geo/vector/fill.py similarity index 100% rename from dissmodel/geo/fill.py rename to dissmodel/geo/vector/fill.py diff --git a/dissmodel/geo/spatial_model.py b/dissmodel/geo/vector/model.py similarity index 99% rename from dissmodel/geo/spatial_model.py rename to dissmodel/geo/vector/model.py index 9d2e225..a94238b 100644 --- a/dissmodel/geo/spatial_model.py +++ b/dissmodel/geo/vector/model.py @@ -53,7 +53,7 @@ def execute(self): from dissmodel.core import Model from dissmodel.geo import attach_neighbors -from dissmodel.geo.neighborhood import StrategyType +from dissmodel.geo.vector.neighborhood import StrategyType class SpatialModel(Model): diff --git a/dissmodel/geo/neighborhood.py b/dissmodel/geo/vector/neighborhood.py similarity index 100% rename from dissmodel/geo/neighborhood.py rename to dissmodel/geo/vector/neighborhood.py diff --git a/dissmodel/geo/regular_grid.py b/dissmodel/geo/vector/regular_grid.py similarity index 100% rename from dissmodel/geo/regular_grid.py rename to dissmodel/geo/vector/regular_grid.py diff --git a/dissmodel/models/ca/game_of_life.py b/dissmodel/models/ca/game_of_life.py index 8e65e8b..b6e405d 100644 --- a/dissmodel/models/ca/game_of_life.py +++ b/dissmodel/models/ca/game_of_life.py @@ -6,7 +6,7 @@ from libpysal.weights import Queen from dissmodel.geo import FillStrategy, fill -from dissmodel.geo.celullar_automaton import CellularAutomaton +from dissmodel.geo.vector.cellular_automaton import CellularAutomaton # --------------------------------------------------------------------------- diff --git a/dissmodel/models/ca/game_of_life_raster.py b/dissmodel/models/ca/game_of_life_raster.py index 4017de4..9ddbbfd 100644 --- a/dissmodel/models/ca/game_of_life_raster.py +++ b/dissmodel/models/ca/game_of_life_raster.py @@ -50,9 +50,9 @@ def rule(self, arrays): import numpy as np -from dissmodel.geo.raster_backend import RasterBackend -from dissmodel.geo.raster_grid import make_raster_grid -from dissmodel.geo.raster_cellular_automaton import RasterCellularAutomaton +from dissmodel.geo.raster.backend import RasterBackend +from dissmodel.geo.raster.regular_grid import make_raster_grid +from dissmodel.geo.raster.cellular_automaton import RasterCellularAutomaton class GameOfLife(RasterCellularAutomaton): diff --git a/examples/cli/ca_game_of_life_raster.py b/examples/cli/ca_game_of_life_raster.py index ee93f70..3e2ec2a 100644 --- a/examples/cli/ca_game_of_life_raster.py +++ b/examples/cli/ca_game_of_life_raster.py @@ -13,7 +13,7 @@ from __future__ import annotations from dissmodel.core import Environment -from dissmodel.geo.raster_grid import make_raster_grid +from dissmodel.geo.raster.regular_grid import make_raster_grid from dissmodel.models.ca.game_of_life_raster import GameOfLife from dissmodel.visualization.raster_map import RasterMap diff --git a/examples/cli/coastal_dynamics/vector/flood_model.py b/examples/cli/coastal_dynamics/vector/flood_model.py index 7ee45ea..49cf05d 100644 --- a/examples/cli/coastal_dynamics/vector/flood_model.py +++ b/examples/cli/coastal_dynamics/vector/flood_model.py @@ -33,7 +33,7 @@ import geopandas as gpd from libpysal.weights import Queen -from dissmodel.geo.spatial_model import SpatialModel +from dissmodel.geo.raster.spatial_model import SpatialModel from coastal_dynamics.common.constants import ( USOS_INUNDADOS, diff --git a/examples/cli/coastal_dynamics/vector/mangue_model.py b/examples/cli/coastal_dynamics/vector/mangue_model.py index f765020..1b2007e 100644 --- a/examples/cli/coastal_dynamics/vector/mangue_model.py +++ b/examples/cli/coastal_dynamics/vector/mangue_model.py @@ -33,7 +33,7 @@ import geopandas as gpd from libpysal.weights import Queen -from dissmodel.geo.spatial_model import SpatialModel +from dissmodel.geo.raster.spatial_model import SpatialModel from coastal_dynamics.common.constants import ( MANGUE, diff --git a/tests/geo/test_cellular_automaton.py b/tests/geo/test_cellular_automaton.py new file mode 100644 index 0000000..88f2dc9 --- /dev/null +++ b/tests/geo/test_cellular_automaton.py @@ -0,0 +1,231 @@ +""" +tests/vector/test_cellular_automaton.py +======================================== +Tests for CellularAutomaton and SpatialModel (vector substrate). +""" +from __future__ import annotations + +import pytest +import numpy as np +from libpysal.weights import Queen, Rook + +from dissmodel.core import Environment +from dissmodel.geo import regular_grid +from dissmodel.geo.vector.cellular_automaton import CellularAutomaton + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +class IdentityCA(CellularAutomaton): + """Returns current state unchanged — useful for structure tests.""" + def rule(self, idx): + return self.gdf.loc[idx, self.state_attr] + + +class SumNeighborsCA(CellularAutomaton): + """New state = sum of neighbor values — useful for transition tests.""" + def rule(self, idx): + return int(self.neighbor_values(idx, self.state_attr).sum()) + + +class IncrementCA(CellularAutomaton): + """New state = current state + 1 — useful for step counting tests.""" + def rule(self, idx): + return self.gdf.loc[idx, self.state_attr] + 1 + + +# ── fixtures ────────────────────────────────────────────────────────────────── + +@pytest.fixture(autouse=True) +def default_env(): + """ + Create a default Environment for every test. + + Required by salabim — any Model instantiation fails without an active + Environment. Tests that need a different end_time create their own + Environment locally, which replaces this default. + """ + return Environment(start_time=1, end_time=1) + + +@pytest.fixture +def grid_3x3(): + return regular_grid(dimension=(3, 3), resolution=1, attrs={"state": 0}) + + +# ══════════════════════════════════════════════════════════════════════════════ +# ABC contract +# ══════════════════════════════════════════════════════════════════════════════ + +class TestABCContract: + + def test_cannot_instantiate_without_rule(self, grid_3x3): + """CellularAutomaton is abstract — must implement rule().""" + with pytest.raises(TypeError, match="rule"): + CellularAutomaton(gdf=grid_3x3) + + def test_can_instantiate_with_rule(self, grid_3x3): + ca = IdentityCA(gdf=grid_3x3) + assert ca is not None + + def test_execute_without_neighborhood_raises(self, grid_3x3): + ca = IdentityCA(gdf=grid_3x3) + with pytest.raises(RuntimeError, match="create_neighborhood"): + ca.execute() + + def test_neighs_without_neighborhood_raises(self, grid_3x3): + ca = IdentityCA(gdf=grid_3x3) + with pytest.raises(RuntimeError, match="create_neighborhood"): + ca.neighs("0-0") + + +# ══════════════════════════════════════════════════════════════════════════════ +# Neighborhood creation +# ══════════════════════════════════════════════════════════════════════════════ + +class TestNeighborhood: + + def test_queen_cache_size(self, grid_3x3): + """Cache must have one entry per cell.""" + ca = IdentityCA(gdf=grid_3x3) + ca.create_neighborhood(strategy=Queen, use_index=True) + assert len(ca._neighs_cache) == 9 + + def test_neighborhood_created_flag(self, grid_3x3): + ca = IdentityCA(gdf=grid_3x3) + assert not ca._neighborhood_created + ca.create_neighborhood(strategy=Queen, use_index=True) + assert ca._neighborhood_created + + def test_center_cell_has_8_queen_neighbors(self, grid_3x3): + """Center cell of 3x3 grid has 8 neighbors under Queen.""" + ca = IdentityCA(gdf=grid_3x3) + ca.create_neighborhood(strategy=Queen, use_index=True) + assert len(ca.neighs_id("1-1")) == 8 + + def test_corner_cell_has_3_queen_neighbors(self, grid_3x3): + """Corner cell of 3x3 grid has 3 neighbors under Queen.""" + ca = IdentityCA(gdf=grid_3x3) + ca.create_neighborhood(strategy=Queen, use_index=True) + corner = grid_3x3.index[0] + assert len(ca.neighs_id(corner)) == 3 + + def test_center_cell_has_4_rook_neighbors(self, grid_3x3): + """Center cell of 3x3 grid has 4 neighbors under Rook.""" + ca = IdentityCA(gdf=grid_3x3) + ca.create_neighborhood(strategy=Rook, use_index=True) + assert len(ca.neighs_id("1-1")) == 4 + + def test_neighs_id_matches_cache(self, grid_3x3): + ca = IdentityCA(gdf=grid_3x3) + ca.create_neighborhood(strategy=Queen, use_index=True) + idx = grid_3x3.index[0] + assert ca.neighs_id(idx) == ca._neighs_cache[idx] + + def test_neighs_returns_geodataframe(self, grid_3x3): + import geopandas as gpd + ca = IdentityCA(gdf=grid_3x3) + ca.create_neighborhood(strategy=Queen, use_index=True) + result = ca.neighs("1-1") + assert isinstance(result, gpd.GeoDataFrame) + assert len(result) == 8 + + +# ══════════════════════════════════════════════════════════════════════════════ +# neighbor_values +# ══════════════════════════════════════════════════════════════════════════════ + +class TestNeighborValues: + + def test_uniform_values(self, grid_3x3): + """All neighbors of center cell have the same value.""" + grid_3x3["state"] = 1 + ca = IdentityCA(gdf=grid_3x3, state_attr="state") + ca.create_neighborhood(strategy=Queen, use_index=True) + vals = ca.neighbor_values("1-1", "state") + assert len(vals) == 8 + assert np.all(vals == 1) + + def test_returns_numpy_array(self, grid_3x3): + ca = IdentityCA(gdf=grid_3x3) + ca.create_neighborhood(strategy=Queen, use_index=True) + vals = ca.neighbor_values("1-1", "state") + assert isinstance(vals, np.ndarray) + + def test_mixed_values(self, grid_3x3): + """neighbor_values returns correct subset for non-uniform grid.""" + grid_3x3.loc["0-0", "state"] = 5 + ca = IdentityCA(gdf=grid_3x3) + ca.create_neighborhood(strategy=Queen, use_index=True) + vals = ca.neighbor_values("1-1", "state") + assert 5 in vals + + +# ══════════════════════════════════════════════════════════════════════════════ +# execute() — state transitions +# ══════════════════════════════════════════════════════════════════════════════ + +class TestExecute: + + def test_identity_rule_preserves_state(self, grid_3x3): + """IdentityCA must not change any cell value.""" + grid_3x3["state"] = 7 + ca = IdentityCA(gdf=grid_3x3) + ca.create_neighborhood(strategy=Queen, use_index=True) + Environment(start_time=1, end_time=1).run() + assert (grid_3x3["state"] == 7).all() + + def test_increment_rule_updates_all_cells(self, grid_3x3): + """IncrementCA must add 1 to every cell per step.""" + ca = IncrementCA(gdf=grid_3x3) + ca.create_neighborhood(strategy=Queen, use_index=True) + Environment(start_time=1, end_time=1).run() + assert (grid_3x3["state"] == 1).all() + + def test_increment_rule_multiple_steps(self, grid_3x3): + """IncrementCA after N steps — all cells equal N.""" + ca = IncrementCA(gdf=grid_3x3) + ca.create_neighborhood(strategy=Queen, use_index=True) + Environment(start_time=1, end_time=5).run() + assert (grid_3x3["state"] == 5).all() + + def test_sum_neighbors_center_cell(self, grid_3x3): + """Center cell surrounded by cells of value 1 gets sum = 8 (Queen).""" + grid_3x3["state"] = 1 + grid_3x3.loc["1-1", "state"] = 0 + ca = SumNeighborsCA(gdf=grid_3x3) + ca.create_neighborhood(strategy=Queen, use_index=True) + Environment(start_time=1, end_time=1).run() + assert grid_3x3.loc["1-1", "state"] == 8 + + def test_execute_uses_state_attr(self): + """execute() writes result to state_attr column.""" + gdf = regular_grid(dimension=(3, 3), resolution=1, attrs={"mystate": 3}) + ca = IncrementCA(gdf=gdf, state_attr="mystate") + ca.create_neighborhood(strategy=Queen, use_index=True) + Environment(start_time=1, end_time=1).run() + assert (gdf["mystate"] == 4).all() + + +# ══════════════════════════════════════════════════════════════════════════════ +# SpatialModel (inherited via CellularAutomaton) +# ══════════════════════════════════════════════════════════════════════════════ + +class TestSpatialModelBase: + + def test_gdf_stored_on_init(self, grid_3x3): + ca = IdentityCA(gdf=grid_3x3) + assert ca.gdf is grid_3x3 + + def test_default_state_attr(self, grid_3x3): + ca = IdentityCA(gdf=grid_3x3) + assert ca.state_attr == "state" + + def test_custom_state_attr(self, grid_3x3): + ca = IdentityCA(gdf=grid_3x3, state_attr="landuse") + assert ca.state_attr == "landuse" + + def test_neighborhood_not_created_on_init(self, grid_3x3): + ca = IdentityCA(gdf=grid_3x3) + assert not ca._neighborhood_created + assert ca._neighs_cache == {} diff --git a/tests/geo/test_raster.py b/tests/geo/test_raster_py similarity index 100% rename from tests/geo/test_raster.py rename to tests/geo/test_raster_py diff --git a/tests/tests_fill_pattern.py b/tests/geo/tests_fill_pattern_py similarity index 96% rename from tests/tests_fill_pattern.py rename to tests/geo/tests_fill_pattern_py index d9795d4..3858b95 100644 --- a/tests/tests_fill_pattern.py +++ b/tests/geo/tests_fill_pattern_py @@ -43,7 +43,7 @@ def test_pattern_out_of_bounds_ignored(): def test_parse_idx_roundtrip(): """parse_idx correctly extracts x, y from row-col index.""" - from dissmodel.geo.regular_grid import parse_idx + from dissmodel.geo.raster.regular_grid import parse_idx x, y = parse_idx("3-4") assert x == 4 assert y == 3 diff --git a/tests/test_cellular_automaton.py b/tests/test_cellular_automaton.py deleted file mode 100644 index f38c838..0000000 --- a/tests/test_cellular_automaton.py +++ /dev/null @@ -1,78 +0,0 @@ -import pytest -from dissmodel.geo import regular_grid -from dissmodel.geo.celullar_automaton import CellularAutomaton -from dissmodel.core import Environment -from libpysal.weights import Queen - - -@pytest.fixture -def env(): - return Environment() - - -class ConcreteCA(CellularAutomaton): - """Minimal concrete subclass for testing.""" - def rule(self, idx): - return self.gdf.loc[idx, self.state_attr] - - -# --------------------------------------------------------------------------- -# Existing tests -# --------------------------------------------------------------------------- - -def test_neighborhood_caching(env): - """Test if caching neighbors actually works and returns correct results.""" - gdf = regular_grid(dimension=(3, 3), resolution=1) - ca = ConcreteCA(gdf) - ca.create_neighborhood(strategy=Queen, use_index=True) - - assert ca._neighs_cache is not None - assert len(ca._neighs_cache) == 9 - - first_idx = gdf.index[0] - neighs = ca.neighs_id(first_idx) - assert isinstance(neighs, list) - assert neighs == ca._neighs_cache[first_idx] - - -def test_neighbor_values(env): - """Test the neighbor_values method.""" - gdf = regular_grid(dimension=(3, 3), resolution=1, attrs={'val': 1}) - ca = ConcreteCA(gdf, state_attr='val') - ca.create_neighborhood(strategy=Queen, use_index=True) - - idx = "1-1" - vals = ca.neighbor_values(idx, 'val') - - assert len(vals) == 8 - assert all(v == 1 for v in vals) - - -# --------------------------------------------------------------------------- -# ABC contract enforcement -# --------------------------------------------------------------------------- - -def test_cannot_instantiate_without_rule(env): - """CellularAutomaton cannot be instantiated without implementing rule.""" - with pytest.raises(TypeError, match="rule"): - CellularAutomaton(gdf=regular_grid(dimension=(3, 3), resolution=1)) - - -def test_can_instantiate_with_rule(env): - """A subclass that implements rule can be instantiated.""" - ca = ConcreteCA(gdf=regular_grid(dimension=(3, 3), resolution=1)) - assert ca is not None - - -def test_execute_without_neighborhood_raises(env): - """execute() raises RuntimeError if neighborhood was not created.""" - ca = ConcreteCA(gdf=regular_grid(dimension=(3, 3), resolution=1)) - with pytest.raises(RuntimeError, match="create_neighborhood"): - ca.execute() - - -def test_neighs_without_neighborhood_raises(env): - """neighs() raises RuntimeError if neighborhood was not created.""" - ca = ConcreteCA(gdf=regular_grid(dimension=(3, 3), resolution=1)) - with pytest.raises(RuntimeError, match="create_neighborhood"): - ca.neighs("0-0") \ No newline at end of file From aaf5cf73cfef12d5d60bff7de0b233dd872fb7ec Mon Sep 17 00:00:00 2001 From: Sergio Souza Costa Date: Tue, 10 Mar 2026 14:35:09 -0300 Subject: [PATCH 11/12] reorganized example folder --- benchmarks/benchmark_raster_vs_vector.py | 2 +- examples/cli/{ => ca}/ca_game_of_life.py | 0 .../cli/{ => ca}/ca_game_of_life_raster.py | 0 examples/cli/coastal_dynamics/__init__.py | 0 .../cli/coastal_dynamics/common/__init__.py | 0 .../cli/coastal_dynamics/common/constants.py | 96 ---------- .../coastal_dynamics/raster/flood_model.py | 77 -------- .../coastal_dynamics/raster/mangrove_model.py | 101 ---------- examples/cli/coastal_dynamics/raster/run.py | 181 ------------------ .../coastal_dynamics/vector/flood_model.py | 140 -------------- .../coastal_dynamics/vector/mangue_model.py | 164 ---------------- examples/cli/coastal_dynamics/vector/run.py | 165 ---------------- examples/cli/{ => demos}/core_behavior.py | 0 examples/cli/{ => demos}/geo_fill.py | 0 .../cli/{ => demos}/geo_load_shapefile.py | 0 examples/cli/{ => sysdyn}/sysdyn_sir.py | 0 examples/data/synthetic_grid_60x60_shp.zip | Bin 85974 -> 0 bytes examples/data/synthetic_grid_60x60_tiff.zip | Bin 12748 -> 0 bytes 18 files changed, 1 insertion(+), 925 deletions(-) rename examples/cli/{ => ca}/ca_game_of_life.py (100%) rename examples/cli/{ => ca}/ca_game_of_life_raster.py (100%) delete mode 100644 examples/cli/coastal_dynamics/__init__.py delete mode 100644 examples/cli/coastal_dynamics/common/__init__.py delete mode 100644 examples/cli/coastal_dynamics/common/constants.py delete mode 100644 examples/cli/coastal_dynamics/raster/flood_model.py delete mode 100644 examples/cli/coastal_dynamics/raster/mangrove_model.py delete mode 100644 examples/cli/coastal_dynamics/raster/run.py delete mode 100644 examples/cli/coastal_dynamics/vector/flood_model.py delete mode 100644 examples/cli/coastal_dynamics/vector/mangue_model.py delete mode 100644 examples/cli/coastal_dynamics/vector/run.py rename examples/cli/{ => demos}/core_behavior.py (100%) rename examples/cli/{ => demos}/geo_fill.py (100%) rename examples/cli/{ => demos}/geo_load_shapefile.py (100%) rename examples/cli/{ => sysdyn}/sysdyn_sir.py (100%) delete mode 100644 examples/data/synthetic_grid_60x60_shp.zip delete mode 100644 examples/data/synthetic_grid_60x60_tiff.zip diff --git a/benchmarks/benchmark_raster_vs_vector.py b/benchmarks/benchmark_raster_vs_vector.py index 32dcf28..f7fe103 100644 --- a/benchmarks/benchmark_raster_vs_vector.py +++ b/benchmarks/benchmark_raster_vs_vector.py @@ -28,7 +28,7 @@ from dissmodel.core import Environment from dissmodel.geo.raster_backend import RasterBackend, DIRS_MOORE from dissmodel.geo.raster_model import RasterModel -from dissmodel.geo.raster.spatial_model import SpatialModel +from dissmodel.geo.vector.model import SpatialModel # ══════════════════════════════════════════════════════════════════════════════ diff --git a/examples/cli/ca_game_of_life.py b/examples/cli/ca/ca_game_of_life.py similarity index 100% rename from examples/cli/ca_game_of_life.py rename to examples/cli/ca/ca_game_of_life.py diff --git a/examples/cli/ca_game_of_life_raster.py b/examples/cli/ca/ca_game_of_life_raster.py similarity index 100% rename from examples/cli/ca_game_of_life_raster.py rename to examples/cli/ca/ca_game_of_life_raster.py diff --git a/examples/cli/coastal_dynamics/__init__.py b/examples/cli/coastal_dynamics/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/cli/coastal_dynamics/common/__init__.py b/examples/cli/coastal_dynamics/common/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/cli/coastal_dynamics/common/constants.py b/examples/cli/coastal_dynamics/common/constants.py deleted file mode 100644 index 2a9772d..0000000 --- a/examples/cli/coastal_dynamics/common/constants.py +++ /dev/null @@ -1,96 +0,0 @@ -""" -brmangue/constants.py — Constantes de domínio BR-MANGUE -========================================================= -tabela_usos / tabela_solos alinhadas com o modelo Lua original (Bezerra, 2014). -CRS e parâmetros geográficos da Ilha do Maranhão. - -Nada neste arquivo pertence ao framework DisSModel. -""" -from __future__ import annotations - -# ── tabela_usos ─────────────────────────────────────────────────────────────── -MANGUE = 1 -VEGETACAO_TERRESTRE = 2 -MAR = 3 -AREA_ANTROPIZADA = 4 -SOLO_DESCOBERTO = 5 -SOLO_INUNDADO = 6 -AREA_ANTROPIZADA_INUNDADA = 7 -MANGUE_MIGRADO = 8 -MANGUE_INUNDADO = 9 -VEG_TERRESTRE_INUNDADA = 10 - -USOS_INUNDADOS: list[int] = [ - MAR, SOLO_INUNDADO, AREA_ANTROPIZADA_INUNDADA, - MANGUE_INUNDADO, VEG_TERRESTRE_INUNDADA, -] - -# seco → inundado (Bezerra 2014) -REGRAS_INUNDACAO: dict[int, int] = { - MANGUE: MANGUE_INUNDADO, - MANGUE_MIGRADO: MANGUE_INUNDADO, - VEGETACAO_TERRESTRE: VEG_TERRESTRE_INUNDADA, - AREA_ANTROPIZADA: AREA_ANTROPIZADA_INUNDADA, - SOLO_DESCOBERTO: SOLO_INUNDADO, -} - -USO_LABELS: dict[int, str] = { - MANGUE: "Mangue", - VEGETACAO_TERRESTRE: "Vegetação Terrestre", - MAR: "Mar", - AREA_ANTROPIZADA: "Área Antropizada", - SOLO_DESCOBERTO: "Solo Descoberto", - SOLO_INUNDADO: "Solo Inundado", - AREA_ANTROPIZADA_INUNDADA: "Área Antrop. Inundada", - MANGUE_MIGRADO: "Mangue Migrado", - MANGUE_INUNDADO: "Mangue Inundado", - VEG_TERRESTRE_INUNDADA: "Veg. Terrestre Inundada", -} - -# cores exatas do Lua (tabela_usos RGB → hex) -USO_COLORS: dict[int, str] = { - MANGUE: "#006400", - VEGETACAO_TERRESTRE: "#808000", - MAR: "#00008b", - AREA_ANTROPIZADA: "#ffd700", - SOLO_DESCOBERTO: "#ffdead", - SOLO_INUNDADO: "#000000", - AREA_ANTROPIZADA_INUNDADA: "#323232", - MANGUE_MIGRADO: "#00ff00", - MANGUE_INUNDADO: "#ff0000", - VEG_TERRESTRE_INUNDADA: "#000000", -} - -# ── tabela_solos ────────────────────────────────────────────────────────────── -SOLO_CANAL_FLUVIAL = 0 -SOLO_MANGUE = 3 -SOLO_MANGUE_MIGRADO = 9 -SOLO_OUTROS = 4 - -SOLO_LABELS: dict[int, str] = { - SOLO_CANAL_FLUVIAL: "Canal Fluvial", - SOLO_MANGUE: "Mangue", - SOLO_MANGUE_MIGRADO: "Mangue Migrado", - SOLO_OUTROS: "Outros", -} - -# ── geografia — Ilha do Maranhão ────────────────────────────────────────────── -ORIGIN_X = 500_000.0 # UTM Easting (SIRGAS 2000 / UTM 24S) -ORIGIN_Y = 9_700_000.0 # UTM Northing -CRS = "EPSG:31984" -CELL_SIZE = 100.0 # metros - -# ── GeoTIFF: especificação de bandas (nome, dtype numpy, nodata) ────────────── -TIFF_BANDS: list[tuple[str, str, float]] = [ - ("uso", "int16", 0), - ("alt", "float32", -9999.0), - ("solo", "int16", -1), -] - -# cores da tabela_solos (para RasterMap) -SOLO_COLORS: dict[int, str] = { - SOLO_CANAL_FLUVIAL: "#0000ff", # azul — canal de drenagem - SOLO_MANGUE: "#006400", # verde escuro - SOLO_MANGUE_MIGRADO: "#228b22", # verde floresta - SOLO_OUTROS: "#888888", # cinza -} diff --git a/examples/cli/coastal_dynamics/raster/flood_model.py b/examples/cli/coastal_dynamics/raster/flood_model.py deleted file mode 100644 index 13d0f4c..0000000 --- a/examples/cli/coastal_dynamics/raster/flood_model.py +++ /dev/null @@ -1,77 +0,0 @@ -""" -brmangue/flood_raster_model.py — Modelo Hidro para DisSModel -============================================================= -Tradução fiel do hidro.lua para DisSModel + RasterBackend. -""" -from __future__ import annotations - -import numpy as np -from dissmodel.geo.raster_model import RasterModel -from dissmodel.geo.raster_backend import RasterBackend - -from coastal_dynamics.common.constants import USOS_INUNDADOS, REGRAS_INUNDACAO, MAR - - -class FloodModel(RasterModel): - """ - Hidro (hidro.lua) → DisSModel + RasterBackend. - - Parâmetros - ---------- - backend : RasterBackend com arrays "uso" e "alt" - taxa_elevacao : m/ano — IPCC RCP8.5 = 0.011 - aim_base : AIM base em metros. Padrão: 6.0 - """ - - def setup( - self, - backend: RasterBackend, - taxa_elevacao: float = 0.011, - aim_base: float = 6.0, - ) -> None: - super().setup(backend) - self.taxa_elevacao = taxa_elevacao - self.aim_base = aim_base - - self.celulas_inundadas = 0 - self.novas_inundadas = 0 - self.nivel_mar_atual = 0.0 - - def execute(self) -> None: - nivel_mar = self.env.now() * self.taxa_elevacao - rows, cols = self.shape - uso_past = self.backend.get("uso").copy() - alt_past = self.backend.get("alt").copy() - - eh_fonte = np.isin(uso_past, USOS_INUNDADOS) & (alt_past >= 0) - - viz_baixos = np.ones((rows, cols), dtype=float) - for dr, dc in self.dirs: - viz_baixos += (self.shift(alt_past, dr, dc) <= alt_past).astype(float) - - fluxo = np.where(eh_fonte, self.taxa_elevacao / viz_baixos, 0.0) - delta_alt = fluxo.copy() - uso_novo = uso_past.copy() - - for dr, dc in self.dirs: - fonte_viz = self.shift(eh_fonte.astype(float), dr, dc) > 0 - alt_viz = self.shift(alt_past, dr, dc) - fluxo_viz = self.shift(fluxo, dr, dc) - - # 1. altimetria — condição relativa - delta_alt += np.where( - fonte_viz & (alt_past <= alt_viz), fluxo_viz, 0.0 - ) - # 2. inundação — cota absoluta - for uso_seco, uso_inund in REGRAS_INUNDACAO.items(): - pode = fonte_viz & (uso_past == uso_seco) & (alt_past <= nivel_mar) - uso_novo = np.where(pode, uso_inund, uso_novo) - - self.backend.arrays["alt"] = alt_past + delta_alt - self.backend.arrays["uso"] = uso_novo - - inund = np.isin(uso_novo, USOS_INUNDADOS) & (uso_novo != MAR) - novas = np.isin(uso_novo, USOS_INUNDADOS) & ~np.isin(uso_past, USOS_INUNDADOS) - self.celulas_inundadas = int(np.sum(inund)) - self.novas_inundadas = int(np.sum(novas)) - self.nivel_mar_atual = round(nivel_mar, 4) diff --git a/examples/cli/coastal_dynamics/raster/mangrove_model.py b/examples/cli/coastal_dynamics/raster/mangrove_model.py deleted file mode 100644 index ebc1a5d..0000000 --- a/examples/cli/coastal_dynamics/raster/mangrove_model.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -brmangue/mangue_raster_model.py — Modelo Mangue para DisSModel -=============================================================== -Tradução fiel do mangue.lua para DisSModel + RasterBackend. -""" -from __future__ import annotations - -import numpy as np -from dissmodel.geo.raster_model import RasterModel -from dissmodel.geo.raster_backend import RasterBackend - -from coastal_dynamics.common.constants import ( - MANGUE, MANGUE_MIGRADO, VEGETACAO_TERRESTRE, SOLO_DESCOBERTO, - USOS_INUNDADOS, - SOLO_MANGUE, SOLO_MANGUE_MIGRADO, SOLO_CANAL_FLUVIAL, -) - - -class MangroveModel(RasterModel): - """ - Mangue (mangue.lua) → DisSModel + RasterBackend. - - Parâmetros - ---------- - backend : RasterBackend com arrays "uso", "alt", "solo" - taxa_elevacao : m/ano — IPCC RCP8.5 = 0.011 - altura_mare : AIM base em metros. Padrão: 6.0 - acrecao_ativa : habilita aplicarAcrecao (Alongi 2008). Padrão: False - """ - - SOLOS_FONTE = [SOLO_MANGUE, SOLO_MANGUE_MIGRADO, SOLO_CANAL_FLUVIAL] - SOLOS_MANGUE = [SOLO_MANGUE, SOLO_MANGUE_MIGRADO] - USOS_FONTE = [MANGUE, MANGUE_MIGRADO] - USOS_ALVO = [VEGETACAO_TERRESTRE, SOLO_DESCOBERTO] - COEF_A, COEF_B = 1.693, 0.939 # Alongi 2008 - - def setup( - self, - backend: RasterBackend, - taxa_elevacao: float = 0.011, - altura_mare: float = 6.0, - acrecao_ativa: bool = False, - ) -> None: - super().setup(backend) - self.taxa_elevacao = taxa_elevacao - self.altura_mare = altura_mare - self.acrecao_ativa = acrecao_ativa - - self.mangue_migrado = 0 - self.solo_migrado = 0 - - def execute(self) -> None: - nivel_mar = self.env.now() * self.taxa_elevacao - zi = self.altura_mare + nivel_mar - taxa_ac = self.COEF_A / 1000.0 + self.COEF_B * nivel_mar - - uso_past = self.backend.get("uso").copy() - alt_past = self.backend.get("alt").copy() - solo_past = self.backend.get("solo").copy() - - # ── migrarSolos ─────────────────────────────────────────────────────── - eh_fonte_solo = np.isin(solo_past, self.SOLOS_FONTE) - solo_novo = solo_past.copy() - - for dr, dc in self.dirs: - fonte_viz = self.shift(eh_fonte_solo.astype(np.int8), dr, dc) > 0 - cond = ( - fonte_viz - & np.isin(uso_past, self.USOS_ALVO) - & (solo_past != SOLO_MANGUE_MIGRADO) - & (alt_past <= zi) - ) - solo_novo = np.where(cond, SOLO_MANGUE_MIGRADO, solo_novo) - - # ── migrarUsos — usa solo_past (fiel ao .past do TerraME) ──────────── - eh_fonte_uso = np.isin(uso_past, self.USOS_FONTE) - uso_novo = uso_past.copy() - - for dr, dc in self.dirs: - fonte_viz = self.shift(eh_fonte_uso.astype(np.int8), dr, dc) > 0 - cond = ( - fonte_viz - & np.isin(uso_past, self.USOS_ALVO) - & np.isin(solo_past, self.SOLOS_MANGUE) # ← solo_past - & (alt_past <= zi) - ) - uso_novo = np.where(cond, MANGUE_MIGRADO, uso_novo) - - # ── aplicarAcrecao (False por padrão) ───────────────────────────────── - if self.acrecao_ativa: - cond_ac = ( - np.isin(solo_past, self.SOLOS_MANGUE) - & ~np.isin(uso_past, USOS_INUNDADOS) - ) - self.backend.arrays["alt"] = np.where(cond_ac, alt_past + taxa_ac, alt_past) - - self.backend.arrays["uso"] = uso_novo - self.backend.arrays["solo"] = solo_novo - - self.mangue_migrado = int(np.sum(uso_novo == MANGUE_MIGRADO)) - self.solo_migrado = int(np.sum(solo_novo == SOLO_MANGUE_MIGRADO)) diff --git a/examples/cli/coastal_dynamics/raster/run.py b/examples/cli/coastal_dynamics/raster/run.py deleted file mode 100644 index 3359e93..0000000 --- a/examples/cli/coastal_dynamics/raster/run.py +++ /dev/null @@ -1,181 +0,0 @@ -""" -brmangue/run.py — Ponto de entrada BR-MANGUE -============================================= -Acopla FloodRasterModel + MangueRasterModel + RasterMap no mesmo -Environment DisSModel, compartilhando um RasterBackend. - -Ordem de execução por passo (ordem de instanciação): - 1. FloodRasterModel — Hidro: altimetria + inundação - 2. MangueRasterModel — Mangue: migração solo/uso + acreção - 3. RasterMap(uso) — visualização - -Uso ---- - python -m brmangue.run flood_p000_0.000m.tif - - # modo interativo (requer display): - RASTER_MAP_INTERACTIVE=1 python -m brmangue.run flood_p000_0.000m.tif - - # múltiplos mapas: - python -m brmangue.run flood_p000_0.000m.tif --bands uso alt solo - - # sem salvar resultado: - python -m brmangue.run flood_p000_0.000m.tif --no-save -""" -from __future__ import annotations - -import argparse -import pathlib -import sys - -from dissmodel.core import Environment -from dissmodel.visualization.raster_map import RasterMap - -from coastal_dynamics.common.constants import ( - USO_COLORS, USO_LABELS, - SOLO_COLORS, SOLO_LABELS, - MAR,TIFF_BANDS, CRS -) -#from raster_io import carregar_tiff, salvar_tiff - -from dissmodel.geo.raster_io import load_geotiff, save_geotiff - - -from coastal_dynamics.raster.flood_model import FloodModel -from coastal_dynamics.raster.mangrove_model import MangroveModel - - -# ── configuração da simulação ───────────────────────────────────────────────── - -TAXA_ELEVACAO = 0.011 # m/ano — IPCC RCP8.5 -ALTURA_MARE = 6.0 # AIM base em metros -END_TIME = 88 # passos (2012–2100) - -# definição visual por band — passada ao RasterMap genérico do dissmodel -BAND_CONFIG: dict[str, dict] = { - "uso": dict( - color_map = USO_COLORS, - labels = USO_LABELS, - title = "Uso do Solo", - ), - "solo": dict( - color_map = SOLO_COLORS, - labels = SOLO_LABELS, - title = "Solo", - ), - "alt": dict( - cmap = "terrain", - colorbar_label = "Altitude (m)", - mask_band = "uso", - mask_value = MAR, - title = "Altimetria", - ), -} - - -# ── main ────────────────────────────────────────────────────────────────────── - -def run( - tif_path: str | pathlib.Path, - bands: list[str] = ("uso",), - acrecao_ativa: bool = False, - save: bool = True, -) -> None: - tif_path = pathlib.Path(tif_path) - - # ── carrega estado inicial ──────────────────────────────────────────────── - print(f"Carregando {tif_path}...") - backend, meta = load_geotiff( - tif_path, - band_spec=TIFF_BANDS - ) - - tags = meta.get("tags", {}) - - print( - f" shape={backend.shape} " - f"passo={tags.get('passo',0)} " - f"nivel_mar={tags.get('nivel_mar',0)}m " - f"crs={meta['crs']}" - ) - - start = int(tags.get("passo", 0)) + 1 - - env = Environment(start_time=start, end_time=END_TIME) - - # ── modelos — compartilham o mesmo backend ──────────────────────────────── - FloodModel( - backend = backend, - taxa_elevacao = TAXA_ELEVACAO, - aim_base = ALTURA_MARE, - ) - MangroveModel( - backend = backend, - taxa_elevacao = TAXA_ELEVACAO, - altura_mare = ALTURA_MARE, - acrecao_ativa = acrecao_ativa, - ) - - # ── visualização — um RasterMap por band solicitado ─────────────────────── - for band in bands: - if band not in BAND_CONFIG: - print(f" aviso: band '{band}' sem configuração visual — usando viridis") - RasterMap(backend=backend, band=band, **BAND_CONFIG.get(band, {})) - - # ── execução ────────────────────────────────────────────────────────────── - print(f"Executando passos {start} → {END_TIME}...") - env.run() - print("Concluído.") - - # ── salva estado final ──────────────────────────────────────────────────── - if save: - - nivel_mar_final = END_TIME * TAXA_ELEVACAO - - out_path = tif_path.with_name( - tif_path.stem + "_resultado.tif" - ) - - save_geotiff( - backend, - out_path, - band_spec=TIFF_BANDS, - crs=CRS, - transform=meta["transform"], - ) - - print(f"Salvo: {out_path}") - - -# ── CLI ─────────────────────────────────────────────────────────────────────── - -def _parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser( - prog="python -m brmangue.run", - description="Simulação BR-MANGUE via DisSModel", - ) - p.add_argument("tif", help="GeoTIFF de entrada (estado inicial)") - p.add_argument( - "--bands", nargs="+", default=["uso"], - choices=list(BAND_CONFIG), metavar="BAND", - help="Bands a visualizar: uso solo alt (padrão: uso)", - ) - p.add_argument( - "--acrecao", action="store_true", - help="Ativa aplicarAcrecao no MangueRasterModel (Alongi 2008)", - ) - p.add_argument( - "--no-save", dest="save", action="store_false", - help="Não salva GeoTIFF de resultado", - ) - return p.parse_args() - - -if __name__ == "__main__": - args = _parse_args() - run( - tif_path = args.tif, - bands = args.bands, - acrecao_ativa = args.acrecao, - save = args.save, - ) diff --git a/examples/cli/coastal_dynamics/vector/flood_model.py b/examples/cli/coastal_dynamics/vector/flood_model.py deleted file mode 100644 index 49cf05d..0000000 --- a/examples/cli/coastal_dynamics/vector/flood_model.py +++ /dev/null @@ -1,140 +0,0 @@ -""" -brmangue/flood_vector_model.py — Modelo Hidro (versão GeoDataFrame) -==================================================================== -Versão do FloodRasterModel usando GeoDataFrame + SpatialModel, -para comparação direta com a versão NumPy (flood_raster_model.py). - -Mesma lógica, diferente substrato: - - flood_raster_model.py RasterBackend (NumPy, vetorizado) - flood_vector_model.py ← GeoDataFrame (libpysal, célula a célula) - -Por que NÃO usar CellularAutomaton ------------------------------------ -CellularAutomaton.rule(idx) calcula o novo estado de uma célula com base -em si mesma e nos seus vizinhos (modelo pull). O Hidro é orientado a -FONTE: células inundadas propagam fluxo e inundação para vizinhos — -a lógica é inversa (modelo push). Por isso herdamos SpatialModel -diretamente e implementamos execute() livremente. - -Uso ---- - from dissmodel.core import Environment - from brmangue.flood_vector_model import FloodVectorModel - import geopandas as gpd - - gdf = gpd.read_file("flood_model.shp") - env = Environment(start_time=1, end_time=88) - FloodVectorModel(gdf=gdf, taxa_elevacao=0.011) - env.run() -""" -from __future__ import annotations - -import geopandas as gpd -from libpysal.weights import Queen - -from dissmodel.geo.raster.spatial_model import SpatialModel - -from coastal_dynamics.common.constants import ( - USOS_INUNDADOS, - REGRAS_INUNDACAO, - MAR, -) - - -class FloodVectorModel(SpatialModel): - """ - Hidro (hidro.lua) → DisSModel + GeoDataFrame. - - Equivalência com a versão Raster - --------------------------------- - RasterBackend.shift2d() → neighs_id(idx) / neighbor_values() - np.isin(uso, USOS_INUNDADOS) → uso_past.isin(USOS_INUNDADOS) - loop sobre DIRS_MOORE → loop sobre vizinhos reais do GDF - vetorizado sobre grade inteira → loop célula a célula (mais lento, - mas fiel à geometria real) - - Parâmetros - ---------- - gdf : GeoDataFrame com colunas attr_uso e attr_alt - taxa_elevacao : m/ano — IPCC RCP8.5 = 0.011 - attr_uso : coluna de uso do solo. Padrão: "uso" - attr_alt : coluna de altitude. Padrão: "alt" - """ - - def setup( - self, - taxa_elevacao: float = 0.011, - attr_uso: str = "uso", - attr_alt: str = "alt", - ) -> None: - self.taxa_elevacao = taxa_elevacao - self.attr_uso = attr_uso - self.attr_alt = attr_alt - - # métricas expostas para @track_plot / Chart - self.celulas_inundadas = 0 - self.novas_inundadas = 0 - self.nivel_mar_atual = 0.0 - - # Queen = vizinhança Moore (8 direções) para grade regular - # silence_warnings suprime aviso de ilhas (células sem vizinhos) - self.create_neighborhood(strategy=Queen, silence_warnings=True) - - def execute(self) -> None: - nivel_mar = self.env.now() * self.taxa_elevacao - - # Snapshots — equivale a celula.past[] do TerraME - uso_past = self.gdf[self.attr_uso].copy() - alt_past = self.gdf[self.attr_alt].copy() - - # ── fontes: ehMarOuInundado(uso) and alt >= 0 ───────────────────────── - fontes = set( - uso_past.index[ - uso_past.isin(USOS_INUNDADOS) & (alt_past >= 0) - ] - ) - - # ── A. Altimetria — difusão de fluxo (condição relativa) ────────────── - # Lua: if vizinho.past[alt] <= altAtual: viz[alt] += fluxo - alt_nova = alt_past.copy() - - for idx in fontes: - alt_atual = alt_past[idx] - vizinhos = self.neighs_id(idx) - - viz_baixos = 1 + sum( - 1 for n in vizinhos if alt_past[n] <= alt_atual - ) - fluxo = self.taxa_elevacao / viz_baixos - - alt_nova[idx] += fluxo - for n in vizinhos: - if alt_past[n] <= alt_atual: - alt_nova[n] += fluxo - - self.gdf[self.attr_alt] = alt_nova - - # ── B. Inundação — cota absoluta (BR-MANGUE, Bezerra 2014) ─────────── - # Lua: if vizinho.past[alt] <= nivelMar and not ehMarOuInundado(viz): - # aplicarInundacao(vizinho) - # Usa alt_past — fiel ao .past do TerraME - uso_novo = uso_past.copy() - - for idx in self.gdf.index: - uso_atual = uso_past[idx] - if uso_atual not in REGRAS_INUNDACAO: - continue - if alt_past[idx] > nivel_mar: - continue - if any(n in fontes for n in self.neighs_id(idx)): - uso_novo[idx] = REGRAS_INUNDACAO[uso_atual] - - self.gdf[self.attr_uso] = uso_novo - - # ── métricas ────────────────────────────────────────────────────────── - inund = uso_novo.isin(USOS_INUNDADOS) & (uso_novo != MAR) - novas = uso_novo.isin(USOS_INUNDADOS) & ~uso_past.isin(USOS_INUNDADOS) - self.celulas_inundadas = int(inund.sum()) - self.novas_inundadas = int(novas.sum()) - self.nivel_mar_atual = round(nivel_mar, 4) diff --git a/examples/cli/coastal_dynamics/vector/mangue_model.py b/examples/cli/coastal_dynamics/vector/mangue_model.py deleted file mode 100644 index 1b2007e..0000000 --- a/examples/cli/coastal_dynamics/vector/mangue_model.py +++ /dev/null @@ -1,164 +0,0 @@ -""" -brmangue/mangue_vector_model.py — Modelo Mangue (versão GeoDataFrame) -====================================================================== -Versão do MangroveModel usando GeoDataFrame + SpatialModel, -para comparação direta com a versão NumPy (mangue_raster_model.py). - -Mesma lógica, diferente substrato: - - mangue_raster_model.py RasterBackend (NumPy, vetorizado) - mangue_vector_model.py ← GeoDataFrame (libpysal, célula a célula) - -Três processos por passo — ordem idêntica ao Lua e à versão raster: - - 1. migrarSolos — propaga substrato de mangue - 2. migrarUsos — propaga uso MANGUE_MIGRADO (usa solo_past) - 3. aplicarAcrecao — eleva altitude (Alongi 2008, False por padrão) - -NOTA CRÍTICA: migrarUsos usa solo_past — fiel ao .past do TerraME. - -Uso ---- - from dissmodel.core import Environment - from brmangue.mangue_vector_model import MangroveVectorModel - import geopandas as gpd - - gdf = gpd.read_file("flood_model.shp") - env = Environment(start_time=1, end_time=88) - MangroveVectorModel(gdf=gdf, taxa_elevacao=0.011) - env.run() -""" -from __future__ import annotations - -import geopandas as gpd -from libpysal.weights import Queen - -from dissmodel.geo.raster.spatial_model import SpatialModel - -from coastal_dynamics.common.constants import ( - MANGUE, - MANGUE_MIGRADO, - VEGETACAO_TERRESTRE, - SOLO_DESCOBERTO, - USOS_INUNDADOS, - SOLO_MANGUE, - SOLO_MANGUE_MIGRADO, - SOLO_CANAL_FLUVIAL, -) - - -class MangroveModel(SpatialModel): - """ - Mangue (mangue.lua) → DisSModel + GeoDataFrame. - - Equivalência com a versão Raster - --------------------------------- - np.isin(solo, SOLOS_FONTE) → solo_past.isin(SOLOS_FONTE) - shift2d loop sobre DIRS_MOORE → loop sobre vizinhos reais do GDF - np.where(cond, novo, atual) → solo_novo[idx] = SOLO_MANGUE_MIGRADO - solo_past (não solo_novo) → solo_past[idx] — mesmo cuidado .past - - Parâmetros - ---------- - gdf : GeoDataFrame com colunas attr_uso, attr_alt, attr_solo - taxa_elevacao : m/ano — IPCC RCP8.5 = 0.011 - altura_mare : AIM base em metros. Padrão: 6.0 - acrecao_ativa : habilita aplicarAcrecao (Alongi 2008). Padrão: False - attr_uso : coluna de uso do solo. Padrão: "uso" - attr_alt : coluna de altitude. Padrão: "alt" - attr_solo : coluna de tipo de solo. Padrão: "solo" - """ - - SOLOS_FONTE = [SOLO_MANGUE, SOLO_MANGUE_MIGRADO, SOLO_CANAL_FLUVIAL] - SOLOS_MANGUE = [SOLO_MANGUE, SOLO_MANGUE_MIGRADO] - USOS_FONTE = [MANGUE, MANGUE_MIGRADO] - USOS_ALVO = [VEGETACAO_TERRESTRE, SOLO_DESCOBERTO] - COEF_A, COEF_B = 1.693, 0.939 # Alongi 2008 - - def setup( - self, - taxa_elevacao: float = 0.011, - altura_mare: float = 6.0, - acrecao_ativa: bool = False, - attr_uso: str = "uso", - attr_alt: str = "alt", - attr_solo: str = "solo", - ) -> None: - self.taxa_elevacao = taxa_elevacao - self.altura_mare = altura_mare - self.acrecao_ativa = acrecao_ativa - self.attr_uso = attr_uso - self.attr_alt = attr_alt - self.attr_solo = attr_solo - - # métricas expostas para @track_plot / Chart - self.mangue_migrado = 0 - self.solo_migrado = 0 - - self.create_neighborhood(strategy=Queen, silence_warnings=True) - - def execute(self) -> None: - nivel_mar = self.env.now() * self.taxa_elevacao - zi = self.altura_mare + nivel_mar - taxa_ac = self.COEF_A / 1000.0 + self.COEF_B * nivel_mar - - # snapshots — equivale a celula.past[] do TerraME - uso_past = self.gdf[self.attr_uso].copy() - alt_past = self.gdf[self.attr_alt].copy() - solo_past = self.gdf[self.attr_solo].copy() - - # ── migrarSolos ─────────────────────────────────────────────────────── - # Fonte: celula.past[solo] in SOLOS_FONTE - # Alvo: viz.uso in USOS_ALVO - # viz.solo != SOLO_MANGUE_MIGRADO - # viz.alt <= zonaInfluencia - fontes_solo = set( - solo_past.index[solo_past.isin(self.SOLOS_FONTE)] - ) - solo_novo = solo_past.copy() - - for idx in self.gdf.index: - if uso_past[idx] not in self.USOS_ALVO: - continue - if solo_past[idx] == SOLO_MANGUE_MIGRADO: - continue - if alt_past[idx] > zi: - continue - if any(n in fontes_solo for n in self.neighs_id(idx)): - solo_novo[idx] = SOLO_MANGUE_MIGRADO - - # ── migrarUsos ──────────────────────────────────────────────────────── - # Fonte: celula.past[uso] in USOS_FONTE - # Alvo: viz.uso in USOS_ALVO - # viz.solo in SOLOS_MANGUE ← solo_PAST, não solo_novo - # viz.alt <= zonaInfluencia - fontes_uso = set( - uso_past.index[uso_past.isin(self.USOS_FONTE)] - ) - uso_novo = uso_past.copy() - - for idx in self.gdf.index: - if uso_past[idx] not in self.USOS_ALVO: - continue - if solo_past[idx] not in self.SOLOS_MANGUE: # ← solo_past - continue - if alt_past[idx] > zi: - continue - if any(n in fontes_uso for n in self.neighs_id(idx)): - uso_novo[idx] = MANGUE_MIGRADO - - # ── aplicarAcrecao (False por padrão — comentada no Lua) ───────────── - if self.acrecao_ativa: - alt_nova = alt_past.copy() - for idx in self.gdf.index: - if solo_past[idx] in self.SOLOS_MANGUE: - if uso_past[idx] not in USOS_INUNDADOS: - alt_nova[idx] += taxa_ac - self.gdf[self.attr_alt] = alt_nova - - self.gdf[self.attr_uso] = uso_novo - self.gdf[self.attr_solo] = solo_novo - - # ── métricas ────────────────────────────────────────────────────────── - self.mangue_migrado = int((uso_novo == MANGUE_MIGRADO).sum()) - self.solo_migrado = int((solo_novo == SOLO_MANGUE_MIGRADO).sum()) diff --git a/examples/cli/coastal_dynamics/vector/run.py b/examples/cli/coastal_dynamics/vector/run.py deleted file mode 100644 index b8ed2de..0000000 --- a/examples/cli/coastal_dynamics/vector/run.py +++ /dev/null @@ -1,165 +0,0 @@ -""" -brmangue/run_vector.py — Ponto de entrada BR-MANGUE (versão GeoDataFrame) -========================================================================= -Versão vetorial para comparação com run.py (RasterBackend). - -Usa FloodVectorModel + MangroveVectorModel + GeoDataFrame + Map/Chart. - -Uso ---- - python -m brmangue.run_vector flood_model.shp - python -m brmangue.run_vector flood_model.gpkg --taxa 0.05 - python -m brmangue.run_vector flood_model.shp --chart - python -m brmangue.run_vector flood_model.shp --acrecao --no-save -""" -from __future__ import annotations - -import argparse -import pathlib - -import geopandas as gpd -from matplotlib.colors import ListedColormap, BoundaryNorm - -from dissmodel.core import Environment -from dissmodel.visualization import Map, Chart - -from coastal_dynamics.common.constants import ( - USO_COLORS, USO_LABELS, - SOLO_COLORS, SOLO_LABELS, -) -from examples.cli.coastal_dynamics.vector.flood_model import FloodVectorModel -from examples.cli.coastal_dynamics.vector.mangue_model import MangroveModel - - -# ── configuração ────────────────────────────────────────────────────────────── - -TAXA_ELEVACAO = 0.011 -ALTURA_MARE = 6.0 -END_TIME = 88 - -_vals = sorted(USO_COLORS) -USO_CMAP = ListedColormap([USO_COLORS[k] for k in _vals]) -USO_NORM = BoundaryNorm([v - 0.5 for v in _vals] + [_vals[-1] + 0.5], USO_CMAP.N) - -_svals = sorted(SOLO_COLORS) -SOLO_CMAP = ListedColormap([SOLO_COLORS[k] for k in _svals]) -SOLO_NORM = BoundaryNorm([v - 0.5 for v in _svals] + [_svals[-1] + 0.5], SOLO_CMAP.N) - - -# ── main ────────────────────────────────────────────────────────────────────── - -def run( - shp_path: str | pathlib.Path, - taxa_elevacao: float = TAXA_ELEVACAO, - altura_mare: float = ALTURA_MARE, - acrecao_ativa: bool = False, - attr_uso: str = "uso", - attr_alt: str = "alt", - attr_solo: str = "solo", - show_chart: bool = False, - save: bool = True, -) -> None: - shp_path = pathlib.Path(shp_path) - - # ── carrega ─────────────────────────────────────────────────────────────── - print(f"Carregando {shp_path}...") - gdf = gpd.read_file(shp_path) - print(f" features={len(gdf)} crs={gdf.crs}") - - # ── ambiente ────────────────────────────────────────────────────────────── - env = Environment(start_time=1, end_time=END_TIME) - - # ── modelos — compartilham o mesmo gdf ──────────────────────────────────── - # Ordem de instanciação = ordem de execução por passo - FloodVectorModel( - gdf = gdf, - taxa_elevacao = taxa_elevacao, - attr_uso = attr_uso, - attr_alt = attr_alt, - ) - MangroveModel( - gdf = gdf, - taxa_elevacao = taxa_elevacao, - altura_mare = altura_mare, - acrecao_ativa = acrecao_ativa, - attr_uso = attr_uso, - attr_alt = attr_alt, - attr_solo = attr_solo, - ) - - # ── visualização ────────────────────────────────────────────────────────── - Map(gdf=gdf, plot_params={"column": attr_uso, "cmap": USO_CMAP, "norm": USO_NORM, "legend": False}) - Map(gdf=gdf, plot_params={"column": attr_alt, "cmap": "terrain", "legend": True}) - Map(gdf=gdf, plot_params={"column": attr_solo, "cmap": SOLO_CMAP, "norm": SOLO_NORM, "legend": False}) - - if show_chart: - Chart(select={"celulas_inundadas", "mangue_migrado"}) - - # ── execução ────────────────────────────────────────────────────────────── - print(f"Executando passos 1 → {END_TIME}...") - env.run() - print("Concluído.") - - # ── salva ───────────────────────────────────────────────────────────────── - if save: - out_path = shp_path.with_name(shp_path.stem + "_resultado.gpkg") - gdf.to_file(out_path, driver="GPKG", layer="flood_vector") - print(f"Salvo: {out_path}") - - -# ── CLI ─────────────────────────────────────────────────────────────────────── - -def _parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser( - prog="python -m brmangue.run_vector", - description="Simulação BR-MANGUE — versão GeoDataFrame", - ) - p.add_argument("shp", help="Shapefile ou GeoPackage de entrada") - p.add_argument( - "--taxa", type=float, default=TAXA_ELEVACAO, metavar="M/ANO", - help=f"Taxa de elevação do mar em m/ano (padrão: {TAXA_ELEVACAO})", - ) - p.add_argument( - "--altura-mare", type=float, default=ALTURA_MARE, metavar="M", - help=f"AIM base em metros (padrão: {ALTURA_MARE})", - ) - p.add_argument( - "--acrecao", action="store_true", - help="Ativa aplicarAcrecao no MangroveVectorModel (Alongi 2008)", - ) - p.add_argument( - "--attr-uso", default="uso", metavar="COL", - help="Coluna de uso do solo (padrão: uso)", - ) - p.add_argument( - "--attr-alt", default="alt", metavar="COL", - help="Coluna de altitude (padrão: alt)", - ) - p.add_argument( - "--attr-solo", default="solo", metavar="COL", - help="Coluna de tipo de solo (padrão: solo)", - ) - p.add_argument( - "--chart", action="store_true", - help="Exibe gráfico de métricas por passo", - ) - p.add_argument( - "--no-save", dest="save", action="store_false", - help="Não salva GeoPackage de resultado", - ) - return p.parse_args() - - -if __name__ == "__main__": - args = _parse_args() - run( - shp_path = args.shp, - taxa_elevacao = args.taxa, - altura_mare = args.altura_mare, - acrecao_ativa = args.acrecao, - attr_uso = args.attr_uso, - attr_alt = args.attr_alt, - attr_solo = args.attr_solo, - show_chart = args.chart, - save = args.save, - ) diff --git a/examples/cli/core_behavior.py b/examples/cli/demos/core_behavior.py similarity index 100% rename from examples/cli/core_behavior.py rename to examples/cli/demos/core_behavior.py diff --git a/examples/cli/geo_fill.py b/examples/cli/demos/geo_fill.py similarity index 100% rename from examples/cli/geo_fill.py rename to examples/cli/demos/geo_fill.py diff --git a/examples/cli/geo_load_shapefile.py b/examples/cli/demos/geo_load_shapefile.py similarity index 100% rename from examples/cli/geo_load_shapefile.py rename to examples/cli/demos/geo_load_shapefile.py diff --git a/examples/cli/sysdyn_sir.py b/examples/cli/sysdyn/sysdyn_sir.py similarity index 100% rename from examples/cli/sysdyn_sir.py rename to examples/cli/sysdyn/sysdyn_sir.py diff --git a/examples/data/synthetic_grid_60x60_shp.zip b/examples/data/synthetic_grid_60x60_shp.zip deleted file mode 100644 index 12617c7ed95fd08bd6e4e370132ed409b4f88ee3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 85974 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 From ab4a96141411f45e590287a8d46800fdc823ae0a Mon Sep 17 00:00:00 2001 From: Sergio Souza Costa Date: Tue, 10 Mar 2026 14:43:06 -0300 Subject: [PATCH 12/12] reorganized --- .gitignore | 2 + benchmarks/benchmark_raster_vs_vector.py | 8 +- benchmarks/ca_game_of_life.py | 2 +- dissmodel/geo/raster/backend.py | 3 +- dissmodel/geo/raster/cellular_automaton.py | 4 +- dissmodel/geo/raster/model.py | 1 - dissmodel/geo/raster/regular_grid.py | 2 +- dissmodel/geo/vector/cellular_automaton.py | 2 - dissmodel/models/ca/fire_model_prob.py | 2 +- dissmodel/models/ca/fire_model_raster.py | 6 +- dissmodel/models/ca/game_of_life_raster.py | 1 - dissmodel/models/ca/propagation.py | 1 - dissmodel/visualization/map.py | 2 +- dissmodel/visualization/raster_map.py | 2 +- saida.txt | 578 ++++++++++++++++++ ...utomaton.py => test_cellular_automaton_py} | 0 tests/geo/{test_raster_py => test_raster.py} | 11 +- ..._fill_pattern_py => tests_fill_pattern.py} | 1 - 18 files changed, 599 insertions(+), 29 deletions(-) create mode 100644 saida.txt rename tests/geo/{test_cellular_automaton.py => test_cellular_automaton_py} (100%) rename tests/geo/{test_raster_py => test_raster.py} (97%) rename tests/geo/{tests_fill_pattern_py => tests_fill_pattern.py} (99%) diff --git a/.gitignore b/.gitignore index 7f8347f..d19d0de 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ __pycache__/ # C extensions *.so +.ruff_cache + # Distribution / packaging .Python build/ diff --git a/benchmarks/benchmark_raster_vs_vector.py b/benchmarks/benchmark_raster_vs_vector.py index f7fe103..de9b13b 100644 --- a/benchmarks/benchmark_raster_vs_vector.py +++ b/benchmarks/benchmark_raster_vs_vector.py @@ -26,8 +26,8 @@ from libpysal.weights import Queen from dissmodel.core import Environment -from dissmodel.geo.raster_backend import RasterBackend, DIRS_MOORE -from dissmodel.geo.raster_model import RasterModel +from dissmodel.geo.raster.backend import RasterBackend +from dissmodel.geo.raster.model import RasterModel from dissmodel.geo.vector.model import SpatialModel @@ -220,7 +220,7 @@ def benchmark(sizes: list[int], steps: int, validate: bool) -> None: cells = n * n print(f"\n── {n}×{n} ({cells:,} células, {steps} passos) ──") - print(f" raster ... ", end="", flush=True) + print(" raster ... ", end="", flush=True) r = run_raster(n, steps) print(f"{r.total_s:.3f}s ({r.ms_per_step:.1f} ms/passo)") @@ -234,7 +234,7 @@ def benchmark(sizes: list[int], steps: int, validate: bool) -> None: } if cells <= VECTOR_MAX_CELLS: - print(f" vector ... ", end="", flush=True) + print(" vector ... ", end="", flush=True) v = run_vector(n, steps) print(f"{v.total_s:.3f}s ({v.ms_per_step:.1f} ms/passo)") diff --git a/benchmarks/ca_game_of_life.py b/benchmarks/ca_game_of_life.py index a0bf4bd..a9ebc98 100644 --- a/benchmarks/ca_game_of_life.py +++ b/benchmarks/ca_game_of_life.py @@ -111,7 +111,7 @@ def run_benchmark( t_orig = run_benchmark(GameOfLifeOriginal, dim=(50, 50), steps=5, name="Original") t_opt = run_benchmark(GameOfLifeOptimized, dim=(50, 50), steps=5, name="Optimized") - print(f"\nResults:") + print("\nResults:") print(f" Original: {t_orig:.4f}s") print(f" Optimized: {t_opt:.4f}s") print(f" Speedup: {t_orig / t_opt:.2f}x") diff --git a/dissmodel/geo/raster/backend.py b/dissmodel/geo/raster/backend.py index 4788da3..b6a5333 100644 --- a/dissmodel/geo/raster/backend.py +++ b/dissmodel/geo/raster/backend.py @@ -14,7 +14,7 @@ Exemplo mínimo -------------- - from dissmodel.geo.raster_backend import RasterBackend, DIRS_MOORE + from dissmodel.geo.raster.backend import RasterBackend, DIRS_MOORE b = RasterBackend(shape=(100, 100)) b.set("estado", np.zeros((100, 100), dtype=np.int8)) @@ -28,7 +28,6 @@ """ from __future__ import annotations -from typing import Any import numpy as np from scipy.ndimage import binary_dilation diff --git a/dissmodel/geo/raster/cellular_automaton.py b/dissmodel/geo/raster/cellular_automaton.py index 0621d4e..a9011b8 100644 --- a/dissmodel/geo/raster/cellular_automaton.py +++ b/dissmodel/geo/raster/cellular_automaton.py @@ -44,7 +44,7 @@ def rule(self, arrays): Usage ----- from dissmodel.geo.raster_cellular_automaton import RasterCellularAutomaton - from dissmodel.geo.raster_backend import RasterBackend + from dissmodel.geo.raster.backend import RasterBackend from dissmodel.core import Environment import numpy as np @@ -78,7 +78,7 @@ class RasterCellularAutomaton(RasterModel, ABC): """ Base class for NumPy-based cellular automata. - Extends :class:`~dissmodel.geo.raster_model.RasterModel` with a + Extends :class:`~dissmodel.geo.raster.model.RasterModel` with a vectorized transition rule — ``rule()`` receives all arrays as a snapshot and returns a dict of updated arrays. diff --git a/dissmodel/geo/raster/model.py b/dissmodel/geo/raster/model.py index bc75699..2bba9a9 100644 --- a/dissmodel/geo/raster/model.py +++ b/dissmodel/geo/raster/model.py @@ -28,7 +28,6 @@ def execute(self): """ from __future__ import annotations -import numpy as np from dissmodel.core import Model from dissmodel.geo.raster.backend import RasterBackend, DIRS_MOORE diff --git a/dissmodel/geo/raster/regular_grid.py b/dissmodel/geo/raster/regular_grid.py index 88dd3b2..af5ad0d 100644 --- a/dissmodel/geo/raster/regular_grid.py +++ b/dissmodel/geo/raster/regular_grid.py @@ -21,7 +21,7 @@ """ from __future__ import annotations -from typing import Any, Union +from typing import Union import numpy as np diff --git a/dissmodel/geo/vector/cellular_automaton.py b/dissmodel/geo/vector/cellular_automaton.py index 7a4e890..c9fa9f8 100644 --- a/dissmodel/geo/vector/cellular_automaton.py +++ b/dissmodel/geo/vector/cellular_automaton.py @@ -17,10 +17,8 @@ from typing import Any, Optional import geopandas as gpd -from libpysal.weights import Queen from dissmodel.geo.vector.model import SpatialModel -from dissmodel.geo.vector.neighborhood import StrategyType class CellularAutomaton(SpatialModel, ABC): diff --git a/dissmodel/models/ca/fire_model_prob.py b/dissmodel/models/ca/fire_model_prob.py index d857bf4..9f41252 100644 --- a/dissmodel/models/ca/fire_model_prob.py +++ b/dissmodel/models/ca/fire_model_prob.py @@ -5,7 +5,7 @@ from libpysal.weights import Queen -from dissmodel.geo import CellularAutomaton, FillStrategy, fill +from dissmodel.geo import CellularAutomaton from dissmodel.models.ca.fire_model import FireState diff --git a/dissmodel/models/ca/fire_model_raster.py b/dissmodel/models/ca/fire_model_raster.py index 53d9bb4..32bdd79 100644 --- a/dissmodel/models/ca/fire_model_raster.py +++ b/dissmodel/models/ca/fire_model_raster.py @@ -31,7 +31,7 @@ def rule(self, arrays): Usage ----- from dissmodel.core import Environment - from dissmodel.geo.raster_backend import RasterBackend + from dissmodel.geo.raster.backend import RasterBackend from dissmodel.examples.fire_model_raster import FireModel import numpy as np @@ -50,7 +50,7 @@ def rule(self, arrays): import numpy as np -from dissmodel.geo.raster_backend import RasterBackend, DIRS_VON_NEUMANN +from dissmodel.geo.raster.backend import RasterBackend, DIRS_VON_NEUMANN from dissmodel.geo.raster_cellular_automaton import RasterCellularAutomaton @@ -98,7 +98,7 @@ class FireModel(RasterCellularAutomaton): Examples -------- >>> from dissmodel.core import Environment - >>> from dissmodel.geo.raster_backend import RasterBackend + >>> from dissmodel.geo.raster.backend import RasterBackend >>> import numpy as np >>> b = RasterBackend(shape=(20, 20)) >>> rng = np.random.default_rng(42) diff --git a/dissmodel/models/ca/game_of_life_raster.py b/dissmodel/models/ca/game_of_life_raster.py index 9ddbbfd..e187745 100644 --- a/dissmodel/models/ca/game_of_life_raster.py +++ b/dissmodel/models/ca/game_of_life_raster.py @@ -51,7 +51,6 @@ def rule(self, arrays): import numpy as np from dissmodel.geo.raster.backend import RasterBackend -from dissmodel.geo.raster.regular_grid import make_raster_grid from dissmodel.geo.raster.cellular_automaton import RasterCellularAutomaton diff --git a/dissmodel/models/ca/propagation.py b/dissmodel/models/ca/propagation.py index 16d6782..237152a 100644 --- a/dissmodel/models/ca/propagation.py +++ b/dissmodel/models/ca/propagation.py @@ -1,6 +1,5 @@ from __future__ import annotations -import random from enum import IntEnum from typing import Any diff --git a/dissmodel/visualization/map.py b/dissmodel/visualization/map.py index dc46186..3901913 100644 --- a/dissmodel/visualization/map.py +++ b/dissmodel/visualization/map.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any import matplotlib.pyplot as plt import matplotlib.figure diff --git a/dissmodel/visualization/raster_map.py b/dissmodel/visualization/raster_map.py index d4528d9..f534727 100644 --- a/dissmodel/visualization/raster_map.py +++ b/dissmodel/visualization/raster_map.py @@ -21,7 +21,7 @@ Uso mínimo (headless / sem cores) ---------------------------------- - from dissmodel.geo.raster_backend import RasterBackend + from dissmodel.geo.raster.backend import RasterBackend from dissmodel.visualization.raster_map import RasterMap from dissmodel.core import Environment diff --git a/saida.txt b/saida.txt new file mode 100644 index 0000000..4b865ba --- /dev/null +++ b/saida.txt @@ -0,0 +1,578 @@ +F401 [*] `dissmodel.geo.raster.backend.DIRS_MOORE` imported but unused + --> benchmarks/benchmark_raster_vs_vector.py:29:57 + | +28 | from dissmodel.core import Environment +29 | from dissmodel.geo.raster.backend import RasterBackend, DIRS_MOORE + | ^^^^^^^^^^ +30 | from dissmodel.geo.raster.model import RasterModel +31 | from dissmodel.geo.vector.model import SpatialModel + | +help: Remove unused import: `dissmodel.geo.raster.backend.DIRS_MOORE` + +F541 [*] f-string without any placeholders + --> benchmarks/benchmark_raster_vs_vector.py:223:15 + | +221 | print(f"\n── {n}×{n} ({cells:,} células, {steps} passos) ──") +222 | +223 | print(f" raster ... ", end="", flush=True) + | ^^^^^^^^^^^^^^^^ +224 | r = run_raster(n, steps) +225 | print(f"{r.total_s:.3f}s ({r.ms_per_step:.1f} ms/passo)") + | +help: Remove extraneous `f` prefix + +F541 [*] f-string without any placeholders + --> benchmarks/benchmark_raster_vs_vector.py:237:19 + | +236 | if cells <= VECTOR_MAX_CELLS: +237 | print(f" vector ... ", end="", flush=True) + | ^^^^^^^^^^^^^^^^ +238 | v = run_vector(n, steps) +239 | print(f"{v.total_s:.3f}s ({v.ms_per_step:.1f} ms/passo)") + | +help: Remove extraneous `f` prefix + +F541 [*] f-string without any placeholders + --> benchmarks/ca_game_of_life.py:114:11 + | +112 | t_opt = run_benchmark(GameOfLifeOptimized, dim=(50, 50), steps=5, name="Optimized") +113 | +114 | print(f"\nResults:") + | ^^^^^^^^^^^^^ +115 | print(f" Original: {t_orig:.4f}s") +116 | print(f" Optimized: {t_opt:.4f}s") + | +help: Remove extraneous `f` prefix + +F401 `.model.Model` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/core/__init__.py:2:20 + | +2 | from .model import Model + | ^^^^^ +3 | from .environment import Environment + | +help: Use an explicit re-export: `Model as Model` + +F401 `.environment.Environment` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/core/__init__.py:3:26 + | +2 | from .model import Model +3 | from .environment import Environment + | ^^^^^^^^^^^ + | +help: Use an explicit re-export: `Environment as Environment` + +F401 `.vector.neighborhood.attach_neighbors` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/geo/__init__.py:4:40 + | +3 | # vector substrate +4 | from .vector.neighborhood import attach_neighbors + | ^^^^^^^^^^^^^^^^ +5 | from .vector.regular_grid import regular_grid, parse_idx +6 | from .vector.fill import fill, FillStrategy + | +help: Use an explicit re-export: `attach_neighbors as attach_neighbors` + +F401 `.vector.regular_grid.regular_grid` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/geo/__init__.py:5:40 + | +3 | # vector substrate +4 | from .vector.neighborhood import attach_neighbors +5 | from .vector.regular_grid import regular_grid, parse_idx + | ^^^^^^^^^^^^ +6 | from .vector.fill import fill, FillStrategy +7 | from .vector.cellular_automaton import CellularAutomaton + | +help: Use an explicit re-export: `regular_grid as regular_grid` + +F401 `.vector.regular_grid.parse_idx` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/geo/__init__.py:5:54 + | +3 | # vector substrate +4 | from .vector.neighborhood import attach_neighbors +5 | from .vector.regular_grid import regular_grid, parse_idx + | ^^^^^^^^^ +6 | from .vector.fill import fill, FillStrategy +7 | from .vector.cellular_automaton import CellularAutomaton + | +help: Use an explicit re-export: `parse_idx as parse_idx` + +F401 `.vector.fill.fill` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/geo/__init__.py:6:40 + | +4 | from .vector.neighborhood import attach_neighbors +5 | from .vector.regular_grid import regular_grid, parse_idx +6 | from .vector.fill import fill, FillStrategy + | ^^^^ +7 | from .vector.cellular_automaton import CellularAutomaton +8 | from .vector.model import SpatialModel + | +help: Use an explicit re-export: `fill as fill` + +F401 `.vector.fill.FillStrategy` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/geo/__init__.py:6:46 + | +4 | from .vector.neighborhood import attach_neighbors +5 | from .vector.regular_grid import regular_grid, parse_idx +6 | from .vector.fill import fill, FillStrategy + | ^^^^^^^^^^^^ +7 | from .vector.cellular_automaton import CellularAutomaton +8 | from .vector.model import SpatialModel + | +help: Use an explicit re-export: `FillStrategy as FillStrategy` + +F401 `.vector.cellular_automaton.CellularAutomaton` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/geo/__init__.py:7:40 + | +5 | from .vector.regular_grid import regular_grid, parse_idx +6 | from .vector.fill import fill, FillStrategy +7 | from .vector.cellular_automaton import CellularAutomaton + | ^^^^^^^^^^^^^^^^^ +8 | from .vector.model import SpatialModel + | +help: Use an explicit re-export: `CellularAutomaton as CellularAutomaton` + +F401 `.vector.model.SpatialModel` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/geo/__init__.py:8:40 + | + 6 | from .vector.fill import fill, FillStrategy + 7 | from .vector.cellular_automaton import CellularAutomaton + 8 | from .vector.model import SpatialModel + | ^^^^^^^^^^^^ + 9 | +10 | # raster substrate + | +help: Use an explicit re-export: `SpatialModel as SpatialModel` + +F401 `.raster.backend.RasterBackend` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/geo/__init__.py:11:40 + | +10 | # raster substrate +11 | from .raster.backend import RasterBackend, DIRS_MOORE, DIRS_VON_NEUMANN + | ^^^^^^^^^^^^^ +12 | from .raster.model import RasterModel +13 | from .raster.cellular_automaton import RasterCellularAutomaton + | +help: Use an explicit re-export: `RasterBackend as RasterBackend` + +F401 `.raster.backend.DIRS_MOORE` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/geo/__init__.py:11:55 + | +10 | # raster substrate +11 | from .raster.backend import RasterBackend, DIRS_MOORE, DIRS_VON_NEUMANN + | ^^^^^^^^^^ +12 | from .raster.model import RasterModel +13 | from .raster.cellular_automaton import RasterCellularAutomaton + | +help: Use an explicit re-export: `DIRS_MOORE as DIRS_MOORE` + +F401 `.raster.backend.DIRS_VON_NEUMANN` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/geo/__init__.py:11:67 + | +10 | # raster substrate +11 | from .raster.backend import RasterBackend, DIRS_MOORE, DIRS_VON_NEUMANN + | ^^^^^^^^^^^^^^^^ +12 | from .raster.model import RasterModel +13 | from .raster.cellular_automaton import RasterCellularAutomaton + | +help: Use an explicit re-export: `DIRS_VON_NEUMANN as DIRS_VON_NEUMANN` + +F401 `.raster.model.RasterModel` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/geo/__init__.py:12:40 + | +10 | # raster substrate +11 | from .raster.backend import RasterBackend, DIRS_MOORE, DIRS_VON_NEUMANN +12 | from .raster.model import RasterModel + | ^^^^^^^^^^^ +13 | from .raster.cellular_automaton import RasterCellularAutomaton +14 | from .raster.regular_grid import make_raster_grid + | +help: Use an explicit re-export: `RasterModel as RasterModel` + +F401 `.raster.cellular_automaton.RasterCellularAutomaton` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/geo/__init__.py:13:40 + | +11 | from .raster.backend import RasterBackend, DIRS_MOORE, DIRS_VON_NEUMANN +12 | from .raster.model import RasterModel +13 | from .raster.cellular_automaton import RasterCellularAutomaton + | ^^^^^^^^^^^^^^^^^^^^^^^ +14 | from .raster.regular_grid import make_raster_grid +15 | from .raster.band_spec import BandSpec # se tiver classe exportável + | +help: Use an explicit re-export: `RasterCellularAutomaton as RasterCellularAutomaton` + +F401 `.raster.regular_grid.make_raster_grid` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/geo/__init__.py:14:40 + | +12 | from .raster.model import RasterModel +13 | from .raster.cellular_automaton import RasterCellularAutomaton +14 | from .raster.regular_grid import make_raster_grid + | ^^^^^^^^^^^^^^^^ +15 | from .raster.band_spec import BandSpec # se tiver classe exportável + | +help: Use an explicit re-export: `make_raster_grid as make_raster_grid` + +F401 `.raster.band_spec.BandSpec` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/geo/__init__.py:15:40 + | +13 | from .raster.cellular_automaton import RasterCellularAutomaton +14 | from .raster.regular_grid import make_raster_grid +15 | from .raster.band_spec import BandSpec # se tiver classe exportável + | ^^^^^^^^ +16 | +17 | # raster io — opcional, não importa por padrão (requer rasterio) + | +help: Use an explicit re-export: `BandSpec as BandSpec` + +F401 [*] `typing.Any` imported but unused + --> dissmodel/geo/raster/backend.py:31:20 + | +29 | from __future__ import annotations +30 | +31 | from typing import Any + | ^^^ +32 | import numpy as np +33 | from scipy.ndimage import binary_dilation + | +help: Remove unused import: `typing.Any` + +F401 [*] `numpy` imported but unused + --> dissmodel/geo/raster/model.py:31:17 + | +29 | from __future__ import annotations +30 | +31 | import numpy as np + | ^^ +32 | +33 | from dissmodel.core import Model + | +help: Remove unused import: `numpy` + +F401 [*] `typing.Any` imported but unused + --> dissmodel/geo/raster/regular_grid.py:24:20 + | +22 | from __future__ import annotations +23 | +24 | from typing import Any, Union + | ^^^ +25 | +26 | import numpy as np + | +help: Remove unused import: `typing.Any` + +F401 [*] `libpysal.weights.Queen` imported but unused + --> dissmodel/geo/vector/cellular_automaton.py:20:30 + | +19 | import geopandas as gpd +20 | from libpysal.weights import Queen + | ^^^^^ +21 | +22 | from dissmodel.geo.vector.model import SpatialModel + | +help: Remove unused import: `libpysal.weights.Queen` + +F401 [*] `dissmodel.geo.vector.neighborhood.StrategyType` imported but unused + --> dissmodel/geo/vector/cellular_automaton.py:23:47 + | +22 | from dissmodel.geo.vector.model import SpatialModel +23 | from dissmodel.geo.vector.neighborhood import StrategyType + | ^^^^^^^^^^^^ + | +help: Remove unused import: `dissmodel.geo.vector.neighborhood.StrategyType` + +F401 `.game_of_life.GameOfLife` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/models/ca/__init__.py:1:27 + | +1 | from .game_of_life import GameOfLife + | ^^^^^^^^^^ +2 | from .fire_model import FireModel +3 | from .fire_model_prob import FireModelProb + | +help: Use an explicit re-export: `GameOfLife as GameOfLife` + +F401 `.fire_model.FireModel` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/models/ca/__init__.py:2:25 + | +1 | from .game_of_life import GameOfLife +2 | from .fire_model import FireModel + | ^^^^^^^^^ +3 | from .fire_model_prob import FireModelProb +4 | from .propagation import Propagation + | +help: Use an explicit re-export: `FireModel as FireModel` + +F401 `.fire_model_prob.FireModelProb` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/models/ca/__init__.py:3:30 + | +1 | from .game_of_life import GameOfLife +2 | from .fire_model import FireModel +3 | from .fire_model_prob import FireModelProb + | ^^^^^^^^^^^^^ +4 | from .propagation import Propagation +5 | from .snow import Snow + | +help: Use an explicit re-export: `FireModelProb as FireModelProb` + +F401 `.propagation.Propagation` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/models/ca/__init__.py:4:26 + | +2 | from .fire_model import FireModel +3 | from .fire_model_prob import FireModelProb +4 | from .propagation import Propagation + | ^^^^^^^^^^^ +5 | from .snow import Snow +6 | from .growth import Growth + | +help: Use an explicit re-export: `Propagation as Propagation` + +F401 `.snow.Snow` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/models/ca/__init__.py:5:19 + | +3 | from .fire_model_prob import FireModelProb +4 | from .propagation import Propagation +5 | from .snow import Snow + | ^^^^ +6 | from .growth import Growth +7 | from .anneal import Anneal + | +help: Use an explicit re-export: `Snow as Snow` + +F401 `.growth.Growth` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/models/ca/__init__.py:6:21 + | +4 | from .propagation import Propagation +5 | from .snow import Snow +6 | from .growth import Growth + | ^^^^^^ +7 | from .anneal import Anneal + | +help: Use an explicit re-export: `Growth as Growth` + +F401 `.anneal.Anneal` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/models/ca/__init__.py:7:21 + | +5 | from .snow import Snow +6 | from .growth import Growth +7 | from .anneal import Anneal + | ^^^^^^ + | +help: Use an explicit re-export: `Anneal as Anneal` + +F401 [*] `dissmodel.geo.FillStrategy` imported but unused + --> dissmodel/models/ca/fire_model_prob.py:8:46 + | +6 | from libpysal.weights import Queen +7 | +8 | from dissmodel.geo import CellularAutomaton, FillStrategy, fill + | ^^^^^^^^^^^^ +9 | from dissmodel.models.ca.fire_model import FireState + | +help: Remove unused import + +F401 [*] `dissmodel.geo.fill` imported but unused + --> dissmodel/models/ca/fire_model_prob.py:8:60 + | +6 | from libpysal.weights import Queen +7 | +8 | from dissmodel.geo import CellularAutomaton, FillStrategy, fill + | ^^^^ +9 | from dissmodel.models.ca.fire_model import FireState + | +help: Remove unused import + +F401 [*] `dissmodel.geo.raster.regular_grid.make_raster_grid` imported but unused + --> dissmodel/models/ca/game_of_life_raster.py:54:47 + | +53 | from dissmodel.geo.raster.backend import RasterBackend +54 | from dissmodel.geo.raster.regular_grid import make_raster_grid + | ^^^^^^^^^^^^^^^^ +55 | from dissmodel.geo.raster.cellular_automaton import RasterCellularAutomaton + | +help: Remove unused import: `dissmodel.geo.raster.regular_grid.make_raster_grid` + +F401 [*] `random` imported but unused + --> dissmodel/models/ca/propagation.py:3:8 + | +1 | from __future__ import annotations +2 | +3 | import random + | ^^^^^^ +4 | from enum import IntEnum +5 | from typing import Any + | +help: Remove unused import: `random` + +F401 `.population_growth.PopulationGrowth` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/models/sysdyn/__init__.py:1:32 + | +1 | from .population_growth import PopulationGrowth + | ^^^^^^^^^^^^^^^^ +2 | from .sir import SIR +3 | from .cofee import Coffee + | +help: Use an explicit re-export: `PopulationGrowth as PopulationGrowth` + +F401 `.sir.SIR` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/models/sysdyn/__init__.py:2:18 + | +1 | from .population_growth import PopulationGrowth +2 | from .sir import SIR + | ^^^ +3 | from .cofee import Coffee +4 | from .lorenz import Lorenz + | +help: Use an explicit re-export: `SIR as SIR` + +F401 `.cofee.Coffee` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/models/sysdyn/__init__.py:3:20 + | +1 | from .population_growth import PopulationGrowth +2 | from .sir import SIR +3 | from .cofee import Coffee + | ^^^^^^ +4 | from .lorenz import Lorenz +5 | from .predatorprey import PredatorPrey + | +help: Use an explicit re-export: `Coffee as Coffee` + +F401 `.lorenz.Lorenz` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/models/sysdyn/__init__.py:4:21 + | +2 | from .sir import SIR +3 | from .cofee import Coffee +4 | from .lorenz import Lorenz + | ^^^^^^ +5 | from .predatorprey import PredatorPrey + | +help: Use an explicit re-export: `Lorenz as Lorenz` + +F401 `.predatorprey.PredatorPrey` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/models/sysdyn/__init__.py:5:27 + | +3 | from .cofee import Coffee +4 | from .lorenz import Lorenz +5 | from .predatorprey import PredatorPrey + | ^^^^^^^^^^^^ + | +help: Use an explicit re-export: `PredatorPrey as PredatorPrey` + +F401 `dissmodel.visualization.map.Map` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/visualization/__init__.py:1:41 + | +1 | from dissmodel.visualization.map import Map + | ^^^ +2 | from dissmodel.visualization.chart import Chart, track_plot +3 | from dissmodel.visualization.widgets import display_inputs + | +help: Use an explicit re-export: `Map as Map` + +F401 `dissmodel.visualization.chart.Chart` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/visualization/__init__.py:2:43 + | +1 | from dissmodel.visualization.map import Map +2 | from dissmodel.visualization.chart import Chart, track_plot + | ^^^^^ +3 | from dissmodel.visualization.widgets import display_inputs + | +help: Use an explicit re-export: `Chart as Chart` + +F401 `dissmodel.visualization.chart.track_plot` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/visualization/__init__.py:2:50 + | +1 | from dissmodel.visualization.map import Map +2 | from dissmodel.visualization.chart import Chart, track_plot + | ^^^^^^^^^^ +3 | from dissmodel.visualization.widgets import display_inputs + | +help: Use an explicit re-export: `track_plot as track_plot` + +F401 `dissmodel.visualization.widgets.display_inputs` imported but unused; consider removing, adding to `__all__`, or using a redundant alias + --> dissmodel/visualization/__init__.py:3:45 + | +1 | from dissmodel.visualization.map import Map +2 | from dissmodel.visualization.chart import Chart, track_plot +3 | from dissmodel.visualization.widgets import display_inputs + | ^^^^^^^^^^^^^^ + | +help: Use an explicit re-export: `display_inputs as display_inputs` + +F401 [*] `typing.Optional` imported but unused + --> dissmodel/visualization/map.py:3:25 + | +1 | from __future__ import annotations +2 | +3 | from typing import Any, Optional + | ^^^^^^^^ +4 | +5 | import matplotlib.pyplot as plt + | +help: Remove unused import: `typing.Optional` + +E702 Multiple statements on one line (semicolon) + --> dissmodel/visualization/raster_map.py:180:26 + | +178 | self._render_continuous(ax, arr) +179 | +180 | ax.set_xticks([]); ax.set_yticks([]) + | ^ +181 | ax.set_title(f"{self.title} [{self.band}] — Step {int(step)}") +182 | plt.tight_layout() + | + +F401 [*] `pytest` imported but unused + --> tests/geo/test_raster.py:15:8 + | +14 | import numpy as np +15 | import pytest + | ^^^^^^ +16 | +17 | from dissmodel.geo.raster.backend import RasterBackend, DIRS_MOORE, DIRS_VON_NEUMANN + | +help: Remove unused import: `pytest` + +F841 Local variable `env` is assigned to but never used + --> tests/geo/test_raster.py:166:9 + | +165 | b = RasterBackend(shape=(4, 4)) +166 | env = Environment(start_time=1, end_time=1) + | ^^^ +167 | m = DummyModel(backend=b) + | +help: Remove assignment to unused variable `env` + +F401 [*] `os` imported but unused + --> tests/geo/test_raster.py:221:16 + | +219 | def test_headless_saves_png(self, tmp_path, monkeypatch): +220 | """RasterMap deve salvar PNG em headless sem levantar exceção.""" +221 | import os + | ^^ +222 | from dissmodel.visualization.raster_map import RasterMap +223 | from dissmodel.core import Environment + | +help: Remove unused import: `os` + +F401 [*] `os` imported but unused + --> tests/geo/test_raster.py:240:16 + | +238 | def test_headless_categorical(self, tmp_path, monkeypatch): +239 | """Modo categórico (color_map) não deve levantar exceção.""" +240 | import os + | ^^ +241 | from dissmodel.visualization.raster_map import RasterMap +242 | from dissmodel.core import Environment + | +help: Remove unused import: `os` + +F401 [*] `pytest` imported but unused + --> tests/geo/tests_fill_pattern.py:2:8 + | +1 | # tests/test_fill_pattern.py +2 | import pytest + | ^^^^^^ +3 | from dissmodel.geo import regular_grid, fill, FillStrategy + | +help: Remove unused import: `pytest` + +Found 52 errors. +[*] 18 fixable with the `--fix` option (1 hidden fix can be enabled with the `--unsafe-fixes` option). diff --git a/tests/geo/test_cellular_automaton.py b/tests/geo/test_cellular_automaton_py similarity index 100% rename from tests/geo/test_cellular_automaton.py rename to tests/geo/test_cellular_automaton_py diff --git a/tests/geo/test_raster_py b/tests/geo/test_raster.py similarity index 97% rename from tests/geo/test_raster_py rename to tests/geo/test_raster.py index e9c78ba..d9d34e5 100644 --- a/tests/geo/test_raster_py +++ b/tests/geo/test_raster.py @@ -12,9 +12,8 @@ from __future__ import annotations import numpy as np -import pytest -from dissmodel.geo.raster_backend import RasterBackend, DIRS_MOORE, DIRS_VON_NEUMANN +from dissmodel.geo.raster.backend import RasterBackend, DIRS_MOORE, DIRS_VON_NEUMANN # ══════════════════════════════════════════════════════════════════════════════ @@ -153,7 +152,7 @@ def test_repr_contains_band_names(self): class TestRasterModel: def test_setup_populates_attrs(self): - from dissmodel.geo.raster_model import RasterModel + from dissmodel.geo.raster.model import RasterModel from dissmodel.core import Environment class DummyModel(RasterModel): @@ -172,7 +171,7 @@ def execute(self): assert m.dirs is DIRS_MOORE def test_execute_called_by_environment(self): - from dissmodel.geo.raster_model import RasterModel + from dissmodel.geo.raster.model import RasterModel from dissmodel.core import Environment calls = [] @@ -191,7 +190,7 @@ def execute(self): assert calls == [1, 2, 3] def test_subclass_can_modify_backend(self): - from dissmodel.geo.raster_model import RasterModel + from dissmodel.geo.raster.model import RasterModel from dissmodel.core import Environment class IncrementModel(RasterModel): @@ -218,7 +217,6 @@ class TestRasterMap: def test_headless_saves_png(self, tmp_path, monkeypatch): """RasterMap deve salvar PNG em headless sem levantar exceção.""" - import os from dissmodel.visualization.raster_map import RasterMap from dissmodel.core import Environment @@ -237,7 +235,6 @@ def test_headless_saves_png(self, tmp_path, monkeypatch): def test_headless_categorical(self, tmp_path, monkeypatch): """Modo categórico (color_map) não deve levantar exceção.""" - import os from dissmodel.visualization.raster_map import RasterMap from dissmodel.core import Environment diff --git a/tests/geo/tests_fill_pattern_py b/tests/geo/tests_fill_pattern.py similarity index 99% rename from tests/geo/tests_fill_pattern_py rename to tests/geo/tests_fill_pattern.py index 3858b95..e9fbcf2 100644 --- a/tests/geo/tests_fill_pattern_py +++ b/tests/geo/tests_fill_pattern.py @@ -1,5 +1,4 @@ # tests/test_fill_pattern.py -import pytest from dissmodel.geo import regular_grid, fill, FillStrategy