Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7ce3596
docs(paper): align validation text with disslucc-benchmark
profsergiocosta Oct 7, 2026
668eb8e
cite disslucc 0.5.0, brmangue-dissmodel 0.3.0
profsergiocosta Oct 7, 2026
26561e4
docs(paper): BR-MANGUE validation with both TerraME
profsergiocosta Oct 7, 2026
c253265
BR-MANGUE residuals, Mesa-Geo, remaining labs,
profsergiocosta Oct 7, 2026
e02c013
docs(paper): AI disclosure for the BR-MANGUE driver, tie
profsergiocosta Oct 8, 2026
c0c365a
docs(paper): final revision for JOSS review
profsergiocosta Oct 8, 2026
2e8e3af
docs(paper): final revision for JOSS review
profsergiocosta Oct 8, 2026
07907b4
docs(paper): name the TerraME adaptation used for BR-MANGUE; clarify …
profsergiocosta Oct 8, 2026
6a07e3d
docs(paper): trim the body back under the 1700-word limit
claude Oct 8, 2026
0d92b42
fix(cli): exact output name on request; real provenance in Experiment…
profsergiocosta Oct 8, 2026
c359e69
docs: replace legacy dissmodel.geo.raster.io guide; fix README execut…
profsergiocosta Oct 8, 2026
576fcbc
perf(vector): MIN_DISTANCE fill via spatial index instead of N×M apply
profsergiocosta Oct 8, 2026
d2e0b0e
perf(vector): build vector_grid cells in one vectorized shapely.box call
profsergiocosta Oct 8, 2026
6918725
chore(release): 0.6.6
profsergiocosta Oct 8, 2026
953a8e3
fix(cli,io): create output directory; clear error on data/extension m…
profsergiocosta Oct 8, 2026
44c4065
fix(widgets): display_inputs no longer exposes framework attributes
profsergiocosta Oct 8, 2026
7c18b08
docs(readme): architecture diagram in Mermaid
profsergiocosta Oct 8, 2026
199115a
docs: install disslucc and disscube from PyPI
profsergiocosta Oct 8, 2026
31e8145
docs(readme): architecture diagram in Mermaid
profsergiocosta Oct 8, 2026
ec19843
ocs: general AI disclosure in the paper; README citation
profsergiocosta Oct 8, 2026
99d7a6d
test: run Chart and RasterMap tests in a temporary directory
profsergiocosta Oct 8, 2026
5d64e72
docs(paper): trim AI disclosure to stay within 1700 words
profsergiocosta Oct 8, 2026
3604374
docs(paper): soften the statement of need
profsergiocosta Oct 8, 2026
3b14e2e
docs(paper): re-run benchmarks on current stack; mention Sync models;…
profsergiocosta Oct 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 30 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/)
[![PyPI version](https://badge.fury.io/py/dissmodel.svg)](https://pypi.org/project/dissmodel/)
[![Coverage](https://img.shields.io/badge/coverage-79%25-orange.svg)](https://github.com/DisSModel/dissmodel/actions)
[![Coverage](https://img.shields.io/badge/coverage-83%25-yellowgreen.svg)](https://github.com/DisSModel/dissmodel/actions)
[![DisSModel](https://img.shields.io/badge/LambdaGeo-Research-green.svg)](https://github.com/DisSModel)
[![JOSS Status](https://joss.theoj.org/papers/46522bc30d2dbec6b509d2dc487170ec/status.svg)](https://joss.theoj.org/papers/46522bc30d2dbec6b509d2dc487170ec)

Expand Down Expand Up @@ -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.
Expand All @@ -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, ...<br/><i>only math, geometry and time</i>"]
end
subgraph infra["Infrastructure layer: ModelExecutor"]
E["BrmangueRasterExecutor, LuccContinuousExecutor, ...<br/><i>only URIs (local or s3://), column_map, parameters</i>"]
end
subgraph core_modules["Core modules"]
direction LR
core["dissmodel.core<br/>Environment, Model, SpatialModel"]
geo["dissmodel.geo<br/>RasterBackend, neighborhoods"]
exe["dissmodel.executor<br/>ModelExecutor, ExperimentRecord"]
io["dissmodel.io<br/>load_dataset, save_dataset"]
viz["dissmodel.visualization<br/>Map, RasterMap, Chart"]
end
E -- "loads data, builds and runs" --> M
M --> core
M --> geo
E --> exe
E --> io
M -. "optional" .-> viz
```

---
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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}
}
```

Expand Down
105 changes: 94 additions & 11 deletions dissmodel/executor/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,19 +98,83 @@ 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:
"""
"<distribution>==<version>" 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)
params, spec = _load_local_params(toml_path) # params already carries the merged [model] spec (see _load_toml)
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",
Expand All @@ -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
Expand All @@ -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}"
Expand All @@ -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...")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand Down
39 changes: 36 additions & 3 deletions dissmodel/geo/vector/fill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


# ---------------------------------------------------------------------------
Expand Down
Loading
Loading