diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1f192918..a36f9edb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 + diff --git a/abses/space/mesa_raster_compat.py b/abses/space/mesa_raster_compat.py new file mode 100644 index 00000000..5d6f2b37 --- /dev/null +++ b/abses/space/mesa_raster_compat.py @@ -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) diff --git a/abses/space/patch.py b/abses/space/patch.py index cd2cbc99..4640ab8d 100644 --- a/abses/space/patch.py +++ b/abses/space/patch.py @@ -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 @@ -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, @@ -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}'" ) diff --git a/docs/home/dependencies.md b/docs/home/dependencies.md index 12e00cc9..366bd335 100644 --- a/docs/home/dependencies.md +++ b/docs/home/dependencies.md @@ -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) | +| 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 diff --git a/docs/home/dependencies.zh.md b/docs/home/dependencies.zh.md index 7017c075..3a79b2a6 100644 --- a/docs/home/dependencies.zh.md +++ b/docs/home/dependencies.zh.md @@ -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 "警告" diff --git a/pyproject.toml b/pyproject.toml index 88989737..5e4b786a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/api/test_patch_mesa_compat.py b/tests/api/test_patch_mesa_compat.py new file mode 100644 index 00000000..9f31060e --- /dev/null +++ b/tests/api/test_patch_mesa_compat.py @@ -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]