Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
37 changes: 37 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,40 @@ jobs:
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

deps-compat:
name: Mesa / mesa-geo compatibility (${{ matrix.variant }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- variant: min-supported
install_cmd: 'uv pip install "mesa==3.1.0" "mesa-geo==0.9.1"'
- variant: latest-upstream
install_cmd: "uv pip install -U mesa mesa-geo"

steps:
- uses: actions/checkout@v4

- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install uv
uses: astral-sh/setup-uv@v4

- name: Install project dependencies
run: uv sync --dev

- name: Override mesa / mesa-geo for this matrix leg
run: ${{ matrix.install_cmd }}

- name: Show installed mesa versions
run: |
uv run python -c "import importlib.metadata as m; print('mesa', m.version('mesa')); print('mesa-geo', m.version('mesa-geo'))"

- name: Run spatial / raster regression tests
run: |
uv run pytest tests/api/test_patch_mesa_compat.py tests/api/test_nature.py tests/foundation/test_basic_functionality.py -v --tb=short

40 changes: 40 additions & 0 deletions abses/space/mesa_raster_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Compatibility helpers for mesa-geo raster APIs across versions.

Mesa-geo's ``RasterLayer`` initialization and ``_update_transform`` behavior
varies between releases. ABSESpy uses ``PatchModule`` with ``_cells`` and a
``cached_property`` for ``cells``; upstream probes like ``getattr(self,
"cells", None)`` during early construction can recurse through ``__getattr__``.
This module centralizes safe, capability-based handling.
"""

from __future__ import annotations

from typing import Any


def raster_base_update_transform(instance: Any) -> None:
"""Apply ``RasterBase._update_transform`` without ``RasterLayer`` side effects.

Args:
instance: A ``RasterLayer`` subclass instance (e.g. ``PatchModule``).
"""
from mesa_geo.raster_layers import RasterBase

RasterBase._update_transform(instance)


def maybe_sync_cell_xy(instance: Any) -> None:
"""Call ``_sync_cell_xy`` when the upstream class provides it and cells exist.

Older mesa-geo releases do not define ``_sync_cell_xy``. Newer releases
sync cell centers after transform updates when cell storage is ready.

Args:
instance: A ``RasterLayer`` subclass instance (e.g. ``PatchModule``).
"""
if instance.__dict__.get("_cells") is None:
return
sync = getattr(type(instance), "_sync_cell_xy", None)
if sync is None:
return
sync(instance)
30 changes: 27 additions & 3 deletions abses/space/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@
from abses.core.base import BaseModule
from abses.core.primitives import DEFAULT_CRS
from abses.space.cells import PatchCell
from abses.space.mesa_raster_compat import (
maybe_sync_cell_xy,
raster_base_update_transform,
)
from abses.utils.errors import ABSESpyError
from abses.utils.func import get_buffer, set_null_values
from abses.utils.random import ListRandom
Expand Down Expand Up @@ -285,6 +289,19 @@ def __init__(
if apply_raster and xda is not None and attr_name is not None:
self.apply_raster(xda.to_numpy(), attr_name=attr_name)

def _update_transform(self) -> None:
"""Recompute affine transform and optionally sync cell centers.

Mesa-geo may call this from ``RasterBase.__init__`` before cells exist.
Upstream ``RasterLayer._update_transform`` can probe ``cells`` via
``getattr``, which is unsafe for ``PatchModule`` during early init.
We always apply ``RasterBase`` math first, then sync only when
``_cells`` is populated (and when the installed mesa-geo provides
``_sync_cell_xy``).
"""
raster_base_update_transform(self)
maybe_sync_cell_xy(self)

def _initialize_cells(
self,
model: MainModelProtocol,
Expand Down Expand Up @@ -552,13 +569,20 @@ def __getattr__(self, name: str) -> Any:
>>> # Save plot to file
>>> grid.elevation.plot(save_path='elevation.png', show=False)
"""
# Check if it's a raster attribute
if name in self.cell_properties:
# Avoid ``self.cell_properties`` while ``cell_cls`` is not set yet
# (e.g. during ``RasterBase.__init__``), which would recurse here.
try:
cell_cls = object.__getattribute__(self, "cell_cls")
except AttributeError as exc:
raise AttributeError(
f"'{self.__class__.__name__}' object has no attribute '{name}'"
) from exc

if name in cell_cls.__attribute_properties__():
from abses.viz import PlotableAttribute

return PlotableAttribute(module=self, attr_name=name)

# Raise AttributeError if not found
raise AttributeError(
f"'{self.__class__.__name__}' object has no attribute '{name}'"
)
Expand Down
26 changes: 17 additions & 9 deletions docs/home/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,24 @@

| Package | Version | Purpose |
|---------------|-------------------|-------------------------------------------------------|
| python | ">=3.9,<3.12" | Core programming language used for development |
| python | ">=3.11,<3.14" | Core programming language used for development |
| netcdf4 | ">=1.6" | To read and write NetCDF and HDF5 files |
| hydra-core | "~1.3" | For managing application configurations |
| mesa-geo | ">=0.6" | To create spatially explicit agent-based models |
| xarray | "~2024" | To work with labelled multi-dimensional arrays |
| fiona | ">1.8" | For reading and writing vector data (shapefiles, etc) |
| loguru | "~0.7" | For better logging |
| rioxarray | ">=0.13" | Operating raster data and xarray |
| pendulum | "~2" | For time control |
| geopandas | "~0" | For shapefile geo-data operating |
| hydra-core | ">=1.3,<1.4" | For managing application configurations |
| mesa | ">=3.1.0" | Agent-based scheduling and core utilities |
| mesa-geo | ">=0.9.1" | Spatially explicit layers and raster support |
| xarray | ">=2023" | To work with labelled multi-dimensional arrays |
| fiona | ">1.8" | For reading and writing vector data (shapefiles, etc) |
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix punctuation in the dependency table description.

Line 12 should use etc. instead of etc for correct abbreviation punctuation.

Suggested edit
-| fiona         | ">1.8"            | For reading and writing vector data (shapefiles, etc) |
+| fiona         | ">1.8"            | For reading and writing vector data (shapefiles, etc.) |
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
| fiona | ">1.8" | For reading and writing vector data (shapefiles, etc) |
| fiona | ">1.8" | For reading and writing vector data (shapefiles, etc.) |
🧰 Tools
🪛 LanguageTool

[style] ~12-~12: In American English, abbreviations like “etc.” require a period.
Context: ...ng and writing vector data (shapefiles, etc) | | rioxarray | ">=0.13" ...

(ETC_PERIOD)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/home/dependencies.md` at line 12, Update the dependency table row for
the fiona entry so the description uses the correct abbreviation punctuation:
change "For reading and writing vector data (shapefiles, etc)" to "For reading
and writing vector data (shapefiles, etc.)" in the table row containing "fiona"
and its version ">1.8".

| rioxarray | ">=0.13" | Operating raster data and xarray |
| pendulum | ">=3.0.0" | For time control |
| geopandas | ">=0,<1" | For shapefile geo-data operating |

### Mesa / mesa-geo compatibility

`ABSESpy` declares **lower bounds only** for `mesa` and `mesa-geo` in `pyproject.toml` so downstream projects can upgrade within their own constraints. Raster initialization differences across `mesa-geo` releases are handled in code (e.g. `abses/space/mesa_raster_compat.py` with `PatchModule`).

CI runs a **dependency compatibility** job on Ubuntu that reinstalls the minimum supported `mesa` / `mesa-geo` pair and the latest published releases, then runs spatial regression tests. This does not guarantee every future `mesa-geo` major release will work without changes, but it catches regressions early.

If you upgrade `mesa` / `mesa-geo` and see errors during `PatchModule` / raster setup, check the installed versions (`pip show mesa mesa-geo` or `uv pip list`) and open an issue with the traceback.

!!! Warning

Expand Down
22 changes: 15 additions & 7 deletions docs/home/dependencies.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,24 @@

| 包 | 版本 | 用途 |
|---------------|-------------------|----------------------------------------|
| python | ">=3.9,<3.12" | 开发使用的核心编程语言 |
| python | ">=3.11,<3.14" | 开发使用的核心编程语言 |
| netcdf4 | ">=1.6" | 读写 NetCDF 和 HDF5 文件 |
| hydra-core | "~1.3" | 管理应用程序配置 |
| mesa-geo | ">=0.6" | 创建空间显式的基于智能体的模型 |
| xarray | "~2024" | 处理标记的多维数组 |
| hydra-core | ">=1.3,<1.4" | 管理应用程序配置 |
| mesa | ">=3.1.0" | 智能体调度与核心工具 |
| mesa-geo | ">=0.9.1" | 空间显式图层与栅格支持 |
| xarray | ">=2023" | 处理标记的多维数组 |
| fiona | ">1.8" | 读写矢量数据(shapefiles 等) |
| loguru | "~0.7" | 更好的日志记录 |
| rioxarray | ">=0.13" | 操作栅格数据和 xarray |
| pendulum | "~2" | 时间控制 |
| geopandas | "~0" | shapefile 地理数据操作 |
| pendulum | ">=3.0.0" | 时间控制 |
| geopandas | ">=0,<1" | shapefile 地理数据操作 |

### Mesa / mesa-geo 兼容性说明

`ABSESpy` 在 `pyproject.toml` 中对 `mesa` 与 `mesa-geo` 仅声明**下界**,以便下游项目按需升级。不同 `mesa-geo` 版本在栅格初始化顺序上的差异在代码中处理(例如 `abses/space/mesa_raster_compat.py` 与 `PatchModule`)。

CI 在 Ubuntu 上运行**依赖兼容**任务:分别安装声明的最低版本与当前 PyPI 最新版本,再跑空间相关回归测试。这不保证未来任意大版本 `mesa-geo` 都无需改动,但能尽早发现破坏性变更。

若升级 `mesa` / `mesa-geo` 后在 `PatchModule` 或栅格初始化阶段报错,请记录 `pip show mesa mesa-geo`(或 `uv pip list`)中的版本并附上完整 traceback 提 issue。

!!! Warning "警告"

Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ classifiers = [
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
# Direct dependency bounds are intentionally loose for mesa / mesa-geo.
# Raster initialization differences across mesa-geo releases are handled in
# code (see abses/space/mesa_raster_compat.py) and exercised in CI.
dependencies = [
"netcdf4>=1.6",
"hydra-core>=1.3,<1.4",
Expand Down
51 changes: 51 additions & 0 deletions tests/api/test_patch_mesa_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
# -*-coding:utf-8 -*-
"""Regression tests for PatchModule vs mesa-geo raster initialization order."""

from __future__ import annotations

import numpy as np
import pytest

from abses.core.model import MainModel
from abses.space.mesa_raster_compat import maybe_sync_cell_xy
from abses.space.patch import PatchModule


def test_create_patch_module_no_recursion(model: MainModel) -> None:
"""Creating a grid must complete without RecursionError (mesa-geo 0.9.3+)."""
module = model.nature.create_module(shape=(4, 4), resolution=1.0)
assert isinstance(module, PatchModule)
assert module.array_cells.shape == (4, 4)
assert len(module.cells) == 4


def test_update_transform_after_init_is_safe(model: MainModel) -> None:
"""Transform updates after cells exist must remain safe."""
module = model.nature.create_module(shape=(2, 3), resolution=1.0)
module._update_transform()
module._update_transform()
assert module.transform is not None


def test_maybe_sync_cell_xy_skips_without_cells() -> None:
"""Helper must not fail when ``_cells`` is absent or sync is undefined."""

class _NoSync:
pass

layer = _NoSync()
maybe_sync_cell_xy(layer)

layer.__dict__["_cells"] = None
maybe_sync_cell_xy(layer)


@pytest.mark.parametrize("shape", [(1, 1), (5, 7)])
def test_create_module_various_shapes(model: MainModel, shape: tuple[int, int]) -> None:
"""Smoke test multiple raster dimensions."""
module = model.nature.create_module(shape=shape, resolution=1.0)
assert module.width == shape[1]
assert module.height == shape[0]
module.apply_raster(np.ones(module.shape3d), attr_name="ones")
assert module.get_raster("ones").sum() == shape[0] * shape[1]
Loading