From 1dd1952a6f1a71998ed845c5b31ca3f3e60d6b3d Mon Sep 17 00:00:00 2001 From: Sergio Souza Costa Date: Thu, 22 Oct 2026 08:16:14 -0300 Subject: [PATCH] fix: Life wrap, raster Fire neighborhood and SolidDiffusion as in TerraME; tests against TerraME runs Compared with TerraME 2.0.1 runs of the ca package (terrame-goldens v0.1.0), three models differed: - GameOfLife (vector and raster) did not wrap around the edges; TerraME's Life uses createNeighborhood{wrap = true}. New wrap parameter, default True. - FireModelRaster passed no neighborhood to focal_sum_mask, which counts the eight Moore neighbors, so the fire spread diagonally; it now uses self.dirs (Von Neumann), as TerraME and the vector version. - SolidDiffusion drew only among atoms and could move a vacancy several times per step. It now follows TerraME: each vacancy draws one of all its neighbors, reads the state at the start of the step and moves at most once; cells in TerraME's order, on NumPy arrays. With these fixes, the 14 deterministic cases match TerraME cell by cell at every step, and SolidDiffusion mixes as in TerraME (26.5 vs 27 atoms past the middle column at t = 400; 35.9 before). tests/test_goldens.py (marker golden) runs the comparisons from an extract of the goldens in tests/goldens (with SHA-256 provenance and the script that makes it): grids cell by cell, ensembles by Kolmogorov-Smirnov and mean tests. About a minute by default; DISSMODEL_GOLDEN_FULL=1 for every step and the slow ensembles. --- CHANGELOG.md | 23 +- README.md | 18 ++ docs/reference.md | 19 +- pyproject.toml | 3 + src/dissmodel_ca/models/fire_model_raster.py | 2 +- src/dissmodel_ca/models/game_of_life.py | 28 +- .../models/game_of_life_raster.py | 19 +- src/dissmodel_ca/models/solid_diffusion.py | 115 +++----- tests/goldens/README.md | 29 ++ tests/goldens/ensembles.csv.gz | Bin 0 -> 17689 bytes tests/goldens/grids/anneal.csv.gz | Bin 0 -> 14327 bytes tests/goldens/grids/cases.csv | 13 + tests/goldens/grids/excitable.csv.gz | Bin 0 -> 3204 bytes tests/goldens/grids/fire.csv.gz | Bin 0 -> 6731 bytes tests/goldens/grids/life_glider.csv.gz | Bin 0 -> 1452 bytes tests/goldens/grids/life_random.csv.gz | Bin 0 -> 3671 bytes tests/goldens/grids/life_rpentomino.csv.gz | Bin 0 -> 1631 bytes tests/goldens/grids/oscillator.csv.gz | Bin 0 -> 23941 bytes tests/goldens/grids/parasit.csv.gz | Bin 0 -> 18378 bytes tests/goldens/grids/parity.csv.gz | Bin 0 -> 3664 bytes tests/goldens/grids/wolfram_110.csv.gz | Bin 0 -> 4674 bytes tests/goldens/grids/wolfram_30.csv.gz | Bin 0 -> 5543 bytes tests/goldens/grids/wolfram_90.csv.gz | Bin 0 -> 4522 bytes tests/goldens/make_reference.py | 107 +++++++ tests/goldens/reference.json | 277 +++++++++++++++++ tests/test_goldens.py | 279 ++++++++++++++++++ 26 files changed, 851 insertions(+), 81 deletions(-) create mode 100644 tests/goldens/README.md create mode 100644 tests/goldens/ensembles.csv.gz create mode 100644 tests/goldens/grids/anneal.csv.gz create mode 100644 tests/goldens/grids/cases.csv create mode 100644 tests/goldens/grids/excitable.csv.gz create mode 100644 tests/goldens/grids/fire.csv.gz create mode 100644 tests/goldens/grids/life_glider.csv.gz create mode 100644 tests/goldens/grids/life_random.csv.gz create mode 100644 tests/goldens/grids/life_rpentomino.csv.gz create mode 100644 tests/goldens/grids/oscillator.csv.gz create mode 100644 tests/goldens/grids/parasit.csv.gz create mode 100644 tests/goldens/grids/parity.csv.gz create mode 100644 tests/goldens/grids/wolfram_110.csv.gz create mode 100644 tests/goldens/grids/wolfram_30.csv.gz create mode 100644 tests/goldens/grids/wolfram_90.csv.gz create mode 100644 tests/goldens/make_reference.py create mode 100644 tests/goldens/reference.json create mode 100644 tests/test_goldens.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b7a2b2e..dba357b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## [Unreleased] -Four models now follow the TerraME `ca` package (https://github.com/terrame/ca), +Six models now follow the TerraME `ca` package (https://github.com/terrame/ca), where they used to differ. The results of these models change; a new minor release (0.2.0) is suggested. @@ -18,6 +18,27 @@ release (0.2.0) is suggested. `initial_fire_density` now defaults to 0.0 and, above zero, starts a random fraction of the cells burning instead (with `empty=0` and a density of 0.05 the old setup is recovered, though not the same random draw). +- `GameOfLife` (vector and raster): the Moore neighborhood wraps around the + grid edges, as in TerraME's `Life` (`wrap = true`); `wrap=False` keeps the + bounded grid. A glider now crosses the edge instead of dying on it. +- `FireModel` (raster): the fire spreads to the four Von Neumann neighbors, + as in TerraME and in the vector version. The rule passed no neighborhood + to `focal_sum_mask`, which counts all eight (Moore), so the fire also + spread diagonally. +- `SolidDiffusion`: the step follows TerraME. Each vacancy draws one of all + its neighbors (a vacancy may draw another vacancy and stay put) and moves + at most once per step, reading the state at the start of the step. The + port drew only among atoms and could move a vacancy several times in one + step, so the atoms mixed faster than in TerraME (after 400 steps, 35.9 + atoms of one kind past the middle column against 26.5 in TerraME; now + 27). +- `tests/test_goldens.py` (marker `golden`): the models against TerraME 2.0.1 + runs from `terrame-goldens` v0.1.0. Deterministic models start from + TerraME's state and must match it cell by cell; stochastic ones are + compared with 50 TerraME runs by a Kolmogorov-Smirnov test and a test on + the means. About a minute by default; `DISSMODEL_GOLDEN_FULL=1` compares + every step and adds the slow ensembles. `tests/goldens/` holds the + extract, its SHA-256 provenance and the script that makes it. - New `dissmodel_ca.models._neighborhood.grid_neighbors`: Von Neumann or Moore neighborhoods built from the `"row-col"` index, with optional wrap. - `tests/test_terrame_rules.py`: Parity and Excitable are compared step by step diff --git a/README.md b/README.md index 124e991..78314cd 100644 --- a/README.md +++ b/README.md @@ -67,3 +67,21 @@ jupyter lab examples/notebooks/ca_game_of_life.ipynb | `Excitable` | Vector | Excitable medium waves (spiral/ring patterns). | | `Parasit` | Vector | Host-parasit spatial dynamics. | | `Interspecific` | Vector | Grass species competition model. | + +--- + +## ✅ Verification against TerraME + +The models were checked against TerraME 2.0.1 running the `ca` package +([`terrame-goldens`](https://github.com/LambdaGeo/terrame-goldens) v0.1.0): + +- **Deterministic rules** (`GameOfLife` vector and raster, `Parity`, + `Excitable`, `Wolfram` 30/90/110, `FireModel` vector and raster, `Anneal`, + `Oscillator`, `Parasit`): started from TerraME's state, they match it + **cell by cell at every step** (up to 500 steps). +- **Stochastic models** (`FireModel` initial state, `SolidDiffusion`, + `Growth`, `Snow`, `InterspecificCompetition`): compared with 50 TerraME + runs by distribution. + +`tests/test_goldens.py` runs these comparisons (`pytest -m golden`, about a +minute; `DISSMODEL_GOLDEN_FULL=1` for every step and the slow ensembles). diff --git a/docs/reference.md b/docs/reference.md index 0d59869..64428f5 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -196,7 +196,7 @@ initial setup (10% empty cells and a single ignition by default). ```python def rule(self, arrays: dict) -> dict: state = arrays[self.state_attr] - has_burning = self.backend.focal_sum_mask(state == int(FireState.BURNING)) > 0 + has_burning = self.backend.focal_sum_mask(state == int(FireState.BURNING), self.dirs) > 0 new_state = state.copy() new_state = np.where(state == int(FireState.BURNING), int(FireState.BURNED), new_state) new_state = np.where((state == int(FireState.FOREST)) & has_burning, int(FireState.BURNING), new_state) @@ -229,7 +229,9 @@ The classic zero-player game demonstrating mathematical emergence. `0 - Dead`, `1 - Alive`. **3. Neighborhood:** -**Moore (Queen)** neighborhood (8 neighbors). +**Moore (Queen)** neighborhood (8 neighbors), wrapped around the grid edges +(torus), as in TerraME (`createNeighborhood{wrap = true}`). +`GameOfLife(gdf=grid, wrap=False)` gives a bounded grid. **4. Transition Rules:** ```python @@ -266,13 +268,16 @@ Raster-based implementation of Conway's Game of Life using NumPy. `0 - Dead`, `1 - Alive`. **3. Neighborhood:** -**Moore (Queen)** neighborhood. +**Moore (Queen)** neighborhood, wrapped around the grid edges as in TerraME; +`wrap=False` gives a bounded grid. **4. Transition Rules:** ```python def rule(self, arrays: dict) -> dict: state = arrays[self.state_attr] - neighbors = self.backend.focal_sum_mask(state == 1) + alive = (state == 1).astype(np.int8) + neighbors = sum(np.roll(alive, (dr, dc), axis=(0, 1)) # wrap = True + for dr in (-1, 0, 1) for dc in (-1, 0, 1) if (dr, dc) != (0, 0)) survive = (state == 1) & np.isin(neighbors, [2, 3]) born = (state == 0) & (neighbors == 3) return {self.state_attr: np.where(survive | born, 1, 0).astype(np.int8)} @@ -570,7 +575,11 @@ Atomic diffusion via random vacancy swapping. **Moore (Queen)**. **4. Transition Rules:** -Uses a **sequential random sweep** in `execute()`. +As in TerraME, in `execute()`: every cell that was a vacancy at the start of +the step draws one of its neighbors at random and takes its atom, unless that +neighbor was a vacancy at the start of the step or its atom has already been +taken in this step. A vacancy moves at most once per step; cells are visited +column by column, in TerraME's order. **5. Usage Example:** ```python diff --git a/pyproject.toml b/pyproject.toml index 6222cac..c95c9dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,3 +41,6 @@ Homepage = "https://github.com/DisSModel/dissmodel-ca" [tool.pytest.ini_options] testpaths = ["tests"] +markers = [ + "golden: compares the models with TerraME runs (tests/goldens; DISSMODEL_GOLDEN_FULL=1 for every step)", +] diff --git a/src/dissmodel_ca/models/fire_model_raster.py b/src/dissmodel_ca/models/fire_model_raster.py index a84c284..2c7cac2 100644 --- a/src/dissmodel_ca/models/fire_model_raster.py +++ b/src/dissmodel_ca/models/fire_model_raster.py @@ -183,7 +183,7 @@ def rule(self, arrays: dict) -> dict: # count BURNING neighbors (Von Neumann / Rook — 4 directions) has_burning = self.backend.focal_sum_mask( - state == int(FireState.BURNING) + state == int(FireState.BURNING), self.dirs ) > 0 new_state = state.copy() diff --git a/src/dissmodel_ca/models/game_of_life.py b/src/dissmodel_ca/models/game_of_life.py index 6c3f578..00c43c6 100644 --- a/src/dissmodel_ca/models/game_of_life.py +++ b/src/dissmodel_ca/models/game_of_life.py @@ -3,11 +3,11 @@ import random from typing import Any -from libpysal.weights import Queen - from dissmodel.geo import FillStrategy, fill from dissmodel.geo import CellularAutomaton +from ._neighborhood import grid_neighbors + # --------------------------------------------------------------------------- # Built-in patterns @@ -99,6 +99,13 @@ class GameOfLife(CellularAutomaton): Extra keyword arguments forwarded to :class:`~dissmodel.geo.CellularAutomaton`. + Notes + ----- + As in the TerraME version (``createNeighborhood{wrap = true}``), the + Moore neighborhood wraps around the grid edges (torus): a glider that + leaves on one side comes back on the other. Pass ``wrap=False`` for a + bounded grid, where cells beyond the edge count as dead. + Examples -------- >>> from dissmodel.geo import vector_grid @@ -109,9 +116,20 @@ class GameOfLife(CellularAutomaton): >>> gol.initialize() """ - def setup(self) -> None: - """Build the Queen neighborhood for the grid.""" - self.create_neighborhood(strategy=Queen, use_index=True) + def setup(self, wrap: bool = True) -> None: + """ + Build the Moore (8-direction) neighborhood. + + Parameters + ---------- + wrap : bool, optional + If ``True`` (default, as in TerraME), opposite edges of the + grid are neighbors (toroidal grid). + """ + self.wrap = wrap + self.create_neighborhood( + neighbors_dict=grid_neighbors(self.gdf.index, "moore", wrap=wrap) + ) def initialize(self) -> None: """ diff --git a/src/dissmodel_ca/models/game_of_life_raster.py b/src/dissmodel_ca/models/game_of_life_raster.py index 3fc20ea..c33e80b 100644 --- a/src/dissmodel_ca/models/game_of_life_raster.py +++ b/src/dissmodel_ca/models/game_of_life_raster.py @@ -73,6 +73,10 @@ class GameOfLife(RasterCellularAutomaton): Random seed for initialization, by default 42. state_attr : str, optional Name of the state array in the backend, by default ``"state"``. + wrap : bool, optional + If ``True`` (default, as in TerraME's ``createNeighborhood{wrap = + true}``), the neighborhood wraps around the grid edges (torus). + With ``False``, cells beyond the edge count as dead. Examples -------- @@ -91,10 +95,12 @@ def setup( density: float = 0.3, seed: int = 42, state_attr: str = "state", + wrap: bool = True, ) -> None: super().setup(backend, state_attr=state_attr) self.density = density self.seed = seed + self.wrap = wrap def initialize(self) -> None: """ @@ -112,7 +118,7 @@ def rule(self, arrays: dict) -> dict: Vectorized Conway transition rule. Applied once per step over the entire grid using Moore neighborhood - (8 directions — all 8 neighbors counted via focal_sum_mask). + (8 directions), wrapped around the edges unless ``wrap=False``. Parameters ---------- @@ -124,8 +130,15 @@ def rule(self, arrays: dict) -> dict: dict[str, np.ndarray] Updated ``"state"`` array (0 = dead, 1 = alive). """ - state = arrays[self.state_attr] - neighbors = self.backend.focal_sum_mask(state == 1) + state = arrays[self.state_attr] + alive = (state == 1).astype(np.int8) + if self.wrap: + neighbors = sum( + np.roll(alive, (dr, dc), axis=(0, 1)) + for dr in (-1, 0, 1) for dc in (-1, 0, 1) if (dr, dc) != (0, 0) + ) + else: + neighbors = self.backend.focal_sum_mask(alive == 1) survive = (state == 1) & np.isin(neighbors, [2, 3]) born = (state == 0) & (neighbors == 3) diff --git a/src/dissmodel_ca/models/solid_diffusion.py b/src/dissmodel_ca/models/solid_diffusion.py index 1bc938f..7aa225e 100644 --- a/src/dissmodel_ca/models/solid_diffusion.py +++ b/src/dissmodel_ca/models/solid_diffusion.py @@ -4,6 +4,7 @@ from enum import IntEnum from typing import Any +import numpy as np from dissmodel.geo import CellularAutomaton, parse_idx @@ -25,35 +26,12 @@ class SolidDiffusionState(IntEnum): VACANCY = 2 -def _queen_neighbors(idx: str, dim: int) -> list[str]: - """ - Return the Moore (Queen) neighbor indices for a cell on a square grid. - - Computes neighbors directly from the ``"{y}-{x}"`` index format, - clipping at grid boundaries (no wrap). - - Parameters - ---------- - idx : str - Cell index in ``"{y}-{x}"`` format. - dim : int - Grid side length (number of rows = number of columns). - - Returns - ------- - list[str] - Indices of valid neighbors (up to 8). - """ - x, y = parse_idx(idx) - neighbors = [] - for dy in (-1, 0, 1): - for dx in (-1, 0, 1): - if dy == 0 and dx == 0: - continue - nx, ny = x + dx, y + dy - if 0 <= nx < dim and 0 <= ny < dim: - neighbors.append(f"{nx}-{ny}") - return neighbors +#: (d_row, d_col) offsets of the Moore neighborhood, without wrap (TerraME default). +_MOORE: tuple[tuple[int, int], ...] = ( + (-1, -1), (-1, 0), (-1, 1), + (0, -1), (0, 1), + (1, -1), (1, 0), (1, 1), +) class SolidDiffusion(CellularAutomaton): @@ -82,16 +60,19 @@ class SolidDiffusion(CellularAutomaton): Notes ----- - Because diffusion requires atomic swaps (two cells updated in a single - operation), synchronous CA update is physically incorrect here — it - would create or destroy atoms. :meth:`execute` therefore uses a - **sequential random sweep**: vacancies are processed in a shuffled - order, each swapping with one randomly chosen non-vacant neighbor. - This matches the intended stochastic diffusion dynamics. + The step follows the TerraME version (``ca`` package, ``SolidDiffusion``): + + - every cell reads the state at the start of the step (``cell.past``); + - each cell that was a vacancy draws **one** of its Moore neighbors at + random (a vacancy may draw another vacancy, and then stays put); + - it takes the drawn atom, which becomes a vacancy, unless that atom + was a vacancy at the start of the step or has already been taken by + another vacancy in this step. - Neighbor indices are computed directly from the ``"{y}-{x}"`` grid - index format via :func:`_queen_neighbors`, avoiding reliance on any - internal weights attribute. + So a vacancy moves at most once per step, and a vacancy created in the + step does not move until the next one. Cells are visited in TerraME's + order (column by column, and row by row within a column). The swaps + keep the number of atoms of each kind. Examples -------- @@ -110,7 +91,7 @@ def setup(self, seed: int = 42) -> None: Parameters ---------- seed : int, optional - Random seed for the sequential shuffle in :meth:`execute`, + Random seed for the neighbor draws in :meth:`execute`, by default 42. """ self.seed = seed @@ -144,36 +125,38 @@ def assign(idx: str) -> int: def execute(self) -> None: """ - Perform one diffusion step via a sequential random vacancy sweep. - - Each vacancy in a randomly shuffled order picks one non-vacant - neighbor at random and swaps states with it. Vacancies that have - already been filled earlier in the same sweep are skipped. - - Neighbor indices are computed directly from grid indices using - :func:`_queen_neighbors` — no spatial weights object required. + Perform one diffusion step with TerraME's rule (see the class notes). """ assert self.dim is not None, "dim must be set — pass dim=N when instantiating" - indices = list(self.gdf.index) - self._rng.shuffle(indices) - - for idx in indices: - if self.gdf.loc[idx, "state"] != int(SolidDiffusionState.VACANCY): - continue - - neighbor_ids = _queen_neighbors(idx, self.dim) - non_vacant = [ - n for n in neighbor_ids - if self.gdf.loc[n, "state"] != int(SolidDiffusionState.VACANCY) - ] - - if not non_vacant: - continue - - chosen = self._rng.choice(non_vacant) - self.gdf.loc[idx, "state"] = self.gdf.loc[chosen, "state"] - self.gdf.loc[chosen, "state"] = int(SolidDiffusionState.VACANCY) + index = list(self.gdf.index) + pos = [parse_idx(i) for i in index] + n_rows = max(p.row for p in pos) + 1 + n_cols = max(p.col for p in pos) + 1 + rows = np.array([p.row for p in pos]) + cols = np.array([p.col for p in pos]) + + past = np.empty((n_rows, n_cols), dtype=np.int64) + past[rows, cols] = self.gdf["state"].to_numpy() + cur = past.copy() + vacancy = int(SolidDiffusionState.VACANCY) + + # TerraME visits the cells column by column (x outer, y inner) + for c in range(n_cols): + for r in range(n_rows): + if past[r, c] != vacancy: + continue + neighbors = [ + (r + dr, c + dc) + for dr, dc in _MOORE + if 0 <= r + dr < n_rows and 0 <= c + dc < n_cols + ] + nr, nc = neighbors[self._rng.randrange(len(neighbors))] + if past[nr, nc] != vacancy and cur[nr, nc] != vacancy: + cur[r, c] = past[nr, nc] + cur[nr, nc] = vacancy + + self.gdf["state"] = cur[rows, cols] def rule(self, idx: Any) -> int: # pragma: no cover """Not used. Diffusion logic is in :meth:`execute`.""" diff --git a/tests/goldens/README.md b/tests/goldens/README.md new file mode 100644 index 0000000..57f3eb9 --- /dev/null +++ b/tests/goldens/README.md @@ -0,0 +1,29 @@ +# TerraME reference values for the ca models + +An extract of the `ca` suite of +[`terrame-goldens`](https://github.com/LambdaGeo/terrame-goldens) v0.1.0: +what TerraME 2.0.1 computed running the models of the +[`ca`](https://github.com/TerraME/ca) package (commit `854a5c4`) with default +parameters. + +- `grids/.csv.gz`: the state of every cell (columns `step, y, x0, x1, + ...`, TerraME's values) for the deterministic models, at every step up to + 10 and then at steps 20, 50, 100, ... and the final one. Step 0 is the + state the test starts from. `grids/cases.csv` lists model, arguments, + seed, final time and size of each case. +- `ensembles.csv.gz`: for the stochastic models, the number of cells in each + state at a few steps, for seeds 1 to 50 (columns `case, value, step, seed, + count`). For SolidDiffusion, `atom1_right` and `atom2_left` count the atoms + past the middle column. +- `reference.json`: the source version, the package commit, the steps kept + and the SHA-256 of each source file. + +To make the extract again from a copy of `terrame-goldens`: + +```bash +python tests/goldens/make_reference.py /path/to/terrame-goldens +``` + +The values are the output of TerraME and of the `ca` package (INPE and +TerraLAB/UFOP); the extract is offered under CC-BY-4.0, as the goldens it +comes from. diff --git a/tests/goldens/ensembles.csv.gz b/tests/goldens/ensembles.csv.gz new file mode 100644 index 0000000000000000000000000000000000000000..bd5127efd6c906d8c19ce785826c093c9d9f693d GIT binary patch literal 17689 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 A4*&oF literal 0 HcmV?d00001 diff --git a/tests/goldens/grids/cases.csv b/tests/goldens/grids/cases.csv new file mode 100644 index 0000000..e9cb8a2 --- /dev/null +++ b/tests/goldens/grids/cases.csv @@ -0,0 +1,13 @@ +case,model,arguments,seed,final_time,xdim,ydim,attribute +life_glider,Life,pattern=glider,,100,30,30,state +life_rpentomino,Life,pattern=rPentomino,,100,30,30,state +life_random,Life,,1,100,30,30,state +parity,Parity,,,500,50,50,state +excitable,Excitable,,,500,50,50,state +wolfram_30,Wolfram,rule=30,,55,111,56,state +wolfram_90,Wolfram,rule=90,,55,111,56,state +wolfram_110,Wolfram,rule=110,,55,111,56,state +fire,Fire,,1,100,60,60,state +anneal,Anneal,,1,100,80,80,state +oscillator,Oscillator,,1,400,50,50,state +parasit,Parasit,,1,500,50,50,state diff --git a/tests/goldens/grids/excitable.csv.gz b/tests/goldens/grids/excitable.csv.gz new file mode 100644 index 0000000000000000000000000000000000000000..3cdb2dbce6dd88ac70b0c4d133abe453881358c7 GIT binary patch literal 3204 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 Y4n$I>meGc6R^xZwCA--@Ab+y!AC9CrtpET3 literal 0 HcmV?d00001 diff --git a/tests/goldens/grids/fire.csv.gz b/tests/goldens/grids/fire.csv.gz new file mode 100644 index 0000000000000000000000000000000000000000..e58c175066acbda82a9a90645448c28615710ca0 GIT binary patch literal 6731 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 literal 0 HcmV?d00001 diff --git a/tests/goldens/grids/life_glider.csv.gz b/tests/goldens/grids/life_glider.csv.gz new file mode 100644 index 0000000000000000000000000000000000000000..437828ae20385ccffe8d0e6ef5d32063d3a607dc GIT binary patch literal 1452 zcmb2|=3oE;rvGQ}Z0wRX7digW?!3{_dCGGqez9Q7PxKJnz-gX8e`mDHgH!je>cme@ zbNw+{$^MJsuKV?S{+0eIwu$R=m+7~Wdt344ap3XCHgb5(T)6*`yc~VdM&8Cxj(eM6eKIGUT%3;pDaVvBDzdzr;zuuQy`+CvY$62rI zH=licN3KP>rJ!3NPw0}b(;;zYbIzA>yqES$FR8Zl*v|Iyn!;VFB~i@@6Ez}KxWqb} zTo<7-`L;7Mx*u5UutV}jn~U_+$NO_@?GxV4{3-KXDR75}h@Mbux64DkOx13elS+Y` zJf`Rfp)xCX@N!r%<*^<*+wfa8FwbpKFZ2A{?+g~zz9z!W8$7N=sATB~nTmBvb-VPs zE;^Z=?L+Pb+UE4G`lW3sB|%5Vg^JQIW!sz zoC}WY3*|a3Dm=bE?7h*V!gj&jN&6ovT`ZWGvBSe8Mny_bh*!L`t=r|W>mnrKSvo?# zVx4N;E|Xmsom9G*GBIP5he@OgQ26ZOT!#aK1=<36Y=_=D>`*MQSDE$wZ`Rjoh0UyM zUMgKInV7N5!z5N^mY$HWc;~Wim&>k;kc6XjgmlF^#kyU(T^Aiyx|lREW248G2o<34 zS~P=qyjAZk%dvjXvZntGD8w^%dTfbNiP96&74MwZ?Q+_6(Nm?1MJU4SULWkX*1ePY z(l7hnmUn*I<+on?Nx$3j(yuvNpos5*9E(g#L$^SI&>j8md5_<%In;jPh2^_A(_bHK zcJMMDSA1x(q$aZA@q_{yp+4tB;%zPYJe={8LhqQoJzfe0?P{5#r??ag(?C(x$AeQi z=upcP1I4GcEn8BhWm+3nfAFmO^83VBv%K1KOTMq&EkEHNcT4{3C#RWY+FM|D6na?j zao(4VaMGGS;gsrdDn9L^5l?hH-GbV7UEkS15J81o2UeW}lBKoRr+C?x)idUEv6%_}XJ7yTs~D7M literal 0 HcmV?d00001 diff --git a/tests/goldens/grids/life_random.csv.gz b/tests/goldens/grids/life_random.csv.gz new file mode 100644 index 0000000000000000000000000000000000000000..fbac140a788883c40b2ae05ec49e88e1b5f8aedd GIT binary patch literal 3671 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 literal 0 HcmV?d00001 diff --git a/tests/goldens/grids/parasit.csv.gz b/tests/goldens/grids/parasit.csv.gz new file mode 100644 index 0000000000000000000000000000000000000000..db75be64a5a2baef0e424234af649f51a76a972e GIT binary patch literal 18378 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 literal 0 HcmV?d00001 diff --git a/tests/goldens/grids/wolfram_110.csv.gz b/tests/goldens/grids/wolfram_110.csv.gz new file mode 100644 index 0000000000000000000000000000000000000000..a2f5c245d519f27083ae8e82bfdc6746b8639850 GIT binary patch literal 4674 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 Wi)iEhL~bi)!8Yg3s)p4v3;qSaX|9g| literal 0 HcmV?d00001 diff --git a/tests/goldens/grids/wolfram_30.csv.gz b/tests/goldens/grids/wolfram_30.csv.gz new file mode 100644 index 0000000000000000000000000000000000000000..616170e38cc77c6f3581b20b77547a7174d41300 GIT binary patch literal 5543 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 A1^@s6 literal 0 HcmV?d00001 diff --git a/tests/goldens/make_reference.py b/tests/goldens/make_reference.py new file mode 100644 index 0000000..5fc20aa --- /dev/null +++ b/tests/goldens/make_reference.py @@ -0,0 +1,107 @@ +""" +Extract the reference values used by ``tests/test_goldens.py`` from the +TerraME ``ca`` goldens of ``terrame-goldens``. + +- ``grids/.csv.gz``: the grid goldens (state of every cell; columns + ``step, y, x0, x1, ...``), kept only at the steps in :func:`grid_steps`. +- ``ensembles.csv.gz``: for the stochastic models, the number of cells in + each state for seeds 1 to 50, at the steps in ``ENSEMBLE_STEPS`` + (columns ``case, value, step, seed, count``; zeros included). +- ``reference.json``: source version, package commit, steps kept and the + SHA-256 of each source file. + + python tests/goldens/make_reference.py /path/to/terrame-goldens +""" +from __future__ import annotations + +import csv +import gzip +import hashlib +import io +import json +import sys +from collections import defaultdict +from pathlib import Path + +HERE = Path(__file__).resolve().parent + +ENSEMBLE_STEPS = { + "fire": (5, 10, 20, 50, 100), + "solid_diffusion": (10, 50, 100, 200, 400), + "growth": (5, 10, 20, 50, 100), + "snow": (10, 50, 100, 300, 600), + "interspecific_competition": (5, 10, 50, 100, 200), +} + + +def grid_steps(final: int) -> list[int]: + """Every step up to 10, then a few more up to the final step.""" + steps = set(range(min(final, 10) + 1)) | {final} + steps |= {s for s in (20, 50, 100, 200, 300, 400, 500) if s <= final} + return sorted(steps) + + +def write_gz(text: str, dest: Path) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + buf = io.BytesIO() + with gzip.GzipFile(filename="", mode="wb", fileobj=buf, mtime=0, compresslevel=9) as gz: + gz.write(text.encode()) + dest.write_bytes(buf.getvalue()) + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def main(root: Path) -> None: + src = root / "goldens" / "ca" + sources: dict[str, str] = {} + grids: dict[str, list[int]] = {} + + with open(src / "grids" / "cases.csv", newline="") as f: + cases = list(csv.DictReader(f)) + for c in cases: + path = src / "grids" / f"{c['case']}.csv.gz" + sources[path.relative_to(root).as_posix()] = sha256(path) + keep = grid_steps(int(c["final_time"])) + with gzip.open(path, "rt", newline="") as f: + lines = f.read().splitlines() + out = [lines[0]] + [ln for ln in lines[1:] if int(ln.split(",", 1)[0]) in keep] + write_gz("\n".join(out) + "\n", HERE / "grids" / f"{c['case']}.csv.gz") + grids[c["case"]] = keep + (HERE / "grids" / "cases.csv").write_bytes((src / "grids" / "cases.csv").read_bytes()) + + rows = [] + for case, steps in ENSEMBLE_STEPS.items(): + path = src / "ensembles" / f"{case}.csv.gz" + sources[path.relative_to(root).as_posix()] = sha256(path) + counts: dict[tuple[str, int, int], int] = defaultdict(int) + values, seeds = set(), set() + with gzip.open(path, "rt", newline="") as f: + for r in csv.DictReader(f): + values.add(r["value"]) + seeds.add(int(r["seed"])) + if int(r["step"]) in steps: + counts[(r["value"], int(r["step"]), int(r["seed"]))] = int(r["count"]) + for v in sorted(values): + for t in steps: + for s in sorted(seeds): + rows.append(f"{case},{v},{t},{s},{counts[(v, t, s)]}") + write_gz("case,value,step,seed,count\n" + "\n".join(rows) + "\n", HERE / "ensembles.csv.gz") + + manifest = json.loads((root / "manifest.json").read_text()) + meta = { + "source": "https://github.com/LambdaGeo/terrame-goldens", + "version": manifest["version"], + "terrame": manifest["terrame"], + "packages": [p for p in manifest["packages"] if p["name"] == "ca"], + "files": sources, + "grid_steps": grids, + "ensemble_steps": {k: list(v) for k, v in ENSEMBLE_STEPS.items()}, + } + (HERE / "reference.json").write_text(json.dumps(meta, indent=2) + "\n") + print(f"{len(grids)} grids, {len(rows)} ensemble values written") + + +if __name__ == "__main__": + main(Path(sys.argv[1])) diff --git a/tests/goldens/reference.json b/tests/goldens/reference.json new file mode 100644 index 0000000..be5dcc4 --- /dev/null +++ b/tests/goldens/reference.json @@ -0,0 +1,277 @@ +{ + "source": "https://github.com/LambdaGeo/terrame-goldens", + "version": "v0.1.0", + "terrame": "2.0.1", + "packages": [ + { + "name": "ca", + "repository": "https://github.com/TerraME/ca", + "commit": "854a5c405478d712013a0baffd382f438f367642" + } + ], + "files": { + "goldens/ca/grids/life_glider.csv.gz": "4864b1ff009f11601706ea33f2d226fa87af3fab911b8a3e21595ac10bbe497e", + "goldens/ca/grids/life_rpentomino.csv.gz": "df597c76e5c271af8576577a94f69025d5cc1e2254550320982fb5333b5254e9", + "goldens/ca/grids/life_random.csv.gz": "20e28a42c86afa85b99d974cbaac3365c55ec6487e0eb9d0b9b1403f0f58660a", + "goldens/ca/grids/parity.csv.gz": "a3412ead9a48d67b2301f9e20c5d04ddb918d513a56d4b75ba1a152d75c0be52", + "goldens/ca/grids/excitable.csv.gz": "bd92ca276cf3424f1439d2b1a78533bc605cfe1e1834434649a573ed9249c477", + "goldens/ca/grids/wolfram_30.csv.gz": "12a74a836d3051da613de58e101f0bbc700f796b4b9ce4ad4f1949c77fc9d72d", + "goldens/ca/grids/wolfram_90.csv.gz": "9b15c41617f909d3355ac46e194aa314dfb8a10ea1a34eb8e8b89d9766f97303", + "goldens/ca/grids/wolfram_110.csv.gz": "5757b0593bdf25759b55ecdf6b61f8721c1538ad57ea910b3b629806e0d5a250", + "goldens/ca/grids/fire.csv.gz": "973a8b9e5024789ac944736f9c376c63acd220014f4cac328ef4e8b6ed8829ac", + "goldens/ca/grids/anneal.csv.gz": "f1d918d6ef51e0db341f3c07676ac88b491dbcf1a47148e1b01bb1ac859613c6", + "goldens/ca/grids/oscillator.csv.gz": "dd26dd943b1d5fa8bfcf82ebaebf697436ce1827f452da715404b394c57a69d7", + "goldens/ca/grids/parasit.csv.gz": "814b9c11648545d337bfe81522641abba41eaaf0a8eba27df7ee8b9508bf4328", + "goldens/ca/ensembles/fire.csv.gz": "2fcdfa432da8e250fd4d7d2a40eec186e36b82e1d5536ec2051762f8657ed24c", + "goldens/ca/ensembles/solid_diffusion.csv.gz": "dc5b71f89981e657e3eb5e4c6a021ddbafeebf7c74e05e4940e75069cb675480", + "goldens/ca/ensembles/growth.csv.gz": "249c76c7645bd269c0fa3ec6d856b9f3e7d9babb23f4452c01ce7a3f841e3f43", + "goldens/ca/ensembles/snow.csv.gz": "c57ed9260d69f7fd25b7571a4c9f66580b555c3078af63ce5367b9f5257d1875", + "goldens/ca/ensembles/interspecific_competition.csv.gz": "b5dcbfb0fe07ef3043cedcbb3594c1b54fcebb3a40371fd53743039edb992697" + }, + "grid_steps": { + "life_glider": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 20, + 50, + 100 + ], + "life_rpentomino": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 20, + 50, + 100 + ], + "life_random": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 20, + 50, + 100 + ], + "parity": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 20, + 50, + 100, + 200, + 300, + 400, + 500 + ], + "excitable": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 20, + 50, + 100, + 200, + 300, + 400, + 500 + ], + "wolfram_30": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 20, + 50, + 55 + ], + "wolfram_90": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 20, + 50, + 55 + ], + "wolfram_110": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 20, + 50, + 55 + ], + "fire": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 20, + 50, + 100 + ], + "anneal": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 20, + 50, + 100 + ], + "oscillator": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 20, + 50, + 100, + 200, + 300, + 400 + ], + "parasit": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 20, + 50, + 100, + 200, + 300, + 400, + 500 + ] + }, + "ensemble_steps": { + "fire": [ + 5, + 10, + 20, + 50, + 100 + ], + "solid_diffusion": [ + 10, + 50, + 100, + 200, + 400 + ], + "growth": [ + 5, + 10, + 20, + 50, + 100 + ], + "snow": [ + 10, + 50, + 100, + 300, + 600 + ], + "interspecific_competition": [ + 5, + 10, + 50, + 100, + 200 + ] + } +} diff --git a/tests/test_goldens.py b/tests/test_goldens.py new file mode 100644 index 0000000..de07f8f --- /dev/null +++ b/tests/test_goldens.py @@ -0,0 +1,279 @@ +""" +The models against TerraME itself. + +``goldens/`` holds an extract of the ``ca`` suite of ``terrame-goldens``: what +TerraME 2.0.1 computed running the models of the ``ca`` package (see +``goldens/README.md``). Two kinds of tests: + +- **Grids**: models whose rule is deterministic start from TerraME's state at + step 0 and must match it **cell by cell** at every step kept in the + extract. +- **Ensembles**: models that draw random numbers while they run are compared + by distribution. At each step kept, the number of cells in each state over + ``SEEDS`` runs and over TerraME's 50 runs must pass a two-sample + Kolmogorov-Smirnov test (p >= ``P_MIN``) and their means must not differ by + more than ``Z_MAX`` standard errors. + +The vector models take 0.2 to 2 s per step, so by default the grids are +compared over the first ``QUICK_STEPS`` steps (the raster models and Wolfram +over all of them) and only the fast ensembles run, in about a minute. Set +``DISSMODEL_GOLDEN_FULL=1`` to compare every step kept and every ensemble +(Growth alone takes hours). +""" +from __future__ import annotations + +import csv +import gzip +import json +import os +import random +from collections import defaultdict +from collections.abc import Callable +from functools import cache +from pathlib import Path +from typing import Any + +import numpy as np +import pytest +from dissmodel.core import Environment, Model +from dissmodel.geo import parse_idx, vector_grid +from dissmodel.geo.raster.backend import RasterBackend + +import dissmodel_ca.models as ca + +pytestmark = pytest.mark.golden + +GOLDENS = Path(__file__).resolve().parent / "goldens" +REFERENCE = json.loads((GOLDENS / "reference.json").read_text()) +FULL = os.environ.get("DISSMODEL_GOLDEN_FULL", "") not in ("", "0") +QUICK_STEPS = 10 +SEEDS = range(1, 21) +Z_MAX = 4.0 +P_MIN = 0.001 + + +# -- grids --------------------------------------------------------------------------------- + + +def load_grid(case: str) -> dict[int, np.ndarray]: + """``{step: (y, x) array of TerraME's values as str}``.""" + with gzip.open(GOLDENS / "grids" / f"{case}.csv.gz", "rt", newline="") as f: + rows = list(csv.reader(f)) + nx = len(rows[0]) - 2 + by_step: dict[int, dict[int, list[str]]] = defaultdict(dict) + for r in rows[1:]: + by_step[int(r[0])][int(r[1])] = r[2:] + return { + t: np.array([g[y] for y in range(len(g))], dtype=object).reshape(len(g), nx) + for t, g in by_step.items() + } + + +class _Recorder(Model): + """Reads the model after each step listed (created after the model, so it runs after it).""" + + def setup(self, read: Callable[[], Any], steps: set[int]) -> None: # type: ignore[override] + self.read, self.steps, self.frames = read, steps, {} + + def execute(self) -> None: + t = int(self.env.now()) + if t in self.steps: + self.frames[t] = self.read() + + +def run_vector(factory: Callable[[Any], Any], init: np.ndarray, steps: set[int]) -> dict[int, np.ndarray]: + ny, nx = init.shape + env = Environment(start_time=1, end_time=max(steps)) + gdf = vector_grid(dimension=(nx, ny), resolution=1, attrs={"state": 0}) + model = factory(gdf) + pos = [parse_idx(i) for i in model.gdf.index] + rows = np.array([p.row for p in pos]) + cols = np.array([p.col for p in pos]) + model.gdf["state"] = init[rows, cols] + + def read() -> np.ndarray: + out = np.zeros((ny, nx), dtype=int) + out[rows, cols] = model.gdf["state"].to_numpy() + return out + + rec = _Recorder(read=read, steps=steps) + env.run() + return rec.frames + + +def run_raster(factory: Callable[[Any], Any], init: np.ndarray, steps: set[int]) -> dict[int, np.ndarray]: + env = Environment(start_time=1, end_time=max(steps)) + backend = RasterBackend(shape=init.shape) + backend.set("state", init.astype(np.int8)) + factory(backend) + rec = _Recorder(read=lambda: backend.arrays["state"].astype(int).copy(), steps=steps) + env.run() + return rec.frames + + +LIFE = {"dead": 0, "alive": 1} +FIRE = {"forest": 0, "burning": 1, "burned": 2, "empty": 3} + +#: id, golden, values -> codes (None: integers), runner, factory, quick (compare every step by default) +GRID_CASES = [ + ("life_glider", "life_glider", LIFE, run_vector, lambda g: ca.GameOfLife(gdf=g), False), + ("life_rpentomino", "life_rpentomino", LIFE, run_vector, lambda g: ca.GameOfLife(gdf=g), False), + ("life_random", "life_random", LIFE, run_vector, lambda g: ca.GameOfLife(gdf=g), False), + ("life_random-raster", "life_random", LIFE, run_raster, + lambda b: ca.GameOfLifeRaster(backend=b), True), + ("parity", "parity", {"off": 0, "on": 1}, run_vector, lambda g: ca.Parity(gdf=g, dim=50), False), + ("excitable", "excitable", None, run_vector, lambda g: ca.Excitable(gdf=g, dim=50), False), + ("wolfram_30", "wolfram_30", LIFE, run_vector, lambda g: ca.Wolfram(gdf=g, rule_number=30), True), + ("wolfram_90", "wolfram_90", LIFE, run_vector, lambda g: ca.Wolfram(gdf=g, rule_number=90), True), + ("wolfram_110", "wolfram_110", LIFE, run_vector, lambda g: ca.Wolfram(gdf=g, rule_number=110), True), + ("fire", "fire", FIRE, run_vector, lambda g: ca.FireModel(gdf=g), False), + ("fire-raster", "fire", FIRE, run_raster, lambda b: ca.FireModelRaster(backend=b), True), + ("anneal", "anneal", {"L": 0, "R": 1}, run_vector, lambda g: ca.Anneal(gdf=g), False), + ("oscillator", "oscillator", None, run_vector, lambda g: ca.Oscillator(gdf=g), False), + ("parasit", "parasit", None, run_vector, lambda g: ca.Parasit(gdf=g), False), +] + + +def codes(grid: np.ndarray, mapping: dict[str, int] | None) -> np.ndarray: + if mapping is None: + return grid.astype(int) + return np.vectorize(mapping.__getitem__, otypes=[int])(grid) + + +@pytest.mark.parametrize( + "golden,mapping,runner,factory,quick", + [c[1:] for c in GRID_CASES], + ids=[c[0] for c in GRID_CASES], +) +def test_grid_matches_terrame_cell_by_cell(golden, mapping, runner, factory, quick) -> None: + ref = {t: codes(g, mapping) for t, g in load_grid(golden).items()} + steps = {t for t in ref if t > 0 and (FULL or quick or t <= QUICK_STEPS)} + ours = runner(factory, ref[0], steps) + for t in sorted(steps): + diff = int((ours[t] != ref[t]).sum()) + assert diff == 0, f"{golden}: {diff} cells differ from TerraME at step {t}" + + +# -- ensembles ------------------------------------------------------------------------------- + + +def ks_pvalue(a: np.ndarray, b: np.ndarray) -> float: + """Two-sample Kolmogorov-Smirnov test, asymptotic p-value (Numerical Recipes ``kstwo``).""" + grid = np.union1d(a, b) + d = np.abs( + np.searchsorted(np.sort(a), grid, side="right") / len(a) + - np.searchsorted(np.sort(b), grid, side="right") / len(b) + ).max() + en = np.sqrt(len(a) * len(b) / (len(a) + len(b))) + lam = (en + 0.12 + 0.11 / en) * d + if lam < 1e-3: + return 1.0 + k = np.arange(1, 101) + return float(np.clip(2 * np.sum((-1.0) ** (k - 1) * np.exp(-2 * (k * lam) ** 2)), 0, 1)) + + +@cache +def ensemble_reference() -> dict[tuple[str, str, int], np.ndarray]: + values: dict[tuple[str, str, int], list[int]] = defaultdict(list) + with gzip.open(GOLDENS / "ensembles.csv.gz", "rt", newline="") as f: + for r in csv.DictReader(f): # sorted by seed within (case, value, step) + values[(r["case"], r["value"], int(r["step"]))].append(int(r["count"])) + return {k: np.array(v) for k, v in values.items()} + + +Counts = dict[str, int] + + +def _counts(state: np.ndarray, mapping: dict[str, int]) -> Counts: + return {value: int((state == code).sum()) for value, code in mapping.items()} + + +def _vector_ensemble(n: int, mapping: dict[str, int], factory: Callable[[Any, int], Any]): + def run(seed: int, steps: set[int]) -> dict[int, Counts]: + random.seed(seed) + np.random.seed(seed) + env = Environment(start_time=1, end_time=max(steps)) + gdf = vector_grid(dimension=(n, n), resolution=1, attrs={"state": 0}) + model = factory(gdf, seed) + model.initialize() + cols = np.array([parse_idx(i).col for i in model.gdf.index]) + + def read() -> Counts: + state = model.gdf["state"].to_numpy() + out = _counts(state, mapping) + if "atom1" in mapping: # SolidDiffusion: atoms across the middle column + middle = n // 2 + out["atom1_right"] = int(((state == mapping["atom1"]) & (cols > middle)).sum()) + out["atom2_left"] = int(((state == mapping["atom2"]) & (cols < middle)).sum()) + return out + + rec = _Recorder(read=read, steps=steps) + env.run() + return rec.frames + return run + + +def _fire_raster(seed: int, steps: set[int]) -> dict[int, Counts]: + env = Environment(start_time=1, end_time=max(steps)) + backend = RasterBackend(shape=(60, 60)) + model = ca.FireModelRaster(backend=backend, seed=seed) + model.initialize() + rec = _Recorder(read=lambda: _counts(backend.arrays["state"], FIRE), steps=steps) + env.run() + return rec.frames + + +SOLID = {"atom1": 0, "atom2": 1, "vacancy": 2} +SPECIES = {"Lolium": 0, "Agrostis": 1, "Holcus": 2, "Poa": 3, "Cynosurus": 4} + +#: case -> (runner, runs by default). SolidDiffusion only swaps atoms, so its +#: counts per state never change; ``atom1_right`` and ``atom2_left`` (atoms +#: past the middle column) measure how far they mixed. +ENSEMBLES: dict[str, tuple[Callable[[int, set[int]], dict[int, Counts]], bool]] = { + "fire": (_fire_raster, True), + "solid_diffusion": ( + _vector_ensemble(31, SOLID, lambda g, s: ca.SolidDiffusion(gdf=g, dim=31, seed=s)), True), + "growth": ( + _vector_ensemble(100, {"alive": 1, "empty": 0}, lambda g, s: ca.Growth(gdf=g, dim=100)), False), + "snow": ( + _vector_ensemble(50, {"snow": 1, "empty": 0}, + lambda g, s: ca.Snow(gdf=g, dim=50, end_time=600)), False), + "interspecific_competition": ( + _vector_ensemble(40, SPECIES, + lambda g, s: ca.InterspecificCompetition(gdf=g, dim=40, seed=s)), False), +} + + +@cache +def ensemble_runs(case: str) -> dict[tuple[str, int], np.ndarray]: + runner, _ = ENSEMBLES[case] + steps = set(REFERENCE["ensemble_steps"][case]) + out: dict[tuple[str, int], list[int]] = defaultdict(list) + for seed in SEEDS: + for t, counts in runner(seed, steps).items(): + for value, count in counts.items(): + out[(value, t)].append(count) + return {k: np.array(v) for k, v in out.items()} + + +ENSEMBLE_CASES = sorted(k for k in ensemble_reference() if k[0] in ENSEMBLES) + + +@pytest.mark.parametrize( + "case,value,step", ENSEMBLE_CASES, ids=[f"{c}-{v}-t{s}" for c, v, s in ENSEMBLE_CASES] +) +def test_ensemble_matches_terrame(case: str, value: str, step: int) -> None: + if not (FULL or ENSEMBLES[case][1]): + pytest.skip("slow ensemble; set DISSMODEL_GOLDEN_FULL=1") + tm = ensemble_reference()[(case, value, step)] + ours = ensemble_runs(case)[(value, step)] + se = np.sqrt(tm.var(ddof=1) / len(tm) + ours.var(ddof=1) / len(ours)) + diff = ours.mean() - tm.mean() + p = ks_pvalue(tm, ours) + msg = (f"{case} '{value}' at t={step}: TerraME {tm.mean():.1f} ± {tm.std():.1f}, " + f"dissmodel-ca {ours.mean():.1f} ± {ours.std():.1f}, KS p = {p:.2g}") + assert p >= P_MIN, msg + if se > 0: + assert abs(diff) <= Z_MAX * se, f"{msg}, z = {diff / se:.1f}" + else: + assert diff == 0, msg