diff --git a/CHANGELOG.md b/CHANGELOG.md
index ea3acfb..2b78104 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.6.6] — 2026-10-08
+
+### Fixed
+- `display_inputs` read `obj.__annotations__`, which for a model with no
+ annotation of its own resolves to the annotations of `Model` itself: the
+ Streamlit sidebar showed `env`, `start_time`, `end_time`, `_step`… and the
+ `env` text box replaced the model's environment with a string (models that
+ call `self.env.now()`, such as `Wolfram` in dissmodel-ca, then failed).
+ It now renders only the annotations of the user's classes, base classes
+ included, and skips private names.
+- CLI: when `--output` is a directory, the generated file name used a
+ hard-coded `.tif` even for vector executors. The extension now comes from
+ the executor's optional `output_suffix` class attribute (default `.tif`).
+- CLI: the `ExperimentRecord` written by `run` had fixed placeholders
+ (`model_name="local"`, `model_commit="local-cli"`, `code_version="dev"`).
+ It now records the executor's `name`, the installed dissmodel version and
+ the package that defines the executor (e.g. `disslucc==0.5.0`); loose
+ scripts keep `local-cli`.
+- Docs: the vector-to-raster guide documented the removed
+ `dissmodel.geo.raster.io` module; rewritten for
+ `dissmodel.io.convert.vector_to_raster_backend`, moved to
+ `docs/examples/vector_to_raster.md` and added to the site navigation.
+ README step 3 now imports `ForestFireModel`.
+- CLI: `run` creates the parent directory of a local `--output` path; with
+ `--output results/` and no `results/` it failed with `FileNotFoundError`.
+- `save_dataset` raises a `TypeError` that names the fix when the data cannot
+ be written to the format of the URI (a GeoDataFrame to `.tif`, a
+ `RasterBackend` to `.gpkg`), instead of an `AttributeError` from deep inside
+ the writer. The README executor declares `output_suffix = ".gpkg"`.
+
+### Performance
+- `fill(FillStrategy.MIN_DISTANCE, ...)` uses the target's spatial index
+ (`sindex.nearest`) instead of measuring every cell against every target;
+ same values, ~10× faster on 40,000 cells × 2,000 lines.
+- `vector_grid` builds all cells with one vectorized `shapely.box` call;
+ same geometries, ids and row order, ~5× faster on a 1000×1000 grid.
+
### Changed
- `load_geotiff` sets `RasterBackend.transform` and `RasterBackend.crs` from
the file (they were only in the returned `meta` dict), and `save_geotiff`
@@ -20,6 +57,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`crs` — square metres on the ellipsoid for a geographic CRS (a 1/12° cell
is ~86 km² at the equator and ~72 km² at 33° S), the pixel area for a
projected one.
+- CLI: `run --preserve-output-name` writes to `--output` exactly as given,
+ without injecting the short experiment ID.
## [0.6.5] — 2026-09-22
diff --git a/CITATION.cff b/CITATION.cff
index cd76e3c..ccec115 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -14,8 +14,8 @@ authors:
given-names: "Denilson da Silva"
orcid: "https://orcid.org/0000-0002-9567-7828"
title: "DisSModel: A Python Framework for Spatially Explicit Dynamic Modeling"
-version: 0.6.5
-date-released: 2026-09-22
+version: 0.6.6
+date-released: 2026-10-08
url: "https://github.com/DisSModel/dissmodel"
repository-code: "https://github.com/DisSModel/dissmodel"
license: MIT
diff --git a/README.md b/README.md
index 6159a7e..65bc46b 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
[](https://opensource.org/licenses/MIT)
[](https://www.python.org/)
[](https://pypi.org/project/dissmodel/)
-[](https://github.com/DisSModel/dissmodel/actions)
+[](https://github.com/DisSModel/dissmodel/actions)
[](https://github.com/DisSModel)
[](https://joss.theoj.org/papers/46522bc30d2dbec6b509d2dc487170ec)
@@ -52,7 +52,7 @@ DisSModel is the synthesis: a Python-native, FAIR-aligned, cloud-ready simulatio
## 🌟 Key Features
- **Dual substrate** — same model logic runs on vector (`GeoDataFrame`) and raster (`RasterBackend`/NumPy).
-- **Lightweight scheduler** — pure-Python time-stepped engine; models auto-register at instantiation and receive clock ticks via `setup / pre_execute / execute / post_execute` lifecycle hooks.
+- **Lightweight scheduler** — pure-Python time-stepped engine; models auto-register at instantiation, run `setup` once and receive each clock tick via `pre_execute / execute / post_execute` lifecycle hooks.
- **Executor pattern** — strict separation between science (models) and infrastructure (I/O, CLI, reproducible execution).
- **Experiment tracking** — every run generates an immutable `ExperimentRecord` with SHA-256 checksums, TOML snapshot, and full provenance.
- **Storage-agnostic I/O** — `dissmodel.io` handles local paths and `s3://` URIs transparently.
@@ -62,23 +62,28 @@ DisSModel is the synthesis: a Python-native, FAIR-aligned, cloud-ready simulatio
## 🏗 Architecture
-```
-┌──────────────────────────────────────────────────────────┐
-│ Science Layer (Model) │
-│ FloodModel, AllocationClueLike, MangroveModel, ... │
-│ → only knows math, geometry and time │
-├──────────────────────────────────────────────────────────┤
-│ Infrastructure Layer (ModelExecutor) │
-│ CoastalRasterExecutor, LUCCVectorExecutor, ... │
-│ → only knows URIs, local/S3, column_map, parameters │
-├──────────────────────────────────────────────────────────┤
-│ Core modules │
-│ dissmodel.core — Environment, Model, SpatialModel │
-│ dissmodel.geo — RasterBackend, neighborhoods │
-│ dissmodel.executor — ModelExecutor ABC, ExperimentRecord│
-│ dissmodel.io — load_dataset / save_dataset │
-│ dissmodel.visualization — Map, RasterMap, Chart │
-└──────────────────────────────────────────────────────────┘
+```mermaid
+flowchart TB
+ subgraph science["Science layer: Model"]
+ M["FloodModel, MangroveModel, AllocationClueLike, ...
only math, geometry and time"]
+ end
+ subgraph infra["Infrastructure layer: ModelExecutor"]
+ E["BrmangueRasterExecutor, LuccContinuousExecutor, ...
only URIs (local or s3://), column_map, parameters"]
+ end
+ subgraph core_modules["Core modules"]
+ direction LR
+ core["dissmodel.core
Environment, Model, SpatialModel"]
+ geo["dissmodel.geo
RasterBackend, neighborhoods"]
+ exe["dissmodel.executor
ModelExecutor, ExperimentRecord"]
+ io["dissmodel.io
load_dataset, save_dataset"]
+ viz["dissmodel.visualization
Map, RasterMap, Chart"]
+ end
+ E -- "loads data, builds and runs" --> M
+ M --> core
+ M --> geo
+ E --> exe
+ E --> io
+ M -. "optional" .-> viz
```
---
@@ -126,8 +131,11 @@ from dissmodel.executor import ExperimentRecord, ModelExecutor
from dissmodel.executor.cli import run_cli
from dissmodel.io import load_dataset, save_dataset
+from forest_fire_model import ForestFireModel # the model from step 1
+
class ForestFireExecutor(ModelExecutor):
name = "forest_fire"
+ output_suffix = ".gpkg" # file type used when --output is a directory
def load(self, record: ExperimentRecord):
gdf, checksum = load_dataset(record.source.uri)
@@ -260,7 +268,8 @@ The map below shows how the pieces fit together, and how each one relates to its
| [`dissmodel-ca`](https://github.com/DisSModel/dissmodel-ca) | Classic Cellular Automata (Game of Life, Forest Fire, Growth) | `pip install "git+https://github.com/DisSModel/dissmodel-ca.git"` |
| [`dissmodel-sysdyn`](https://github.com/DisSModel/dissmodel-sysdyn) | System Dynamics (SIR, Predator-Prey, Lorenz) | `pip install "git+https://github.com/DisSModel/dissmodel-sysdyn.git"` |
| [`brmangue-dissmodel`](https://github.com/DisSModel/brmangue-dissmodel) | BR-MANGUE coastal flooding and mangrove succession model (raster + vector, validated against TerraME) | `pip install "git+https://github.com/DisSModel/brmangue-dissmodel.git"` |
-| [`disslucc`](https://github.com/DisSModel/disslucc) | Land Use and Cover Change models, continuous and discrete allocation (CLUE-inspired), raster-only | `pip install "git+https://github.com/DisSModel/disslucc.git"` |
+| [`disslucc`](https://github.com/DisSModel/disslucc) | Land Use and Cover Change models, continuous and discrete allocation (CLUE-inspired), raster-only | `pip install disslucc` |
+| [`disscube`](https://github.com/DisSModel/disscube) | Declarative spatial data cubes (a Python alternative to TerraME's fillCell): sources, grid and derived variables in TOML; hands the cube to DisSModel models | `pip install "disscube[dissmodel]"` |
### 🛠 Implementation Templates
@@ -311,7 +320,7 @@ Contributions are welcome! Please read our [Contributing Guidelines](CONTRIBUTIN
year = {2026},
publisher = {GitHub},
url = {https://github.com/DisSModel/dissmodel},
- version = {0.6.5}
+ version = {0.6.6}
}
```
diff --git a/dissmodel/executor/cli.py b/dissmodel/executor/cli.py
index c0a94b0..3597cc7 100644
--- a/dissmodel/executor/cli.py
+++ b/dissmodel/executor/cli.py
@@ -98,9 +98,73 @@ def _load_local_params(toml_path: str | None = None) -> tuple[dict, dict]:
return _load_toml(str(path))
+# ── Provenance helpers ────────────────────────────────────────────────────────
+
+def _dissmodel_version() -> str:
+ """Installed dissmodel version, or "unknown" when not installed as a package."""
+ from importlib.metadata import PackageNotFoundError, version
+ try:
+ return version("dissmodel")
+ except PackageNotFoundError:
+ return "unknown"
+
+
+def _executor_package_version(executor_cls) -> str:
+ """
+ "==" of the package that defines the executor
+ (e.g. "disslucc==0.5.0"), or "local-cli" when the executor is a loose
+ script or its package is not installed.
+ """
+ from importlib.metadata import PackageNotFoundError, packages_distributions, version
+
+ pkg_dists = packages_distributions()
+ top_level = (getattr(executor_cls, "__module__", "") or "").split(".")[0]
+ if top_level == "__main__":
+ # Executor file run as a script (python path/to/pkg/executor.py):
+ # recover the package from the file location, but only if that
+ # directory really is the installed package of that name.
+ top_level = _installed_package_containing(executor_cls, pkg_dists)
+ if not top_level:
+ return "local-cli"
+ for dist in pkg_dists.get(top_level, []):
+ try:
+ return f"{dist}=={version(dist)}"
+ except PackageNotFoundError:
+ continue
+ return "local-cli"
+
+
+def _installed_package_containing(executor_cls, pkg_dists) -> str:
+ """Name of the installed top-level package whose directory holds the executor's file."""
+ import importlib.util
+ import inspect
+
+ try:
+ path = Path(inspect.getfile(executor_cls)).resolve()
+ except (TypeError, OSError):
+ return ""
+ for parent in path.parents:
+ if parent.name not in pkg_dists:
+ continue
+ try:
+ spec = importlib.util.find_spec(parent.name)
+ except (ImportError, ValueError):
+ continue
+ locations = list(getattr(spec, "submodule_search_locations", None) or [])
+ if any(Path(loc).resolve() == parent for loc in locations):
+ return parent.name
+ return ""
+
+
+def _model_name(executor_cls) -> str:
+ if executor_cls is None:
+ return "local"
+ return getattr(executor_cls, "name", None) or executor_cls.__name__
+
+
# ── Record factory ────────────────────────────────────────────────────────────
-def _build_record(args):
+def _build_record(args, executor_cls=None):
from dissmodel.executor.schemas import DataSource, ExperimentRecord
toml_path = getattr(args, "toml", None)
@@ -108,9 +172,9 @@ def _build_record(args):
params = {**params, **_parse_params(args.param)} # CLI overrides TOML+spec
record = ExperimentRecord(
- model_name = "local",
- model_commit = "local-cli",
- code_version = "dev",
+ model_name = _model_name(executor_cls),
+ model_commit = _executor_package_version(executor_cls) if executor_cls else "local-cli",
+ code_version = _dissmodel_version(),
resolved_spec = {"model": spec} if spec else {},
source = DataSource(
type = "s3" if args.input.startswith("s3://") else "local",
@@ -130,15 +194,19 @@ def _build_record(args):
# ── Output path intelligence (CLI-only) ───────────────────────────────────────
-def _apply_output_path_intelligence(record, args) -> None:
+def _apply_output_path_intelligence(record, args, executor_cls=None) -> None:
"""
Inject experiment_id into the output path for traceability.
Must run before execute_lifecycle so that save() receives the
already-corrected path.
- Scenario A: user passed a directory → generate a safe filename.
- Scenario B: user passed a file without the experiment ID → inject it.
+ Scenario A: user passed a directory → generate a safe filename. The
+ extension comes from the executor's ``output_suffix`` class
+ attribute (default ``".tif"``).
+ Scenario B: user passed a file without the experiment ID → inject it,
+ unless ``--preserve-output-name`` was given, in which case
+ the file name is used exactly as passed.
"""
if not record.output_path:
return
@@ -148,8 +216,12 @@ def _apply_output_path_intelligence(record, args) -> None:
if str(record.output_path).endswith(("/", "\\")) or p.is_dir():
# Scenario A — directory only, generate filename
- safe_name = f"simulacao_{exp_id_short}.tif"
+ suffix = getattr(executor_cls, "output_suffix", None) or ".tif"
+ safe_name = f"simulacao_{exp_id_short}{suffix}"
record.output_path = str(p / safe_name)
+ elif getattr(args, "preserve_output_name", False):
+ # Explicit opt-out: keep the exact name the user asked for
+ pass
elif exp_id_short not in p.name:
# Scenario B — file path without the experiment ID
safe_name = f"{p.stem}_{exp_id_short}{p.suffix}"
@@ -159,17 +231,25 @@ def _apply_output_path_intelligence(record, args) -> None:
args.output = record.output_path
+def _ensure_local_output_dir(output_path: str | None) -> None:
+ """Create the parent directory of a local output path (s3:// is left alone)."""
+ if not output_path or "://" in output_path:
+ return
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
+
+
# ── Commands ──────────────────────────────────────────────────────────────────
def _cmd_run(executor_cls, args) -> None:
from dissmodel.executor.runner import execute_lifecycle
from dissmodel.io._utils import write_text
- record = _build_record(args)
+ record = _build_record(args, executor_cls)
executor = executor_cls()
# Output path intelligence runs before save() inside execute_lifecycle
- _apply_output_path_intelligence(record, args)
+ _apply_output_path_intelligence(record, args, executor_cls)
+ _ensure_local_output_dir(record.output_path)
print("▶ Validating...")
print("▶ Loading...")
@@ -244,7 +324,7 @@ def _cmd_validate(executor_cls, args) -> None:
ok = harness.run_contract_tests()
if getattr(args, "input", None):
- record = _build_record(args)
+ record = _build_record(args, executor_cls)
harness.run_with_sample_data(record)
sys.exit(0 if ok else 1)
@@ -299,6 +379,9 @@ def _build_parser():
help="Input URI: local path or s3://bucket/key")
run_p.add_argument("--output", "-o", default=None,
help="Output path: local file or s3://bucket/key")
+ run_p.add_argument("--preserve-output-name", action="store_true",
+ help="Write to --output exactly as given, without "
+ "injecting the short experiment ID into the file name")
run_p.add_argument("--column-map", action="append", metavar="CANONICAL=REAL",
help="Column mapping for vector input (repeatable)")
run_p.add_argument("--band-map", action="append", metavar="CANONICAL=REAL",
diff --git a/dissmodel/geo/vector/fill.py b/dissmodel/geo/vector/fill.py
index 67ba6e0..806feec 100644
--- a/dissmodel/geo/vector/fill.py
+++ b/dissmodel/geo/vector/fill.py
@@ -251,10 +251,43 @@ def _fill_min_distance(
attr_name : str, optional
Name of the column to store the distances, by default
``"min_distance"``.
+
+ Notes
+ -----
+ Uses the spatial index of ``to_gdf`` (an STRtree) to find, for each
+ row, the nearest target geometry and then measures that one pair, so
+ the cost grows roughly as N log M instead of N × M. The distances are
+ the same as a brute-force minimum over all targets. Rows with a
+ missing or empty geometry, or an empty ``to_gdf``, get ``NaN``.
"""
- from_gdf[attr_name] = from_gdf.geometry.apply(
- lambda geom: to_gdf.geometry.distance(geom).min()
- )
+ import warnings
+
+ import shapely
+
+ if (
+ from_gdf.crs is not None
+ and to_gdf.crs is not None
+ and not from_gdf.crs.equals(to_gdf.crs)
+ ):
+ warnings.warn(
+ "CRS mismatch between from_gdf and to_gdf "
+ f"({from_gdf.crs.to_string()} != {to_gdf.crs.to_string()}); "
+ "distances are computed in raw coordinates. "
+ "Use to_crs() to align them.",
+ UserWarning,
+ stacklevel=3,
+ )
+
+ result = np.full(len(from_gdf), np.nan)
+ if len(from_gdf) and len(to_gdf):
+ # (positions in from_gdf, positions in to_gdf) of each nearest pair;
+ # rows with missing/empty geometries produce no pair and stay NaN.
+ src_pos, tgt_pos = to_gdf.sindex.nearest(from_gdf.geometry, return_all=False)
+ result[src_pos] = shapely.distance(
+ from_gdf.geometry.values[src_pos],
+ to_gdf.geometry.values[tgt_pos],
+ )
+ from_gdf[attr_name] = result
# ---------------------------------------------------------------------------
diff --git a/dissmodel/geo/vector/vector_grid.py b/dissmodel/geo/vector/vector_grid.py
index 8b0b73c..6f2ff8d 100644
--- a/dissmodel/geo/vector/vector_grid.py
+++ b/dissmodel/geo/vector/vector_grid.py
@@ -5,7 +5,7 @@
import geopandas as gpd
import numpy as np
-from shapely.geometry import box
+import shapely
# Reusable type aliases
Bounds = tuple[float, float, float, float] # (xmin, ymin, xmax, ymax)
@@ -144,12 +144,12 @@ def vector_grid(
x_edges: np.ndarray = np.arange(xmin, xmax, resolution_x)
y_edges: np.ndarray = np.arange(ymin, ymax, resolution_y)
- grid_cells = []
- ids = []
- for i, x0 in enumerate(x_edges):
- for j, y0 in enumerate(y_edges):
- grid_cells.append(box(x0, y0, x0 + resolution_x, y0 + resolution_y))
- ids.append(f"{j}-{i}")
+ # Column-major order (x outer, y inner) and "row-col" ids, as before:
+ # neighbourhoods and raster conversions rely on this row order.
+ x0, y0 = np.meshgrid(x_edges, y_edges, indexing="ij")
+ x0, y0 = x0.ravel(), y0.ravel()
+ grid_cells = shapely.box(x0, y0, x0 + resolution_x, y0 + resolution_y)
+ ids = [f"{j}-{i}" for i in range(len(x_edges)) for j in range(len(y_edges))]
data: dict[str, Any] = {"geometry": grid_cells, "id": ids}
for key, value in attrs.items():
diff --git a/dissmodel/io/_dispatch.py b/dissmodel/io/_dispatch.py
index 94749d3..f81cc1a 100644
--- a/dissmodel/io/_dispatch.py
+++ b/dissmodel/io/_dispatch.py
@@ -36,6 +36,32 @@ def load_dataset(uri: str, minio_client=None, fmt: str | None = None, **kwargs):
raise ValueError(f"Unsupported format: '{resolved_fmt}'")
+def _check_data_matches_format(data, uri: str, fmt: str) -> None:
+ """
+ Fail early, with a readable message, when the data type cannot be
+ written to the format chosen by the URI (e.g. a GeoDataFrame to .tif).
+ Without this the writer fails deep inside with an AttributeError.
+ """
+ import geopandas as gpd
+
+ from dissmodel.geo.raster.backend import RasterBackend
+
+ backend = data[0] if isinstance(data, tuple) and data else data
+ if fmt == "raster" and isinstance(data, gpd.GeoDataFrame):
+ raise TypeError(
+ f"Cannot save a GeoDataFrame to raster path '{uri}'. Use a vector "
+ "extension (.gpkg, .shp, .geojson); for the CLI, pass --output "
+ "with that extension or set `output_suffix = \".gpkg\"` on the "
+ "executor class."
+ )
+ if fmt == "vector" and isinstance(backend, RasterBackend):
+ raise TypeError(
+ f"Cannot save a RasterBackend to vector path '{uri}'. Use a raster "
+ "extension (.tif); for the CLI, pass --output with that extension "
+ "or set `output_suffix = \".tif\"` on the executor class."
+ )
+
+
def save_dataset(data, uri: str, minio_client=None, fmt: str | None = None, **kwargs) -> str:
"""
Save any supported dataset to a URI.
@@ -47,6 +73,7 @@ def save_dataset(data, uri: str, minio_client=None, fmt: str | None = None, **kw
sha256_checksum of the saved file
"""
resolved_fmt = fmt or detect_format(uri)
+ _check_data_matches_format(data, uri, resolved_fmt)
if resolved_fmt == "vector":
from dissmodel.io.vector import save_gdf
diff --git a/dissmodel/visualization/widgets.py b/dissmodel/visualization/widgets.py
index 3b0dbf7..1f3a647 100644
--- a/dissmodel/visualization/widgets.py
+++ b/dissmodel/visualization/widgets.py
@@ -1,13 +1,36 @@
from __future__ import annotations
+import inspect
from typing import Any
+def _parameter_annotations(obj: Any) -> dict[str, Any]:
+ """
+ Annotations declared by the user's classes, base classes included.
+
+ Classes from the ``dissmodel`` package itself are skipped: their
+ annotations (``env``, ``start_time``, ``end_time``, ``name``…) are
+ framework state, not model parameters. Reading ``obj.__annotations__``
+ directly would return them for any model that declares no annotation of
+ its own, and the widget written back would replace ``env`` with a string.
+ Names starting with ``_`` are skipped as private.
+ """
+ cls = obj if isinstance(obj, type) else type(obj)
+ found: dict[str, Any] = {}
+ for klass in reversed(cls.__mro__):
+ module = getattr(klass, "__module__", "") or ""
+ if module == "dissmodel" or module.startswith("dissmodel."):
+ continue
+ found.update(inspect.get_annotations(klass))
+ return {k: v for k, v in found.items() if not k.startswith("_")}
+
+
def display_inputs(obj: Any, st: Any) -> None:
"""
Render Streamlit input widgets for every annotated attribute on ``obj``.
- Iterates over ``obj.__annotations__`` and creates an appropriate widget
+ Iterates over the annotations of ``obj``'s class (and of its user-defined
+ base classes; not those of dissmodel's own classes) and creates an appropriate widget
for each attribute based on its current value type. The attribute is
updated in-place on ``obj`` after each interaction.
@@ -36,7 +59,7 @@ def display_inputs(obj: Any, st: Any) -> None:
display_inputs(ca_model, st)
```
"""
- annotations: dict[str, Any] = getattr(obj, "__annotations__", {})
+ annotations = _parameter_annotations(obj)
for name in annotations:
value: Any = getattr(obj, name, None)
diff --git a/docs/api/executor/cli.md b/docs/api/executor/cli.md
index 0f98148..fe5c6a0 100644
--- a/docs/api/executor/cli.md
+++ b/docs/api/executor/cli.md
@@ -146,8 +146,21 @@ accidental overwrites:
# File already has ID → unchanged
--output results/run_ec17096d.tif → unchanged
+
+# Exact name requested → unchanged
+--output results/run.tif --preserve-output-name → results/run.tif
```
+When `--output` is a directory, the extension of the generated file comes
+from the executor's optional `output_suffix` class attribute (default
+`.tif`), so a vector executor can declare `output_suffix = ".gpkg"`.
+
+The record written next to the output (`.record.json`) fills its
+provenance fields from the environment: `model_name` is the executor's
+`name`, `code_version` the installed dissmodel version, and `model_commit`
+the package that defines the executor (e.g. `disslucc==0.5.0`), or
+`local-cli` for an executor defined in a loose script.
+
---
## API Reference
diff --git a/docs/api/executor/schemas.md b/docs/api/executor/schemas.md
index f9f2886..3ba4e1f 100644
--- a/docs/api/executor/schemas.md
+++ b/docs/api/executor/schemas.md
@@ -83,8 +83,8 @@ from dissmodel.executor.schemas import JobRequest
job = JobRequest(
model = {
- "class": "coastal_raster",
- "package": "git+https://github.com/DisSModel/coastal-dynamics@main",
+ "class": "brmangue_raster",
+ "package": "git+https://github.com/DisSModel/brmangue-dissmodel@v0.5.0",
"parameters": {"end_time": 88},
},
source = {"type": "s3", "uri": "s3://dissmodel-inputs/grid.zip"},
diff --git a/docs/examples/vector_to_raster.md b/docs/examples/vector_to_raster.md
new file mode 100644
index 0000000..ba71c8c
--- /dev/null
+++ b/docs/examples/vector_to_raster.md
@@ -0,0 +1,141 @@
+# Loading Vector Data into the Raster Substrate
+
+DisSModel can load any vector file (Shapefile, GeoJSON, GeoPackage, or a
+`.zip` containing a shapefile) and convert it directly into a
+`RasterBackend`, so raster models can run on real geographic data without
+intermediate GIS steps.
+
+## How it works
+
+```
+vector file → GeoDataFrame → rasterize → NumPy arrays → RasterBackend → raster model
+```
+
+The rasterization step (powered by `rasterio.features.rasterize`) runs
+once; after that the model runs entirely on NumPy arrays.
+
+!!! note "Grid regularity"
+ This workflow is most accurate when the input already contains a
+ **regular grid** of equal-area polygons (e.g. 100×100 m cells), the
+ typical output of spatial homogenization tools. For irregular polygons
+ (municipalities, watersheds), cell values are burned by centroid or by
+ touch — inspect the result before running long simulations.
+
+---
+
+## `vector_to_raster_backend`
+
+```python
+from dissmodel.io.convert import vector_to_raster_backend
+
+b = vector_to_raster_backend(
+ source = "data/mangue_grid.shp", # path, .zip, or a GeoDataFrame
+ resolution = 100, # 100 m cells
+ attrs = ["uso", "alt", "solo"],
+ crs = "EPSG:31984", # reproject if needed
+)
+
+print(b.shape) # (rows, cols) from bounding box + resolution
+print(b.band_names()) # ['mask', 'uso', 'alt', 'solo']
+```
+
+### Parameters
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `source` | `str`, `Path` or `GeoDataFrame` | Vector file or in-memory GeoDataFrame |
+| `resolution` | `float` | Cell size in CRS units (metres for a metric CRS) |
+| `attrs` | `list[str]` or `dict[str, default]` | Columns to rasterize |
+| `crs` | `str`, `int`, or `None` | Target CRS — reprojects if needed |
+| `all_touched` | `bool` | Burn all touched cells (default: centre only) |
+| `nodata` | `int` or `float` | Fill value for uncovered cells (default: `0`) |
+| `add_mask` | `bool` | Add a `"mask"` band marking covered cells (default: `True`) |
+
+To set per-column defaults for uncovered cells, pass a dict:
+
+```python
+b = vector_to_raster_backend(
+ source = "data/mangue_grid.shp",
+ resolution = 100,
+ attrs = {"uso": 5, "alt": 0.0, "solo": 1},
+ crs = "EPSG:31984",
+)
+```
+
+`shapefile_to_raster_backend` is a deprecated alias of this function and
+emits a `FutureWarning`.
+
+---
+
+## Full example — raster model from a vector file
+
+```python
+from dissmodel.core import Environment
+from dissmodel.io.convert import vector_to_raster_backend
+from dissmodel.visualization.raster_map import RasterMap
+
+# your own RasterModel subclass, e.g. the BR-MANGUE flood model
+from myproject.flood import FloodRasterModel
+
+# 1. vector file → RasterBackend
+b = vector_to_raster_backend(
+ source = "data/mangue_grid.shp",
+ resolution = 100,
+ attrs = {"uso": 5, "alt": 0.0, "solo": 1},
+ crs = "EPSG:31984",
+)
+print(f"Grid: {b.shape[0]} rows × {b.shape[1]} cols = {b.shape[0] * b.shape[1]:,} cells")
+
+# 2. run the raster model
+env = Environment(start_time=2012, end_time=2100)
+FloodRasterModel(backend=b, taxa=0.011)
+RasterMap(backend=b, band="uso", title="Land Use")
+env.run()
+```
+
+---
+
+## Saving results to GeoTIFF
+
+`vector_to_raster_backend` stores the CRS and affine transform on the
+backend, so `save_geotiff` writes a georeferenced file without extra
+arguments:
+
+```python
+from dissmodel.io.raster import save_geotiff
+
+checksum = save_geotiff(b, "output/flood_result.tif") # returns SHA-256
+```
+
+---
+
+## Same data, two substrates
+
+```python
+# ── vector substrate ─────────────────────────────────────────────────────────
+import geopandas as gpd
+
+gdf = gpd.read_file("data/mangue_grid.shp").to_crs("EPSG:31984")
+# → GeoDataFrame with real geometries; use with SpatialModel / CellularAutomaton
+
+# ── raster substrate ─────────────────────────────────────────────────────────
+from dissmodel.io.convert import vector_to_raster_backend
+
+b = vector_to_raster_backend(
+ "data/mangue_grid.shp", resolution=100,
+ attrs=["uso", "alt", "solo"], crs="EPSG:31984",
+)
+# → RasterBackend with the same attributes; use with RasterModel
+```
+
+The data source is the same file; only the substrate changes. For measured
+run times of the two substrates on the same model, see the benchmarks in
+`benchmarks/` and the `brmangue-dissmodel` benchmark executor.
+
+---
+
+## API Reference
+
+::: dissmodel.io.convert.vector_to_raster_backend
+
+::: dissmodel.io.raster.save_geotiff
diff --git a/mkdocs.yml b/mkdocs.yml
index c4b1613..6421c36 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -43,6 +43,7 @@ nav:
- Overview: examples.md
- CLI Examples: examples/cli.md
- Streamlit Examples: examples/streamlit.md
+ - Vector to Raster: examples/vector_to_raster.md
- Notebooks:
- Game of Life: examples/notebooks/ca_game_of_life.ipynb
- SIR Model: examples/notebooks/sysdyn_sir.ipynb
diff --git a/paper.bib b/paper.bib
index 0513a37..6a56e0a 100644
--- a/paper.bib
+++ b/paper.bib
@@ -38,29 +38,13 @@ @incollection{Bezerra2025BM
@software{Jordahl2021,
author = {Kelsey Jordahl and Joris Van den Bossche and Martin Fleischmann and James McBride and Jacob Wasserman and others},
- title = {{geopandas/geopandas}: {v0.10.2}},
- month = oct,
- year = {2021},
+ title = {{geopandas/geopandas}},
+ year = {2026},
publisher = {Zenodo},
- doi = {10.5281/zenodo.5573592}
+ doi = {10.5281/zenodo.2585848}
}
-@article{Bezerra2022,
- title = {New land-use change scenarios for Brazil: Refining global SSPs with a regional spatially-explicit allocation model},
- author = {Bezerra, Francisco Gilney Silva and Von Randow, Celso and
- Assis, Talita Oliveira and Bezerra, Karine Rocha Aguiar and
- Tejada, Graciela and Castro, Aline Anderson and
- Gomes, Diego Melo de Paula and Avancini, Rodrigo and
- Aguiar, Ana Paula},
- journal = {PLOS ONE},
- volume = {17},
- number = {4},
- pages = {e0256052},
- year = {2022},
- doi = {10.1371/journal.pone.0256052}
-}
-
@mastersthesis{SantosJunior2025,
title = {Discrete Spatial Modeling: Uma proposta de arcabouço para construção de modelos dinâmicos espacialmente explícitos baseado no ecossistema Python},
author = {Santos Junior, Nerval de Jesus},
@@ -104,45 +88,93 @@ @article{Ferreira2020
}
@software{BRMangue,
- author = {Costa, Sérgio Souza and Bezerra, Denilson da Silva and Sousa, Felipe Martins},
+ author = {Bezerra, Denilson da Silva and Sousa, Felipe Martins and Costa, Sérgio Souza},
title = {{brmangue-dissmodel}: The BR-MANGUE coupled flood and mangrove succession model for DisSModel},
+ version = {0.5.0},
year = {2026},
- publisher = {GitHub},
+ publisher = {Zenodo},
+ doi = {10.5281/zenodo.23240385},
url = {https://github.com/DisSModel/brmangue-dissmodel}
}
@software{DisSModelCA,
- author = {Costa, Sérgio Souza and Santos Junior, Nerval de Jesus},
+ author = {Costa, Sérgio Souza and Santos Junior, Nerval de Jesus and Zhao, Samylli Kalei Silva},
title = {{dissmodel-ca}: Cellular Automata extension for DisSModel},
- year = {2025},
- publisher = {GitHub},
+ version = {0.1.0},
+ year = {2026},
+ publisher = {Zenodo},
+ doi = {10.5281/zenodo.23241654},
url = {https://github.com/DisSModel/dissmodel-ca}
}
@software{DisSModelSysDyn,
- author = {Costa, Sérgio Souza and Santos Junior, Nerval de Jesus},
+ author = {Costa, Sérgio Souza and Santos Junior, Nerval de Jesus and Vale, Mateus Dutra},
title = {{dissmodel-sysdyn}: System Dynamics extension for DisSModel},
- year = {2025},
- publisher = {GitHub},
+ version = {0.1.1},
+ year = {2026},
+ publisher = {Zenodo},
+ doi = {10.5281/zenodo.23242935},
url = {https://github.com/DisSModel/dissmodel-sysdyn}
}
@software{DisSLUCC,
author = {Costa, Sérgio Souza},
title = {{disslucc}: Raster-only continuous and discrete LUCC allocation models for DisSModel},
- version = {0.4.0},
+ version = {0.5.0},
year = {2026},
- publisher = {GitHub},
+ publisher = {Zenodo},
+ doi = {10.5281/zenodo.23219338},
url = {https://github.com/DisSModel/disslucc}
}
+@software{DisSCube,
+ author = {Costa, Sérgio Souza},
+ title = {{DisSCube}: Declarative spatial data cubes},
+ version = {0.5.0},
+ year = {2026},
+ publisher = {Zenodo},
+ doi = {10.5281/zenodo.23172450},
+ url = {https://github.com/DisSModel/disscube}
+}
+
+@software{DisSLUCCBenchmark,
+ author = {Costa, Sérgio Souza},
+ title = {{disslucc-benchmark}: Reproducible validation of {disslucc} against the {LuccME} 3.1 labs},
+ version = {0.2.2},
+ year = {2026},
+ publisher = {Zenodo},
+ doi = {10.5281/zenodo.23224097},
+ url = {https://github.com/LambdaGeo/disslucc-benchmark}
+}
+
+@software{BRMangueTerraME,
+ author = {Bezerra, Denilson da Silva and Costa, Sérgio Souza},
+ title = {{brmangue-terrame}: {BR-MANGUE} adapted to {TerraME} 2.0, with a
+ headless driver for reference outputs},
+ version = {0.1.0},
+ year = {2026},
+ publisher = {Zenodo},
+ doi = {10.5281/zenodo.23240318},
+ url = {https://github.com/LambdaGeo/brmangue-terrame}
+}
+
+@dataset{LuccMEGoldens,
+ author = {Costa, Sérgio Souza},
+ title = {{luccme-goldens}: Canonical Reference Execution Outputs for {TerraME} 2.0.1 and {LuccME} 3.1},
+ version = {1.1.0},
+ year = {2026},
+ publisher = {Zenodo},
+ doi = {10.5281/zenodo.23161342},
+ url = {https://github.com/LambdaGeo/luccme-goldens}
+}
+
@software{TerraMEDocker,
author = {Costa, Sérgio Souza},
- title = {{terrame-docker}: {TerraME} 2.0.1 and {LuccME} in a container, with
- year-by-year reference outputs of the {LuccME} labs},
- version = {0.1.1},
+ title = {{terrame-docker}: {TerraME} and {LuccME} in a {Docker} image},
+ version = {0.4.2},
year = {2026},
- publisher = {GitHub},
+ publisher = {Zenodo},
+ doi = {10.5281/zenodo.23160784},
url = {https://github.com/LambdaGeo/terrame-docker}
}
@@ -221,7 +253,8 @@ @inproceedings{Costa2009
author = {Costa, Sérgio Souza and Aguiar, Ana Paula Dutra and Câmara, Gilberto and Moreira, Evaldinolia},
booktitle = {Simpósio Brasileiro de Sensoriamento Remoto (SBSR)},
address = {Natal, RN, Brazil},
- year = {2009}
+ year = {2009},
+ url = {https://www.researchgate.net/publication/228981638_Common_Concepts_to_Development_of_the_Top-Down_Models_of_Land_Changes}
}
@article{Verburg2002,
@@ -248,6 +281,16 @@ @article{SoaresFilho2002
doi = {10.1016/S0304-3800(02)00059-5}
}
+@inproceedings{Wang2022,
+ title = {{Mesa-Geo}: A {GIS} Extension for the {Mesa} Agent-Based Modeling Framework in {Python}},
+ author = {Wang, Boyu and Hess, Vincent and Crooks, Andrew},
+ booktitle = {Proceedings of the 5th ACM SIGSPATIAL International Workshop on GeoSpatial Simulation (GeoSim 2022)},
+ publisher = {ACM},
+ pages = {1--10},
+ year = {2022},
+ doi = {10.1145/3557989.3566157}
+}
+
@inproceedings{Kazil2020,
title = {Utilizing {Python} for Agent-Based Modeling: The {Mesa} Framework},
author = {Kazil, Jackie and Masad, David and Crooks, Andrew},
@@ -278,3 +321,13 @@ @article{SoaresFilho2013
pages = {80--87},
doi = {10.1016/j.envsoft.2013.01.010}
}
+
+@misc{Costa2026INPE,
+ author = {Costa, Sérgio Souza},
+ title = {Building Open and Reproducible Geospatial Simulations with {Python}},
+ howpublished = {Seminar at INPE (remote), recorded on the INPE program's YouTube channel},
+ year = {2026},
+ month = may,
+ url = {https://lambdageo.github.io/presentations/2026-05-07-inpe-seminar/},
+ note = {Video: \url{https://www.youtube.com/watch?v=o7pMJt0CvXU}}
+}
\ No newline at end of file
diff --git a/paper.md b/paper.md
index d4542ce..d66f4f2 100644
--- a/paper.md
+++ b/paper.md
@@ -27,56 +27,46 @@ affiliations:
city: São Luís
state: MA
country: Brazil
-date: 24 September 2026
+date: 8 October 2026
bibliography: paper.bib
---
## Summary
-DisSModel (Discrete Spatial Modeling) is a modular Python framework for spatially
-explicit dynamic modeling, targeting the complexities of Land Use and Land Cover
-Change (LUCC). It translates the modeling paradigms of the TerraME framework
-[@Carneiro2013] into the Python ecosystem, enabling researchers to simulate
-complex socio-environmental systems — forest fires, epidemiological spreads,
-coastal dynamics — by coupling a time-stepped clock with the spatial data
-structures of GeoPandas [@Jordahl2021].
+ DisSModel (Discrete Spatial Modeling) is a modular Python framework for spatially explicit dynamic modeling, targeting Land Use and Land Cover Change (LUCC). It adapts the modeling paradigm of TerraME [@Carneiro2013] to the Python ecosystem, enabling researchers to simulate socio-environmental systems — forest fires, epidemics, coastal dynamics — by coupling a time-stepped clock with the spatial data structures of GeoPandas [@Jordahl2021].
-The framework provides a **dual-substrate architecture**: a vector substrate
-backed by GeoDataFrame for spatial expressiveness, and a raster substrate backed
-by NumPy 2D arrays for high-performance vectorised computation. DisSModel is
-available through the DisSModel GitHub organisation and on PyPI.
+Its **dual-substrate architecture** pairs a vector substrate (GeoDataFrame) for spatial expressiveness
+with a raster substrate (NumPy 2D arrays) for high-performance vectorized computation. DisSModel is
+available through the DisSModel GitHub organization and on PyPI.
## Statement of Need
-Python has become the lingua franca for geospatial data science, supported by
-libraries such as GeoPandas and PySAL — but these tools target static analysis.
-Dynamic spatial modeling, simulating how landscapes evolve over time, has
-historically required specialised platforms. In Brazil, TerraME [@Carneiro2013] and
-Dinamica EGO [@SoaresFilho2002; @SoaresFilho2013] are the most widely adopted general-purpose frameworks, while
-institutions elsewhere rely on narrower allocation models such as CLUE and CLUE-S
-[@Veldkamp1996; @Verburg2002]. This fragmentation leaves researchers choosing between a Lua-based
-toolchain and single-purpose implementations with no shared contract.
-
-While TerraME is conceptually robust, its reliance on Lua — a language with far
-smaller adoption in data science than Python — creates a barrier for data
-scientists, and the framework has seen no new release since August 2020.
-General-purpose simulation libraries, meanwhile, lack native synchronisation
-between a time-stepped clock and the geographical state of a GeoDataFrame.
-DisSModel fills this gap with a lightweight scheduler coupled directly to vector
-and raster spatial state, providing a Pythonic, actively maintained implementation
-of the TerraME paradigm — and, through its satellite packages, of allocation
-models like CLUE and CLUE-S.
+Python has become the lingua franca for geospatial data science, supported by libraries such as
+GeoPandas and PySAL — but these tools target static analysis. Dynamic spatial modeling, simulating
+how landscapes evolve over time, has historically required specialized platforms. In Brazil, TerraME
+[@Carneiro2013] and Dinamica EGO [@SoaresFilho2002; @SoaresFilho2013] are the most widely adopted
+general-purpose frameworks, while narrower allocation models such as CLUE and CLUE-S
+[@Veldkamp1996; @Verburg2002] are used elsewhere. Researchers working in Python thus lack a
+general-purpose option: the established frameworks are Lua- or GUI-based, and allocation models
+are usually standalone implementations without a shared interface.
+
+TerraME is conceptually robust, but its reliance on Lua — far less common in data science than
+Python — is a barrier, and the framework has seen no new release since August 2020. General-purpose
+simulation libraries, meanwhile, lack native synchronization between a time-stepped clock and the
+state of a GeoDataFrame. DisSModel fills this gap with a lightweight scheduler coupled to vector and
+raster spatial state: an actively maintained Python implementation of the TerraME paradigm and,
+through its satellite package `disslucc`, of LuccME's CLUE-like and CLUE-S-like allocation components.
Reproducibility is a first-class concern: the `executor` module provides a
-standardised lifecycle — `validate → load → run → save` — capturing provenance
+standardized lifecycle — `validate → load → run → save` — capturing provenance
metadata (input checksums, parameters, timing, output paths) in an
`ExperimentRecord` generated automatically for every run, with no additional
-instrumentation by the modeller.
+instrumentation by the modeler.
## State of the Field
DisSModel occupies a niche between general-purpose agent-based modeling (ABM)
-libraries and specialised GIS simulation software:
+libraries and specialized GIS simulation software:
| Aspect | TerraME | Dinamica EGO | DisSModel |
|--------|---------|--------------|-----------|
@@ -86,140 +76,112 @@ libraries and specialised GIS simulation software:
| GIS Integration | TerraLib | Native Raster | GeoPandas / Rasterio |
| Extensibility | Script-based | Block-based | Class Inheritance |
| Reproducibility | Manual | Manual | Automated (ExperimentRecord) |
-| Neighborhoods | GPM Support | Limited | libpysal weights (Queen, Rook, KNN, custom) |
-NetLogo [@Wilensky1999] and Mesa [@Kazil2020] are excellent for ABM but require boilerplate to handle
-real-world spatial projections. DisSModel uses GeoPandas as its core engine,
-following the discrete spatial modeling approach of @SantosJunior2025.
+NetLogo [@Wilensky1999] and Mesa [@Kazil2020] are excellent for ABM; Mesa-Geo [@Wang2022] adds GIS
+data to Mesa, whereas DisSModel centers on cellular-space state with a synchronized clock and a
+reproducible executor lifecycle. DisSModel builds on GeoPandas, following the discrete spatial modeling approach of @SantosJunior2025.
## Software Design
-DisSModel is organised into five modules with strict separation of concerns,
-extensible through class inheritance. **Core** manages the simulation clock: the
-`Environment` orchestrates time progression, and spatial models auto-register at
-instantiation, receiving ticks through `setup / pre_execute / execute /
-post_execute` hooks. **Geo** provides the dual-substrate design: a vector
-substrate (`SpatialModel`, `CellularAutomaton`) on GeoDataFrame with libpysal
-neighbourhoods [@Rey2021], and a raster substrate (`RasterModel`,
-`RasterCellularAutomaton`) on NumPy arrays with vectorised operations (`shift2d`,
-`focal_sum`, `neighbor_contact`) replacing cell-by-cell loops. **Executor** defines
-the `ModelExecutor` four-phase lifecycle (`validate`, `load`, `run`, `save`);
-subclasses self-register via `__init_subclass__`, and every run produces an
-`ExperimentRecord` with input checksum, parameters, timing, and output paths.
-**IO** provides a unified dataset abstraction (`load_dataset` / `save_dataset`)
-across GeoDataFrame, GeoTIFF, and Xarray/Zarr, with transparent `s3://`
-resolution. **Visualization** integrates Matplotlib, Streamlit-compatible widgets,
-and `RasterMap`.
-
-This extensibility has already produced independent domain packages:
-`dissmodel-ca` [@DisSModelCA] (Cellular Automata patterns), `dissmodel-sysdyn`
-[@DisSModelSysDyn] (System Dynamics), and `disslucc` [@DisSLUCC], which
-implements LUCCME's continuous and discrete components — Demand, Potential,
-and Allocation [@Veldkamp1996; @Verburg2002] — on the raster substrate and
-the same `ModelExecutor` contract, an explicit Python counterpart to
-TerraME/LuccME.
+DisSModel is organized into five modules with strict separation of concerns, extensible through
+class inheritance. **Core** manages the simulation clock: the `Environment` orchestrates time, and
+models auto-register at instantiation, run `setup` once, and receive each tick through
+`pre_execute / execute / post_execute` hooks. **Geo** provides the dual-substrate design: a vector
+substrate (`SpatialModel`, `CellularAutomaton`) on GeoDataFrame with libpysal neighborhoods
+[@Rey2021], and a raster substrate (`RasterModel`, `RasterCellularAutomaton`) on NumPy arrays with
+vectorized operations (`shift2d`, `focal_sum`, `neighbor_contact`) replacing cell-by-cell loops.
+`SyncSpatialModel` and `SyncRasterModel` mirror TerraME's `synchronize()`, so coupled models sharing
+one grid read the same start-of-step state. **Executor** defines the `ModelExecutor` four-phase
+lifecycle (`validate`, `load`, `run`, `save`); subclasses self-register, and every run produces an
+`ExperimentRecord` with input checksum, parameters, timing, and output paths. **IO** provides a
+unified dataset abstraction across GeoDataFrame, GeoTIFF, and Xarray/Zarr, with transparent `s3://`
+resolution. **Visualization** integrates Matplotlib, Streamlit-compatible widgets, and `RasterMap`.
+
+This extensibility has produced independent domain packages: `dissmodel-ca` [@DisSModelCA]
+(Cellular Automata), `dissmodel-sysdyn` [@DisSModelSysDyn] (System Dynamics), and `disslucc`
+[@DisSLUCC], which implements LuccME's continuous and discrete Demand, Potential, and Allocation
+components [@Veldkamp1996; @Verburg2002] on the raster substrate and the same `ModelExecutor`
+contract, an explicit Python counterpart to TerraME/LuccME.
## Validation and Performance
-The vector substrate offers spatial expressiveness; the raster substrate enforces
-vectorised rules over NumPy arrays. All benchmarks ran on an Intel Core
-i7-7700T @ 2.90GHz, 15 GB RAM (Ubuntu, Python 3.12.3, NumPy 2.4.6, GeoPandas
-1.1.3); absolute timings vary by hardware, but the relative speedup is the result
-of interest.
-
-**Conway's Game of Life** confirms mathematical equivalence across substrates with
-different throughput:
-
-| Grid | Cells | Raster, vectorised rule (ms/step) | Vector, per-cell `rule(idx)` (ms/step) | Speedup |
-|-----:|------:|-----------------:|-----------------:|--------:|
-| 10×10 | 100 | 0.12 | 74.81 | 639× |
-| 50×50 | 2,500 | 0.19 | 1,707.76 | 8,809× |
-| 100×100 | 10,000 | 0.41 | 7,069.14 | 17,394× |
-| 200×200 | 40,000 | 1.21 | — | — |
-| 500×500 | 250,000 | 9.74 | — | — |
-| 1,000×1,000 | 1,000,000 | 30.60 | — | — |
-
-The speedup therefore measures a per-cell rule (one Python call per cell) against a
-vectorised one, not the substrates themselves; vector runs above 10,000 cells were omitted.
-
-**BR-MANGUE coastal dynamics.** The foundation for coupled mangrove-flood modeling
-was established by Bezerra et al. [@Bezerra2013] and extended in @Bezerra2025BM,
-whose co-authors include Denilson da Silva Bezerra, Felipe Martins Sousa and the
-submitting author — the same researchers responsible for the DisSModel reimplementation. The
-`brmangue-dissmodel` package [@BRMangue]
-validates the raster implementation against TerraME over the Maranhão Island
-dataset (50,496 cells, 19 steps): land use and soil match exactly at every
-checkpoint (MAE 0, max error 0), and elevation on 97.3% of cells within 1 mm
-(MAE 0.00068 m) — match percentage being the appropriate metric for categorical
-outputs [@PontiusEtAl2011]. In this scenario the flood component triggers no
-land-use transition and the golden files confirm TerraME does the same, so the
-agreement above exercises mangrove migration; flooding is covered separately under
-the laboratory parameters. Reproducible via the package's validation executor
-against its committed golden files.
-
-Cross-substrate equivalence (60×60 synthetic grid, 3,600 cells, 10 steps) shows
-100% match for land use, soil, and elevation under tolerance (MAE 0.000959 m, max
-error 0.024 m), with raster at 2.1 ms/step against 84.2 ms/step for vector (40.1×
-speedup; the vector port follows TerraME's per-cell loops); the residual elevation divergence is floating-point rounding, not
-algorithmic disagreement.
-
-**disslucc** [@DisSLUCC] implements the continuous CLUE-like allocation algorithm
-[@Veldkamp1996]; MAE is the appropriate metric for its fractional outputs
-[@PontiusEtAl2011; @Willmott2005]. Over the Lab1 study area (6,574 cells, 6 steps),
-the raster implementation reproduces the TerraME/LUCCME reference at MAE = 0.0036
-(RMSE 0.0062, max error 0.027) in [0,1] scale, at 44.0 ms/step. The residual is a
-single, deliberate deviation: LuccME's per-cell consistency correction
-(`correctCellChange`) never executes, because its guard tests a misspelled
-attribute, while `disslucc` runs it by default. With that step disabled
-(`cell_correction=False`), `disslucc` matches TerraME in every simulated year,
-including the number of convergence iterations per year (MAE < 1e-7). Reproducible via
-`disslucc/tests/test_validation_lab1.py` and `test_goldens_per_year.py`, against
-year-by-year reference outputs generated in a containerised TerraME
-[@TerraMEDocker]; `disslucc/tests/test_benchmark_discriminance_lab1.py` confirms
-that perturbing the regression coefficients breaks the tolerance criterion.
-
-The discrete CLUE-S-like allocation in `disslucc`, with a logistic-regression
-potential, reproduces the Lab15 case study (Mojui, Pará 5,914 cells, 6 steps)
-from the reference LuccME implementation [@LuccME] cell for cell — zero quantity and
-zero allocation disagreement [@PontiusMillones2011] — at 10.3 ms/step. A shipped
-discriminance test shows the final map is also reproduced by a trivial static ranking,
-so the map alone validates only coefficient transcription; the convergence loop is
-validated separately, as the number of CLUE-S iterations matches TerraME in every
-simulated year (0, 67, 56, 56, 61, 61).
+All benchmarks ran on an Intel Core i7-7700T @ 2.90GHz, 15 GB RAM (Linux Mint 22.2, Python 3.12.3,
+NumPy 2.5.3, GeoPandas 1.2.0); absolute timings vary by hardware.
+
+**Conway's Game of Life** compares the two substrates with the same rule and initial state. The
+vector rule is a per-cell Python method querying libpysal Queen neighbors; the raster rule is one
+whole-grid focal sum; final states are compared cell by cell. At 100×100 cells the raster takes
+0.34 ms/step against 3,783 ms/step for the vector (a gap that reflects the per-cell style of the
+vector API) and scales to 10⁶ cells at 26.6 ms/step.
+
+**BR-MANGUE coastal dynamics.** The coupled mangrove–flood model was established by
+Bezerra et al. [@Bezerra2013] and extended in @Bezerra2025BM, co-authored by D.S.B., F.M.S. and the
+submitting author, who are also responsible for the DisSModel reimplementation.
+`brmangue-dissmodel` [@BRMangue] validates the raster implementation against a TerraME 2.0
+adaptation of the published model [@BRMangueTerraME] over the Maranhão Island dataset (50,496 cells);
+fidelity to the thesis is documented in the repository. Figures refer to `brmangue-dissmodel` v0.5.0. Match percentage suits categorical outputs [@PontiusEtAl2011]. In the baseline scenario (19 steps) land use and soil match
+exactly at every checkpoint and elevation matches on 97.4% of cells within 1 mm (MAE 0.00038 m;
+maximum error 0.10 m). In the flooding scenario (laboratory parameters) TerraME floods 2,469 non-sea
+cells by step 11 and the Python model 2,467; soil matches exactly, land use differs in 4 cells
+(0.008%), and at step 11 elevation matches on 94.4% of cells (MAE 0.008 m, maximum error 1.0 m). The residuals
+come from exact ties in elevation: the flux rule compares accumulated elevations with `<=`, so a
+~1e-16 difference in summation order (sequential in TerraME, per direction in NumPy) can flip a
+comparison and, near the flooding threshold, a cell's class. They are floating-point effects, not a
+difference in the rules. Both scenarios are reproducible with the validation executor against golden
+files shipped with the package, which the adaptation's headless driver regenerates [@BRMangueTerraME].
+
+Cross-substrate equivalence (60×60 synthetic grid, 10 steps) gives 100% match for land use, soil
+and elevation within a 5 cm tolerance (MAE 0.0011 m, max 0.024 m; floating-point rounding), with
+raster at 2.5 ms/step against 69.1 ms/step for vector (28.1×; the vector port follows TerraME's
+per-cell loops).
+
+**disslucc as a second case.** `disslucc` [@DisSLUCC], built on DisSModel, reimplements the LuccME
+demand, potential and allocation components (continuous CLUE-like [@Veldkamp1996] and discrete
+CLUE-S-like). It tests the `ModelExecutor` contract and the raster substrate against a second
+TerraME application, not as a result in itself. Its checks live in a separate, versioned benchmark
+[@DisSLUCCBenchmark] that compares every simulated year, iteration counts included, with per-year
+reference outputs [@LuccMEGoldens] generated in a containerized TerraME [@TerraMEDocker] and checked by SHA-256. Scenarios
+follow the LuccME test labs (`labNN`); the `labNN_mdX` variants exercise the convergence loop. MAE
+suits fractional outputs [@PontiusEtAl2011; @Willmott2005].
+
+`lab01_md1643` (continuous, 6,574 cells, 2008–2014) differs from the reference by MAE = 0.0036 in
+the final year ([0,1] scale) with `disslucc`'s defaults. The difference is one deliberate deviation: LuccME's `correctCellChange` never executes (its guard
+reads a mismatched field name), while `disslucc` runs it by default. With `cell_correction=False`
+(the benchmark's `lab01_md1643` scenario) the match holds in every year, with identical iteration
+counts (MAE < 1e-7).
+`lab15_md10` (discrete, 5,914 cells) reproduces the reference cell for cell, with zero quantity and
+allocation disagreement [@PontiusMillones2011] and identical iteration counts. Overall, ten of 21 LuccME test labs and both variants match with identical iteration
+counts and differences up to 3e-6 (single precision); a validated lab runs in 0.11 to 0.55 s on
+the hardware above (median of five; `make timing`). The other eleven labs need potential and
+allocation components not yet implemented; student fellows are developing them, and each lab joins
+the benchmark as its components land.
## Research Impact Statement
-DisSModel's scientific lineage is rooted in the TerraME/LuccME research program at
-INPE. The submitting author conducted doctoral research at INPE under Prof.
-Gilberto Câmara and Dr. Ana Paula Dutra Aguiar — principal architects of
-TerraME/LuccME — and has co-authored the modeling program since 2009
-[@Moreira2009; @Costa2009]; `disslucc` reimplements in Python the
-continuous and discrete allocation components of that lineage [@LuccME]. On
-7 May 2026, DisSModel was presented at INPE's Graduate Program in Applied
-Computing seminar series (recording: https://youtu.be/o7pMJt0CvXU).
-
-The framework is in active use across two UFMA research groups. Within LambdaGeo,
-`brmangue-dissmodel` builds on a Master's student's reference implementation. Independently, Prof. Denilson da Silva Bezerra (UFMA, former
-INPE), whose doctoral work established BR-MANGUE's scientific foundation
-[@Bezerra2013], uses the DisSModel reimplementation in his own coastal dynamics
-program (UFMA projects PVCBS4959-2025 and PVCBS4960-2025), a
-collaboration predating DisSModel itself [@Bezerra2025BM].
-
-Starting August 2026, the project receives its first undergraduate research
-fellows, funded by UFMA and by CNPq. The 2026 development effort was oriented toward this milestone:
-stabilizing the `ModelExecutor` contract so each fellow can own an independent
-repository — `disslucc`, `brmangue-dissmodel`, or
-`disscube` (a data-cube layer, a Python alternative to TerraME's
-`fillCellularSpace`) — without core changes.
-
-Studies such as @Bezerra2022, developed using LuccME, are the class
-of models `disslucc` aims to reproduce. A roadmap toward
-DisSModel 1.0 (May 2027) anchors community outreach including an open textbook,
-*Geospatial Modeling with Python*
-(https://lambdageo.github.io/geospatial-modeling-python/), already in progress.
-This positions DisSModel as the simulation layer in the Brazilian Earth
-Observation stack, complementary to SITS [@Simoes2021] and the Brazil Data Cube
-[@Ferreira2020].
+DisSModel's scientific lineage is rooted in the TerraME/LuccME research program at INPE, where
+TerraME was developed by Tiago Carneiro [@Carneiro2013] and LuccME by Ana Paula Dutra Aguiar
+[@LuccME], both former doctoral students of Prof. Gilberto Câmara. The submitting author conducted
+doctoral research in the same program, under Câmara and Aguiar, and has co-authored its modeling
+work since 2009 [@Moreira2009; @Costa2009]; `disslucc` reimplements in Python the continuous and
+discrete allocation components of that lineage. DisSModel was presented at a seminar of INPE's
+Applied Computing graduate program (CAP) on 7 May 2026 [@Costa2026INPE].
+
+The framework is in use across two UFMA research groups. Within LambdaGeo, `brmangue-dissmodel`
+builds on the reference implementation by co-author F.M.S. Co-author D.S.B. (former INPE), co-leader
+of UFMA's Geotechnologies and Environmental Sciences Laboratory (GEOTAM), whose doctoral work founded
+BR-MANGUE [@Bezerra2013], also adopts the DisSModel reimplementation in his coastal dynamics projects (UFMA PVCBS4959-2025 and PVCBS4960-2025), a collaboration predating
+DisSModel itself [@Bezerra2025BM].
+
+Since September 2026, four undergraduate research fellows (UFMA, CNPq) are being trained on the
+project; the stabilized `ModelExecutor` contract lets each own an independent repository —
+`disslucc`, `brmangue-dissmodel`, or `disscube` [@DisSCube] (a data-cube layer, a Python alternative
+to TerraME's `fillCellularSpace`) — without core changes.
+
+A roadmap toward DisSModel 1.0 (May 2027) includes an open textbook, *Geospatial Modeling with
+Python* (https://lambdageo.github.io/geospatial-modeling-python/), already in progress. On the data
+side, `disscube` already reads Brazil Data Cube collections through their STAC catalog
+[@Ferreira2020], so DisSModel models can run on the same Earth observation data used by SITS
+[@Simoes2021].
## Author Contributions
@@ -238,19 +200,15 @@ We thank José Magno Pinheiro Alves for early validation testing.
## AI Usage Disclosure
-Development followed several phases. The initial prototype (May–June 2025),
-corresponding to the second author's undergraduate thesis [@SantosJunior2025], did
-not involve generative AI. Development resumed in February–March 2026 with Claude
-(chat) used mainly for documentation; from April 2026, Gemini CLI accelerated code
-generation and refactoring; from June 2026, Claude Code (CLI) was used on newer
-satellite repositories such as `disslucc`, including the audit of the
-validation routines against the original TerraME scripts. AI tools also assisted
-with writing in English, not the submitting author's native language. The
-scientific design — the TerraME compatibility contract, executor pattern,
-dual-substrate architecture, and validation methodology — predates and is
-independent of this AI-assisted phase, tracing to the submitting author's doctoral
-research at INPE and the undergraduate thesis cited above. AI assisted with
-implementation velocity and language clarity, not scientific or architectural
-decisions. All outputs were reviewed and validated by the authors.
-
-## References
+The initial prototype (May–June 2025), the second author's undergraduate thesis
+[@SantosJunior2025], did not involve generative AI. From February 2026, Claude and Gemini CLI
+supported documentation, tests and refactoring of existing code. Since mid-2026, Claude Code has
+been used across the whole ecosystem (framework, satellite packages and benchmarks) for coding, bug fixing, refactoring, code translation, tests, validation scripts,
+documentation, and this paper's text. This made the ecosystem envisioned by the submitting author feasible. The reference outputs used in validation are
+produced by TerraME itself, running the model files in a container; AI-assisted changes to those
+files were checked to leave the outputs unchanged. The scientific design — TerraME compatibility
+contract, executor pattern, dual-substrate architecture, validation methodology — is the
+authors', and traces to the submitting author's doctoral research at INPE. The authors reviewed,
+tested and ran all AI-generated code and text.
+
+## References
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 60ee5b0..81e03f4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "dissmodel"
-version = "0.6.5"
+version = "0.6.6"
description = "Discrete Spatial Modeling framework for raster and vector simulations"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.10"
diff --git a/shapefile_to_raster.md b/shapefile_to_raster.md
deleted file mode 100644
index 6bdc781..0000000
--- a/shapefile_to_raster.md
+++ /dev/null
@@ -1,141 +0,0 @@
-# Loading Shapefiles into the Raster Substrate
-
-DisSModel can load any vector file (Shapefile, GeoJSON, GeoPackage) and
-convert it directly into a `RasterBackend`, making it possible to run
-high-performance raster models on real geographic data without any
-intermediate GIS steps.
-
-## How it works
-
-```
-Shapefile → GeoDataFrame → rasterize → NumPy arrays → RasterBackend → raster model
-```
-
-The rasterization step (powered by `rasterio.features.rasterize`) is fast
-and happens once — after that, the model runs entirely in NumPy at full
-vectorized speed.
-
-!!! note "Grid regularity"
- This workflow is most accurate when the input shapefile already contains
- a **regular grid** of equal-area polygons (e.g. 100×100m cells), which is
- the typical output of spatial homogenization tools. For irregular polygons
- (municipalities, watersheds), cell values are burned by centroid or by
- touch — inspect the result before running long simulations.
-
----
-
-## `shapefile_to_raster_backend`
-
-```python
-from dissmodel.geo.raster.io import shapefile_to_raster_backend
-
-b = shapefile_to_raster_backend(
- path = "data/mangue_grid.shp",
- resolution = 100, # 100m cells
- attrs = ["uso", "alt", "solo"],
- crs = "EPSG:31984", # reproject if needed
-)
-
-print(b.shape) # (rows, cols) derived from bounding box + resolution
-print(b.get("uso").dtype) # int32
-```
-
-### Parameters
-
-| Parameter | Type | Description |
-|-----------|------|-------------|
-| `path` | `str` or `Path` | Path to the vector file |
-| `resolution` | `float` | Cell size in CRS units (metres for metric CRS) |
-| `attrs` | `list[str]` or `dict[str, default]` | Columns to rasterize |
-| `crs` | `str`, `int`, or `None` | Target CRS — reprojects if needed |
-| `all_touched` | `bool` | Burn all touched cells (default: centre only) |
-| `nodata` | `int` or `float` | Fill value for uncovered cells (default: `0`) |
-
-To set per-column defaults for uncovered cells, pass a dict:
-
-```python
-b = shapefile_to_raster_backend(
- path = "data/mangue_grid.shp",
- resolution = 100,
- attrs = {"uso": 5, "alt": 0.0, "solo": 1},
- crs = "EPSG:31984",
-)
-```
-
----
-
-## Full example — flood model from shapefile
-
-```python
-from dissmodel.core import Environment
-from dissmodel.geo.raster.io import shapefile_to_raster_backend
-from dissmodel.visualization.raster_map import RasterMap
-
-# from coastal_dynamics.raster import FloodRasterModel
-# (or your own RasterModel subclass)
-from myproject.flood import FloodRasterModel
-
-# 1. load shapefile → RasterBackend
-b = shapefile_to_raster_backend(
- path = "data/mangue_grid.shp",
- resolution = 100,
- attrs = {"uso": 5, "alt": 0.0, "solo": 1},
- crs = "EPSG:31984",
-)
-
-print(f"Grid: {b.shape[0]} rows × {b.shape[1]} cols = {b.shape[0]*b.shape[1]:,} cells")
-
-# 2. run the raster model
-env = Environment(start_time=2012, end_time=2100)
-FloodRasterModel(backend=b, taxa=0.011)
-RasterMap(backend=b, band="uso", title="Land Use")
-env.run()
-```
-
----
-
-## Saving results back to GeoTIFF
-
-```python
-from dissmodel.geo.raster.io import save_raster_backend
-
-save_raster_backend(
- backend = b,
- path = "output/flood_result.tif",
- bands = ["uso", "alt"],
- crs = "EPSG:31984",
- transform = transform, # rasterio Affine from original bounds
-)
-```
-
----
-
-## Comparison: vector vs raster workflow from the same shapefile
-
-```python
-# ── vector workflow ───────────────────────────────────────────────────────────
-import geopandas as gpd
-from dissmodel.geo.vector.model import SpatialModel
-
-gdf = gpd.read_file("data/mangue_grid.shp").to_crs("EPSG:31984")
-# → GeoDataFrame with real geometries, Queen neighbourhood, ~2 min/step @ 94k cells
-
-# ── raster workflow ───────────────────────────────────────────────────────────
-from dissmodel.geo.raster.io import shapefile_to_raster_backend
-
-b = shapefile_to_raster_backend(
- "data/mangue_grid.shp", resolution=100,
- attrs=["uso", "alt", "solo"], crs="EPSG:31984"
-)
-# → RasterBackend, same data, ~8 ms/step @ 94k cells (≈ 4,500× faster)
-```
-
-The data source is the same shapefile. The only difference is the substrate.
-
----
-
-## API Reference
-
-::: dissmodel.geo.raster.io.shapefile_to_raster_backend
-
-::: dissmodel.geo.raster.io.save_raster_backend
diff --git a/tests/executor/test_cli.py b/tests/executor/test_cli.py
index bc09dd4..39ca104 100644
--- a/tests/executor/test_cli.py
+++ b/tests/executor/test_cli.py
@@ -287,4 +287,133 @@ def test_no_output_path_is_noop(self, make_record):
args = SimpleNamespace(output=None)
_apply_output_path_intelligence(record, args)
assert record.output_path is None
- assert args.output is None
\ No newline at end of file
+ assert args.output is None
+
+# ── --preserve-output-name and executor-specific suffix ──────────────────────
+
+class TestPreserveOutputName:
+
+ def test_flag_keeps_exact_file_name(self, make_record):
+ record = make_record()
+ record.output_path = "outputs/result.gpkg"
+ args = SimpleNamespace(output="outputs/result.gpkg", preserve_output_name=True)
+ _apply_output_path_intelligence(record, args)
+ assert record.output_path == "outputs/result.gpkg"
+ assert args.output == "outputs/result.gpkg"
+
+ def test_flag_absent_keeps_previous_behaviour(self, make_record):
+ record = make_record()
+ record.output_path = "outputs/result.gpkg"
+ args = SimpleNamespace(output="outputs/result.gpkg")
+ _apply_output_path_intelligence(record, args)
+ assert record.output_path == f"outputs/result_{record.experiment_id[:8]}.gpkg"
+
+ def test_flag_still_generates_name_for_directory(self, make_record):
+ record = make_record()
+ record.output_path = "outputs/"
+ args = SimpleNamespace(output="outputs/", preserve_output_name=True)
+ _apply_output_path_intelligence(record, args)
+ assert record.output_path == f"outputs/simulacao_{record.experiment_id[:8]}.tif"
+
+ def test_directory_uses_executor_output_suffix(self, make_record):
+ class VectorExec:
+ output_suffix = ".gpkg"
+ record = make_record()
+ record.output_path = "outputs/"
+ args = SimpleNamespace(output="outputs/")
+ _apply_output_path_intelligence(record, args, VectorExec)
+ assert record.output_path == f"outputs/simulacao_{record.experiment_id[:8]}.gpkg"
+
+ def test_parser_accepts_flag(self):
+ from dissmodel.executor.cli import _build_parser
+ ns = _build_parser().parse_args(
+ ["run", "--input", "in.gpkg", "--output", "out.gpkg", "--preserve-output-name"]
+ )
+ assert ns.preserve_output_name is True
+
+
+# ── Provenance fields filled from the environment ────────────────────────────
+
+class TestRecordProvenance:
+
+ def _args(self):
+ return SimpleNamespace(
+ toml=None, param=None, input="data.zip", output=None,
+ format="auto", column_map=None, band_map=None,
+ )
+
+ def test_code_version_is_installed_dissmodel(self, tmp_path, monkeypatch):
+ from importlib.metadata import version
+ monkeypatch.chdir(tmp_path) # no stray model.toml
+ record = _build_record(self._args())
+ assert record.code_version == version("dissmodel")
+ assert record.code_version != "dev"
+
+ def test_model_name_comes_from_executor(self, tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ class Exec:
+ name = "forest_fire"
+ assert _build_record(self._args(), Exec).model_name == "forest_fire"
+
+ def test_model_name_falls_back_to_class_name(self, tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ class MyExecutor:
+ pass
+ assert _build_record(self._args(), MyExecutor).model_name == "MyExecutor"
+
+ def test_model_commit_records_executor_package(self, tmp_path, monkeypatch):
+ from importlib.metadata import version
+
+ from dissmodel.executor.model_executor import ModelExecutor
+ monkeypatch.chdir(tmp_path)
+ # ModelExecutor lives in the installed dissmodel distribution
+ record = _build_record(self._args(), ModelExecutor)
+ assert record.model_commit == f"dissmodel=={version('dissmodel')}"
+
+ def _main_module_from(self, path, monkeypatch):
+ """Load ``path`` and install it as ``__main__``, as ``python path`` would."""
+ import importlib.util
+ import sys
+
+ spec = importlib.util.spec_from_file_location("__main__", path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ monkeypatch.setitem(sys.modules, "__main__", module)
+ return module
+
+ def test_loose_script_executor_is_local_cli(self, tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ script = tmp_path / "my_executor.py"
+ script.write_text("class Exec:\n name = 'loose'\n")
+ module = self._main_module_from(script, monkeypatch)
+ assert _build_record(self._args(), module.Exec).model_commit == "local-cli"
+
+ def test_script_inside_installed_package_records_package(self, tmp_path, monkeypatch):
+ # python path/to/dissmodel/executor/some_script.py → dissmodel==
+ from importlib.metadata import version
+ from pathlib import Path as _P
+
+ import dissmodel.executor as pkg
+ monkeypatch.chdir(tmp_path)
+ script = _P(pkg.__file__).parent / "model_executor.py"
+ module = self._main_module_from(script, monkeypatch)
+ record = _build_record(self._args(), module.ModelExecutor)
+ assert record.model_commit == f"dissmodel=={version('dissmodel')}"
+
+
+# ── Output directory creation ────────────────────────────────────────────────
+
+class TestEnsureLocalOutputDir:
+
+ def test_creates_missing_parent(self, tmp_path):
+ from dissmodel.executor.cli import _ensure_local_output_dir
+ target = tmp_path / "a" / "b" / "out.gpkg"
+ _ensure_local_output_dir(str(target))
+ assert target.parent.is_dir()
+
+ def test_s3_and_none_are_ignored(self, tmp_path, monkeypatch):
+ from dissmodel.executor.cli import _ensure_local_output_dir
+ monkeypatch.chdir(tmp_path)
+ _ensure_local_output_dir("s3://bucket/key/out.tif")
+ _ensure_local_output_dir(None)
+ assert list(tmp_path.iterdir()) == []
diff --git a/tests/io/test_dispatch.py b/tests/io/test_dispatch.py
index 383649f..5ee35c1 100644
--- a/tests/io/test_dispatch.py
+++ b/tests/io/test_dispatch.py
@@ -164,3 +164,33 @@ def test_missing_minio_package_raises_importerror(self, monkeypatch):
monkeypatch.setitem(sys.modules, "minio", None)
with pytest.raises(ImportError, match="minio"):
_storage.get_default_client()
+
+
+# ---------------------------------------------------------------------------
+# save_dataset — data type must match the format of the URI
+# ---------------------------------------------------------------------------
+
+class TestSaveDatasetTypeMismatch:
+
+ def test_geodataframe_to_tif_raises_clear_error(self, tmp_path):
+ from dissmodel.geo import vector_grid
+
+ gdf = vector_grid(dimension=(2, 2), resolution=1.0, crs="EPSG:31984")
+ with pytest.raises(TypeError, match="GeoDataFrame to raster path"):
+ save_dataset(gdf, str(tmp_path / "out.tif"))
+ assert not (tmp_path / "out.tif").exists()
+
+ def test_raster_backend_to_gpkg_raises_clear_error(self, tmp_path):
+ from dissmodel.geo import raster_grid
+
+ backend = raster_grid(rows=2, cols=2)
+ with pytest.raises(TypeError, match="RasterBackend to vector path"):
+ save_dataset(backend, str(tmp_path / "out.gpkg"))
+ with pytest.raises(TypeError, match="RasterBackend to vector path"):
+ save_dataset((backend, {}), str(tmp_path / "out.gpkg"))
+
+ def test_matching_types_still_save(self, tmp_path):
+ from dissmodel.geo import vector_grid
+
+ gdf = vector_grid(dimension=(2, 2), resolution=1.0, crs="EPSG:31984")
+ assert len(save_dataset(gdf, str(tmp_path / "out.gpkg"))) == 64
diff --git a/tests/raster/test_raster_map.py b/tests/raster/test_raster_map.py
index 500f100..1a6a360 100644
--- a/tests/raster/test_raster_map.py
+++ b/tests/raster/test_raster_map.py
@@ -19,6 +19,13 @@
from dissmodel.visualization.raster_map import RasterMap
+@pytest.fixture(autouse=True)
+def _run_in_tmp_path(tmp_path, monkeypatch):
+ """Headless Chart/RasterMap write PNG frames to the working directory;
+ run every test in a temporary one so the repository stays clean."""
+ monkeypatch.chdir(tmp_path)
+
+
# ── force headless for all tests ──────────────────────────────────────────────
@pytest.fixture(autouse=True)
diff --git a/tests/test_widgets.py b/tests/test_widgets.py
index 42c436a..809bf8c 100644
--- a/tests/test_widgets.py
+++ b/tests/test_widgets.py
@@ -72,3 +72,48 @@ def test_object_without_annotations_is_noop(self):
st = StubStreamlit()
display_inputs(object(), st)
assert st.calls == []
+
+
+class TestFrameworkAttributes:
+ """Models with no annotation of their own must not expose framework state."""
+
+ def _models(self):
+ from dissmodel.core import Environment, Model
+
+ class NoParams(Model):
+ def execute(self):
+ pass
+
+ class Base(Model):
+ rate: float
+
+ def setup(self, rate: float = 0.5):
+ self.rate = rate
+
+ def execute(self):
+ pass
+
+ class Child(Base):
+ steps: int
+
+ def setup(self, rate: float = 0.5, steps: int = 3):
+ super().setup(rate)
+ self.steps = steps
+
+ return Environment, NoParams, Child
+
+ def test_model_without_annotations_renders_nothing(self):
+ Environment, NoParams, _ = self._models()
+ env = Environment(start_time=0, end_time=3)
+ model = NoParams()
+ st = StubStreamlit()
+ display_inputs(model, st)
+ assert st.calls == []
+ assert model.env is env # was overwritten by a text_input string
+
+ def test_inherited_user_parameters_are_rendered(self):
+ Environment, _, Child = self._models()
+ Environment(start_time=0, end_time=3)
+ st = StubStreamlit()
+ display_inputs(Child(), st)
+ assert st.calls == [("slider", "rate"), ("slider", "steps")]
diff --git a/tests/vector/test_fill.py b/tests/vector/test_fill.py
index 1e9f1d2..9005870 100644
--- a/tests/vector/test_fill.py
+++ b/tests/vector/test_fill.py
@@ -57,3 +57,91 @@ def test_parse_idx_roundtrip():
pos = parse_idx("3-4")
assert pos.row == 3
assert pos.col == 4
+
+
+# ---------------------------------------------------------------------------
+# MIN_DISTANCE — spatial-index implementation must equal the brute force
+# ---------------------------------------------------------------------------
+
+def _brute_force_min_distance(from_gdf, to_gdf):
+ return from_gdf.geometry.apply(lambda g: to_gdf.geometry.distance(g).min()).to_numpy()
+
+
+def _targets(kind, rng, n):
+ import geopandas as gpd
+ from shapely.geometry import LineString, Point, box
+
+ if kind == "points":
+ geoms = [Point(*rng.random(2) * 20) for _ in range(n)]
+ elif kind == "lines":
+ geoms = [LineString(rng.random((3, 2)) * 20) for _ in range(n)]
+ else:
+ geoms = []
+ for _ in range(n):
+ x, y = rng.random(2) * 18
+ geoms.append(box(x, y, x + 1.5, y + 1.5))
+ return gpd.GeoDataFrame(geometry=geoms)
+
+
+@pytest.mark.parametrize("kind", ["points", "lines", "polygons"])
+def test_min_distance_matches_brute_force(kind):
+ import numpy as np
+
+ rng = np.random.default_rng(42)
+ grid = vector_grid(dimension=(20, 20), resolution=1.0)
+ targets = _targets(kind, rng, 25)
+ expected = _brute_force_min_distance(grid, targets)
+
+ fill(FillStrategy.MIN_DISTANCE, from_gdf=grid, to_gdf=targets, attr_name="d")
+
+ np.testing.assert_array_equal(grid["d"].to_numpy(), expected)
+ # cells overlapped by a target are at distance zero
+ if kind == "polygons":
+ assert (grid["d"] == 0).any()
+
+
+def test_min_distance_keeps_index_and_row_order():
+ import geopandas as gpd
+ from shapely.geometry import Point
+
+ grid = vector_grid(dimension=(4, 3), resolution=1.0)
+ shuffled = grid.sample(frac=1.0, random_state=0)
+ targets = gpd.GeoDataFrame(geometry=[Point(0, 0)])
+ fill(FillStrategy.MIN_DISTANCE, from_gdf=shuffled, to_gdf=targets)
+
+ assert list(shuffled.index) == list(grid.sample(frac=1.0, random_state=0).index)
+ for idx, row in shuffled.iterrows():
+ assert row["min_distance"] == row.geometry.distance(Point(0, 0))
+
+
+def test_min_distance_empty_target_gives_nan():
+ import geopandas as gpd
+
+ grid = vector_grid(dimension=(3, 3), resolution=1.0)
+ fill(FillStrategy.MIN_DISTANCE, from_gdf=grid, to_gdf=gpd.GeoDataFrame(geometry=[]))
+ assert grid["min_distance"].isna().all()
+
+
+def test_min_distance_missing_geometry_gives_nan():
+ import geopandas as gpd
+ import numpy as np
+ from shapely.geometry import Point
+
+ grid = vector_grid(dimension=(3, 1), resolution=1.0)
+ grid.loc[grid.index[1], "geometry"] = None
+ targets = gpd.GeoDataFrame(geometry=[Point(-1, 0.5)])
+ fill(FillStrategy.MIN_DISTANCE, from_gdf=grid, to_gdf=targets)
+
+ values = grid["min_distance"].to_numpy()
+ assert values[0] == 1.0 and values[2] == 3.0 # cells span x in [0,1] and [2,3]
+ assert np.isnan(values[1])
+
+
+def test_min_distance_warns_on_crs_mismatch():
+ import geopandas as gpd
+ from shapely.geometry import Point
+
+ grid = vector_grid(dimension=(2, 2), resolution=1.0, crs="EPSG:31984")
+ targets = gpd.GeoDataFrame(geometry=[Point(0, 0)], crs="EPSG:4326")
+ with pytest.warns(UserWarning, match="CRS mismatch"):
+ fill(FillStrategy.MIN_DISTANCE, from_gdf=grid, to_gdf=targets)
diff --git a/tests/vector/test_vector_grid.py b/tests/vector/test_vector_grid.py
index 1d86cb1..3aa90f9 100644
--- a/tests/vector/test_vector_grid.py
+++ b/tests/vector/test_vector_grid.py
@@ -178,3 +178,39 @@ def test_consistent_with_grid_index(self):
for idx in gdf.index:
pos = parse_idx(idx)
assert f"{pos.row}-{pos.col}" == idx
+
+
+# ── Vectorized construction must reproduce the original loop exactly ─────────
+
+def _loop_reference(x_edges, y_edges, rx, ry):
+ """The pre-vectorization implementation, kept as the reference."""
+ from shapely.geometry import box
+
+ cells, ids = [], []
+ for i, x0 in enumerate(x_edges):
+ for j, y0 in enumerate(y_edges):
+ cells.append(box(x0, y0, x0 + rx, y0 + ry))
+ ids.append(f"{j}-{i}")
+ return cells, ids
+
+
+@pytest.mark.parametrize(
+ "kwargs, x_edges, y_edges, rx, ry",
+ [
+ (dict(dimension=(4, 3), resolution=1.0),
+ np.arange(0, 4, 1.0), np.arange(0, 3, 1.0), 1.0, 1.0),
+ (dict(bounds=(10.0, -5.0, 13.5, -1.0), resolution=0.5),
+ np.arange(10.0, 13.5, 0.5), np.arange(-5.0, -1.0, 0.5), 0.5, 0.5),
+ (dict(bounds=(0.0, 0.0, 10.0, 6.0), dimension=(5, 3)),
+ np.arange(0.0, 10.0, 2.0), np.arange(0.0, 6.0, 2.0), 2.0, 2.0),
+ ],
+)
+def test_vectorized_grid_identical_to_loop(kwargs, x_edges, y_edges, rx, ry):
+ gdf = vector_grid(**kwargs)
+ cells, ids = _loop_reference(x_edges, y_edges, rx, ry)
+
+ assert list(gdf.index) == ids
+ assert gdf.index.name == "id"
+ for got, ref in zip(gdf.geometry, cells, strict=True):
+ assert got.equals_exact(ref, 0.0)
+ assert list(got.exterior.coords) == list(ref.exterior.coords)
diff --git a/tests/visualization/test_chart.py b/tests/visualization/test_chart.py
index a78d658..6314fb7 100644
--- a/tests/visualization/test_chart.py
+++ b/tests/visualization/test_chart.py
@@ -22,6 +22,13 @@
from dissmodel.visualization.chart import Chart, track_plot
+@pytest.fixture(autouse=True)
+def _run_in_tmp_path(tmp_path, monkeypatch):
+ """Headless Chart/RasterMap write PNG frames to the working directory;
+ run every test in a temporary one so the repository stays clean."""
+ monkeypatch.chdir(tmp_path)
+
+
# ── helper model with tracked attributes ──────────────────────────────────────
@track_plot(label="Infected", color="red")