diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..494f3f2 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,22 @@ +name: tests + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: pip install ".[dev]" + - run: pytest -q + env: + MPLBACKEND: Agg diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ec45b15 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +## [0.1.0] — 2026-10-08 + +First citable release. + +- Models compatible with dissmodel 0.5.0 or later (tested with 0.5.0, 0.6.0 and 0.6.6). +- `tests/test_models_smoke.py` runs every exported model for a few steps, headless, + and checks that the grid is not corrupted; a GitHub Actions workflow runs it. +- `Excitable`, `Parity` and `Wolfram` raise a `ValueError` on a grid too small + for their fixed seeds (Excitable, Parity: 50×50; Wolfram: `xdim` columns × + `final_time + 1` rows). Before, the seed assignment silently appended rows + without geometry and the simulation ran on a corrupted grid. +- `streamlit` is no longer a required dependency: no module of the package uses + it, only the Streamlit examples (`pip install "dissmodel-ca[examples]"`). +- `demo/`: self-contained Streamlit app (Dockerfile, pinned requirements, + Hugging Face Space header) that can be deployed on its own. +- MIT license and `CITATION.cff`. diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..8b56ab7 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,26 @@ +cff-version: 1.2.0 +message: "If you use this software, please cite it as below." +title: "dissmodel-ca: Cellular Automata extension for DisSModel" +abstract: >- + Cellular automata models for the DisSModel framework (vector and raster substrates): Game of Life, fire spread, growth, snow, diffusion, Wolfram rules and other classic examples, ported from the TerraME model collection. +authors: + - family-names: "Costa" + given-names: "Sérgio Souza" + orcid: "https://orcid.org/0000-0002-0232-4549" + - family-names: "Santos Junior" + given-names: "Nerval de Jesus" + orcid: "https://orcid.org/0009-0000-2339-3191" + - family-names: "Zhao" + given-names: "Samylli Kalei Silva" + orcid: "https://orcid.org/0009-0009-6490-4459" +version: 0.1.0 +date-released: "2026-10-08" +license: MIT +url: "https://github.com/DisSModel/dissmodel-ca" +repository-code: "https://github.com/DisSModel/dissmodel-ca" +keywords: + - cellular automata + - DisSModel + - spatial dynamic modeling + - TerraME +type: software diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..46a8eb4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2025 Sérgio Souza Costa, Samylli Kalei Silva Zhao, +Nerval de Jesus Santos Junior + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index ad62129..124e991 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,14 @@ This library provides a collection of cellular automata models implemented using ## ⚙️ Installation +The package is not on PyPI. Install it from GitHub: + ```bash -pip install . +pip install git+https://github.com/DisSModel/dissmodel-ca ``` +or, from a clone, `pip install .` (`pip install ".[examples]"` for the Streamlit apps, `pip install ".[dev]"` for the tests). + ## 🚀 Usage ### 1. Simple Script (Quick Start) diff --git a/check_env.py b/check_env.py deleted file mode 100644 index d366a27..0000000 --- a/check_env.py +++ /dev/null @@ -1,6 +0,0 @@ -from dissmodel.core import Environment -env = Environment(end_time=10) -print(dir(env)) -print(f"Now: {env.now()}") -env.run() # This runs to the end -print(f"Now after run: {env.now()}") diff --git a/check_geoms.py b/check_geoms.py deleted file mode 100644 index 6d68363..0000000 --- a/check_geoms.py +++ /dev/null @@ -1,4 +0,0 @@ -from dissmodel.geo import vector_grid -gdf = vector_grid(dimension=(3, 3), resolution=1, attrs={"state": 0}) -for idx, row in gdf.iterrows(): - print(f"ID: {idx}, Centroid: {row.geometry.centroid.x}, {row.geometry.centroid.y}") diff --git a/demo/Dockerfile b/demo/Dockerfile index 23d0d5c..2b4d092 100644 --- a/demo/Dockerfile +++ b/demo/Dockerfile @@ -1,30 +1,24 @@ +# Self-contained: build from this folder only (e.g. a Hugging Face Space). FROM python:3.11-slim -# Install system dependencies -RUN apt-get update && apt-get install -y \ - git \ - build-essential \ - libgl1 \ - libglib2.0-0 \ +# git: dissmodel-ca is installed from GitHub (not on PyPI) +RUN apt-get update && apt-get install -y --no-install-recommends git \ && rm -rf /var/lib/apt/lists/* -# Set up a new user to avoid running as root +# Hugging Face runs the container as uid 1000 RUN useradd -m -u 1000 user USER user ENV HOME=/home/user \ - PATH=/home/user/.local/bin:$PATH + PATH=/home/user/.local/bin:$PATH \ + MPLBACKEND=Agg WORKDIR $HOME/app -# Copy requirements and install COPY --chown=user requirements.txt . RUN pip install --no-cache-dir --upgrade -r requirements.txt -# Copy the rest of the application -COPY --chown=user . . +COPY --chown=user app.py . -# Expose the port Streamlit will run on (Hugging Face uses 7860) +# Hugging Face Spaces expect port 7860 EXPOSE 7860 - -# Command to run the app CMD ["streamlit", "run", "app.py", "--server.port", "7860", "--server.address", "0.0.0.0"] diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..0af898c --- /dev/null +++ b/demo/README.md @@ -0,0 +1,29 @@ +--- +title: DisSModel Cellular Automata Explorer +emoji: 🗺️ +colorFrom: green +colorTo: blue +sdk: docker +app_port: 7860 +license: mit +short_description: Run the dissmodel-ca cellular automata in the browser +--- + +# Cellular Automata Explorer + +Interactive demo of [dissmodel-ca](https://github.com/DisSModel/dissmodel-ca): +pick a model, set its parameters in the sidebar and run it on a vector grid. + +This folder is self-contained and is what gets deployed: + +```bash +# locally +pip install -r requirements.txt +streamlit run app.py + +# or with Docker +docker build -t ca-demo . && docker run -p 7860:7860 ca-demo +``` + +To publish on Hugging Face, create a Space with the Docker SDK and push the +contents of this folder to it. The header above is the Space configuration. diff --git a/demo/app.py b/demo/app.py index 8aef057..111afa5 100644 --- a/demo/app.py +++ b/demo/app.py @@ -11,18 +11,16 @@ - ``initialize()`` uses the parameters set by ``display_inputs``. - ``execute()`` applies ``rule()`` to every cell each step. +Standalone: this folder is what gets deployed (e.g. to a Hugging Face +Space), so the app imports only installed packages (see requirements.txt). + Usage ----- - streamlit run examples/streamlit/ca_all.py + pip install -r requirements.txt + streamlit run app.py """ from __future__ import annotations -import sys -from pathlib import Path - -# Adiciona o diretorio src ao caminho de busca do Python -sys.path.append(str(Path(__file__).resolve().parent.parent / "src")) - import inspect import matplotlib.pyplot as plt @@ -68,6 +66,12 @@ # Get selected class ModelClass = model_classes[model_name] +# Models whose initial state is placed at fixed cells (TerraME setups) +MIN_GRID = {"Excitable": 50, "Parity": 50} +if grid_size < MIN_GRID.get(model_name, 0): + grid_size = MIN_GRID[model_name] + st.sidebar.caption(f"{model_name} seeds fixed cells: grid set to {grid_size}×{grid_size}.") + # Display Model Description if ModelClass.__doc__: with st.expander("📖 About this model", expanded=True): @@ -89,16 +93,17 @@ # 1. Environment env = Environment(start_time=0, end_time=steps) -# 2. Grid -gdf = vector_grid( - dimension=(grid_size, grid_size), - resolution=1, - attrs={"state": 0}, -) +# 2. Grid — Wolfram stores one generation per row: grid_size columns, +# steps + 1 rows +if model_name == "Wolfram": + gdf = vector_grid(dimension=(grid_size, steps + 1), resolution=1, attrs={"state": 0}) + extra = {"xdim": grid_size, "final_time": steps} +else: + gdf = vector_grid(dimension=(grid_size, grid_size), resolution=1, attrs={"state": 0}) + extra = {} # 3. Model — instantiated dynamically from sidebar selection -ModelClass = model_classes[model_name] -model = ModelClass(gdf=gdf, dim=grid_size, start_time=0, end_time=steps) +model = ModelClass(gdf=gdf, dim=grid_size, start_time=0, end_time=steps, **extra) # 4. Sidebar widgets — model-specific parameters rendered automatically st.sidebar.markdown("---") diff --git a/demo/requirements.txt b/demo/requirements.txt index 9f99108..932f022 100644 --- a/demo/requirements.txt +++ b/demo/requirements.txt @@ -1,6 +1,4 @@ -dissmodel @ git+https://github.com/DisSModel/dissmodel.git -dissmodel-ca @ git+https://github.com/DisSModel/dissmodel-ca.git -streamlit -matplotlib -geopandas -libpysal +# Pinned so that the deployed demo is reproducible; bump together with releases. +dissmodel==0.6.6 +dissmodel-ca @ git+https://github.com/DisSModel/dissmodel-ca@v0.1.0 +streamlit>=1.30 diff --git a/pyproject.toml b/pyproject.toml index ad95207..fdc7df0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,19 +5,27 @@ build-backend = "setuptools.build_meta" [project] name = "dissmodel-ca" version = "0.1.0" -description = "Cellular Automata examples and library for dissmodel" +description = "Cellular automata models for DisSModel" readme = "README.md" -requires-python = ">=3.9" +license = { text = "MIT" } +authors = [ + { name = "Sérgio Souza Costa", email = "sergio.costa@ufma.br" }, + { name = "Samylli Kalei Silva Zhao" }, + { name = "Nerval de Jesus Santos Junior" }, +] +requires-python = ">=3.10" dependencies = [ - "dissmodel", + "dissmodel>=0.5.0", "libpysal", "numpy", "geopandas", "matplotlib", - "streamlit", ] [project.optional-dependencies] +examples = [ + "streamlit", +] dev = [ "pytest", "black", @@ -27,3 +35,9 @@ dev = [ [tool.setuptools] package-dir = {"" = "src"} packages = ["dissmodel_ca", "dissmodel_ca.models"] + +[project.urls] +Homepage = "https://github.com/DisSModel/dissmodel-ca" + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/dissmodel_ca/models/excitable.py b/src/dissmodel_ca/models/excitable.py index 42e82a6..3da0e64 100644 --- a/src/dissmodel_ca/models/excitable.py +++ b/src/dissmodel_ca/models/excitable.py @@ -57,10 +57,16 @@ def initialize(self) -> None: matching the original TerraME model on a 50×50 grid. All other cells start at state ``0`` (resting). """ + seeds = ["15-20", "15-30"] # TerraME (x, y) → DisSModel index "{y}-{x}" + missing = [s for s in seeds if s not in self.gdf.index] + if missing: + # .loc on a missing label would silently append geometry-less rows + raise ValueError( + f"Excitable seeds {missing} are outside the grid: it needs at least " + "31 columns × 16 rows (the canonical setup is 50×50)." + ) self.gdf["state"] = 0 - # TerraME (x, y) → DisSModel index "{y}-{x}" - self.gdf.loc["15-20", "state"] = 1 - self.gdf.loc["15-30", "state"] = 1 + self.gdf.loc[seeds, "state"] = 1 def rule(self, idx: Any) -> int: """ diff --git a/src/dissmodel_ca/models/parity.py b/src/dissmodel_ca/models/parity.py index ee93050..53d2373 100644 --- a/src/dissmodel_ca/models/parity.py +++ b/src/dissmodel_ca/models/parity.py @@ -70,10 +70,16 @@ def initialize(self) -> None: matching the original TerraME model on a 50×50 grid. All other cells start as ``OFF``. """ + seeds = ["25-10", "25-40"] # TerraME (x, y) → DisSModel index "{y}-{x}" + missing = [s for s in seeds if s not in self.gdf.index] + if missing: + # .loc on a missing label would silently append geometry-less rows + raise ValueError( + f"Parity seeds {missing} are outside the grid: it needs at least " + "41 columns × 26 rows (the canonical setup is 50×50)." + ) self.gdf["state"] = ParityState.OFF - # TerraME (x, y) → DisSModel index "{y}-{x}" - self.gdf.loc["25-10", "state"] = ParityState.ON - self.gdf.loc["25-40", "state"] = ParityState.ON + self.gdf.loc[seeds, "state"] = ParityState.ON def rule(self, idx: Any) -> int: """ diff --git a/src/dissmodel_ca/models/wolfram.py b/src/dissmodel_ca/models/wolfram.py index c0a0503..37b56cf 100644 --- a/src/dissmodel_ca/models/wolfram.py +++ b/src/dissmodel_ca/models/wolfram.py @@ -115,6 +115,13 @@ def initialize(self) -> None: All other cells start as dead (0). The seed is placed at the middle column: ``(xdim - 1) // 2``. """ + needed = [f"{self.final_time}-{self.xdim - 1}"] # last row, last column + if needed[0] not in self.gdf.index: + # .loc on a missing label would silently append geometry-less rows + raise ValueError( + f"Wolfram needs a grid of {self.xdim} columns × {self.final_time + 1} rows " + f"(xdim × final_time + 1); pass xdim/final_time matching the grid." + ) self.gdf[self.state_attr] = 0 mid = (self.xdim - 1) // 2 # Use explicit y-x indexing: row 0, column 'mid' diff --git a/test_notebooks.py b/test_notebooks.py deleted file mode 100644 index db004cd..0000000 --- a/test_notebooks.py +++ /dev/null @@ -1,20 +0,0 @@ -import json -import glob -import ast - -for nb_file in glob.glob("examples/notebooks/*.ipynb"): - with open(nb_file, 'r') as f: - nb = json.load(f) - print(f"Testing {nb_file}...") - for cell in nb.get("cells", []): - if cell.get("cell_type") == "code": - source = "".join(cell.get("source", [])) - try: - # remove magics if any - clean_source = "\n".join([line for line in source.split('\n') if not line.startswith('%')]) - ast.parse(clean_source) - except SyntaxError as e: - print(f" SyntaxError in cell: {e}") - print(f" Source:\n{clean_source}") - -print("Done.") diff --git a/tests/test_models_smoke.py b/tests/test_models_smoke.py new file mode 100644 index 0000000..da5763f --- /dev/null +++ b/tests/test_models_smoke.py @@ -0,0 +1,105 @@ +""" +Smoke test: every cellular automaton exported by dissmodel_ca.models is +instantiated on a small grid and run for a few steps, headless. + +It checks that the models work with the installed dissmodel and that the +state stays numeric with the right number of cells; it does not check the +dynamics of each model. +""" +from __future__ import annotations + +import inspect + +import matplotlib +import numpy as np +import pytest + +matplotlib.use("Agg") + +import dissmodel_ca.models as ca_models +from dissmodel.core import Environment +from dissmodel.geo import CellularAutomaton, raster_grid, vector_grid +from dissmodel.geo.raster.cellular_automaton import RasterCellularAutomaton + +DIM, STEPS = 10, 5 + +# Models whose seeds sit at fixed positions of the canonical TerraME grid. +GRID = {"Excitable": 50, "Parity": 50} + + +def _concrete(base): + return sorted( + (name, cls) + for name, cls in inspect.getmembers(ca_models, inspect.isclass) + if issubclass(cls, base) and cls is not base and not inspect.isabstract(cls) + ) + + +VECTOR_MODELS = _concrete(CellularAutomaton) +RASTER_MODELS = _concrete(RasterCellularAutomaton) + + +def test_every_exported_model_is_covered(): + exported = {n for n in ca_models.__all__ if inspect.isclass(getattr(ca_models, n))} + models = {n for n, c in inspect.getmembers(ca_models, inspect.isclass) + if issubclass(c, (CellularAutomaton, RasterCellularAutomaton))} + assert models <= exported + assert len(VECTOR_MODELS) + len(RASTER_MODELS) == len(models) > 0 + + +@pytest.mark.parametrize("name, cls", VECTOR_MODELS, ids=[n for n, _ in VECTOR_MODELS]) +def test_vector_model_runs(name, cls): + np.random.seed(0) + env = Environment(start_time=0, end_time=STEPS) + if name == "Wolfram": + # one row per generation: xdim columns × (final_time + 1) rows + xdim, final_time = 21, STEPS + gdf = vector_grid(dimension=(xdim, final_time + 1), resolution=1, attrs={"state": 0}) + model = cls(gdf=gdf, xdim=xdim, final_time=final_time) + n_cells = xdim * (final_time + 1) + else: + dim = GRID.get(name, DIM) + gdf = vector_grid(dimension=(dim, dim), resolution=1, attrs={"state": 0}) + model = cls(gdf=gdf, dim=dim, start_time=0, end_time=STEPS) + n_cells = dim * dim + model.initialize() + env.run() + assert len(gdf) == n_cells # no rows appended + assert gdf.geometry.notna().all() + assert np.isfinite(np.asarray(gdf["state"], dtype=float)).all() + + +@pytest.mark.parametrize("name, cls", RASTER_MODELS, ids=[n for n, _ in RASTER_MODELS]) +def test_raster_model_runs(name, cls): + np.random.seed(0) + backend = raster_grid(rows=DIM, cols=DIM, attrs={"state": 0}) + env = Environment(start_time=0, end_time=STEPS) + model = cls(backend=backend) + if hasattr(model, "initialize"): + model.initialize() + env.run() + state = backend.get("state") + assert state.shape == (DIM, DIM) + assert np.isfinite(state.astype(float)).all() + + +@pytest.mark.parametrize("name", sorted(GRID)) +def test_seeded_models_reject_small_grid(name): + """Seeds outside the grid raise instead of appending geometry-less rows.""" + cls = getattr(ca_models, name) + Environment(start_time=0, end_time=STEPS) + gdf = vector_grid(dimension=(DIM, DIM), resolution=1, attrs={"state": 0}) + model = cls(gdf=gdf, dim=DIM) + with pytest.raises(ValueError, match="outside the grid"): + model.initialize() + assert len(gdf) == DIM * DIM + + +def test_wolfram_rejects_grid_smaller_than_its_dimensions(): + """Default final_time=55 needs 111 × 56; a 20 × 20 grid must raise.""" + Environment(start_time=0, end_time=STEPS) + gdf = vector_grid(dimension=(DIM, DIM), resolution=1, attrs={"state": 0}) + model = ca_models.Wolfram(gdf=gdf) + with pytest.raises(ValueError, match="Wolfram needs a grid"): + model.initialize() + assert len(gdf) == DIM * DIM