├── tests ├── __init__.py ├── test_geospace_component.py ├── test_WMSWebTile.py ├── test_RasterWebTile.py ├── test_GIS_examples.py ├── test_ImageLayer.py ├── test_RasterLayer.py ├── test_GeoJupyterViz.py ├── test_AgentCreator.py ├── test_MapModule.py └── test_GeoSpace.py ├── .codespellignore ├── .coveragerc ├── docs ├── apis │ ├── geo_base.rst │ ├── geoagent.rst │ ├── geospace.rst │ ├── visualization.rst │ ├── tile_layers.rst │ ├── raster_layers.rst │ └── api_main.md ├── examples │ ├── overview.md │ ├── urban_growth.md │ ├── geo_schelling.md │ ├── geo_sir.md │ ├── geo_schelling_points.md │ ├── population.md │ └── rainfall.md ├── README.md ├── index.md ├── Makefile └── conf.py ├── .github ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── asking-help.md │ ├── feature-request.md │ └── bug-report.md ├── workflows │ ├── codespell.yml │ ├── examples.yml │ ├── release.yml │ └── build_lint.yml └── release.yml ├── .gitattributes ├── mesa_geo ├── visualization │ ├── __init__.py │ ├── geojupyter_viz.py │ ├── leaflet_viz.py │ └── components │ │ └── geospace_component.py ├── __init__.py ├── tile_layers.py ├── geo_base.py ├── geoagent.py └── geospace.py ├── NOTICE ├── .readthedocs.yml ├── .pre-commit-config.yaml ├── CITATION.cff ├── CODE_OF_CONDUCT.md ├── README.md ├── pyproject.toml ├── CONTRIBUTING.md ├── .gitignore ├── LICENSE └── HISTORY.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.codespellignore: -------------------------------------------------------------------------------- 1 | hist 2 | hart 3 | mutch 4 | ist 5 | inactivate 6 | ue 7 | fpr 8 | falsy -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = mesa_geo 3 | branch = True 4 | 5 | [report] 6 | omit = 7 | tests/* -------------------------------------------------------------------------------- /docs/apis/geo_base.rst: -------------------------------------------------------------------------------- 1 | .. automodule:: mesa_geo.geo_base 2 | :members: 3 | :inherited-members: 4 | :undoc-members: -------------------------------------------------------------------------------- /docs/apis/geoagent.rst: -------------------------------------------------------------------------------- 1 | .. automodule:: mesa_geo.geoagent 2 | :members: 3 | :inherited-members: 4 | :undoc-members: -------------------------------------------------------------------------------- /docs/apis/geospace.rst: -------------------------------------------------------------------------------- 1 | .. automodule:: mesa_geo.geospace 2 | :members: 3 | :inherited-members: 4 | :undoc-members: -------------------------------------------------------------------------------- /docs/apis/visualization.rst: -------------------------------------------------------------------------------- 1 | Visualization 2 | ------------- 3 | 4 | .. automodule:: visualization.__init__ 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/apis/tile_layers.rst: -------------------------------------------------------------------------------- 1 | .. automodule:: mesa_geo.tile_layers 2 | :members: 3 | :inherited-members: 4 | :undoc-members: -------------------------------------------------------------------------------- /docs/apis/raster_layers.rst: -------------------------------------------------------------------------------- 1 | .. automodule:: mesa_geo.raster_layers 2 | :members: 3 | :inherited-members: 4 | :undoc-members: -------------------------------------------------------------------------------- /docs/apis/api_main.md: -------------------------------------------------------------------------------- 1 | # APIs 2 | 3 | ```{toctree} 4 | --- 5 | maxdepth: 3 6 | --- 7 | GeoBase 8 | GeoAgent 9 | GeoSpace 10 | Raster Layers 11 | visualization 12 | Tile Layers 13 | ``` -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | # Check for updates to GitHub Actions every week 8 | interval: "weekly" 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/asking-help.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Asking for help 3 | about: If you need help using Mesa-Geo, you should post in https://github.com/mesa/mesa-geo/discussions 4 | --- 5 | 6 | -------------------------------------------------------------------------------- /.github/workflows/codespell.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - release** 8 | pull_request: 9 | 10 | jobs: 11 | codespell: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v5 15 | - uses: codespell-project/actions-codespell@master 16 | with: 17 | ignore_words_file: .codespellignore 18 | skip: .*bootstrap.*,*.js,.*bootstrap-theme.css.map -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | 7 | # Standard to msysgit 8 | *.doc diff=astextplain 9 | *.DOC diff=astextplain 10 | *.docx diff=astextplain 11 | *.DOCX diff=astextplain 12 | *.dot diff=astextplain 13 | *.DOT diff=astextplain 14 | *.pdf diff=astextplain 15 | *.PDF diff=astextplain 16 | *.rtf diff=astextplain 17 | *.RTF diff=astextplain 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest a new feature for Mesa-Geo 4 | 5 | --- 6 | 7 | **What's the problem this feature will solve?** 8 | 9 | 10 | **Describe the solution you'd like** 11 | 12 | 13 | **Additional context** 14 | -------------------------------------------------------------------------------- /mesa_geo/visualization/__init__.py: -------------------------------------------------------------------------------- 1 | # Import specific classes or functions from the modules 2 | from .components.geospace_component import ( 3 | MapModule, 4 | make_geospace_component, 5 | make_geospace_leaflet, 6 | ) 7 | from .geojupyter_viz import GeoJupyterViz 8 | from .leaflet_viz import LeafletViz 9 | 10 | __all__ = [ 11 | "GeoJupyterViz", 12 | "LeafletViz", 13 | "MapModule", 14 | "make_geospace_component", 15 | "make_geospace_leaflet", 16 | ] 17 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright 2017-2024 Core Mesa Team and contributors 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | https://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Let us know if something is broken on Mesa-Geo 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | 9 | 10 | **Expected behavior** 11 | 12 | 13 | **To Reproduce** 14 | 15 | 16 | **Additional context** 17 | 22 | -------------------------------------------------------------------------------- /docs/examples/overview.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | **Vector Data** 4 | 5 | - [GeoSchelling Model (Polygons)](geo_schelling.md) 6 | - [GeoSchelling Model (Points & Polygons)](geo_schelling_points.md) 7 | - [GeoSIR Epidemics Model](geo_sir.md) 8 | 9 | **Raster Data** 10 | 11 | - [Rainfall Model](rainfall.md) 12 | - [Urban Growth Model](urban_growth.md) 13 | 14 | **Raster and Vector Data Overlay** 15 | 16 | - [Population Model](population.md) 17 | 18 | ```{toctree} 19 | --- 20 | maxdepth: 2 21 | hidden: true 22 | caption: Examples 23 | --- 24 | Overview 25 | geo_schelling 26 | geo_schelling_points 27 | geo_sir 28 | rainfall 29 | urban_growth 30 | population 31 | ``` 32 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Build documentation in the docs/ directory with Sphinx 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | # Optionally build your docs in additional formats such as PDF 12 | formats: 13 | - pdf 14 | 15 | build: 16 | os: ubuntu-lts-latest 17 | tools: 18 | python: latest 19 | 20 | # Optionally set the version of Python and requirements required to build your docs 21 | python: 22 | install: 23 | - method: pip 24 | path: . 25 | extra_requirements: 26 | - docs 27 | -------------------------------------------------------------------------------- /docs/examples/urban_growth.md: -------------------------------------------------------------------------------- 1 | # Urban Growth Model 2 | 3 | 10 | 11 | ## Summary 12 | 13 | This is an implementation of the [UrbanGrowth Model](https://github.com/abmgis/abmgis/tree/master/Chapter06-IntegratingABMandGIS/Models/UrbanGrowth) in Python, using [Mesa](https://github.com/mesa/mesa) and [Mesa-Geo](https://github.com/mesa/mesa-geo). 14 | 15 | ## How to run 16 | 17 | To run the model interactively, run `mesa runserver` in [this directory](https://github.com/mesa/mesa-examples/tree/main/gis/urban_growth). e.g. 18 | 19 | ```bash 20 | mesa runserver 21 | ``` 22 | 23 | Then open your browser to [http://127.0.0.1:8521/](http://127.0.0.1:8521/) and press `Start`. 24 | -------------------------------------------------------------------------------- /mesa_geo/__init__.py: -------------------------------------------------------------------------------- 1 | """mesa_geo Agent-Based Modeling Framework. 2 | 3 | Core Objects: GeoSpace, GeoAgent 4 | 5 | """ 6 | 7 | import datetime 8 | 9 | from mesa_geo.geoagent import AgentCreator, GeoAgent 10 | from mesa_geo.geospace import GeoSpace 11 | from mesa_geo.raster_layers import Cell, ImageLayer, RasterLayer 12 | from mesa_geo.tile_layers import RasterWebTile, WMSWebTile 13 | 14 | __all__ = [ 15 | "AgentCreator", 16 | "Cell", 17 | "GeoAgent", 18 | "GeoSpace", 19 | "ImageLayer", 20 | "RasterLayer", 21 | "RasterWebTile", 22 | "WMSWebTile", 23 | "visualization", 24 | ] 25 | 26 | __title__ = "Mesa-Geo" 27 | __version__ = "0.9.1" 28 | __license__ = "Apache 2.0" 29 | _this_year = datetime.datetime.now(tz=datetime.timezone.utc).date().year 30 | __copyright__ = f"Copyright {_this_year} Project Mesa Team" 31 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: 'monthly' 3 | autofix_prs: true 4 | 5 | repos: 6 | - repo: https://github.com/astral-sh/ruff-pre-commit 7 | # Ruff version. 8 | rev: v0.14.7 9 | hooks: 10 | # Run the linter with fix argument. 11 | - id: ruff 12 | types_or: [ python, pyi, jupyter ] 13 | args: [--fix] # This will enable automatic fixing of lint issues where possible. 14 | # Run the formatter. 15 | - id: ruff-format 16 | types_or: [ python, pyi, jupyter ] 17 | - repo: https://github.com/asottile/pyupgrade 18 | rev: v3.21.2 19 | hooks: 20 | - id: pyupgrade 21 | args: [--py310-plus] 22 | - repo: https://github.com/pre-commit/pre-commit-hooks 23 | rev: v6.0.0 # Use the ref you want to point at 24 | hooks: 25 | - id: trailing-whitespace 26 | - id: check-toml 27 | - id: check-yaml 28 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | # Docs: https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes 2 | changelog: 3 | exclude: 4 | labels: 5 | - ignore-for-release 6 | categories: 7 | - title: ⚠️ Breaking changes 8 | labels: 9 | - breaking 10 | - title: 🧪 Experimental features 11 | labels: 12 | - experimental 13 | - title: 🎉 New features added 14 | labels: 15 | - feature 16 | - title: 🛠 Enhancements made 17 | labels: 18 | - enhancement 19 | - title: 🐛 Bugs fixed 20 | labels: 21 | - bug 22 | - title: 📜 Documentation improvements 23 | labels: 24 | - docs 25 | - title: 🔧 Maintenance 26 | labels: 27 | - ci 28 | - testing 29 | - dependency 30 | - maintenance 31 | - packaging 32 | - title: Other changes 33 | labels: 34 | - "*" 35 | -------------------------------------------------------------------------------- /.github/workflows/examples.yml: -------------------------------------------------------------------------------- 1 | name: Test GIS examples 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - release** 8 | - "**maintenance" 9 | paths-ignore: 10 | - '**.md' 11 | pull_request: 12 | paths-ignore: 13 | - '**.md' 14 | workflow_dispatch: 15 | schedule: 16 | - cron: '0 6 * * 1' 17 | 18 | jobs: 19 | examples: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v5 23 | - name: Set up Python 24 | uses: actions/setup-python@v6 25 | with: 26 | python-version: "3.12" 27 | cache: 'pip' 28 | - name: Install uv 29 | run: pip install uv 30 | - name: Install Mesa 31 | run: uv pip install --system .[examples] 32 | - name: Checkout mesa-examples 33 | uses: actions/checkout@v5 34 | with: 35 | repository: mesa/mesa-examples 36 | path: mesa-examples 37 | - name: Test examples 38 | run: | 39 | cd mesa-examples 40 | pytest -rA -Werror -Wdefault::FutureWarning test_gis_examples.py 41 | -------------------------------------------------------------------------------- /tests/test_geospace_component.py: -------------------------------------------------------------------------------- 1 | import mesa 2 | import solara 3 | import xyzservices 4 | from mesa.visualization.solara_viz import SolaraViz 5 | 6 | import mesa_geo.visualization as mgv 7 | from mesa_geo.visualization import make_geospace_component 8 | 9 | 10 | def test_geospace_component(mocker): 11 | mock_geospace_component = mocker.spy( 12 | mgv.components.geospace_component, "GeoSpaceLeaflet" 13 | ) 14 | 15 | model = mesa.Model() 16 | mocker.patch.object(mesa.Model, "__new__", return_value=model) 17 | mocker.patch.object(mesa.Model, "__init__", return_value=None) 18 | 19 | agent_portrayal = { 20 | "Shape": "circle", 21 | "color": "gray", 22 | } 23 | # initialize with space drawer unspecified (use default) 24 | # component must be rendered for code to run 25 | solara.render( 26 | SolaraViz(model, components=[make_geospace_component(agent_portrayal)]) 27 | ) 28 | # should call default method with class instance and agent portrayal 29 | mock_geospace_component.assert_called_with( 30 | model, agent_portrayal, None, xyzservices.providers.OpenStreetMap.Mapnik 31 | ) 32 | -------------------------------------------------------------------------------- /docs/examples/geo_schelling.md: -------------------------------------------------------------------------------- 1 | # GeoSchelling Model (Polygons) 2 | 3 | 10 | 11 | ## Summary 12 | 13 | This is a geoversion of a simplified Schelling example. For the original implementation details please see the Mesa Schelling examples. 14 | 15 | ### GeoSpace 16 | 17 | Instead of an abstract grid space, we represent the space using NUTS-2 regions to create the GeoSpace in the model. 18 | 19 | ### GeoAgent 20 | 21 | NUTS-2 regions are the GeoAgents. The neighbors of a polygon are considered those polygons that touch its border (i.e., edge neighbours). During the running of the model, a polygon queries the colors of the surrounding polygon and if the ratio falls below a certain threshold (e.g., 40% of the same color), the agent moves to an uncolored polygon. 22 | 23 | ## How to Run 24 | 25 | To run the model interactively, run `mesa runserver` in [this directory](https://github.com/mesa/mesa-examples/tree/main/gis/geo_schelling). e.g. 26 | 27 | ```bash 28 | mesa runserver 29 | ``` 30 | 31 | Then open your browser to [http://127.0.0.1:8521/](http://127.0.0.1:8521/) and press `Start`. 32 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | branches: 7 | - main 8 | - release** 9 | paths-ignore: 10 | - '**.md' 11 | - '**.rst' 12 | pull_request: 13 | paths-ignore: 14 | - '**.md' 15 | - '**.rst' 16 | workflow_dispatch: 17 | 18 | permissions: 19 | id-token: write 20 | 21 | jobs: 22 | release: 23 | name: Deploy release to PyPI 24 | runs-on: ubuntu-latest 25 | permissions: 26 | id-token: write 27 | steps: 28 | - name: Checkout source 29 | uses: actions/checkout@v5 30 | - name: Set up Python 31 | uses: actions/setup-python@v6 32 | with: 33 | python-version: "3.12" 34 | - name: Install dependencies 35 | run: pip install -U pip build wheel setuptools 36 | - name: Build distributions 37 | run: python -m build 38 | - name: Upload package as artifact to GitHub 39 | if: github.repository == 'mesa/mesa-geo' && startsWith(github.ref, 'refs/tags') 40 | uses: actions/upload-artifact@v4 41 | with: 42 | name: package 43 | path: dist/ 44 | - name: Publish package to PyPI 45 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 46 | uses: pypa/gh-action-pypi-publish@release/v1 -------------------------------------------------------------------------------- /tests/test_WMSWebTile.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import mesa_geo as mg 4 | 5 | 6 | class TestWMSWebTile(unittest.TestCase): 7 | def setUp(self) -> None: 8 | self.map_tile_url = "https://gis.charttools.noaa.gov/arcgis/rest/services/MCS/NOAAChartDisplay/MapServer/exts/MaritimeChartService/WMSServer/" 9 | 10 | def test_to_dict(self): 11 | map_tile = mg.WMSWebTile( 12 | url=self.map_tile_url, 13 | options={ 14 | "layers": "0,1,2,3,4,5,6,7,8,9,10,11,12", 15 | "version": "1.3.0", 16 | "format": "image/png", 17 | }, 18 | ) 19 | self.assertEqual( 20 | map_tile.to_dict(), 21 | { 22 | "kind": "wms_web_tile", 23 | "url": self.map_tile_url, 24 | "options": { 25 | "layers": "0,1,2,3,4,5,6,7,8,9,10,11,12", 26 | "version": "1.3.0", 27 | "format": "image/png", 28 | }, 29 | }, 30 | ) 31 | 32 | map_tile = mg.WMSWebTile(url=self.map_tile_url) 33 | self.assertEqual( 34 | map_tile.to_dict(), 35 | { 36 | "kind": "wms_web_tile", 37 | "url": self.map_tile_url, 38 | "options": None, 39 | }, 40 | ) 41 | -------------------------------------------------------------------------------- /docs/examples/geo_sir.md: -------------------------------------------------------------------------------- 1 | # GeoSIR Epidemics Model 2 | 3 | 10 | 11 | ## Summary 12 | 13 | This is a geoversion of a simple agent-based pandemic SIR model, as an example to show the capabilities of mesa-geo. 14 | 15 | It uses geographical data of Toronto's regions on top of a an Leaflet map to show the location of agents (in a continuous space). 16 | 17 | Person agents are initially located in random positions in the city, then start moving around unless they die. 18 | A fraction of agents start with an infection and may recover or die in each step. 19 | Susceptible agents (those who have never been infected) who come in proximity with an infected agent may become infected. 20 | 21 | Neighbourhood agents represent neighbourhoods in the Toronto, and become hot-spots (colored red) if there are infected agents inside them. 22 | Data obtained from [this link](http://adamw523.com/toronto-geojson/). 23 | 24 | ## How to run 25 | 26 | To run the model interactively, run `mesa runserver` in [this directory](https://github.com/mesa/mesa-examples/tree/main/gis/geo_sir). e.g. 27 | 28 | ```bash 29 | mesa runserver 30 | ``` 31 | 32 | Then open your browser to [http://127.0.0.1:8521/](http://127.0.0.1:8521/) and press `Start`. 33 | -------------------------------------------------------------------------------- /docs/examples/geo_schelling_points.md: -------------------------------------------------------------------------------- 1 | # GeoSchelling Model (Points & Polygons) 2 | 3 | 10 | 11 | ## Summary 12 | 13 | This is a geoversion of a simplified Schelling example. 14 | 15 | ### GeoSpace 16 | 17 | The NUTS-2 regions are considered as a shared definition of neighborhood among all people agents, instead of a locally defined neighborhood such as Moore or von Neumann. 18 | 19 | ### GeoAgent 20 | 21 | There are two types of GeoAgents: people and regions. Each person resides in a randomly assigned region, and checks the color ratio of its region against a pre-defined "happiness" threshold at every time step. If the ratio falls below a certain threshold (e.g., 40%), the agent is found to be "unhappy", and randomly moves to another region. People are represented as points, with locations randomly chosen within their regions. The color of a region depends on the color of the majority population it contains (i.e., point in polygon calculations). 22 | 23 | ## How to run 24 | 25 | To run the model interactively, run `mesa runserver` in [this directory](https://github.com/mesa/mesa-examples/tree/main/gis/geo_schelling_points). e.g. 26 | 27 | ```bash 28 | mesa runserver 29 | ``` 30 | 31 | Then open your browser to [http://127.0.0.1:8521/](http://127.0.0.1:8521/) and press `Start`. 32 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: 1.2.0 2 | message: "If you use this software, please cite the associated paper (see preferred-citation)." 3 | authors: 4 | - family-names: Wang 5 | given-names: Boyu 6 | orcid: "https://orcid.org/0000-0001-9879-2138" 7 | - family-names: Hess 8 | given-names: Vincent 9 | orcid: "https://orcid.org/0000-0002-9242-8500" 10 | - family-names: Crooks 11 | given-names: Andrew 12 | orcid: "https://orcid.org/0000-0002-5034-6654" 13 | title: "Mesa-Geo: A GIS Extension for the Mesa Agent-Based Modeling Framework in Python" 14 | preferred-citation: 15 | type: conference-paper 16 | authors: 17 | - family-names: Wang 18 | given-names: Boyu 19 | orcid: "https://orcid.org/0000-0001-9879-2138" 20 | - family-names: Hess 21 | given-names: Vincent 22 | orcid: "https://orcid.org/0000-0002-9242-8500" 23 | - family-names: Crooks 24 | given-names: Andrew 25 | orcid: "https://orcid.org/0000-0002-5034-6654" 26 | doi: 10.1145/3557989.3566157 27 | url: "https://doi.org/10.1145/3557989.3566157" 28 | publisher: 29 | name: Association for Computing Machinery 30 | title: "Mesa-Geo: A GIS Extension for the Mesa Agent-Based Modeling Framework in Python" 31 | collection-title: "Proceedings of the 5th ACM SIGSPATIAL International Workshop on GeoSpatial Simulation" 32 | start: 1 33 | end: 10 34 | numpages: 10 35 | conference: 36 | name: "GeoSim '22" 37 | city: Seattle 38 | region: Washington 39 | country: USA 40 | year: 2022 41 | isbn: 9781450395373 42 | -------------------------------------------------------------------------------- /docs/examples/population.md: -------------------------------------------------------------------------------- 1 | # Population Model 2 | 3 | 10 | 11 | ## Summary 12 | 13 | This is an implementation of the [Uganda Example](https://github.com/abmgis/abmgis/tree/master/Chapter05-GIS/Models/UgandaExample) in Python, using [Mesa](https://github.com/mesa/mesa) and [Mesa-Geo](https://github.com/mesa/mesa-geo). 14 | 15 | ### GeoSpace 16 | 17 | The GeoSpace consists of both a raster and a vector layer. The raster layer contains population data for each cell, and it is this data that is used for model initialisation, in the sense creating the agents. The vector layer shown in blue color represents a lake in Uganda. It overlays with the raster layer to mask out the cells that agents cannot move into. 18 | 19 | ### GeoAgent 20 | 21 | The GeoAgents are people, created based on the population data. As this is a simple example model, the agents only move randomly to neighboring cells at each time step. To make the simulation more realistic and visually appealing, the agents in the same cell have a randomized position within the cell, so that they don’t stand on top of each other at exactly the same coordinate. 22 | 23 | ## How to run 24 | 25 | To run the model interactively, run `mesa runserver` in [this directory](https://github.com/mesa/mesa-examples/tree/main/gis/population). e.g. 26 | 27 | ```bash 28 | mesa runserver 29 | ``` 30 | 31 | Then open your browser to [http://127.0.0.1:8521/](http://127.0.0.1:8521/) and press `Start`. 32 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | Docs for Mesa-Geo 2 | ============= 3 | 4 | The readable version of the docs is hosted at [mesa-geo.readthedocs.org](http://mesa-geo.readthedocs.io/). 5 | 6 | This folder contains the docs that build the docs for Mesa-Geo on readthdocs. 7 | 8 | ## How to publish updates to the docs 9 | 10 | Updating docs can be confusing. Here are the basic setups. 11 | 12 | ### Install dependencies 13 | 14 | From the project root, install the dependencies for building the docs: 15 | 16 | ```shell 17 | pip install -e ".[docs]" 18 | ``` 19 | 20 | ### Submit a pull request with updates 21 | 22 | 1. Create branch (either via branching or fork of repo) -- try to use a descriptive name. 23 | * `git checkout -b doc-updates` 24 | 2. Update the docs. Save. 25 | 3. Build the docs, from the inside of the docs folder. 26 | * `make html` 27 | 4. Commit the changes. If there are new files, you will have to explicit add them. 28 | * `git commit -am "update docs"` 29 | 5. Push the branch. 30 | * `git push origin doc-updates` 31 | 6. From here you will want to submit a pull request to main. 32 | 33 | ### Update read the docs 34 | 35 | From this point, you will need to find someone that has access to readthedocs. Currently, that is [@wang-boyu](https://github.com/wang-boyu). 36 | 37 | 1. Accept the pull request into main. 38 | 2. Log into readthedocs and launch a new build -- builds take about 10 minutes or so. 39 | 40 | ## Helpful Sphnix tips 41 | * Build html from docs: 42 | * `make html` 43 | * Autogenerate / update sphninx from docstrings (replace your name as the author): 44 | * `sphinx-apidoc -A "Jackie Kazil" -F -o docs mesa_geo/` 45 | -------------------------------------------------------------------------------- /mesa_geo/tile_layers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tile Layers 3 | ----------- 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import dataclasses 9 | from dataclasses import dataclass 10 | 11 | import xyzservices 12 | 13 | LeafletOption = str | bool | int | float 14 | 15 | 16 | @dataclass 17 | class RasterWebTile: 18 | """ 19 | A class for the background tile layer of Leaflet map that uses a raster 20 | tile server as the source of the tiles. 21 | 22 | The available options can be found at: https://leafletjs.com/reference.html#tilelayer 23 | """ 24 | 25 | url: str 26 | options: dict[str, LeafletOption] | None = None 27 | kind: str = "raster_web_tile" 28 | 29 | @classmethod 30 | def from_xyzservices(cls, provider: xyzservices.TileProvider) -> RasterWebTile: 31 | """ 32 | Create a RasterWebTile from an xyzservices TileProvider. 33 | 34 | :param provider: The xyzservices TileProvider to use. 35 | :return: A RasterWebTile instance. 36 | """ 37 | provider_dict = dict(provider) 38 | url = provider_dict.pop("url") 39 | attribution = provider_dict.pop("html_attribution") 40 | provider_dict.update({"attribution": attribution}) 41 | return cls(url=url, options=provider_dict) 42 | 43 | def to_dict(self) -> dict: 44 | return dataclasses.asdict(self) 45 | 46 | 47 | @dataclass 48 | class WMSWebTile(RasterWebTile): 49 | """ 50 | A class for the background tile layer of Leaflet map that uses a WMS 51 | service as the source of the tiles. 52 | 53 | The available options can be found at: https://leafletjs.com/reference.html#tilelayer-wms 54 | """ 55 | 56 | kind: str = "wms_web_tile" 57 | -------------------------------------------------------------------------------- /docs/examples/rainfall.md: -------------------------------------------------------------------------------- 1 | # Rainfall Model 2 | 3 | 10 | 11 | ## Summary 12 | 13 | This is an implementation of the [Rainfall Model](https://github.com/abmgis/abmgis/tree/master/Chapter06-IntegratingABMandGIS/Models/Rainfall) in Python, using [Mesa](https://github.com/mesa/mesa) and [Mesa-Geo](https://github.com/mesa/mesa-geo). Inspired by the NetLogo [Grand Canyon model](http://ccl.northwestern.edu/netlogo/models/GrandCanyon), this is an example of how a digital elevation model (DEM) can be used to create an artificial world. 14 | 15 | ### GeoSpace 16 | 17 | The GeoSpace contains a raster layer representing elevations. It is this elevation value that impacts how the raindrops move over the terrain. Apart from `elevation`, each cell of the raster layer also has a `water_level` attribute that is used to track the amount of water it contains. 18 | 19 | ### GeoAgent 20 | 21 | In this example, the raindrops are the GeoAgents. At each time step, raindrops are randomly created across the landscape to simulate rainfall. The raindrops flow from cells of higher elevation to lower elevation based on their eight surrounding cells (i.e., Moore neighbourhood). The raindrop also has its own height, which allows them to accumulate, gain height and flow if they are trapped at places such as potholes, pools, or depressions. When they reach the boundary of the GeoSpace, they are removed from the model as outflow. 22 | 23 | ## How to run 24 | 25 | To run the model interactively, run `mesa runserver` in [this directory](https://github.com/mesa/mesa-examples/tree/main/gis/rainfall). e.g. 26 | 27 | ```bash 28 | mesa runserver 29 | ``` 30 | 31 | Then open your browser to [http://127.0.0.1:8521/](http://127.0.0.1:8521/) and press `Start`. 32 | -------------------------------------------------------------------------------- /.github/workflows/build_lint.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - release** 8 | paths-ignore: 9 | - '**.md' 10 | - '**.rst' 11 | pull_request: 12 | paths-ignore: 13 | - '**.md' 14 | - '**.rst' 15 | workflow_dispatch: 16 | schedule: 17 | - cron: '0 6 * * 1' 18 | 19 | # This will cancel previous run if a newer job that obsoletes the said previous 20 | # run, is started. 21 | # Based on https://github.com/zulip/zulip/commit/4a11642cee3c8aec976d305d51a86e60e5d70522 22 | concurrency: 23 | group: "${{ github.workflow }}-${{ github.head_ref || github.run_id }}" 24 | cancel-in-progress: true 25 | 26 | jobs: 27 | build: 28 | runs-on: ${{ matrix.os }}-latest 29 | # We need an explicit timeout because sometimes the batch_runner test never 30 | # completes. 31 | timeout-minutes: 6 32 | strategy: 33 | fail-fast: False 34 | matrix: 35 | os: [windows, ubuntu, macos] 36 | python-version: ["3.12"] 37 | name: [""] 38 | include: 39 | - os: ubuntu 40 | python-version: "3.11" 41 | - os: ubuntu 42 | python-version: "3.10" 43 | - os: ubuntu 44 | python-version: "3.12" 45 | pip-pre: "--pre" # Installs pre-release versions of pip dependencies 46 | name: "Pre-release dependencies" # Mainly to test Mesa pre-releases 47 | 48 | steps: 49 | - uses: actions/checkout@v5 50 | - name: Set up Python ${{ matrix.python-version }} 51 | uses: actions/setup-python@v6 52 | with: 53 | python-version: ${{ matrix.python-version }} 54 | cache: 'pip' 55 | - name: Install uv 56 | run: pip install uv 57 | - name: Install Mesa-Geo 58 | # See https://github.com/astral-sh/uv/issues/1945 59 | run: uv pip install --system .[dev] ${{ matrix.pip-pre }} 60 | - name: Test with pytest 61 | run: pytest --durations=10 --cov=mesa_geo tests/ --cov-report=xml 62 | - if: matrix.os == 'ubuntu' 63 | name: Codecov 64 | uses: codecov/codecov-action@v5 65 | -------------------------------------------------------------------------------- /mesa_geo/geo_base.py: -------------------------------------------------------------------------------- 1 | """ 2 | GeoBase 3 | ------- 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | from abc import abstractmethod 9 | 10 | import numpy as np 11 | import pyproj 12 | 13 | 14 | class GeoBase: 15 | """ 16 | Base class for all geo-related classes. 17 | """ 18 | 19 | _crs: pyproj.CRS | None 20 | 21 | def __init__(self, crs=None): 22 | """ 23 | Create a new GeoBase object. 24 | 25 | :param crs: The coordinate reference system of the object. 26 | """ 27 | 28 | self.crs = crs 29 | 30 | @property 31 | @abstractmethod 32 | def total_bounds(self) -> np.ndarray | None: 33 | """ 34 | Return the bounds of the object in [min_x, min_y, max_x, max_y] format. 35 | 36 | :return: The bounds of the object in [min_x, min_y, max_x, max_y] format. 37 | :rtype: np.ndarray | None 38 | """ 39 | 40 | raise NotImplementedError 41 | 42 | @property 43 | def crs(self) -> pyproj.CRS | None: 44 | """ 45 | Return the coordinate reference system of the object. 46 | """ 47 | 48 | return self._crs 49 | 50 | @crs.setter 51 | def crs(self, crs): 52 | """ 53 | Set the coordinate reference system of the object. 54 | """ 55 | 56 | self._crs = pyproj.CRS.from_user_input(crs) if crs else None 57 | 58 | @abstractmethod 59 | def to_crs(self, crs, inplace=False) -> GeoBase | None: 60 | """ 61 | Transform the object to a new coordinate reference system. 62 | 63 | :param crs: The coordinate reference system to transform to. 64 | :param inplace: Whether to transform the object in place or 65 | return a new object. Defaults to False. 66 | 67 | :return: The transformed object if not inplace. 68 | :rtype: GeoBase | None 69 | """ 70 | raise NotImplementedError 71 | 72 | def _to_crs_check(self, crs) -> None: 73 | """ 74 | Check that the object has a coordinate reference system to 75 | transform from and to. 76 | """ 77 | if self.crs is None: 78 | raise TypeError("Need a valid crs to transform from.") 79 | if crs is None: 80 | raise TypeError("Need a valid crs to transform to.") 81 | -------------------------------------------------------------------------------- /tests/test_RasterWebTile.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import xyzservices.providers as xyz 4 | 5 | import mesa_geo as mg 6 | 7 | 8 | class TestRasterWebTile(unittest.TestCase): 9 | def test_from_xyzservices(self): 10 | map_tile = mg.RasterWebTile.from_xyzservices(xyz.CartoDB.Positron) 11 | 12 | self.assertEqual(map_tile.url, xyz.CartoDB.Positron.url) 13 | self.assertEqual( 14 | map_tile.options, 15 | { 16 | "attribution": '© OpenStreetMap contributors © CARTO', 17 | "subdomains": "abcd", 18 | "max_zoom": 20, 19 | "variant": "light_all", 20 | "name": "CartoDB.Positron", 21 | }, 22 | ) 23 | self.assertEqual(map_tile.kind, "raster_web_tile") 24 | self.assertEqual( 25 | map_tile.to_dict(), 26 | { 27 | "url": xyz.CartoDB.Positron.url, 28 | "options": { 29 | "attribution": '© OpenStreetMap contributors © CARTO', 30 | "subdomains": "abcd", 31 | "max_zoom": 20, 32 | "variant": "light_all", 33 | "name": "CartoDB.Positron", 34 | }, 35 | "kind": "raster_web_tile", 36 | }, 37 | ) 38 | 39 | def test_to_dict(self): 40 | map_tile = mg.RasterWebTile( 41 | url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", 42 | options={ 43 | "attribution": "Map data © OpenStreetMap contributors" 44 | }, 45 | ) 46 | self.assertEqual( 47 | map_tile.to_dict(), 48 | { 49 | "kind": "raster_web_tile", 50 | "url": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", 51 | "options": { 52 | "attribution": "Map data © OpenStreetMap contributors", 53 | }, 54 | }, 55 | ) 56 | 57 | map_tile = mg.RasterWebTile( 58 | url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" 59 | ) 60 | self.assertEqual( 61 | map_tile.to_dict(), 62 | { 63 | "kind": "raster_web_tile", 64 | "url": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", 65 | "options": None, 66 | }, 67 | ) 68 | -------------------------------------------------------------------------------- /tests/test_GIS_examples.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import importlib 3 | import os.path 4 | import sys 5 | import unittest 6 | 7 | 8 | def classcase(name): 9 | return "".join(x.capitalize() for x in name.replace("-", "_").split("_")) 10 | 11 | 12 | @unittest.skip( 13 | "Skipping TextExamples, because examples folder was moved. More discussion needed." 14 | ) 15 | class TestExamples(unittest.TestCase): 16 | """ 17 | Test examples' models. This creates a model object and iterates it through 18 | some steps. The idea is to get code coverage, rather than to test the 19 | details of each example's model. 20 | """ 21 | 22 | EXAMPLES = os.path.abspath(os.path.join(os.path.dirname(__file__), "../gis")) 23 | 24 | @contextlib.contextmanager 25 | def active_example_dir(self, example): 26 | "save and restore sys.path and sys.modules" 27 | old_sys_path = sys.path[:] 28 | old_sys_modules = sys.modules.copy() 29 | old_cwd = os.getcwd() 30 | example_path = os.path.abspath(os.path.join(self.EXAMPLES, example)) 31 | try: 32 | sys.path.insert(0, example_path) 33 | os.chdir(example_path) 34 | yield 35 | finally: 36 | os.chdir(old_cwd) 37 | added = [m for m in sys.modules if m not in old_sys_modules] 38 | for mod in added: 39 | del sys.modules[mod] 40 | sys.modules.update(old_sys_modules) 41 | sys.path[:] = old_sys_path 42 | 43 | def test_examples(self): 44 | for example in os.listdir(self.EXAMPLES): 45 | if not os.path.isdir(os.path.join(self.EXAMPLES, example)): 46 | continue 47 | if hasattr(self, f"test_{example.replace('-', '_')}"): 48 | # non-standard example; tested below 49 | continue 50 | 51 | print(f"testing example {example!r}") 52 | with self.active_example_dir(example): 53 | try: 54 | # model.py at the top level 55 | mod = importlib.import_module("model") 56 | server = importlib.import_module("server") 57 | server.server.render_model() 58 | except ImportError: 59 | # /model.py 60 | mod = importlib.import_module(f"{example.replace('-', '_')}.model") 61 | server = importlib.import_module( 62 | f"{example.replace('-', '_')}.server" 63 | ) 64 | server.server.render_model() 65 | model_class = getattr(mod, classcase(example)) 66 | model = model_class() 67 | for _ in range(10): 68 | model.step() 69 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by using Matrix to direct message a [Mesa administrator](@edgeofchaos:matrix.org). The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ -------------------------------------------------------------------------------- /tests/test_ImageLayer.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | import rasterio as rio 5 | 6 | import mesa_geo as mg 7 | 8 | 9 | class TestImageLayer(unittest.TestCase): 10 | def setUp(self) -> None: 11 | self.src_shape = (3, 500, 500) 12 | self.dst_shape = (3, 400, 583) 13 | self.src_crs = "epsg:4326" 14 | self.dst_crs = "epsg:3857" 15 | self.src_transform = rio.transform.Affine( 16 | 0.0006333333333759583, 17 | 0.00, 18 | -122.26638888878, 19 | 0.00, 20 | -0.00031777777779916507, 21 | 43.01472222189958, 22 | ) 23 | self.dst_transform = rio.transform.Affine( 24 | 60.43686114587856, 25 | 0.00, 26 | -13610632.15223135, 27 | 0.00, 28 | -60.43686114587856, 29 | 5314212.987773206, 30 | ) 31 | self.src_bounds = [ 32 | -122.26638888878, 33 | 42.855833333, 34 | -121.94972222209202, 35 | 43.01472222189958, 36 | ] 37 | self.dst_bounds = [ 38 | -13610632.15223135, 39 | 5290053.890954778, 40 | -13575380.980144441, 41 | 5314212.987773206, 42 | ] 43 | self.src_resolution = (0.0006333333333759583, 0.00031777777779916507) 44 | self.dst_resolution = (60.43686114587856, 60.43686114587856) 45 | self.image_layer = mg.ImageLayer( 46 | values=np.random.uniform(low=0, high=255, size=self.src_shape), 47 | crs=self.src_crs, 48 | total_bounds=self.src_bounds, 49 | ) 50 | 51 | def tearDown(self) -> None: 52 | pass 53 | 54 | def test_to_crs(self): 55 | transformed_image_layer = self.image_layer.to_crs(self.dst_crs) 56 | self.assertEqual(transformed_image_layer.crs, self.dst_crs) 57 | self.assertEqual(transformed_image_layer.height, self.dst_shape[1]) 58 | self.assertEqual(transformed_image_layer.width, self.dst_shape[2]) 59 | 60 | self.assertTrue( 61 | transformed_image_layer.transform.almost_equals(self.dst_transform) 62 | ) 63 | np.testing.assert_almost_equal( 64 | transformed_image_layer.total_bounds, self.dst_bounds 65 | ) 66 | np.testing.assert_almost_equal( 67 | transformed_image_layer.resolution, self.dst_resolution 68 | ) 69 | 70 | # no change to original layer 71 | self.assertEqual(self.image_layer.crs, self.src_crs) 72 | self.assertEqual(self.image_layer.height, self.src_shape[1]) 73 | self.assertEqual(self.image_layer.width, self.src_shape[2]) 74 | 75 | self.assertTrue(self.image_layer.transform.almost_equals(self.src_transform)) 76 | np.testing.assert_almost_equal(self.image_layer.total_bounds, self.src_bounds) 77 | 78 | np.testing.assert_almost_equal(self.image_layer.resolution, self.src_resolution) 79 | 80 | def test_to_crs_inplace(self): 81 | self.image_layer.to_crs(self.dst_crs, inplace=True) 82 | self.assertEqual(self.image_layer.crs, self.dst_crs) 83 | self.assertEqual(self.image_layer.height, self.dst_shape[1]) 84 | self.assertEqual(self.image_layer.width, self.dst_shape[2]) 85 | self.assertTrue(self.image_layer.transform.almost_equals(self.dst_transform)) 86 | np.testing.assert_almost_equal(self.image_layer.total_bounds, self.dst_bounds) 87 | np.testing.assert_almost_equal(self.image_layer.resolution, self.dst_resolution) 88 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Mesa-Geo: GIS Extension for Mesa Agent-Based Modeling 2 | 3 | [![GitHub CI](https://github.com/mesa/mesa-geo/workflows/build/badge.svg)](https://github.com/mesa/mesa-geo/actions) 4 | [![Read the Docs](https://readthedocs.org/projects/mesa-geo/badge/?version=stable)](https://mesa-geo.readthedocs.io/stable) 5 | [![Codecov](https://codecov.io/gh/projectmesa/mesa-geo/branch/main/graph/badge.svg)](https://codecov.io/gh/projectmesa/mesa-geo) 6 | [![Code Style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 7 | [![PyPI](https://img.shields.io/pypi/v/mesa-geo.svg)](https://pypi.org/project/mesa-geo) 8 | [![PyPI - License](https://img.shields.io/pypi/l/mesa-geo)](https://pypi.org/project/mesa-geo/) 9 | [![PyPI - Downloads](https://img.shields.io/pypi/dw/mesa-geo)](https://pypistats.org/packages/mesa-geo) 10 | [![Matrix Chat](https://img.shields.io/matrix/mesa-geo:matrix.org?label=chat&logo=Matrix)](https://matrix.to/#/#mesa-geo:matrix.org) 11 | [![DOI](https://zenodo.org/badge/DOI/10.1145/3557989.3566157.svg)](https://doi.org/10.1145/3557989.3566157) 12 | 13 | Mesa-Geo implements a `GeoSpace` that can host GIS-based `GeoAgents`, which are like normal Agents, except they have a `geometry` attribute that is a [Shapely object](https://shapely.readthedocs.io/en/latest/manual.html) and a `crs` attribute for its Coordinate Reference System. You can use `Shapely` directly to create arbitrary geometries, but in most cases you will want to import your geometries from a file. Mesa-Geo allows you to create GeoAgents from any vector data file (e.g. shapefiles), valid GeoJSON objects or a GeoPandas GeoDataFrame. 14 | 15 | ## Using Mesa-Geo 16 | 17 | To install Mesa-Geo on linux or macOS run 18 | 19 | ```shell 20 | pip install mesa-geo 21 | ``` 22 | 23 | On windows you should first use Anaconda to install some of the requirements with 24 | 25 | ```shell 26 | conda install fiona pyproj rtree shapely 27 | pip install mesa-geo 28 | ``` 29 | 30 | Since Mesa-Geo is in early development you could also install the latest version directly from Github via 31 | 32 | ```shell 33 | pip install -e git+https://github.com/mesa/mesa-geo.git#egg=mesa-geo 34 | ``` 35 | 36 | Take a look at the [examples](https://github.com/mesa/mesa-examples/tree/main/gis) folder for sample models demonstrating Mesa-Geo features. 37 | 38 | For more help on using Mesa-Geo, check out the following resources: 39 | 40 | - [Introductory Tutorial](http://mesa-geo.readthedocs.io/stable/tutorials/intro_tutorial.html) 41 | - [Docs](http://mesa-geo.readthedocs.io/stable/) 42 | - [Mesa-Geo Discussions](https://github.com/mesa/mesa-geo/discussions) 43 | - [PyPI](https://pypi.org/project/mesa-geo/) 44 | 45 | ## Contributing to Mesa-Geo 46 | 47 | Want to join the team or just curious about what is happening with Mesa & Mesa-Geo? You can... 48 | 49 | * Join our [Matrix chat room](https://matrix.to/#/#mesa-geo:matrix.org) in which questions, issues, and ideas can be (informally) discussed. 50 | * Come to a monthly dev session (you can find dev session times, agendas and notes at [Mesa discussions](https://github.com/mesa/mesa/discussions). 51 | * Just check out the code at [GitHub](https://github.com/mesa/mesa-geo/). 52 | 53 | If you run into an issue, please file a [ticket](https://github.com/mesa/mesa-geo/issues) for us to discuss. If possible, follow up with a pull request. 54 | 55 | If you would like to add a feature, please reach out via [ticket](https://github.com/mesa/mesa-geo/issues) or join a dev session (see [Mesa discussions](https://github.com/mesa/mesa/discussions)). 56 | A feature is most likely to be added if you build it! 57 | 58 | Don't forget to check out the [Contributors guide](https://github.com/mesa/mesa-geo/blob/main/CONTRIBUTING.md). 59 | 60 | ## Citing Mesa-Geo 61 | 62 | To cite Mesa-Geo in your publication, you can click the "Cite this repository" button in the right sidebar of the [repository landing page](https://github.com/mesa/mesa-geo), and choose either the APA or BibTeX citation format. 63 | 64 | 65 | ```{toctree} 66 | --- 67 | maxdepth: 2 68 | hidden: true 69 | --- 70 | Introduction 71 | Tutorial 72 | examples/overview 73 | API Documentation 74 | ``` 75 | 76 | ## Indices and tables 77 | 78 | - {ref}`genindex` 79 | - {ref}`modindex` 80 | - {ref}`search` 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mesa-Geo: GIS Extension for Mesa Agent-Based Modeling 2 | 3 | | | | 4 | | --- | --- | 5 | | CI/CD | [![GitHub CI](https://github.com/mesa/mesa-geo/workflows/build/badge.svg)](https://github.com/mesa/mesa-geo/actions) [![Read the Docs](https://readthedocs.org/projects/mesa-geo/badge/?version=stable)](https://mesa-geo.readthedocs.io/stable) [![Codecov](https://codecov.io/gh/projectmesa/mesa-geo/branch/main/graph/badge.svg)](https://codecov.io/gh/projectmesa/mesa-geo) | 6 | | Package | [![PyPI](https://img.shields.io/pypi/v/mesa-geo.svg)](https://pypi.org/project/mesa-geo) [![PyPI - License](https://img.shields.io/pypi/l/mesa-geo)](https://pypi.org/project/mesa-geo/) [![PyPI - Downloads](https://img.shields.io/pypi/dw/mesa-geo)](https://pypistats.org/packages/mesa-geo) | 7 | | Meta | [![linting - Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![Hatch project](https://img.shields.io/badge/%F0%9F%A5%9A-Hatch-4051b5.svg)](https://github.com/pypa/hatch) [![DOI](https://zenodo.org/badge/DOI/10.1145/3557989.3566157.svg)](https://doi.org/10.1145/3557989.3566157) | 8 | | Chat | [![chat](https://img.shields.io/matrix/project-mesa:matrix.org?label=chat&logo=Matrix)](https://matrix.to/#/#project-mesa:matrix.org) | 9 | 10 | Mesa-Geo implements a `GeoSpace` that can host GIS-based `GeoAgents`, which are like normal Agents, except they have a `geometry` attribute that is a [Shapely object](https://shapely.readthedocs.io/en/latest/manual.html) and a `crs` attribute for its Coordinate Reference System. You can use `Shapely` directly to create arbitrary geometries, but in most cases you will want to import your geometries from a file. Mesa-Geo allows you to create GeoAgents from any vector data file (e.g. shapefiles), valid GeoJSON objects or a GeoPandas GeoDataFrame. 11 | 12 | ## Using Mesa-Geo 13 | 14 | To install Mesa-Geo, run: 15 | ```bash 16 | pip install -U mesa-geo 17 | ``` 18 | 19 | Mesa-Geo pre-releases can be installed with: 20 | ```bash 21 | pip install -U --pre mesa-geo 22 | ``` 23 | 24 | You can also use `pip` to install the GitHub version: 25 | ```bash 26 | pip install -U -e git+https://github.com/mesa/mesa-geo.git#egg=mesa-geo 27 | ``` 28 | 29 | Or any other (development) branch on this repo or your own fork: 30 | ``` bash 31 | pip install -U -e git+https://github.com/YOUR_FORK/mesa-geo@YOUR_BRANCH#egg=mesa-geo 32 | ``` 33 | 34 | Take a look at the [examples](https://github.com/mesa/mesa-examples/tree/main/gis) repository for sample models demonstrating Mesa-Geo features. 35 | 36 | For more help on using Mesa-Geo, check out the following resources: 37 | 38 | - [Introductory Tutorial](http://mesa-geo.readthedocs.io/stable/tutorials/intro_tutorial.html) 39 | - [Docs](http://mesa-geo.readthedocs.io/stable/) 40 | - [Mesa-Geo Discussions](https://github.com/mesa/mesa-geo/discussions) 41 | - [PyPI](https://pypi.org/project/mesa-geo/) 42 | 43 | ## Contributing to Mesa-Geo 44 | 45 | Want to join the team or just curious about what is happening with Mesa & Mesa-Geo? You can... 46 | 47 | * Join our [Matrix chat room](https://matrix.to/#/#mesa-geo:matrix.org) in which questions, issues, and ideas can be (informally) discussed. 48 | * Come to a monthly dev session (you can find dev session times, agendas and notes at [Mesa discussions](https://github.com/mesa/mesa/discussions). 49 | * Just check out the code at [GitHub](https://github.com/mesa/mesa-geo/). 50 | 51 | If you run into an issue, please file a [ticket](https://github.com/mesa/mesa-geo/issues) for us to discuss. If possible, follow up with a pull request. 52 | 53 | If you would like to add a feature, please reach out via [ticket](https://github.com/mesa/mesa-geo/issues) or join a dev session (see [Mesa discussions](https://github.com/mesa/mesa/discussions)). 54 | A feature is most likely to be added if you build it! 55 | 56 | Don't forget to check out the [Contributors guide](https://github.com/mesa/mesa-geo/blob/main/CONTRIBUTING.md). 57 | 58 | ## Citing Mesa-Geo 59 | 60 | To cite Mesa-Geo in your publication, you can click the "Cite this repository" button in the right sidebar of the [repository landing page](https://github.com/mesa/mesa-geo), and choose either the APA or BibTeX citation format. 61 | -------------------------------------------------------------------------------- /tests/test_RasterLayer.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import mesa 4 | import numpy as np 5 | 6 | import mesa_geo as mg 7 | 8 | 9 | class TestRasterLayer(unittest.TestCase): 10 | def setUp(self) -> None: 11 | self.model = mesa.Model() 12 | self.raster_layer = mg.RasterLayer( 13 | width=2, 14 | height=3, 15 | crs="epsg:4326", 16 | total_bounds=[ 17 | -122.26638888878, 18 | 42.855833333, 19 | -121.94972222209202, 20 | 43.01472222189958, 21 | ], 22 | model=self.model, 23 | ) 24 | 25 | def tearDown(self) -> None: 26 | pass 27 | 28 | def test_apple_raster(self): 29 | raster_data = np.array([[[1, 2], [3, 4], [5, 6]]]) 30 | self.raster_layer.apply_raster(raster_data) 31 | """ 32 | (x, y) coordinates: 33 | (0, 2), (1, 2) 34 | (0, 1), (1, 1) 35 | (0, 0), (1, 0) 36 | 37 | values: 38 | [[[1, 2], 39 | [3, 4], 40 | [5, 6]]] 41 | """ 42 | self.assertEqual(self.raster_layer.cells[0][1].attribute_5, 3) 43 | self.assertEqual(self.raster_layer.attributes, {"attribute_5"}) 44 | 45 | self.raster_layer.apply_raster(raster_data, attr_name="elevation") 46 | self.assertEqual(self.raster_layer.cells[0][1].elevation, 3) 47 | self.assertEqual(self.raster_layer.attributes, {"attribute_5", "elevation"}) 48 | 49 | with self.assertRaises(ValueError): 50 | self.raster_layer.apply_raster(np.empty((1, 100, 100))) 51 | 52 | def test_get_raster(self): 53 | raster_data = np.array([[[1, 2], [3, 4], [5, 6]]]) 54 | self.raster_layer.apply_raster(raster_data) 55 | """ 56 | (x, y) coordinates: 57 | (0, 2), (1, 2) 58 | (0, 1), (1, 1) 59 | (0, 0), (1, 0) 60 | 61 | values: 62 | [[[1, 2], 63 | [3, 4], 64 | [5, 6]]] 65 | """ 66 | self.raster_layer.apply_raster(raster_data, attr_name="elevation") 67 | np.testing.assert_array_equal( 68 | self.raster_layer.get_raster(attr_name="elevation"), raster_data 69 | ) 70 | 71 | self.raster_layer.apply_raster(raster_data) 72 | np.testing.assert_array_equal( 73 | self.raster_layer.get_raster(), np.concatenate((raster_data, raster_data)) 74 | ) 75 | with self.assertRaises(ValueError): 76 | self.raster_layer.get_raster("not_existing_attr") 77 | 78 | def test_get_min_cell(self): 79 | self.raster_layer.apply_raster( 80 | np.array([[[1, 2], [3, 4], [5, 6]]]), attr_name="elevation" 81 | ) 82 | 83 | min_cell = min( 84 | self.raster_layer.get_neighboring_cells(pos=(0, 2), moore=True), 85 | key=lambda cell: cell.elevation, 86 | ) 87 | self.assertEqual(min_cell.pos, (1, 2)) 88 | self.assertEqual(min_cell.elevation, 2) 89 | 90 | min_cell = min( 91 | self.raster_layer.get_neighboring_cells( 92 | pos=(0, 2), moore=True, include_center=True 93 | ), 94 | key=lambda cell: cell.elevation, 95 | ) 96 | self.assertEqual(min_cell.pos, (0, 2)) 97 | self.assertEqual(min_cell.elevation, 1) 98 | 99 | self.raster_layer.apply_raster( 100 | np.array([[[1, 2], [3, 4], [5, 6]]]), attr_name="water_level" 101 | ) 102 | min_cell = min( 103 | self.raster_layer.get_neighboring_cells( 104 | pos=(0, 2), moore=True, include_center=True 105 | ), 106 | key=lambda cell: cell.elevation + cell.water_level, 107 | ) 108 | self.assertEqual(min_cell.pos, (0, 2)) 109 | self.assertEqual(min_cell.elevation, 1) 110 | self.assertEqual(min_cell.water_level, 1) 111 | 112 | def test_get_max_cell(self): 113 | self.raster_layer.apply_raster( 114 | np.array([[[1, 2], [3, 4], [5, 6]]]), attr_name="elevation" 115 | ) 116 | 117 | max_cell = max( 118 | self.raster_layer.get_neighboring_cells(pos=(0, 2), moore=True), 119 | key=lambda cell: cell.elevation, 120 | ) 121 | self.assertEqual(max_cell.pos, (1, 1)) 122 | self.assertEqual(max_cell.elevation, 4) 123 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "Mesa-Geo" 7 | description = "GIS Agent-based modeling (ABM) in Python" 8 | license = { text = "Apache 2.0" } 9 | requires-python = ">=3.10" 10 | authors = [ 11 | { name = "Mesa Team", email = "maintainers@projectmesa.dev" }, 12 | ] 13 | keywords = [ 14 | "agent", 15 | "based", 16 | "modeling", 17 | "model", 18 | "ABM", 19 | "simulation", 20 | "multi-agent", 21 | "GIS", 22 | "geographic", 23 | "information", 24 | "systems" 25 | ] 26 | classifiers = [ 27 | "Topic :: Scientific/Engineering", 28 | "Topic :: Scientific/Engineering :: Artificial Life", 29 | "Topic :: Scientific/Engineering :: Artificial Intelligence", 30 | "Intended Audience :: Science/Research", 31 | "Programming Language :: Python :: 3 :: Only", 32 | "Programming Language :: Python :: 3.10", 33 | "Programming Language :: Python :: 3.11", 34 | "Programming Language :: Python :: 3.12", 35 | "License :: OSI Approved :: Apache Software License", 36 | "Operating System :: OS Independent", 37 | "Development Status :: 3 - Alpha", 38 | "Natural Language :: English", 39 | ] 40 | readme = "README.md" 41 | dependencies = [ 42 | "mesa[rec]>=3.0", 43 | "geopandas", 44 | "libpysal", 45 | "rtree", 46 | "rasterio>=1.4b1", 47 | "shapely", 48 | "pyproj", 49 | "folium", 50 | "xyzservices>=2022.9.0", 51 | "ipyleaflet" 52 | ] 53 | dynamic = ["version"] 54 | 55 | [project.optional-dependencies] 56 | dev = [ 57 | "ruff", 58 | "coverage", 59 | "pytest >= 4.6", 60 | "pytest-cov", 61 | "sphinx", 62 | "pytest-mock", 63 | ] 64 | docs = [ 65 | "sphinx", 66 | "ipython", 67 | "pydata_sphinx_theme", 68 | "seaborn", 69 | "myst_nb", 70 | "myst-parser", # Markdown in Sphinx 71 | ] 72 | examples = [ 73 | "pytest", 74 | "momepy", 75 | ] 76 | 77 | [project.urls] 78 | homepage = "https://github.com/mesa/mesa-geo" 79 | repository = "https://github.com/mesa/mesa-geo" 80 | 81 | [project.scripts] 82 | mesa = "mesa.main:cli" 83 | 84 | [tool.hatch.build.targets.wheel] 85 | packages = ["mesa_geo"] 86 | 87 | [tool.hatch.version] 88 | path = "mesa_geo/__init__.py" 89 | 90 | [tool.ruff] 91 | # See https://github.com/charliermarsh/ruff#rules for error code definitions. 92 | # Hardcode to Python 3.10. 93 | # Reminder to update mesa-examples if the value below is changed. 94 | target-version = "py310" 95 | extend-exclude = ["docs", "build"] 96 | 97 | lint.select = [ 98 | # "ANN", # annotations TODO 99 | "B", # bugbear 100 | "C4", # comprehensions 101 | "DTZ", # naive datetime 102 | "E", # style errors 103 | "F", # flakes 104 | "I", # import sorting 105 | "ISC", # string concatenation 106 | "N", # naming 107 | "PGH", # pygrep-hooks 108 | "PIE", # miscellaneous 109 | "PLC", # pylint convention 110 | "PLE", # pylint error 111 | # "PLR", # pylint refactor TODO 112 | "PLW", # pylint warning 113 | "Q", # quotes 114 | "RUF", # Ruff 115 | "S", # security 116 | "SIM", # simplify 117 | "T10", # debugger 118 | "UP", # upgrade 119 | "W", # style warnings 120 | "YTT", # sys.version 121 | ] 122 | # Ignore list taken from https://github.com/psf/black/blob/master/.flake8 123 | # E203 Whitespace before ':' 124 | # E266 Too many leading '#' for block comment 125 | # E501 Line too long (82 > 79 characters) 126 | # W503 Line break occurred before a binary operator 127 | # But we don't specify them because ruff's Black already 128 | # checks for it. 129 | # See https://github.com/charliermarsh/ruff/issues/1842#issuecomment-1381210185 130 | lint.extend-ignore = [ 131 | "E501", 132 | "S101", # Use of `assert` detected 133 | "B017", # `assertRaises(Exception)` should be considered evil TODO 134 | "PGH004", # Use specific rule codes when using `noqa` TODO 135 | "B905", # `zip()` without an explicit `strict=` parameter 136 | "N802", # Function name should be lowercase 137 | "N999", # Invalid module name. We should revisit this in the future, TODO 138 | "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` TODO 139 | "S310", # Audit URL open for permitted schemes. Allowing use of `file:` or custom schemes is often unexpected. 140 | "S603", # `subprocess` call: check for execution of untrusted input 141 | "ISC001", # ruff format asks to disable this feature 142 | "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes 143 | ] 144 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | As an open source project, Mesa-Geo welcomes contributions of many forms, and from beginners to experts. If you are curious or just want to see what is happening, we post our development session agendas and development session notes on [Mesa discussions]. We also have a threaded discussion forum on [Matrix] for casual conversation. 5 | 6 | In no particular order, examples include: 7 | 8 | - Code patches 9 | - Bug reports and patch reviews 10 | - New features 11 | - Documentation improvements 12 | - Tutorials 13 | 14 | No contribution is too small. Although, contributions can be too big, so let's discuss via [Matrix] or via an [issue]. 15 | 16 | [Mesa discussions]: https://github.com/mesa/mesa/discussions 17 | [Matrix]: https://matrix.to/#/#project-mesa:matrix.org 18 | [issue]: https://github.com/mesa/mesa-geo/issues 19 | 20 | **To submit a contribution** 21 | 22 | - Create a ticket for the item that you are working on. 23 | - Fork the Mesa-Geo repository. 24 | - [Clone your repository] from GitHub to your machine. 25 | - Create a new branch in your fork: `git checkout -b BRANCH_NAME` 26 | - Run `git config pull.rebase true`. This prevents messy merge commits when updating your branch on top of Mesa-Geo main branch. 27 | - Install an editable version with developer requirements locally: `pip install -e ".[dev]"` 28 | - Edit the code. Save. 29 | - Git add the new files and files with changes: `git add FILE_NAME` 30 | - Git commit your changes with a meaningful message: `git commit -m "Fix issue X"` 31 | - If implementing a new feature, include some documentation in docs folder. 32 | - Make sure that your submission passes the [GH Actions build]. See "Testing and Standards below" to be able to run these locally. 33 | - Make sure that your code is formatted according to the [black] standard (you can do it via [pre-commit]). 34 | - Push your changes to your fork on Github: `git push origin NAME_OF_BRANCH`. 35 | - [Create a pull request]. 36 | - Describe the change w/ ticket number(s) that the code fixes. 37 | 38 | [Clone your repository]: https://help.github.com/articles/cloning-a-repository/ 39 | [GH Actions build]: https://github.com/mesa/mesa-geo/actions/workflows/build_lint.yml 40 | [Create a pull request]: https://help.github.com/articles/creating-a-pull-request/ 41 | [pre-commit]: https://github.com/pre-commit/pre-commit 42 | [black]: https://github.com/psf/black 43 | 44 | Testing and Code Standards 45 | -------------------------- 46 | 47 | [![](https://codecov.io/gh/projectmesa/mesa-geo/branch/main/graph/badge.svg)](https://codecov.io/gh/projectmesa/mesa-geo) [![](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 48 | 49 | As part of our contribution process, we practice continuous integration and use GH Actions to help enforce best practices. 50 | 51 | If you're changing previous Mesa-Geo features, please make sure of the following: 52 | 53 | - Your changes pass the current tests. 54 | - Your changes pass our style standards. 55 | - Your changes don't break the models or your changes include updated models. 56 | - Additional features or rewrites of current features are accompanied by tests. 57 | - New features are demonstrated in a model, so folks can understand more easily. 58 | 59 | To ensure that your submission will not break the build, you will need to install Ruff and pytest. 60 | 61 | ```bash 62 | pip install ruff pytest pytest-cov 63 | ``` 64 | 65 | We test by implementing simple models and through traditional unit tests in the tests/ folder. The following only covers unit tests coverage. Ensure that your test coverage has not gone down. If it has and you need help, we will offer advice on how to structure tests for the contribution. 66 | 67 | ```bash 68 | pytest --cov=mesa_geo tests/ 69 | ``` 70 | 71 | With respect to code standards, we follow [PEP8] and the [Google Style Guide]. We recommend to use [black] as an automated code formatter. You can automatically format your code using [pre-commit], which will prevent `git commit` of unstyled code and will automatically apply black style so you can immediately re-run `git commit`. To set up pre-commit run the following commands: 72 | 73 | ```bash 74 | pip install pre-commit 75 | pre-commit install 76 | ``` 77 | 78 | You should no longer have to worry about code formatting. If still in doubt you may run the following command. If the command generates errors, fix all errors that are returned. 79 | 80 | ```bash 81 | ruff . 82 | ``` 83 | 84 | [PEP8]: https://www.python.org/dev/peps/pep-0008 85 | [Google Style Guide]: https://google.github.io/styleguide/pyguide.html 86 | [pre-commit]: https://github.com/pre-commit/pre-commit 87 | [black]: https://github.com/psf/black 88 | 89 | Licensing 90 | --------- 91 | 92 | The license of this project is located in [LICENSE]. By submitting a contribution to this project, you are agreeing that your contribution will be released under the terms of this license. 93 | 94 | [LICENSE]: https://github.com/mesa/mesa-geo/blob/main/LICENSE 95 | 96 | Special Thanks 97 | -------------- 98 | 99 | A special thanks to the following projects who offered inspiration for this contributing file. 100 | 101 | - [Django](https://github.com/django/django/blob/master/CONTRIBUTING.rst) 102 | - [18F's FOIA](https://github.com/18F/foia-hub/blob/master/CONTRIBUTING.md) 103 | - [18F's Midas](https://github.com/18F/midas/blob/devel/CONTRIBUTING.md) 104 | -------------------------------------------------------------------------------- /tests/test_GeoJupyterViz.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import MagicMock, patch 3 | 4 | import solara 5 | 6 | from mesa_geo.visualization.geojupyter_viz import Card, GeoJupyterViz 7 | 8 | 9 | class TestGeoViz(unittest.TestCase): 10 | @patch("mesa_geo.visualization.geojupyter_viz.rv.CardTitle") 11 | @patch("mesa_geo.visualization.geojupyter_viz.rv.Card") 12 | @patch("mesa_geo.visualization.geojupyter_viz.components_matplotlib.PlotMatplotlib") 13 | @patch("mesa_geo.visualization.geojupyter_viz.leaflet_viz.map") 14 | def test_card_function( 15 | self, 16 | mock_map, 17 | mock_PlotMatplotlib, # noqa: N803 18 | mock_Card, # noqa: N803 19 | mock_CardTitle, # noqa: N803 20 | ): 21 | model = MagicMock() 22 | measures = {"Measure1": lambda x: x} 23 | agent_portrayal = MagicMock() 24 | map_drawer = (MagicMock(),) 25 | zoom = (10,) 26 | scroll_wheel_zoom = (True,) 27 | center_default = ([0, 0],) 28 | current_step = MagicMock() 29 | current_step.value = 0 30 | color = "white" 31 | layout_type = {"Map": "default", "Measure": "Measure1"} 32 | 33 | with patch( 34 | "mesa_geo.visualization.geojupyter_viz.rv.Card", return_value=MagicMock() 35 | ) as mock_rv_card: 36 | _ = Card( 37 | model, 38 | measures, 39 | agent_portrayal, 40 | map_drawer, 41 | center_default, 42 | zoom, 43 | scroll_wheel_zoom, 44 | current_step, 45 | color, 46 | layout_type, 47 | ) 48 | 49 | mock_rv_card.assert_called_once() 50 | mock_CardTitle.assert_any_call(children=["Map"]) 51 | mock_map.assert_called_once_with( 52 | model, map_drawer, zoom, center_default, scroll_wheel_zoom 53 | ) 54 | # mock_PlotMatplotlib.assert_called_once() 55 | 56 | @patch("mesa_geo.visualization.geojupyter_viz.solara.GridDraggable") 57 | @patch("mesa_geo.visualization.geojupyter_viz.solara.Sidebar") 58 | @patch("mesa_geo.visualization.geojupyter_viz.solara.Card") 59 | @patch("mesa_geo.visualization.geojupyter_viz.solara.Markdown") 60 | @patch("mesa_geo.visualization.geojupyter_viz.jv.ModelController") 61 | @patch("mesa_geo.visualization.geojupyter_viz.jv.UserInputs") 62 | @patch("mesa_geo.visualization.geojupyter_viz.jv.split_model_params") 63 | @patch("mesa_geo.visualization.geojupyter_viz.jv.make_initial_grid_layout") 64 | @patch("mesa_geo.visualization.geojupyter_viz.solara.use_memo") 65 | @patch("mesa_geo.visualization.geojupyter_viz.solara.use_reactive") 66 | @patch("mesa_geo.visualization.geojupyter_viz.solara.use_state") 67 | @patch("mesa_geo.visualization.geojupyter_viz.solara.AppBarTitle") 68 | @patch("mesa_geo.visualization.geojupyter_viz.solara.AppBar") 69 | @patch("mesa_geo.visualization.geojupyter_viz.leaflet_viz.MapModule") 70 | @patch("mesa_geo.visualization.geojupyter_viz.rv.Card") 71 | def test_geojupyterviz_function( 72 | self, 73 | mock_rv_Card, # noqa: N803 74 | mock_MapModule, # noqa: N803 75 | mock_AppBar, # noqa: N803 76 | mock_AppBarTitle, # noqa: N803 77 | mock_use_state, 78 | mock_use_reactive, 79 | mock_use_memo, 80 | mock_make_initial_grid_layout, 81 | mock_split_model_params, 82 | mock_UserInputs, # noqa: N803 83 | mock_ModelController, # noqa: N803 84 | mock_Markdown, # noqa: N803 85 | mock_Card, # noqa: N803 86 | mock_Sidebar, # noqa: N803 87 | mock_GridDraggable, # noqa: N803 88 | ): 89 | model_class = MagicMock() 90 | model_params = MagicMock() 91 | measures = [lambda x: x] 92 | name = "TestModel" 93 | agent_portrayal = MagicMock() 94 | play_interval = 150 95 | view = [0, 0] 96 | zoom = 10 97 | center_point = [0, 0] 98 | 99 | mock_use_reactive.side_effect = [MagicMock(value=0), MagicMock(value=0)] 100 | mock_split_model_params.return_value = ({}, {}) 101 | mock_use_state.return_value = ({}, MagicMock()) 102 | mock_use_memo.return_value = MagicMock() 103 | mock_make_initial_grid_layout.return_value = {} 104 | 105 | mock_rv_Card.return_value.__enter__ = MagicMock() 106 | mock_rv_Card.return_value.__exit__ = MagicMock() 107 | 108 | solara.render( 109 | GeoJupyterViz( 110 | model_class=model_class, 111 | model_params=model_params, 112 | measures=measures, 113 | name=name, 114 | agent_portrayal=agent_portrayal, 115 | play_interval=play_interval, 116 | view=view, 117 | zoom=zoom, 118 | center_point=center_point, 119 | ) 120 | ) 121 | 122 | mock_AppBar.assert_called_once() 123 | mock_AppBarTitle.assert_called_once_with(name) 124 | mock_split_model_params.assert_called_once_with(model_params) 125 | mock_use_memo.assert_called_once() 126 | mock_UserInputs.assert_called_once() 127 | mock_ModelController.assert_called_once() 128 | mock_Markdown.assert_called() 129 | mock_Card.assert_called() 130 | mock_Sidebar.assert_called_once() 131 | mock_GridDraggable.assert_called_once() 132 | mock_MapModule.assert_called_once() 133 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ################# 2 | ## Eclipse 3 | ################# 4 | 5 | *.pydevproject 6 | .project 7 | .metadata 8 | bin/ 9 | tmp/ 10 | *.tmp 11 | *.bak 12 | *.swp 13 | *~.nib 14 | local.properties 15 | .classpath 16 | .settings/ 17 | .loadpath 18 | 19 | # External tool builders 20 | .externalToolBuilders/ 21 | 22 | # Locally stored "Eclipse launch configurations" 23 | *.launch 24 | 25 | # CDT-specific 26 | .cproject 27 | 28 | # PDT-specific 29 | .buildpath 30 | 31 | 32 | ################# 33 | ## Visual Studio 34 | ################# 35 | 36 | ## Ignore Visual Studio temporary files, build results, and 37 | ## files generated by popular Visual Studio add-ons. 38 | 39 | # User-specific files 40 | *.suo 41 | *.user 42 | *.sln.docstates 43 | 44 | # Build results 45 | 46 | [Dd]ebug/ 47 | [Rr]elease/ 48 | x64/ 49 | build/ 50 | [Bb]in/ 51 | [Oo]bj/ 52 | 53 | # MSTest test Results 54 | [Tt]est[Rr]esult*/ 55 | [Bb]uild[Ll]og.* 56 | 57 | *_i.c 58 | *_p.c 59 | *.ilk 60 | *.meta 61 | *.obj 62 | *.pch 63 | *.pdb 64 | *.pgc 65 | *.pgd 66 | *.rsp 67 | *.sbr 68 | *.tlb 69 | *.tli 70 | *.tlh 71 | *.tmp 72 | *.tmp_proj 73 | *.log 74 | *.vspscc 75 | *.vssscc 76 | .builds 77 | *.pidb 78 | *.log 79 | *.scc 80 | 81 | # Visual C++ cache files 82 | ipch/ 83 | *.aps 84 | *.ncb 85 | *.opensdf 86 | *.sdf 87 | *.cachefile 88 | 89 | # Visual Studio profiler 90 | *.psess 91 | *.vsp 92 | *.vspx 93 | 94 | # Guidance Automation Toolkit 95 | *.gpState 96 | 97 | # ReSharper is a .NET coding add-in 98 | _ReSharper*/ 99 | *.[Rr]e[Ss]harper 100 | 101 | # TeamCity is a build add-in 102 | _TeamCity* 103 | 104 | # DotCover is a Code Coverage Tool 105 | *.dotCover 106 | 107 | # NCrunch 108 | *.ncrunch* 109 | .*crunch*.local.xml 110 | 111 | # Installshield output folder 112 | [Ee]xpress/ 113 | 114 | # DocProject is a documentation generator add-in 115 | DocProject/buildhelp/ 116 | DocProject/Help/*.HxT 117 | DocProject/Help/*.HxC 118 | DocProject/Help/*.hhc 119 | DocProject/Help/*.hhk 120 | DocProject/Help/*.hhp 121 | DocProject/Help/Html2 122 | DocProject/Help/html 123 | 124 | # Click-Once directory 125 | publish/ 126 | 127 | # Publish Web Output 128 | *.Publish.xml 129 | *.pubxml 130 | *.publishproj 131 | 132 | # NuGet Packages Directory 133 | ## TODO: If you have NuGet Package Restore enabled, uncomment the next line 134 | #packages/ 135 | 136 | # Windows Azure Build Output 137 | csx 138 | *.build.csdef 139 | 140 | # Windows Store app package directory 141 | AppPackages/ 142 | 143 | # Others 144 | sql/ 145 | *.Cache 146 | ClientBin/ 147 | [Ss]tyle[Cc]op.* 148 | ~$* 149 | *~ 150 | *.dbmdl 151 | *.[Pp]ublish.xml 152 | *.pfx 153 | *.publishsettings 154 | 155 | # RIA/Silverlight projects 156 | Generated_Code/ 157 | 158 | # Backup & report files from converting an old project file to a newer 159 | # Visual Studio version. Backup files are not needed, because we have git ;-) 160 | _UpgradeReport_Files/ 161 | Backup*/ 162 | UpgradeLog*.XML 163 | UpgradeLog*.htm 164 | 165 | # SQL Server files 166 | App_Data/*.mdf 167 | App_Data/*.ldf 168 | 169 | ############# 170 | ## Windows detritus 171 | ############# 172 | 173 | # Windows image file caches 174 | Thumbs.db 175 | ehthumbs.db 176 | 177 | # Folder config file 178 | Desktop.ini 179 | 180 | # Recycle Bin used on file shares 181 | $RECYCLE.BIN/ 182 | 183 | # Mac crap 184 | .DS_Store 185 | 186 | 187 | ############# 188 | ## Python 189 | ############# 190 | 191 | *.py[cod] 192 | 193 | # Packages 194 | *.egg 195 | *.egg-info 196 | dist/ 197 | build/ 198 | eggs/ 199 | 200 | # Byte-compiled / optimized / DLL files 201 | __pycache__/ 202 | *.py[cod] 203 | *$py.class 204 | 205 | # C extensions 206 | *.so 207 | 208 | # Distribution / packaging 209 | .Python 210 | build/ 211 | develop-eggs/ 212 | dist/ 213 | downloads/ 214 | eggs/ 215 | .eggs/ 216 | lib/ 217 | lib64/ 218 | parts/ 219 | sdist/ 220 | var/ 221 | wheels/ 222 | *.egg-info/ 223 | .installed.cfg 224 | *.egg 225 | MANIFEST 226 | 227 | # PyInstaller 228 | # Usually these files are written by a python script from a template 229 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 230 | *.manifest 231 | *.spec 232 | 233 | # Installer logs 234 | pip-log.txt 235 | pip-delete-this-directory.txt 236 | 237 | # Unit test / coverage reports 238 | htmlcov/ 239 | .tox/ 240 | .coverage 241 | .coverage.* 242 | .cache 243 | nosetests.xml 244 | coverage.xml 245 | *.cover 246 | .hypothesis/ 247 | 248 | # Translations 249 | *.mo 250 | *.pot 251 | 252 | # Django stuff: 253 | *.log 254 | local_settings.py 255 | 256 | # Flask stuff: 257 | instance/ 258 | .webassets-cache 259 | 260 | # Scrapy stuff: 261 | .scrapy 262 | 263 | # Sphinx documentation 264 | docs/_build/ 265 | 266 | # PyBuilder 267 | target/ 268 | 269 | # Jupyter Notebook 270 | .ipynb_checkpoints 271 | *.virtual_documents 272 | 273 | 274 | # pyenv 275 | .python-version 276 | 277 | # celery beat schedule file 278 | celerybeat-schedule 279 | 280 | # SageMath parsed files 281 | *.sage.py 282 | 283 | # Environments 284 | .env 285 | .venv 286 | env/ 287 | venv/ 288 | ENV/ 289 | env.bak/ 290 | venv.bak/ 291 | 292 | # Spyder project settings 293 | .spyderproject 294 | .spyproject 295 | 296 | # Rope project settings 297 | .ropeproject 298 | 299 | # mkdocs documentation 300 | /site 301 | 302 | # mypy 303 | .mypy_cache/ 304 | parts/ 305 | var/ 306 | sdist/ 307 | develop-eggs/ 308 | .installed.cfg 309 | 310 | # Installer logs 311 | pip-log.txt 312 | 313 | # Unit test / coverage reports 314 | .coverage 315 | .tox 316 | .pytest_cache 317 | 318 | #Translations 319 | *.mo 320 | 321 | #Mr Developer 322 | .mr.developer.cfg 323 | 324 | #Visual studio code 325 | .vscode 326 | 327 | # Idea file 328 | .idea 329 | 330 | # Mesa file 331 | mesa_geo/visualization/templates/css/* 332 | mesa_geo/visualization/templates/js/* 333 | !mesa_geo/visualization/templates/js/MapModule.js 334 | -------------------------------------------------------------------------------- /tests/test_AgentCreator.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import geopandas as gpd 4 | import mesa 5 | import pandas as pd 6 | from shapely.geometry import Point 7 | 8 | import mesa_geo as mg 9 | 10 | 11 | class TestAgentCreator(unittest.TestCase): 12 | def setUp(self) -> None: 13 | self.model = mesa.Model() 14 | self.model.space = mg.GeoSpace(crs="epsg:4326") 15 | self.agent_creator_without_crs = mg.AgentCreator( 16 | agent_class=mg.GeoAgent, model=self.model 17 | ) 18 | self.agent_creator_with_crs = mg.AgentCreator( 19 | model=self.model, 20 | agent_class=mg.GeoAgent, 21 | crs="epsg:3857", 22 | ) 23 | self.df = pd.DataFrame( 24 | { 25 | "City": ["Buenos Aires", "Brasilia", "Santiago", "Bogota", "Caracas"], 26 | "Country": ["Argentina", "Brazil", "Chile", "Colombia", "Venezuela"], 27 | "Latitude": [-34.58, -15.78, -33.45, 4.60, 10.48], 28 | "Longitude": [-58.66, -47.91, -70.66, -74.08, -66.86], 29 | } 30 | ) 31 | 32 | self.geojson_str = { 33 | "type": "FeatureCollection", 34 | "features": [ 35 | { 36 | "id": "0", 37 | "type": "Feature", 38 | "properties": {"col1": "name1"}, 39 | "geometry": {"type": "Point", "coordinates": (1.0, 2.0)}, 40 | "bbox": (1.0, 2.0, 1.0, 2.0), 41 | }, 42 | { 43 | "id": "1", 44 | "type": "Feature", 45 | "properties": {"col1": "name2"}, 46 | "geometry": {"type": "Point", "coordinates": (2.0, 1.0)}, 47 | "bbox": (2.0, 1.0, 2.0, 1.0), 48 | }, 49 | ], 50 | "bbox": (1.0, 1.0, 2.0, 2.0), 51 | } 52 | 53 | def tearDown(self) -> None: 54 | pass 55 | 56 | def test_create_agent_with_crs(self): 57 | agent = self.agent_creator_with_crs.create_agent(geometry=Point(1, 1)) 58 | self.assertIsInstance(agent, mg.GeoAgent) 59 | self.assertEqual(agent.geometry, Point(1, 1)) 60 | self.assertEqual(agent.model, self.model) 61 | self.assertEqual(agent.crs, self.agent_creator_with_crs.crs) 62 | 63 | def test_create_agent_without_crs(self): 64 | with self.assertRaises(TypeError): 65 | self.agent_creator_without_crs.create_agent(geometry=Point(1, 1)) 66 | 67 | def test_from_GeoDataFrame_with_default_geometry_name(self): 68 | gdf = gpd.GeoDataFrame( 69 | self.df, 70 | geometry=gpd.points_from_xy(self.df.Longitude, self.df.Latitude), 71 | crs="epsg:3857", 72 | ) 73 | agents = self.agent_creator_without_crs.from_GeoDataFrame(gdf) 74 | 75 | self.assertEqual(len(agents), 5) 76 | 77 | self.assertEqual(agents[0].City, "Buenos Aires") 78 | self.assertEqual(agents[0].Country, "Argentina") 79 | self.assertEqual(agents[0].geometry, Point(-58.66, -34.58)) 80 | self.assertEqual(agents[0].crs, gdf.crs) 81 | 82 | def test_from_GeoDataFrame_with_custom_geometry_name(self): 83 | gdf = gpd.GeoDataFrame( 84 | self.df, 85 | geometry=gpd.points_from_xy(self.df.Longitude, self.df.Latitude), 86 | crs="epsg:3857", 87 | ) 88 | gdf.rename_geometry("custom_name", inplace=True) 89 | 90 | agents = self.agent_creator_without_crs.from_GeoDataFrame(gdf) 91 | 92 | self.assertEqual(len(agents), 5) 93 | 94 | self.assertEqual(agents[0].City, "Buenos Aires") 95 | self.assertEqual(agents[0].Country, "Argentina") 96 | self.assertEqual(agents[0].geometry, Point(-58.66, -34.58)) 97 | self.assertFalse(hasattr(agents[0], "custom_name")) 98 | self.assertEqual(agents[0].crs, gdf.crs) 99 | 100 | def test_from_GeoJSON_and_agent_creator_without_crs(self): 101 | agents = self.agent_creator_without_crs.from_GeoJSON(self.geojson_str) 102 | 103 | self.assertEqual(len(agents), 2) 104 | 105 | self.assertEqual(agents[0].unique_id, 1) 106 | self.assertEqual(agents[0].col1, "name1") 107 | self.assertEqual(agents[0].geometry, Point(1.0, 2.0)) 108 | self.assertEqual(agents[0].crs, "epsg:4326") 109 | 110 | self.assertEqual(agents[1].unique_id, 2) 111 | self.assertEqual(agents[1].col1, "name2") 112 | self.assertEqual(agents[1].geometry, Point(2.0, 1.0)) 113 | self.assertEqual(agents[1].crs, "epsg:4326") 114 | 115 | def test_from_GeoJSON_and_agent_creator_with_crs(self): 116 | agents = self.agent_creator_with_crs.from_GeoJSON(self.geojson_str) 117 | 118 | self.assertEqual(len(agents), 2) 119 | 120 | self.assertEqual(agents[0].unique_id, 1) 121 | self.assertEqual(agents[0].col1, "name1") 122 | self.assertTrue( 123 | agents[0].geometry.equals_exact( 124 | Point(111319.49079327357, 222684.20850554403), tolerance=1e-6 125 | ) 126 | ) 127 | self.assertEqual(agents[0].crs, self.agent_creator_with_crs.crs) 128 | 129 | self.assertEqual(agents[1].unique_id, 2) 130 | self.assertEqual(agents[1].col1, "name2") 131 | self.assertTrue( 132 | agents[1].geometry.equals_exact( 133 | Point(222638.98158654713, 111325.14286638508), tolerance=1e-6 134 | ) 135 | ) 136 | self.assertEqual(agents[1].crs, self.agent_creator_with_crs.crs) 137 | 138 | def test_from_GeoDataFrame_without_crs_and_agent_creator_without_crs(self): 139 | gdf = gpd.GeoDataFrame( 140 | self.df, geometry=gpd.points_from_xy(self.df.Longitude, self.df.Latitude) 141 | ) 142 | with self.assertRaises(TypeError): 143 | self.agent_creator_without_crs.from_GeoDataFrame(gdf) 144 | 145 | def test_from_GeoDataFrame_without_crs_and_agent_creator_with_crs(self): 146 | gdf = gpd.GeoDataFrame( 147 | self.df, geometry=gpd.points_from_xy(self.df.Longitude, self.df.Latitude) 148 | ) 149 | agents = self.agent_creator_with_crs.from_GeoDataFrame(gdf) 150 | 151 | self.assertEqual(len(agents), 5) 152 | 153 | self.assertEqual(agents[0].City, "Buenos Aires") 154 | self.assertEqual(agents[0].Country, "Argentina") 155 | self.assertEqual(agents[0].geometry, Point(-58.66, -34.58)) 156 | self.assertEqual(agents[0].crs, self.agent_creator_with_crs.crs) 157 | 158 | def test_from_GeoDataFrame_with_crs_and_agent_creator_without_crs(self): 159 | gdf = gpd.GeoDataFrame( 160 | self.df, 161 | geometry=gpd.points_from_xy(self.df.Longitude, self.df.Latitude), 162 | crs="epsg:2263", 163 | ) 164 | agents = self.agent_creator_without_crs.from_GeoDataFrame(gdf) 165 | 166 | self.assertEqual(len(agents), 5) 167 | 168 | self.assertEqual(agents[0].City, "Buenos Aires") 169 | self.assertEqual(agents[0].Country, "Argentina") 170 | self.assertEqual(agents[0].geometry, Point(-58.66, -34.58)) 171 | self.assertEqual(agents[0].crs, gdf.crs) 172 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Mesa-Geo.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Mesa-Geo.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Mesa-Geo" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Mesa-Geo" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /mesa_geo/geoagent.py: -------------------------------------------------------------------------------- 1 | """ 2 | GeoAgent and AgentCreator classes 3 | --------------------------------- 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import copy 9 | import json 10 | 11 | import geopandas as gpd 12 | import numpy as np 13 | import pyproj 14 | from mesa import Agent, Model 15 | from shapely.geometry import mapping 16 | from shapely.geometry.base import BaseGeometry 17 | from shapely.ops import transform 18 | 19 | from mesa_geo.geo_base import GeoBase 20 | 21 | 22 | class GeoAgent(Agent, GeoBase): 23 | """ 24 | Base class for a geo model agent. 25 | """ 26 | 27 | def __init__(self, model, geometry, crs): 28 | """ 29 | Create a new agent. 30 | 31 | :param model: The model the agent is in. 32 | :param geometry: A Shapely object representing the geometry of the agent. 33 | :param crs: The coordinate reference system of the geometry. 34 | """ 35 | 36 | Agent.__init__(self, model) 37 | GeoBase.__init__(self, crs=crs) 38 | self.geometry = geometry 39 | 40 | @property 41 | def total_bounds(self) -> np.ndarray | None: 42 | if self.geometry is not None: 43 | return self.geometry.bounds 44 | else: 45 | return None 46 | 47 | def to_crs(self, crs, inplace=False) -> GeoAgent | None: 48 | super()._to_crs_check(crs) 49 | 50 | agent = self if inplace else copy.copy(self) 51 | 52 | if not agent.crs.is_exact_same(crs): 53 | transformer = pyproj.Transformer.from_crs( 54 | crs_from=agent.crs, crs_to=crs, always_xy=True 55 | ) 56 | agent.geometry = agent.get_transformed_geometry(transformer) 57 | agent.crs = crs 58 | 59 | if not inplace: 60 | return agent 61 | 62 | def get_transformed_geometry(self, transformer): 63 | """ 64 | Return the transformed geometry given a transformer. 65 | """ 66 | 67 | return transform(transformer.transform, self.geometry) 68 | 69 | def step(self): 70 | """ 71 | Advance one step. 72 | """ 73 | 74 | def __geo_interface__(self): 75 | """ 76 | Return a GeoJSON Feature. Removes geometry from attributes. 77 | """ 78 | 79 | properties = dict(vars(self)) 80 | properties["model"] = str(self.model) 81 | geometry = properties.pop("geometry") 82 | geometry = transform(self.model.space.transformer.transform, geometry) 83 | 84 | return { 85 | "type": "Feature", 86 | "geometry": mapping(geometry), 87 | "properties": properties, 88 | } 89 | 90 | 91 | class AgentCreator: 92 | """ 93 | Create GeoAgents from files, GeoDataFrames, GeoJSON or Shapely objects. 94 | """ 95 | 96 | def __init__(self, agent_class, model=None, crs=None, agent_kwargs=None): 97 | """ 98 | Define the agent_class and required agent_kwargs. 99 | 100 | :param agent_class: The class of the agent to create. 101 | :param model: The model to create the agent in. 102 | :param crs: The coordinate reference system of the agent. Default to None, 103 | and the crs from the file/GeoDataFrame/GeoJSON will be used. 104 | Otherwise, geometries are converted into this crs automatically. 105 | :param agent_kwargs: Keyword arguments to pass to the agent_class. 106 | """ 107 | 108 | self.agent_class = agent_class 109 | self.model = model 110 | self.crs = crs 111 | self.agent_kwargs = agent_kwargs if agent_kwargs else {} 112 | 113 | @property 114 | def crs(self): 115 | """ 116 | Return the coordinate reference system of the GeoAgents. 117 | """ 118 | 119 | return self._crs 120 | 121 | @crs.setter 122 | def crs(self, crs): 123 | """ 124 | Set the coordinate reference system of the GeoAgents. 125 | """ 126 | 127 | self._crs = pyproj.CRS.from_user_input(crs) if crs else None 128 | 129 | def create_agent(self, geometry): 130 | """ 131 | Create a single agent from a geometry. Shape must be a valid Shapely object. 132 | 133 | :param geometry: The geometry of the agent. 134 | :return: The created agent. 135 | :rtype: self.agent_class 136 | """ 137 | 138 | if not isinstance(geometry, BaseGeometry): 139 | raise TypeError("Geometry must be a Shapely Geometry") 140 | 141 | if not self.crs: 142 | raise TypeError( 143 | f"Unable to set CRS for {self.agent_class.__name__} due to empty CRS in {self.__class__.__name__}" 144 | ) 145 | 146 | if not isinstance(self.model, Model): 147 | raise ValueError("Model must be a valid Mesa model object") 148 | 149 | new_agent = self.agent_class( 150 | model=self.model, 151 | geometry=geometry, 152 | crs=self.crs, 153 | **self.agent_kwargs, 154 | ) 155 | 156 | return new_agent 157 | 158 | def from_GeoDataFrame(self, gdf, set_attributes=True): 159 | """ 160 | Create a list of agents from a GeoDataFrame. 161 | 162 | :param gdf: The GeoDataFrame to create agents from. 163 | :param set_attributes: Set agent attributes from GeoDataFrame columns. 164 | Default True. 165 | """ 166 | 167 | if self.crs: 168 | if gdf.crs: 169 | gdf.to_crs(self.crs, inplace=True) 170 | else: 171 | gdf.set_crs(self.crs, inplace=True) 172 | else: 173 | if gdf.crs: 174 | self.crs = gdf.crs 175 | else: 176 | raise TypeError( 177 | f"Unable to set CRS for {self.agent_class.__name__} due to empty CRS in both " 178 | f"{self.__class__.__name__} and {gdf.__class__.__name__}." 179 | ) 180 | 181 | agents = [] 182 | for _, row in gdf.iterrows(): 183 | geometry = row[gdf.geometry.name] 184 | new_agent = self.create_agent(geometry=geometry) 185 | 186 | if set_attributes: 187 | for col in row.index: 188 | if col != gdf.geometry.name: 189 | setattr(new_agent, col, row[col]) 190 | agents.append(new_agent) 191 | 192 | return agents 193 | 194 | def from_file(self, filename, set_attributes=True): 195 | """ 196 | Create agents from vector data files (e.g. Shapefiles). 197 | 198 | :param filename: The vector data file to create agents from. 199 | :param set_attributes: Set agent attributes from GeoDataFrame columns. Default True. 200 | """ 201 | 202 | gdf = gpd.read_file(filename) 203 | agents = self.from_GeoDataFrame(gdf, set_attributes=set_attributes) 204 | return agents 205 | 206 | def from_GeoJSON( 207 | self, 208 | GeoJSON, # noqa: N803 209 | set_attributes=True, 210 | ): 211 | """ 212 | Create agents from a GeoJSON object or string. CRS is set to epsg:4326. 213 | 214 | :param GeoJSON: The GeoJSON object or string to create agents from. 215 | :param set_attributes: Set agent attributes from GeoDataFrame columns. Default True. 216 | """ 217 | 218 | gj = json.loads(GeoJSON) if isinstance(GeoJSON, str) else GeoJSON 219 | 220 | gdf = gpd.GeoDataFrame.from_features(gj) 221 | # epsg:4326 is the CRS for all GeoJSON: https://datatracker.ietf.org/doc/html/rfc7946#section-4 222 | gdf.crs = "epsg:4326" 223 | agents = self.from_GeoDataFrame(gdf, set_attributes=set_attributes) 224 | return agents 225 | -------------------------------------------------------------------------------- /mesa_geo/visualization/geojupyter_viz.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | import matplotlib.pyplot as plt 4 | import mesa.visualization.components.matplotlib_components as components_matplotlib 5 | import solara 6 | import xyzservices.providers as xyz 7 | from mesa.visualization import solara_viz as jv 8 | from solara.alias import rv 9 | 10 | import mesa_geo.visualization.leaflet_viz as leaflet_viz 11 | 12 | # Avoid interactive backend 13 | plt.switch_backend("agg") 14 | 15 | 16 | # TODO: Turn this function into a Solara component once the current_step.value 17 | # dependency is passed to measure() 18 | """ 19 | Geo-Mesa Visualization Module 20 | ============================= 21 | Card: Helper Function that initiates the Solara Card for Browser 22 | GeoJupyterViz: Main Function users employ to create visualization 23 | """ 24 | 25 | 26 | def Card( 27 | model, 28 | measures, 29 | agent_portrayal, 30 | map_drawer, 31 | center_default, 32 | zoom, 33 | scroll_wheel_zoom, 34 | current_step, 35 | color, 36 | layout_type, 37 | ): 38 | """ 39 | 40 | 41 | Parameters 42 | ---------- 43 | model : Mesa Model Object 44 | A pointer to the Mesa Model object this allows the visual to get get 45 | model information, such as scheduler and space. 46 | measures : List 47 | Plots associated with model typically from datacollector that represent 48 | critical information collected from the model. 49 | agent_portrayal : Dictionary 50 | Contains details of how visualization should plot key elements of the 51 | such as agent color etc 52 | map_drawer : Method 53 | Function that generates map from GIS data of model 54 | center_default : List 55 | Latitude and Longitude of where center of map should be located 56 | zoom : Int 57 | Zoom level at which to initialize the map 58 | scroll_wheel_zoom: Boolean 59 | True of False on whether user can zoom on map with mouse scroll wheel 60 | default is True 61 | current_step : Int 62 | Number on which step is the model 63 | color : String 64 | Background color for visual 65 | layout_type : String 66 | Type of layout Map or Measure 67 | 68 | Returns 69 | ------- 70 | main : Solara object 71 | Visualization of model 72 | 73 | """ 74 | 75 | with rv.Card( 76 | style_=f"background-color: {color}; width: 100%; height: 100%" 77 | ) as main: 78 | if "Map" in layout_type: 79 | rv.CardTitle(children=["Map"]) 80 | leaflet_viz.map(model, map_drawer, zoom, center_default, scroll_wheel_zoom) 81 | 82 | if "Measure" in layout_type: 83 | rv.CardTitle(children=["Measure"]) 84 | measure = measures[layout_type["Measure"]] 85 | if callable(measure): 86 | # Is a custom object 87 | measure(model) 88 | else: 89 | components_matplotlib.PlotMatplotlib( 90 | model, measure, dependencies=[current_step.value] 91 | ) 92 | return main 93 | 94 | 95 | @solara.component 96 | def GeoJupyterViz( 97 | model_class, 98 | model_params, 99 | measures=None, 100 | name=None, 101 | agent_portrayal=None, 102 | play_interval=150, 103 | # parameters for leaflet_viz 104 | view=None, 105 | zoom=None, 106 | scroll_wheel_zoom=True, 107 | tiles=xyz.OpenStreetMap.Mapnik, 108 | center_point=None, # Due to projection challenges in calculation allow user to specify center point 109 | ): 110 | """ 111 | 112 | 113 | Parameters 114 | ---------- 115 | model_class : Mesa Model Object 116 | A pointer to the Mesa Model object this allows the visual to get get 117 | model information, such as scheduler and space. 118 | model_params : Dictionary 119 | Parameters of model with key being the parameter as a string and values being the options 120 | measures : List, optional 121 | Plots associated with model typically from datacollector that represent 122 | critical information collected from the model. The default is None. 123 | name : String, optional 124 | Name of simulation to appear on visual. The default is None. 125 | agent_portrayal : Dictionary, optional 126 | Dictionary of how the agent showed appear. The default is None. 127 | play_interval : INT, optional 128 | Rendering interval of model. The default is 150. 129 | # parameters for leaflet_viz 130 | view : List, optional 131 | Bounds of map to be displayed; must be set with zoom. The default is None. 132 | zoom : Int, optional 133 | Zoom level of map on leaflet 134 | scroll_wheel_zoom : Boolean, optional 135 | True of False for whether or not to enable scroll wheel. The default is True. 136 | Recommend False when using jupyter due to multiple scroll wheel options 137 | tiles : Data source for GIS data, optional 138 | Data Source for GIS map data. The default is xyz.OpenStreetMap.Mapnik. 139 | # Due to projection challenges in calculation allow user to specify 140 | center_point : List, optional 141 | Option to pass in center coordinates of map The default is None.. The default is None. 142 | 143 | 144 | Returns 145 | ------- 146 | Provides information to Card to render model 147 | 148 | """ 149 | 150 | warnings.warn( 151 | "`GeoJupyterViz` is deprecated and will be removed in a future release. Use Mesa's SolaraViz and Mesa-Geo's `make_geospace_leaflet` instead.", 152 | DeprecationWarning, 153 | stacklevel=2, 154 | ) 155 | 156 | if name is None: 157 | name = model_class.__name__ 158 | 159 | current_step = solara.use_reactive(0) 160 | 161 | # 1. Set up model parameters 162 | user_params, fixed_params = jv.split_model_params(model_params) 163 | model_parameters, set_model_parameters = solara.use_state( 164 | {**fixed_params, **{k: v.get("value") for k, v in user_params.items()}} 165 | ) 166 | 167 | # 2. Set up Model 168 | def make_model(): 169 | model = model_class(**model_parameters) 170 | current_step.value = 0 171 | return model 172 | 173 | reset_counter = solara.use_reactive(0) 174 | model = solara.use_memo( 175 | make_model, dependencies=[*list(model_parameters.values()), reset_counter.value] 176 | ) 177 | 178 | def handle_change_model_params(name: str, value: any): 179 | set_model_parameters({**model_parameters, name: value}) 180 | 181 | # 3. Set up UI 182 | with solara.AppBar(): 183 | solara.AppBarTitle(name) 184 | 185 | # 4. Set Up Map 186 | # render layout, pass through map build parameters 187 | map_drawer = leaflet_viz.MapModule( 188 | portrayal_method=agent_portrayal, 189 | view=view, 190 | zoom=zoom, 191 | tiles=tiles, 192 | scroll_wheel_zoom=scroll_wheel_zoom, 193 | ) 194 | layers = map_drawer.render(model) 195 | 196 | # determine center point 197 | if center_point: 198 | center_default = center_point 199 | else: 200 | bounds = layers["layers"]["total_bounds"] 201 | center_default = [ 202 | (bounds[0][0] + bounds[1][0]) / 2, 203 | (bounds[0][1] + bounds[1][1]) / 2, 204 | ] 205 | 206 | # Build base data structure for layout 207 | layout_types = [{"Map": "default"}] 208 | 209 | if measures: 210 | layout_types += [{"Measure": elem} for elem in range(len(measures))] 211 | 212 | grid_layout_initial = jv.make_initial_grid_layout(layout_types=layout_types) 213 | grid_layout, set_grid_layout = solara.use_state(grid_layout_initial) 214 | 215 | with solara.Sidebar(): 216 | with solara.Card("Controls", margin=1, elevation=2): 217 | jv.UserInputs(user_params, on_change=handle_change_model_params) 218 | jv.ModelController(model, play_interval, current_step, reset_counter) 219 | with solara.Card("Progress", margin=1, elevation=2): 220 | solara.Markdown(md_text=f"####Step - {current_step}") 221 | 222 | items = [ 223 | Card( 224 | model, 225 | measures, 226 | agent_portrayal, 227 | map_drawer, 228 | center_default, 229 | zoom, 230 | scroll_wheel_zoom, 231 | current_step, 232 | color="white", 233 | layout_type=layout_types[i], 234 | ) 235 | for i in range(len(layout_types)) 236 | ] 237 | 238 | solara.GridDraggable( 239 | items=items, 240 | grid_layout=grid_layout, 241 | resizable=True, 242 | draggable=True, 243 | on_grid_layout=set_grid_layout, 244 | ) 245 | -------------------------------------------------------------------------------- /tests/test_MapModule.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import mesa 4 | import numpy as np 5 | import xyzservices.providers as xyz 6 | from ipyleaflet import Circle, CircleMarker, Marker 7 | from shapely.geometry import LineString, Point, Polygon 8 | 9 | import mesa_geo as mg 10 | import mesa_geo.visualization as mgv 11 | 12 | 13 | class TestMapModule(unittest.TestCase): 14 | def setUp(self) -> None: 15 | self.model = mesa.Model() 16 | self.model.space = mg.GeoSpace(crs="epsg:4326") 17 | self.agent_creator = mg.AgentCreator( 18 | agent_class=mg.GeoAgent, model=self.model, crs="epsg:4326" 19 | ) 20 | self.points = [Point(1, 1)] * 7 21 | self.point_agents = [ 22 | self.agent_creator.create_agent(point) for point in self.points 23 | ] 24 | self.lines = [LineString([(1, 1), (2, 2)])] * 9 25 | self.line_agents = [ 26 | self.agent_creator.create_agent(line) for line in self.lines 27 | ] 28 | self.polygons = [Polygon([(1, 1), (2, 2), (4, 4)])] * 3 29 | self.polygon_agents = [ 30 | self.agent_creator.create_agent(polygon) for polygon in self.polygons 31 | ] 32 | self.raster_layer = mg.RasterLayer( 33 | 1, 1, crs="epsg:4326", total_bounds=[0, 0, 1, 1], model=self.model 34 | ) 35 | self.raster_layer.apply_raster(np.array([[[0]]])) 36 | 37 | def tearDown(self) -> None: 38 | pass 39 | 40 | def test_render_point_agents(self): 41 | # test length point agents and Circle marker as default 42 | map_module = mgv.MapModule( 43 | portrayal_method=lambda x: {"color": "Green"}, 44 | tiles=xyz.OpenStreetMap.Mapnik, 45 | ) 46 | self.model.space.add_agents(self.point_agents) 47 | self.assertEqual(len(map_module.render(self.model).get("agents")[1]), 7) 48 | self.assertIsInstance(map_module.render(self.model).get("agents")[1][3], Circle) 49 | # test CircleMarker option 50 | map_module = mgv.MapModule( 51 | portrayal_method=lambda x: { 52 | "marker_type": "CircleMarker", 53 | "color": "Green", 54 | }, 55 | tiles=xyz.OpenStreetMap.Mapnik, 56 | ) 57 | self.model.space.add_agents(self.point_agents) 58 | self.assertIsInstance( 59 | map_module.render(self.model).get("agents")[1][3], CircleMarker 60 | ) 61 | 62 | # test Marker option 63 | map_module = mgv.MapModule( 64 | portrayal_method=lambda x: { 65 | "marker_type": "AwesomeIcon", 66 | "name": "bus", 67 | "color": "Green", 68 | }, 69 | tiles=xyz.OpenStreetMap.Mapnik, 70 | ) 71 | self.model.space.add_agents(self.point_agents) 72 | self.assertEqual(len(map_module.render(self.model).get("agents")[1]), 7) 73 | self.assertIsInstance(map_module.render(self.model).get("agents")[1][3], Marker) 74 | # test popupProperties for Point 75 | map_module = mgv.MapModule( 76 | portrayal_method=lambda x: { 77 | "color": "Red", 78 | "radius": 7, 79 | "description": "popupMsg", 80 | }, 81 | tiles=xyz.OpenStreetMap.Mapnik, 82 | ) 83 | self.model.space.add_agents(self.point_agents) 84 | print(map_module.render(self.model).get("agents")[0]) 85 | self.assertDictEqual( 86 | map_module.render(self.model).get("agents")[0], 87 | { 88 | "type": "FeatureCollection", 89 | "features": [] * len(self.point_agents), 90 | }, 91 | ) 92 | 93 | # test ValueError if not known markertype 94 | map_module = mgv.MapModule( 95 | portrayal_method=lambda x: {"marker_type": "Hexagon", "color": "Green"}, 96 | tiles=xyz.OpenStreetMap.Mapnik, 97 | ) 98 | self.model.space.add_agents(self.point_agents) 99 | with self.assertRaises(ValueError): 100 | map_module.render(self.model) 101 | 102 | def test_render_line_agents(self): 103 | map_module = mgv.MapModule( 104 | portrayal_method=lambda x: {"color": "#3388ff", "weight": 7}, 105 | tiles=xyz.OpenStreetMap.Mapnik, 106 | ) 107 | self.model.space.add_agents(self.line_agents) 108 | self.assertDictEqual( 109 | map_module.render(self.model).get("agents")[0], 110 | { 111 | "type": "FeatureCollection", 112 | "features": [ 113 | { 114 | "type": "Feature", 115 | "geometry": { 116 | "type": "LineString", 117 | "coordinates": ((1.0, 1.0), (2.0, 2.0)), 118 | }, 119 | "properties": {"style": {"color": "#3388ff", "weight": 7}}, 120 | } 121 | ] 122 | * len(self.line_agents), 123 | }, 124 | ) 125 | 126 | map_module = mgv.MapModule( 127 | portrayal_method=lambda x: { 128 | "color": "#3388ff", 129 | "weight": 7, 130 | "description": "popupMsg", 131 | }, 132 | tiles=xyz.OpenStreetMap.Mapnik, 133 | ) 134 | self.model.space.add_agents(self.line_agents) 135 | self.assertDictEqual( 136 | map_module.render(self.model).get("agents")[0], 137 | { 138 | "type": "FeatureCollection", 139 | "features": [ 140 | { 141 | "type": "Feature", 142 | "geometry": { 143 | "type": "LineString", 144 | "coordinates": ((1.0, 1.0), (2.0, 2.0)), 145 | }, 146 | "properties": { 147 | "style": {"color": "#3388ff", "weight": 7}, 148 | "popupProperties": "popupMsg", 149 | }, 150 | } 151 | ] 152 | * len(self.line_agents), 153 | }, 154 | ) 155 | 156 | def test_render_polygon_agents(self): 157 | self.maxDiff = None 158 | 159 | map_module = mgv.MapModule( 160 | portrayal_method=lambda x: {"fillColor": "#3388ff", "fillOpacity": 0.7}, 161 | tiles=xyz.OpenStreetMap.Mapnik, 162 | ) 163 | self.model.space.add_agents(self.polygon_agents) 164 | self.assertDictEqual( 165 | map_module.render(self.model).get("agents")[0], 166 | { 167 | "type": "FeatureCollection", 168 | "features": [ 169 | { 170 | "type": "Feature", 171 | "geometry": { 172 | "type": "Polygon", 173 | "coordinates": ( 174 | ((1.0, 1.0), (2.0, 2.0), (4.0, 4.0), (1.0, 1.0)), 175 | ), 176 | }, 177 | "properties": { 178 | "style": {"fillColor": "#3388ff", "fillOpacity": 0.7} 179 | }, 180 | } 181 | ] 182 | * len(self.polygon_agents), 183 | }, 184 | ) 185 | 186 | map_module = mgv.MapModule( 187 | portrayal_method=lambda x: { 188 | "fillColor": "#3388ff", 189 | "fillOpacity": 0.7, 190 | "description": "popupMsg", 191 | }, 192 | tiles=xyz.OpenStreetMap.Mapnik, 193 | ) 194 | self.model.space.add_agents(self.polygon_agents) 195 | self.assertDictEqual( 196 | map_module.render(self.model).get("agents")[0], 197 | { 198 | "type": "FeatureCollection", 199 | "features": [ 200 | { 201 | "type": "Feature", 202 | "geometry": { 203 | "type": "Polygon", 204 | "coordinates": ( 205 | ((1.0, 1.0), (2.0, 2.0), (4.0, 4.0), (1.0, 1.0)), 206 | ), 207 | }, 208 | "properties": { 209 | "style": {"fillColor": "#3388ff", "fillOpacity": 0.7}, 210 | "popupProperties": "popupMsg", 211 | }, 212 | } 213 | ] 214 | * len(self.polygon_agents), 215 | }, 216 | ) 217 | 218 | def test_render_raster_layers(self): 219 | map_module = mgv.MapModule( 220 | portrayal_method=lambda x: (255, 255, 255, 0.5), 221 | tiles=xyz.OpenStreetMap.Mapnik, 222 | ) 223 | self.model.space.add_layer(self.raster_layer) 224 | self.model.space.add_layer( 225 | self.raster_layer.to_image(colormap=lambda x: (0, 0, 0, 1)) 226 | ) 227 | self.assertDictEqual( 228 | map_module.render(self.model).get("layers"), 229 | { 230 | "rasters": [ 231 | { 232 | "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP4DwQACfsD/Wj6HMwAAAAASUVORK5CYII=", 233 | "bounds": [[0.0, 0.0], [1.0, 1.0]], 234 | }, 235 | { 236 | "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNgYGD4DwABBAEAgLvRWwAAAABJRU5ErkJggg==", 237 | "bounds": [[0.0, 0.0], [1.0, 1.0]], 238 | }, 239 | ], 240 | "total_bounds": [[0.0, 0.0], [1.0, 1.0]], 241 | "vectors": [], 242 | }, 243 | ) 244 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Mesa-Geo documentation build configuration file, created by 4 | # sphinx-quickstart on Sun Jan 4 23:34:09 2015. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | from datetime import date 18 | 19 | 20 | # If extensions (or modules to document with autodoc) are in another directory, 21 | # add these directories to sys.path here. If the directory is relative to the 22 | # documentation root, use os.path.abspath to make it absolute, like shown here. 23 | sys.path.insert(0, os.path.abspath(".")) 24 | sys.path.insert(0, "../examples") 25 | sys.path.insert(0, "../mesa_geo") 26 | 27 | 28 | # -- General configuration ------------------------------------------------ 29 | 30 | # If your documentation needs a minimal Sphinx version, state it here. 31 | # needs_sphinx = '1.0' 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 35 | # ones. 36 | extensions = [ 37 | "sphinx.ext.autodoc", 38 | "sphinx.ext.doctest", 39 | "sphinx.ext.intersphinx", 40 | "sphinx.ext.todo", 41 | "sphinx.ext.coverage", 42 | "sphinx.ext.mathjax", 43 | "sphinx.ext.ifconfig", 44 | "sphinx.ext.viewcode", 45 | "myst_nb" 46 | ] 47 | 48 | # Add any paths that contain templates here, relative to this directory. 49 | templates_path = ["_templates"] 50 | 51 | # The suffix of source filenames. 52 | source_suffix = [".rst", ".md"] 53 | 54 | # The encoding of source files. 55 | # source_encoding = 'utf-8-sig' 56 | 57 | # The master toctree document. 58 | master_doc = "index" 59 | 60 | # General information about the project. 61 | project = "Mesa-Geo" 62 | copyright = f"2017-{date.today().year}, Project Mesa Team" 63 | 64 | # The version info for the project you're documenting, acts as replacement for 65 | # |version| and |release|, also used in various other places throughout the 66 | # built documents. 67 | # 68 | # The short X.Y version. 69 | version = "0.9.1" 70 | # The full version, including alpha/beta/rc tags. 71 | release = "0.9.1" 72 | 73 | # The language for content autogenerated by Sphinx. Refer to documentation 74 | # for a list of supported languages. 75 | # language = None 76 | 77 | # There are two options for replacing |today|: either, you set today to some 78 | # non-false value, then it is used: 79 | # today = '' 80 | # Else, today_fmt is used as the format for a strftime call. 81 | # today_fmt = '%B %d, %Y' 82 | 83 | # List of patterns, relative to source directory, that match files and 84 | # directories to ignore when looking for source files. 85 | exclude_patterns = ["_build"] 86 | 87 | # The reST default role (used for this markup: `text`) to use for all 88 | # documents. 89 | # default_role = None 90 | 91 | # If true, '()' will be appended to :func: etc. cross-reference text. 92 | # add_function_parentheses = True 93 | 94 | # If true, the current module name will be prepended to all description 95 | # unit titles (such as .. function::). 96 | add_module_names = False 97 | 98 | # Sort members by the order in the source files instead of alphabetically 99 | autodoc_member_order = "bysource" 100 | 101 | # Show both the class-level docstring and the constructor docstring 102 | autoclass_content = "both" 103 | 104 | # If true, sectionauthor and moduleauthor directives will be shown in the 105 | # output. They are ignored by default. 106 | # show_authors = False 107 | 108 | # The name of the Pygments (syntax highlighting) style to use. 109 | pygments_style = "gruvbox-dark" 110 | 111 | # A list of ignored prefixes for module index sorting. 112 | # modindex_common_prefix = [] 113 | 114 | # If true, keep warnings as "system message" paragraphs in the built documents. 115 | # keep_warnings = False 116 | 117 | # --- Options for myst_nb ----------------------------------------------- 118 | 119 | #prevents cell output 120 | nb_remove_code_outputs = True 121 | nb_execution_allow_errors = False 122 | nb_execution_raise_on_error = True 123 | 124 | # -- Options for HTML output ---------------------------------------------- 125 | 126 | # The theme to use for HTML and HTML Help pages. See the documentation for 127 | # a list of builtin themes. 128 | html_theme = "pydata_sphinx_theme" 129 | 130 | # Theme options are theme-specific and customize the look and feel of a theme 131 | # further. For a list of options available for each theme, see the 132 | # documentation. 133 | # html_theme_options = {} 134 | 135 | # Add any paths that contain custom themes here, relative to this directory. 136 | # html_theme_path = [] 137 | 138 | # The name for this set of Sphinx documents. If None, it defaults to 139 | # " v documentation". 140 | # html_title = None 141 | 142 | # A shorter title for the navigation bar. Default is the same as html_title. 143 | # html_short_title = None 144 | 145 | # The name of an image file (relative to this directory) to place at the top 146 | # of the sidebar. 147 | # html_logo = "images/mesa_geo_logo.png" 148 | 149 | # The name of an image file (within the static path) to use as favicon of the 150 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 151 | # pixels large. 152 | # html_favicon = "images/mesa_geo_logo.ico" 153 | 154 | # Add any paths that contain custom static files (such as style sheets) here, 155 | # relative to this directory. They are copied after the builtin static files, 156 | # so a file named "default.css" will overwrite the builtin "default.css". 157 | html_static_path = ["_static"] 158 | 159 | # Add any extra paths that contain custom files (such as robots.txt or 160 | # .htaccess) here, relative to this directory. These files are copied 161 | # directly to the root of the documentation. 162 | # html_extra_path = [] 163 | 164 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 165 | # using the given strftime format. 166 | # html_last_updated_fmt = '%b %d, %Y' 167 | 168 | # If true, SmartyPants will be used to convert quotes and dashes to 169 | # typographically correct entities. 170 | # html_use_smartypants = True 171 | 172 | # Custom sidebar templates, maps document names to template names. 173 | # html_sidebars = {} 174 | 175 | # Additional templates that should be rendered to pages, maps page names to 176 | # template names. 177 | # html_additional_pages = {} 178 | 179 | # If false, no module index is generated. 180 | # html_domain_indices = True 181 | 182 | # If false, no index is generated. 183 | # html_use_index = True 184 | 185 | # If true, the index is split into individual pages for each letter. 186 | # html_split_index = False 187 | 188 | # If true, links to the reST sources are added to the pages. 189 | # html_show_sourcelink = True 190 | 191 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 192 | html_show_sphinx = False 193 | 194 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 195 | # html_show_copyright = True 196 | 197 | # If true, an OpenSearch description file will be output, and all pages will 198 | # contain a tag referring to it. The value of this option must be the 199 | # base URL from which the finished HTML is served. 200 | # html_use_opensearch = '' 201 | 202 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 203 | # html_file_suffix = None 204 | 205 | # Output file base name for HTML help builder. 206 | htmlhelp_basename = "MesaGeodoc" 207 | 208 | 209 | # -- Options for LaTeX output --------------------------------------------- 210 | 211 | latex_elements = { 212 | # The paper size ('letterpaper' or 'a4paper'). 213 | #'papersize': 'letterpaper', 214 | # The font size ('10pt', '11pt' or '12pt'). 215 | #'pointsize': '10pt', 216 | # Additional stuff for the LaTeX preamble. 217 | #'preamble': '', 218 | } 219 | 220 | # Grouping the document tree into LaTeX files. List of tuples 221 | # (source start file, target name, title, 222 | # author, documentclass [howto, manual, or own class]). 223 | latex_documents = [ 224 | ( 225 | "index", 226 | "Mesa-Geo.tex", 227 | "Mesa-Geo Documentation", 228 | "Project Mesa Team", 229 | "manual", 230 | ) 231 | ] 232 | 233 | # The name of an image file (relative to this directory) to place at the top of 234 | # the title page. 235 | # latex_logo = None 236 | 237 | # For "manual" documents, if this is true, then toplevel headings are parts, 238 | # not chapters. 239 | # latex_use_parts = False 240 | 241 | # If true, show page references after internal links. 242 | # latex_show_pagerefs = False 243 | 244 | # If true, show URL addresses after external links. 245 | # latex_show_urls = False 246 | 247 | # Documents to append as an appendix to all manuals. 248 | # latex_appendices = [] 249 | 250 | # If false, no module index is generated. 251 | # latex_domain_indices = True 252 | 253 | 254 | # -- Options for manual page output --------------------------------------- 255 | 256 | # One entry per manual page. List of tuples 257 | # (source start file, name, description, authors, manual section). 258 | man_pages = [ 259 | ("index", "mesa-geo", "Mesa-Geo Documentation", ["Project Mesa Team"], 1) 260 | ] 261 | 262 | # If true, show URL addresses after external links. 263 | # man_show_urls = False 264 | 265 | 266 | # -- Options for Texinfo output ------------------------------------------- 267 | 268 | # Grouping the document tree into Texinfo files. List of tuples 269 | # (source start file, target name, title, author, 270 | # dir menu entry, description, category) 271 | texinfo_documents = [ 272 | ( 273 | "index", 274 | "Mesa-Geo", 275 | "Mesa-Geo Documentation", 276 | "Project Mesa Team", 277 | "One line description of project.", 278 | "Miscellaneous", 279 | ) 280 | ] 281 | 282 | # Documents to append as an appendix to all manuals. 283 | # texinfo_appendices = [] 284 | 285 | # If false, no module index is generated. 286 | # texinfo_domain_indices = True 287 | 288 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 289 | # texinfo_show_urls = 'footnote' 290 | 291 | # If true, do not generate a @detailmenu in the "Top" node's menu. 292 | # texinfo_no_detailmenu = False 293 | 294 | 295 | # Example configuration for intersphinx: refer to the Python standard library. 296 | intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} 297 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /tests/test_GeoSpace.py: -------------------------------------------------------------------------------- 1 | import random 2 | import unittest 3 | import warnings 4 | 5 | import geopandas as gpd 6 | import mesa 7 | import numpy as np 8 | from shapely.geometry import Point, Polygon 9 | 10 | import mesa_geo as mg 11 | 12 | 13 | class TestGeoSpace(unittest.TestCase): 14 | def setUp(self) -> None: 15 | self.model = mesa.Model() 16 | self.model.space = mg.GeoSpace(crs="epsg:4326") 17 | self.agent_creator = mg.AgentCreator( 18 | agent_class=mg.GeoAgent, model=self.model, crs="epsg:3857" 19 | ) 20 | self.geometries = [Point(1, 1)] * 7 21 | self.agents = [ 22 | self.agent_creator.create_agent( 23 | geometry=geometry, 24 | ) 25 | for geometry in self.geometries 26 | ] 27 | self.polygon_agent = mg.GeoAgent( 28 | model=self.model, 29 | geometry=Polygon([(0, 0), (0, 2), (2, 2), (2, 0)]), 30 | crs="epsg:3857", 31 | ) 32 | self.touching_agent = mg.GeoAgent( 33 | model=self.model, 34 | geometry=Polygon([(2, 0), (2, 2), (4, 2), (4, 0)]), 35 | crs="epsg:3857", 36 | ) 37 | self.disjoint_agent = mg.GeoAgent( 38 | model=self.model, 39 | geometry=Polygon([(10, 10), (10, 12), (12, 12), (12, 10)]), 40 | crs="epsg:3857", 41 | ) 42 | self.image_layer = mg.ImageLayer( 43 | values=np.random.uniform(low=0, high=255, size=(3, 500, 500)), 44 | crs="epsg:4326", 45 | total_bounds=[ 46 | -122.26638888878, 47 | 42.855833333, 48 | -121.94972222209202, 49 | 43.01472222189958, 50 | ], 51 | ) 52 | self.vector_layer = gpd.GeoDataFrame( 53 | {"name": ["point_1", "point_2"], "geometry": [Point(1, 2), Point(2, 1)]}, 54 | crs="epsg:4326", 55 | ) 56 | self.geo_space = mg.GeoSpace() 57 | self.geo_space_with_different_crs = mg.GeoSpace(crs="epsg:2283") 58 | 59 | def tearDown(self) -> None: 60 | pass 61 | 62 | def test_add_agents_with_the_same_crs(self): 63 | self.assertEqual(len(self.geo_space.agents), 0) 64 | self.geo_space.add_agents(self.agents) 65 | self.assertEqual(len(self.geo_space.agents), len(self.agents)) 66 | 67 | for agent in self.geo_space.agents: 68 | self.assertEqual(agent.crs, self.geo_space.crs) 69 | 70 | def test_add_agents_with_different_crs(self): 71 | self.assertEqual(len(self.geo_space_with_different_crs.agents), 0) 72 | with self.assertWarns(Warning): 73 | self.geo_space_with_different_crs.add_agents(self.agents) 74 | 75 | self.geo_space_with_different_crs.warn_crs_conversion = False 76 | # assert no warning 77 | with warnings.catch_warnings(): 78 | warnings.simplefilter("error") 79 | self.geo_space_with_different_crs.add_agents(self.agents) 80 | 81 | self.geo_space_with_different_crs.add_agents(self.agents) 82 | self.assertEqual( 83 | len(self.geo_space_with_different_crs.agents), len(self.agents) 84 | ) 85 | 86 | for agent in self.geo_space_with_different_crs.agents: 87 | self.assertEqual(agent.crs, self.geo_space_with_different_crs.crs) 88 | 89 | def test_remove_agent(self): 90 | self.geo_space.add_agents(self.agents) 91 | agent_to_remove = random.choice(self.agents) 92 | self.geo_space.remove_agent(agent_to_remove) 93 | remaining_agent_idx = {agent.unique_id for agent in self.geo_space.agents} 94 | 95 | self.assertEqual(len(self.geo_space.agents), len(self.agents) - 1) 96 | self.assertTrue(agent_to_remove.unique_id not in remaining_agent_idx) 97 | 98 | def test_add_image_layer(self): 99 | with self.assertWarns(Warning): 100 | self.geo_space.add_layer(self.image_layer) 101 | self.assertEqual(len(self.geo_space.layers), 1) 102 | 103 | self.geo_space.warn_crs_conversion = False 104 | # assert no warning 105 | with warnings.catch_warnings(): 106 | warnings.simplefilter("error") 107 | self.geo_space.add_layer(self.image_layer) 108 | self.assertEqual(len(self.geo_space.layers), 2) 109 | 110 | def test_add_vector_layer(self): 111 | with self.assertWarns(Warning): 112 | self.geo_space.add_layer(self.vector_layer) 113 | self.assertEqual(len(self.geo_space.layers), 1) 114 | 115 | self.geo_space.warn_crs_conversion = False 116 | # assert no warning 117 | with warnings.catch_warnings(): 118 | warnings.simplefilter("error") 119 | self.geo_space.add_layer(self.vector_layer) 120 | self.assertEqual(len(self.geo_space.layers), 2) 121 | 122 | def test_get_neighbors_within_distance(self): 123 | self.geo_space.add_agents(self.agents) 124 | agent_to_check = random.choice(self.agents) 125 | 126 | neighbors = list( 127 | self.geo_space.get_neighbors_within_distance( 128 | agent_to_check, distance=1.0, center=True 129 | ) 130 | ) 131 | self.assertEqual(len(neighbors), 7) 132 | 133 | neighbors = list( 134 | self.geo_space.get_neighbors_within_distance(agent_to_check, distance=1.0) 135 | ) 136 | self.assertEqual(len(neighbors), 7) 137 | 138 | def test_get_agents_as_GeoDataFrame(self): 139 | self.geo_space.add_agents(self.agents) 140 | 141 | agents_list = [{"geometry": agent.geometry} for agent in self.agents] 142 | agents_gdf = gpd.GeoDataFrame.from_records(agents_list) 143 | # workaround for geometry column not being set in `from_records` 144 | # see https://github.com/geopandas/geopandas/issues/3152 145 | # may be removed when the issue is resolved 146 | agents_gdf.set_geometry("geometry", inplace=True) 147 | agents_gdf.crs = self.geo_space.crs 148 | 149 | self.assertEqual( 150 | self.geo_space.get_agents_as_GeoDataFrame().crs, agents_gdf.crs 151 | ) 152 | 153 | def test_get_relation_contains(self): 154 | self.geo_space.add_agents(self.polygon_agent) 155 | self.assertEqual( 156 | list(self.geo_space.get_relation(self.polygon_agent, relation="contains")), 157 | [], 158 | ) 159 | 160 | self.geo_space.add_agents(self.agents) 161 | agents_id = {agent.unique_id for agent in self.agents} 162 | contained_agents_id = { 163 | agent.unique_id 164 | for agent in self.geo_space.get_relation( 165 | self.polygon_agent, relation="contains" 166 | ) 167 | } 168 | self.assertEqual(contained_agents_id, agents_id) 169 | 170 | def test_get_relation_within(self): 171 | self.geo_space.add_agents(self.agents[0]) 172 | self.assertEqual( 173 | list(self.geo_space.get_relation(self.agents[0], relation="within")), [] 174 | ) 175 | self.geo_space.add_agents(self.polygon_agent) 176 | within_agent = next( 177 | self.geo_space.get_relation(self.agents[0], relation="within") 178 | ) 179 | self.assertEqual(within_agent.unique_id, self.polygon_agent.unique_id) 180 | 181 | def test_get_relation_touches(self): 182 | self.geo_space.add_agents(self.polygon_agent) 183 | self.assertEqual( 184 | list(self.geo_space.get_relation(self.polygon_agent, relation="touches")), 185 | [], 186 | ) 187 | self.geo_space.add_agents(self.touching_agent) 188 | self.assertEqual( 189 | len( 190 | list( 191 | self.geo_space.get_relation(self.polygon_agent, relation="touches") 192 | ) 193 | ), 194 | 1, 195 | ) 196 | self.assertEqual( 197 | next( 198 | self.geo_space.get_relation(self.polygon_agent, relation="touches") 199 | ).unique_id, 200 | self.touching_agent.unique_id, 201 | ) 202 | 203 | def test_get_relation_intersects(self): 204 | self.geo_space.add_agents(self.polygon_agent) 205 | self.assertEqual( 206 | list( 207 | self.geo_space.get_relation(self.polygon_agent, relation="intersects") 208 | ), 209 | [], 210 | ) 211 | 212 | self.geo_space.add_agents(self.agents) 213 | agents_id = {agent.unique_id for agent in self.agents} 214 | intersecting_agents_id = { 215 | agent.unique_id 216 | for agent in self.geo_space.get_relation( 217 | self.polygon_agent, relation="intersects" 218 | ) 219 | } 220 | self.assertEqual(intersecting_agents_id, agents_id) 221 | 222 | # disjoint agent should not be returned since it is not intersecting 223 | self.geo_space.add_agents(self.disjoint_agent) 224 | intersecting_agents_id = { 225 | agent.unique_id 226 | for agent in self.geo_space.get_relation( 227 | self.polygon_agent, relation="intersects" 228 | ) 229 | } 230 | self.assertEqual(intersecting_agents_id, agents_id) 231 | 232 | def test_get_intersecting_agents(self): 233 | self.geo_space.add_agents(self.polygon_agent) 234 | self.assertEqual( 235 | list(self.geo_space.get_intersecting_agents(self.polygon_agent)), 236 | [], 237 | ) 238 | 239 | self.geo_space.add_agents(self.agents) 240 | agents_id = {agent.unique_id for agent in self.agents} 241 | intersecting_agents_id = { 242 | agent.unique_id 243 | for agent in self.geo_space.get_intersecting_agents(self.polygon_agent) 244 | } 245 | self.assertEqual(intersecting_agents_id, agents_id) 246 | 247 | # disjoint agent should not be returned since it is not intersecting 248 | self.geo_space.add_agents(self.disjoint_agent) 249 | intersecting_agents_id = { 250 | agent.unique_id 251 | for agent in self.geo_space.get_intersecting_agents(self.polygon_agent) 252 | } 253 | self.assertEqual(intersecting_agents_id, agents_id) 254 | 255 | def test_agents_at(self): 256 | self.geo_space.add_agents(self.agents) 257 | self.assertEqual( 258 | len(list(self.geo_space.agents_at(self.agents[0].geometry))), 259 | len(self.agents), 260 | ) 261 | agents_id = {agent.unique_id for agent in self.agents} 262 | agents_id_found = { 263 | agent.unique_id for agent in self.geo_space.agents_at((1, 1)) 264 | } 265 | self.assertEqual(agents_id_found, agents_id) 266 | 267 | def test_get_neighbors(self): 268 | self.geo_space.add_agents(self.polygon_agent) 269 | self.assertEqual(len(self.geo_space.get_neighbors(self.polygon_agent)), 0) 270 | self.geo_space.add_agents(self.touching_agent) 271 | self.assertEqual(len(self.geo_space.get_neighbors(self.polygon_agent)), 1) 272 | self.assertEqual( 273 | self.geo_space.get_neighbors(self.polygon_agent)[0].unique_id, 274 | self.touching_agent.unique_id, 275 | ) 276 | -------------------------------------------------------------------------------- /mesa_geo/visualization/leaflet_viz.py: -------------------------------------------------------------------------------- 1 | """ 2 | # ipyleaflet 3 | Map visualization using [ipyleaflet](https://ipyleaflet.readthedocs.io/), a ipywidgets wrapper for [leaflet.js](https://leafletjs.com/) 4 | """ 5 | 6 | import dataclasses 7 | from dataclasses import dataclass 8 | 9 | import geopandas as gpd 10 | import ipyleaflet 11 | import solara 12 | import xyzservices 13 | from folium.utilities import image_to_url 14 | from shapely.geometry import Point, mapping 15 | 16 | from mesa_geo.raster_layers import RasterBase, RasterLayer 17 | from mesa_geo.tile_layers import LeafletOption, RasterWebTile 18 | 19 | 20 | @solara.component 21 | def map(model, map_drawer, zoom, center_default, scroll_wheel_zoom): 22 | # render map in browser 23 | zoom_map = solara.reactive(zoom) 24 | center = solara.reactive(center_default) 25 | 26 | base_map = map_drawer.tiles 27 | layers = map_drawer.render(model) 28 | 29 | ipyleaflet.Map.element( 30 | zoom=zoom_map.value, 31 | center=center.value, 32 | scroll_wheel_zoom=scroll_wheel_zoom, 33 | layers=[ 34 | ipyleaflet.TileLayer.element(url=base_map["url"]), 35 | ipyleaflet.GeoJSON.element(data=layers["agents"][0]), 36 | *layers["agents"][1], 37 | ], 38 | ) 39 | 40 | 41 | @dataclass 42 | class LeafletViz: 43 | """A dataclass defining the portrayal of a GeoAgent in Leaflet map. 44 | 45 | The fields are defined to be consistent with GeoJSON options in 46 | Leaflet.js: https://leafletjs.com/reference.html#geojson 47 | """ 48 | 49 | style: dict[str, LeafletOption] | None = None 50 | popupProperties: dict[str, LeafletOption] | None = None # noqa: N815 51 | 52 | 53 | class MapModule: 54 | """A MapModule for Leaflet maps that uses a user-defined portrayal method 55 | to generate a portrayal of a raster Cell or a GeoAgent. 56 | 57 | For a raster Cell, the portrayal method should return a (r, g, b, a) tuple. 58 | 59 | For a GeoAgent, the portrayal method should return a dictionary. 60 | - For a Line or a Polygon, the available options can be found at: https://leafletjs.com/reference.html#path-option 61 | - For a Point, the available options can be found at: https://leafletjs.com/reference.html#circlemarker-option 62 | - In addition, the portrayal dictionary can contain a "description" key, which will be used as the popup text. 63 | """ 64 | 65 | def __init__( 66 | self, 67 | portrayal_method, 68 | view, 69 | zoom, 70 | scroll_wheel_zoom, 71 | tiles, 72 | ): 73 | """ 74 | Create a new MapModule. 75 | 76 | :param portrayal_method: A method that takes a GeoAgent (or a Cell) and returns 77 | a dictionary of options (or a (r, g, b, a) tuple) for Leaflet.js. 78 | :param view: The initial view of the map. Must be set together with zoom. 79 | If both view and zoom are None, the map will be centered on the total bounds 80 | of the space. Default is None. 81 | :param zoom: The initial zoom level of the map. Must be set together with view. 82 | If both view and zoom are None, the map will be centered on the total bounds 83 | of the space. Default is None. 84 | :param scroll_wheel_zoom: Boolean whether not user can scroll on map with mouse wheel 85 | :param tiles: An optional tile layer to use. Can be a :class:`RasterWebTile` or 86 | a :class:`xyzservices.TileProvider`. Default is `xyzservices.providers.OpenStreetMap.Mapnik`. 87 | 88 | If the tile provider requires registration, you can pass the API key inside 89 | the `options` parameter of the :class:`RasterWebTile` constructor. 90 | 91 | For example, to use the `Mapbox` raster tile provider, you can use: 92 | 93 | .. code-block:: python 94 | 95 | import mesa_geo as mg 96 | 97 | mg.RasterWebTile( 98 | url="https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}.png?access_token={access_token}", 99 | options={ 100 | "access_token": "my-private-ACCESS_TOKEN", 101 | "attribution": '© Mapbox © OpenStreetMap contributors Improve this map', 102 | }, 103 | ) 104 | 105 | Note that `access_token` can have different names depending on the provider, 106 | e.g., `api_key` or `key`. You can check the documentation of the provider 107 | for more details. 108 | 109 | `xyzservices` provides a list of providers requiring registration as well: 110 | https://xyzservices.readthedocs.io/en/stable/registration.html 111 | 112 | For example, you may use the following code to use the `Mapbox` provider: 113 | 114 | .. code-block:: python 115 | 116 | import xyzservices.providers as xyz 117 | 118 | xyz.MapBox(id="", accessToken="my-private-ACCESS_TOKEN") 119 | 120 | :param scale_options: A dictionary of options for the map scale. Default is None 121 | (no map scale). The available options can be found at: https://leafletjs.com/reference.html#control-scale-option 122 | """ 123 | self.portrayal_method = portrayal_method 124 | self._crs = "epsg:4326" 125 | 126 | if isinstance(tiles, xyzservices.TileProvider): 127 | tiles = RasterWebTile.from_xyzservices(tiles).to_dict() 128 | self.tiles = tiles 129 | 130 | def render(self, model): 131 | return { 132 | "layers": self._render_layers(model), 133 | "agents": self._render_agents(model), 134 | } 135 | 136 | def _render_layers(self, model): 137 | layers = {"rasters": [], "vectors": [], "total_bounds": []} 138 | for layer in model.space.layers: 139 | if isinstance(layer, RasterBase): 140 | if isinstance(layer, RasterLayer): 141 | layer_to_render = layer.to_image( 142 | colormap=self.portrayal_method 143 | ).to_crs(self._crs) 144 | else: 145 | layer_to_render = layer.to_crs(self._crs) 146 | layers["rasters"].append( 147 | image_to_url(layer_to_render.values.transpose([1, 2, 0])) 148 | ) 149 | elif isinstance(layer, gpd.GeoDataFrame): 150 | layers["vectors"].append( 151 | layer.to_crs(self._crs)[["geometry"]].__geo_interface__ 152 | ) 153 | # longlat [min_x, min_y, max_x, max_y] to latlong [min_y, min_x, max_y, max_x] 154 | if model.space.total_bounds is not None: 155 | transformed_xx, transformed_yy = model.space.transformer.transform( 156 | xx=[model.space.total_bounds[0], model.space.total_bounds[2]], 157 | yy=[model.space.total_bounds[1], model.space.total_bounds[3]], 158 | ) 159 | layers["total_bounds"] = [ 160 | [transformed_yy[0], transformed_xx[0]], # min_y, min_x 161 | [transformed_yy[1], transformed_xx[1]], # max_y, max_x 162 | ] 163 | return layers 164 | 165 | def _get_marker(self, location, properties): 166 | """ 167 | takes point objects and transforms them to ipyleaflet marker objects 168 | 169 | allowed marker types are point marker types from ipyleaflet 170 | https://ipyleaflet.readthedocs.io/en/latest/layers/index.html 171 | 172 | default is circle with radius 5 173 | 174 | Parameters 175 | ---------- 176 | location: iterable 177 | iterable of location in models geometry 178 | 179 | properties : dict 180 | properties passed in through agent portrayal 181 | 182 | 183 | Returns 184 | ------- 185 | ipyleaflet marker element 186 | 187 | """ 188 | 189 | if "marker_type" not in properties: # make circle default marker type 190 | properties["marker_type"] = "Circle" 191 | properties["radius"] = 5 192 | 193 | marker = properties["marker_type"] 194 | if marker == "Circle": 195 | return ipyleaflet.Circle(location=location, **properties) 196 | elif marker == "CircleMarker": 197 | return ipyleaflet.CircleMarker(location=location, **properties) 198 | elif marker == "Marker": 199 | return ipyleaflet.Marker(location=location, **properties) 200 | elif marker == "Icon": 201 | icon_url = properties["icon_url"] 202 | icon_size = properties.get("icon_size", [20, 20]) 203 | icon_properties = properties.get("icon_properties", {}) 204 | icon = ipyleaflet.Icon( 205 | icon_url=icon_url, icon_size=icon_size, **icon_properties 206 | ) 207 | return ipyleaflet.Marker(location=location, icon=icon, **properties) 208 | elif marker == "AwesomeIcon": 209 | name = properties["name"] 210 | icon_properties = properties.get("icon_properties", {}) 211 | icon = ipyleaflet.AwesomeIcon(name=name, **icon_properties) 212 | return ipyleaflet.Marker(location=location, icon=icon, **properties) 213 | 214 | else: 215 | raise ValueError( 216 | f"Unsupported marker type:{marker}", 217 | ) 218 | 219 | def _render_agents(self, model): 220 | feature_collection = {"type": "FeatureCollection", "features": []} 221 | point_markers = [] 222 | agent_portrayal = {} 223 | for agent in model.space.agents: 224 | transformed_geometry = agent.get_transformed_geometry( 225 | model.space.transformer 226 | ) 227 | 228 | if self.portrayal_method: 229 | properties = self.portrayal_method(agent) 230 | agent_portrayal = LeafletViz( 231 | popupProperties=properties.pop("description", None) 232 | ) 233 | if isinstance(agent.geometry, Point): 234 | location = mapping(transformed_geometry) 235 | # for some reason points are reversed 236 | location = (location["coordinates"][1], location["coordinates"][0]) 237 | point_markers.append(self._get_marker(location, properties)) 238 | else: 239 | agent_portrayal.style = properties 240 | agent_portrayal = dataclasses.asdict( 241 | agent_portrayal, 242 | dict_factory=lambda x: {k: v for (k, v) in x if v is not None}, 243 | ) 244 | 245 | feature_collection["features"].append( 246 | { 247 | "type": "Feature", 248 | "geometry": mapping(transformed_geometry), 249 | "properties": agent_portrayal, 250 | } 251 | ) 252 | return [feature_collection, point_markers] 253 | -------------------------------------------------------------------------------- /mesa_geo/visualization/components/geospace_component.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import warnings 3 | from dataclasses import dataclass 4 | 5 | import geopandas as gpd 6 | import ipyleaflet 7 | import solara 8 | import xyzservices 9 | from folium.utilities import image_to_url 10 | from mesa.visualization.utils import update_counter 11 | from shapely.geometry import Point, mapping 12 | 13 | from mesa_geo.raster_layers import RasterBase, RasterLayer 14 | from mesa_geo.tile_layers import LeafletOption, RasterWebTile 15 | 16 | 17 | def make_geospace_leaflet( 18 | agent_portrayal, 19 | view=None, 20 | tiles=xyzservices.providers.OpenStreetMap.Mapnik, 21 | **kwargs, 22 | ): 23 | warnings.warn( 24 | "make_geospace_leaflet is deprecated, use make_geospace_component instead", 25 | DeprecationWarning, 26 | stacklevel=2, 27 | ) 28 | return make_geospace_component(agent_portrayal, view, tiles, **kwargs) 29 | 30 | 31 | def make_geospace_component( 32 | agent_portrayal, 33 | view=None, 34 | tiles=xyzservices.providers.OpenStreetMap.Mapnik, 35 | **kwargs, 36 | ): 37 | def MakeSpaceMatplotlib(model): 38 | return GeoSpaceLeaflet(model, agent_portrayal, view, tiles, **kwargs) 39 | 40 | return MakeSpaceMatplotlib 41 | 42 | 43 | @solara.component 44 | def GeoSpaceLeaflet(model, agent_portrayal, view, tiles, **kwargs): 45 | update_counter.get() 46 | map_drawer = MapModule(portrayal_method=agent_portrayal, tiles=tiles) 47 | model_view = map_drawer.render(model) 48 | 49 | if view is None: 50 | # longlat [min_x, min_y, max_x, max_y] to latlong [min_y, min_x, max_y, max_x] 51 | transformed_xx, transformed_yy = model.space.transformer.transform( 52 | xx=[model.space.total_bounds[0], model.space.total_bounds[2]], 53 | yy=[model.space.total_bounds[1], model.space.total_bounds[3]], 54 | ) 55 | view = [ 56 | (transformed_yy[0] + transformed_yy[1]) / 2, 57 | (transformed_xx[0] + transformed_xx[1]) / 2, 58 | ] 59 | 60 | layers = ( 61 | [ipyleaflet.TileLayer.element(url=map_drawer.tiles["url"])] if tiles else [] 62 | ) 63 | for layer in model_view["layers"]["rasters"]: 64 | layers.append( 65 | ipyleaflet.ImageOverlay( 66 | url=layer["url"], 67 | bounds=layer["bounds"], 68 | ) 69 | ) 70 | for layer in model_view["layers"]["vectors"]: 71 | layers.append(ipyleaflet.GeoJSON(element=layer)) 72 | ipyleaflet.Map.element( 73 | center=view, 74 | layers=[ 75 | *layers, 76 | ipyleaflet.GeoJSON.element(data=model_view["agents"][0]), 77 | *model_view["agents"][1], 78 | ], 79 | **kwargs, 80 | ) 81 | 82 | 83 | @dataclass 84 | class LeafletViz: 85 | """A dataclass defining the portrayal of a GeoAgent in Leaflet map. 86 | 87 | The fields are defined to be consistent with GeoJSON options in 88 | Leaflet.js: https://leafletjs.com/reference.html#geojson 89 | """ 90 | 91 | style: dict[str, LeafletOption] | None = None 92 | popupProperties: dict[str, LeafletOption] | None = None # noqa: N815 93 | 94 | 95 | class MapModule: 96 | """A MapModule for Leaflet maps that uses a user-defined portrayal method 97 | to generate a portrayal of a raster Cell or a GeoAgent. 98 | 99 | For a raster Cell, the portrayal method should return a (r, g, b, a) tuple. 100 | 101 | For a GeoAgent, the portrayal method should return a dictionary. 102 | - For a Line or a Polygon, the available options can be found at: https://leafletjs.com/reference.html#path-option 103 | - For a Point, the available options can be found at: https://leafletjs.com/reference.html#circlemarker-option 104 | - In addition, the portrayal dictionary can contain a "description" key, which will be used as the popup text. 105 | """ 106 | 107 | def __init__( 108 | self, 109 | portrayal_method, 110 | tiles, 111 | ): 112 | """ 113 | Create a new MapModule. 114 | 115 | :param portrayal_method: A method that takes a GeoAgent (or a Cell) and returns 116 | a dictionary of options (or a (r, g, b, a) tuple) for Leaflet.js. 117 | :param view: The initial view of the map. Must be set together with zoom. 118 | If both view and zoom are None, the map will be centered on the total bounds 119 | of the space. Default is None. 120 | :param zoom: The initial zoom level of the map. Must be set together with view. 121 | If both view and zoom are None, the map will be centered on the total bounds 122 | of the space. Default is None. 123 | :param scroll_wheel_zoom: Boolean whether not user can scroll on map with mouse wheel 124 | :param tiles: An optional tile layer to use. Can be a :class:`RasterWebTile` or 125 | a :class:`xyzservices.TileProvider`. Default is `xyzservices.providers.OpenStreetMap.Mapnik`. 126 | 127 | If the tile provider requires registration, you can pass the API key inside 128 | the `options` parameter of the :class:`RasterWebTile` constructor. 129 | 130 | For example, to use the `Mapbox` raster tile provider, you can use: 131 | 132 | .. code-block:: python 133 | 134 | import mesa_geo as mg 135 | 136 | mg.RasterWebTile( 137 | url="https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}.png?access_token={access_token}", 138 | options={ 139 | "access_token": "my-private-ACCESS_TOKEN", 140 | "attribution": '© Mapbox © OpenStreetMap contributors Improve this map', 141 | }, 142 | ) 143 | 144 | Note that `access_token` can have different names depending on the provider, 145 | e.g., `api_key` or `key`. You can check the documentation of the provider 146 | for more details. 147 | 148 | `xyzservices` provides a list of providers requiring registration as well: 149 | https://xyzservices.readthedocs.io/en/stable/registration.html 150 | 151 | For example, you may use the following code to use the `Mapbox` provider: 152 | 153 | .. code-block:: python 154 | 155 | import xyzservices.providers as xyz 156 | 157 | xyz.MapBox(id="", accessToken="my-private-ACCESS_TOKEN") 158 | 159 | :param scale_options: A dictionary of options for the map scale. Default is None 160 | (no map scale). The available options can be found at: https://leafletjs.com/reference.html#control-scale-option 161 | """ 162 | self.portrayal_method = portrayal_method 163 | self._crs = "epsg:4326" 164 | 165 | if isinstance(tiles, xyzservices.TileProvider): 166 | tiles = RasterWebTile.from_xyzservices(tiles).to_dict() 167 | self.tiles = tiles 168 | 169 | def render(self, model): 170 | return { 171 | "layers": self._render_layers(model), 172 | "agents": self._render_agents(model), 173 | } 174 | 175 | def _render_layers(self, model): 176 | layers = {"rasters": [], "vectors": [], "total_bounds": []} 177 | for layer in model.space.layers: 178 | if isinstance(layer, RasterBase): 179 | if isinstance(layer, RasterLayer): 180 | layer_to_render = layer.to_image( 181 | colormap=self.portrayal_method 182 | ).to_crs(self._crs) 183 | else: 184 | layer_to_render = layer.to_crs(self._crs) 185 | layers["rasters"].append( 186 | { 187 | "url": image_to_url( 188 | layer_to_render.values.transpose([1, 2, 0]) 189 | ), 190 | # longlat [min_x, min_y, max_x, max_y] to latlong [[min_y, min_x], [max_y, max_x]] 191 | "bounds": [ 192 | [ 193 | layer_to_render.total_bounds[1], 194 | layer_to_render.total_bounds[0], 195 | ], 196 | [ 197 | layer_to_render.total_bounds[3], 198 | layer_to_render.total_bounds[2], 199 | ], 200 | ], 201 | } 202 | ) 203 | elif isinstance(layer, gpd.GeoDataFrame): 204 | layers["vectors"].append( 205 | layer.to_crs(self._crs)[["geometry"]].__geo_interface__ 206 | ) 207 | # longlat [min_x, min_y, max_x, max_y] to latlong [min_y, min_x, max_y, max_x] 208 | if model.space.total_bounds is not None: 209 | transformed_xx, transformed_yy = model.space.transformer.transform( 210 | xx=[model.space.total_bounds[0], model.space.total_bounds[2]], 211 | yy=[model.space.total_bounds[1], model.space.total_bounds[3]], 212 | ) 213 | layers["total_bounds"] = [ 214 | [transformed_yy[0], transformed_xx[0]], # min_y, min_x 215 | [transformed_yy[1], transformed_xx[1]], # max_y, max_x 216 | ] 217 | return layers 218 | 219 | def _get_marker(self, location, properties): 220 | """ 221 | takes point objects and transforms them to ipyleaflet marker objects 222 | 223 | allowed marker types are point marker types from ipyleaflet 224 | https://ipyleaflet.readthedocs.io/en/latest/layers/index.html 225 | 226 | default is circle with radius 5 227 | 228 | Parameters 229 | ---------- 230 | location: iterable 231 | iterable of location in models geometry 232 | 233 | properties : dict 234 | properties passed in through agent portrayal 235 | 236 | 237 | Returns 238 | ------- 239 | ipyleaflet marker element 240 | 241 | """ 242 | 243 | if "marker_type" not in properties: # make circle default marker type 244 | properties["marker_type"] = "Circle" 245 | properties["radius"] = 5 246 | 247 | marker = properties["marker_type"] 248 | if marker == "Circle": 249 | return ipyleaflet.Circle(location=location, **properties) 250 | elif marker == "CircleMarker": 251 | return ipyleaflet.CircleMarker(location=location, **properties) 252 | elif marker == "Marker": 253 | return ipyleaflet.Marker(location=location, **properties) 254 | elif marker == "Icon": 255 | icon_url = properties["icon_url"] 256 | icon_size = properties.get("icon_size", [20, 20]) 257 | icon_properties = properties.get("icon_properties", {}) 258 | icon = ipyleaflet.Icon( 259 | icon_url=icon_url, icon_size=icon_size, **icon_properties 260 | ) 261 | return ipyleaflet.Marker(location=location, icon=icon, **properties) 262 | elif marker == "AwesomeIcon": 263 | name = properties["name"] 264 | icon_properties = properties.get("icon_properties", {}) 265 | icon = ipyleaflet.AwesomeIcon(name=name, **icon_properties) 266 | return ipyleaflet.Marker(location=location, icon=icon, **properties) 267 | 268 | else: 269 | raise ValueError( 270 | f"Unsupported marker type:{marker}", 271 | ) 272 | 273 | def _render_agents(self, model): 274 | feature_collection = {"type": "FeatureCollection", "features": []} 275 | point_markers = [] 276 | agent_portrayal = {} 277 | for agent in model.space.agents: 278 | transformed_geometry = agent.get_transformed_geometry( 279 | model.space.transformer 280 | ) 281 | 282 | if self.portrayal_method: 283 | properties = self.portrayal_method(agent) 284 | agent_portrayal = LeafletViz( 285 | popupProperties=properties.pop("description", None) 286 | ) 287 | if isinstance(agent.geometry, Point): 288 | location = mapping(transformed_geometry) 289 | # for some reason points are reversed 290 | location = (location["coordinates"][1], location["coordinates"][0]) 291 | point_markers.append(self._get_marker(location, properties)) 292 | else: 293 | agent_portrayal.style = properties 294 | agent_portrayal = dataclasses.asdict( 295 | agent_portrayal, 296 | dict_factory=lambda x: {k: v for (k, v) in x if v is not None}, 297 | ) 298 | 299 | feature_collection["features"].append( 300 | { 301 | "type": "Feature", 302 | "geometry": mapping(transformed_geometry), 303 | "properties": agent_portrayal, 304 | } 305 | ) 306 | return [feature_collection, point_markers] 307 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | Release History 2 | --------------- 3 | 4 | # 0.9.1 (2025-02-04) 5 | 6 | ## What's Changed 7 | 8 | ### 🛠 Enhancements made 9 | * refactor: :recycle: separate cells' initialization into a private method by @SongshGeo in https://github.com/mesa/mesa-geo/pull/274 10 | ### 🐛 Bugs fixed 11 | * fix typo in intro tutorial by @wang-boyu in https://github.com/mesa/mesa-geo/pull/275 12 | 13 | ## New Contributors 14 | * @SongshGeo made their first contribution in https://github.com/mesa/mesa-geo/pull/274 15 | 16 | **Full Changelog**: https://github.com/mesa/mesa-geo/compare/v0.9.0...v0.9.1 17 | 18 | # 0.9.0 (2024-12-21) 19 | 20 | ## What's Changed 21 | 22 | ### 🐛 Bugs fixed 23 | 24 | * fix links to readthedocs site by @wang-boyu in https://github.com/mesa/mesa-geo/pull/257 25 | * fix broken mesa dependencies in GeoJupyterViz by @AdamZh0u in https://github.com/mesa/mesa-geo/pull/269 26 | 27 | ### 🔧 Maintenance 28 | 29 | * rename make_geospace_leaflet to make_geospace_component by @wang-boyu in https://github.com/mesa/mesa-geo/pull/270 30 | * update make_plot_measure method name from mesa viz by @wang-boyu in https://github.com/mesa/mesa-geo/pull/264 31 | * Require Mesa 3.0 stable by @EwoutH in https://github.com/mesa/mesa-geo/pull/260 32 | 33 | **Full Changelog**: https://github.com/mesa/mesa-geo/compare/v0.9.0a1...v0.9.0 34 | 35 | # 0.9.0a1 (2024-10-17) 36 | 37 | ## Highlights 38 | This small pre-release fixes a bug in the RasterLayer rendering and deprecated the old GeoJupyterViz, in favor of the new SolaraViz. 39 | 40 | ## What's Changed 41 | 42 | ### 🐛 Bugs fixed 43 | * fix raster layer rendering in solaraviz by @wang-boyu in https://github.com/mesa/mesa-geo/pull/254 44 | ### 📜 Documentation improvements 45 | * Deprecate geojupyterviz and update intro tutorial by @wang-boyu in https://github.com/mesa/mesa-geo/pull/255 46 | 47 | **Full Changelog**: https://github.com/mesa/mesa-geo/compare/v0.9.0a0...v0.9.0a1 48 | 49 | # 0.9.0a0 (2024-09-27) 50 | ## Highlights 51 | The Mesa-geo `v0.9.0a0` pre-release is the first Mesa-geo version compatible with Mesa 3.0. 52 | 53 | One of the most notable changes is the automatic assignment of unique IDs to agents. This eliminates the need for manual ID specification, simplifying agent creation. For example, where you previously might have initialized an agent with: 54 | 55 | ```python 56 | agent = MyGeoAgent(unique_id=1, model=model, geometry=point, crs="EPSG:4326") 57 | ``` 58 | 59 | You now simply omit the `unique_id`: 60 | 61 | ```python 62 | agent = MyGeoAgent(model=model, geometry=point, crs="EPSG:4326") 63 | ``` 64 | 65 | Mesa-geo can now directly use Mesa 3.0's SolaraViz visualisation, with an additional `make_geospace_leaflet` method to support geospaces. The new visualization can be used like: 66 | 67 | ```python 68 | from mesa.visualization import SolaraViz 69 | import mesa_geo.visualization as mgv 70 | 71 | model = GeoSIR() 72 | SolaraViz( 73 | model, 74 | name="GeoSIR", 75 | components=[ 76 | mgv.make_geospace_leaflet(SIR_draw, zoom=12, scroll_wheel_zoom=False), 77 | mesa.visualization.make_plot_measure(["infected", "susceptible", "recovered", "dead"]), 78 | mesa.visualization.make_plot_measure(["safe", "hotspot"]), 79 | ] 80 | ) 81 | ``` 82 | 83 | The `v0.9.0a0` pre-release is a snapshot release to allow starting testing against Mesa 3.0, and might introduce new breaking changes in upcoming (pre-)releases. 84 | 85 | ## What's Changed 86 | ### ⚠️ Breaking changes 87 | * Require Mesa 3.0 by @EwoutH in https://github.com/mesa/mesa-geo/pull/244 88 | * Automatically assign unique_id's by @EwoutH in https://github.com/mesa/mesa-geo/pull/248 89 | ### 🛠 Enhancements made 90 | * add method to make geospace as a solara component by @wang-boyu in https://github.com/mesa/mesa-geo/pull/246 91 | ### 🐛 Bugs fixed 92 | * raster_layer: Don't pass unique_id to Agent in Cell by @EwoutH in https://github.com/mesa/mesa-geo/pull/249 93 | ### 📜 Documentation improvements 94 | * Readthedocs: Don't let notebook failures pass silently by @EwoutH in https://github.com/mesa/mesa-geo/pull/250 95 | * intro tutorial: Remove unique_id from Agent init by @EwoutH in https://github.com/mesa/mesa-geo/pull/251 96 | 97 | **Full Changelog**: https://github.com/mesa/mesa-geo/compare/v0.8.1...v0.9.0a0 98 | 99 | # 0.8.1 (2024-09-03) 100 | ## Highlights 101 | Mesa-Geo 0.8.1 is a small patch release containing a single feature, a documentation update and a bug fixed. 102 | 103 | The real novelty is that from now on, all GIS examples on [Mesa-examples](https://github.com/mesa/mesa-examples) are tested in CI against Mesa-Geo. We fixed 16 bugs in the 7 GIS example models ([mesa-examples#172](https://github.com/mesa/mesa-examples/issues/172)), which are now available on two branches: 104 | - On the `main` branch [GIS examples](https://github.com/mesa/mesa-examples/tree/main/gis) can be found will keep being updated for the latest Mesa and Mesa-Geo versions. 105 | - On the `mesa-2.x` branch [GIS examples](https://github.com/mesa/mesa-examples/tree/mesa-2.x/gis) examples can be found that keep working with Mesa 2.x and Mesa-Geo 0.8.x. 106 | 107 | The Mesa-Geo 0.8.x. series is compatible with Mesa 2.3.x. The next Mesa-Geo release series, 0.9.x, will be compatible with with Mesa 3.0. 108 | 109 | ## What's Changed 110 | ### 🎉 New features added 111 | * Expose rasterio's opener argument in Rasterlayer.from_file by @EwoutH in https://github.com/mesa/mesa-geo/pull/237 112 | ### 🐛 Bugs fixed 113 | * add model parameter in RasterLayer class method by @wang-boyu in https://github.com/mesa/mesa-geo/pull/240 114 | ### 📜 Documentation improvements 115 | * Update intro_tutorial.ipynb by @tpike3 in https://github.com/mesa/mesa-geo/pull/234 116 | ### 🔧 Maintenance 117 | * Add test script for GIS examples and run that in CI by @EwoutH in https://github.com/mesa/mesa-geo/pull/241 118 | 119 | **Full Changelog**: https://github.com/mesa/mesa-geo/compare/v0.8.0...v0.8.1 120 | 121 | # 0.8.0 (2024-08-21) 122 | 123 | ## Highlights 124 | 125 | - The Tornado visualization server is removed and replaced with SolaraViz, which also works within Jupyter notebooks (https://github.com/mesa/mesa-geo/pull/212). This is in line with Mesa's recent changes to use Solara for visualization. 126 | - The [Introductory Tutorial](https://mesa-geo.readthedocs.io/en/stable/tutorials/intro_tutorial.html) has been fully rewritten for Mesa-Geo 0.8.0 127 | - The 0.8.x series are the releases compatible with Mesa 2.3.x. The next major release will be compatible with Mesa 3.0+. 128 | 129 | ### 🎉 New features added 130 | 131 | * Update mesa-geo to sync with mesa >=2.3.0 by @tpike3 in https://github.com/mesa/mesa-geo/pull/212 132 | 133 | ### 🛠 Enhancements made 134 | 135 | * Update tutorial and viz by @tpike3 in https://github.com/mesa/mesa-geo/pull/217 136 | 137 | ### 📜 Documentation improvements 138 | 139 | * fix links and installation instructions in README file by @wang-boyu in https://github.com/mesa/mesa-geo/pull/213 140 | * .readthedocs.yaml: Use latest Ubuntu and Python versions by @EwoutH in https://github.com/mesa/mesa-geo/pull/221 141 | * docs: update conf.py to be in sync with mesa by @wang-boyu in https://github.com/mesa/mesa-geo/pull/223 142 | * docs: remove api docs entry for removed visualization module by @wang-boyu in https://github.com/mesa/mesa-geo/pull/224 143 | * Fix kernel issue by @tpike3 in https://github.com/mesa/mesa-geo/pull/229 144 | * Remove cell output by @tpike3 in https://github.com/mesa/mesa-geo/pull/231 145 | 146 | ### 🔧 Maintenance 147 | 148 | * Update configuration, metadata and tests by @tpike3 in https://github.com/mesa/mesa-geo/pull/208 149 | * fix: Use correct package name for Pip by @rht in https://github.com/mesa/mesa-geo/pull/214 150 | * pyproject.toml: Always use latest ruff by @EwoutH in https://github.com/mesa/mesa-geo/pull/219 151 | * pyproject.toml: Use mesa version smaller than 3 for now by @EwoutH in https://github.com/mesa/mesa-geo/pull/220 152 | * CI: Add job to test with pre-release dependencies, including Mesa by @EwoutH in https://github.com/mesa/mesa-geo/pull/218 153 | 154 | **Full Changelog**: https://github.com/mesa/mesa-geo/compare/v0.7.1...v0.8.0 155 | 156 | ## 0.7.1 (2024-03-27) 157 | 158 | ### 🐛 Bugs fixed 159 | 160 | * fix: remove old map layers before rendering new layers by @wang-boyu in https://github.com/mesa/mesa-geo/pull/194 (thanks @rw73mg for reporting) 161 | 162 | **Full Changelog**: https://github.com/mesa/mesa-geo/compare/v0.7.0...v0.7.1 163 | 164 | ## 0.7.0 (2024-01-17) 165 | 166 | ### Special Notes 167 | 168 | - Update Mesa dependency to v2.2 169 | - The pinning of Mesa is now on the major version, instead of the minor version. This means that Mesa-Geo v0.7.0 will work with Mesa v2.2, v2.3, v2.4, etc. but not with Mesa v3.0 or later. 170 | 171 | ### 🛠 Enhancements made 172 | 173 | * create and update rtree spatial index only when needed by @wang-boyu in https://github.com/mesa/mesa-geo/pull/179 174 | 175 | ### 🔧 Maintenance 176 | 177 | * fix link to examples by @wang-boyu in https://github.com/mesa/mesa-geo/pull/167 178 | * Correct link to GeoSchelling example and update copyright string by @Holzhauer in https://github.com/mesa/mesa-geo/pull/175 179 | * fix rtd build error and upgrade to python 3.9 by @wang-boyu in https://github.com/mesa/mesa-geo/pull/176 180 | * update pre-commit and ga workflows to be consistent with mesa by @wang-boyu in https://github.com/mesa/mesa-geo/pull/181 181 | * add config file to automatically generate release notes by @wang-boyu in https://github.com/mesa/mesa-geo/pull/184 182 | * update ga workflows to be consistent with mesa by @wang-boyu in https://github.com/mesa/mesa-geo/pull/185 183 | 184 | ## New Contributors 185 | 186 | * @Holzhauer made their first contribution in https://github.com/mesa/mesa-geo/pull/175 187 | 188 | **Full Changelog**: https://github.com/mesa/mesa-geo/compare/v0.6.0...v0.7.0 189 | 190 | ## 0.6.0 (2023-09-13) 191 | 192 | ### Special Notes 193 | 194 | - update mesa dependency to v2.1 195 | 196 | ### Improvements 197 | 198 | - use Pathlib [#149](https://github.com/mesa/mesa-geo/pull/149) (thanks @catherinedevlin for contributing) 199 | 200 | - ***Docs updates*** 201 | - docs: use pydata theme [#152](https://github.com/mesa/mesa-geo/pull/152) 202 | - docs: use myst-nb to compile notebooks at build time [#159](https://github.com/mesa/mesa-geo/pull/159) 203 | 204 | - ***Example updates*** 205 | - remove examples and their tests [#163](https://github.com/mesa/mesa-geo/pull/163) 206 | 207 | ### Fixes 208 | 209 | - fix AttributeError in GeoSpace.agents_at() [#165](https://github.com/mesa/mesa-geo/pull/165) (thanks @SongshGeo for reporting) 210 | 211 | ## 0.5.0 (2023-03-09) 212 | 213 | ### Improvements 214 | 215 | - ***Docs updates*** 216 | - add citation information about mesa-geo [#117](https://github.com/mesa/mesa-geo/pull/117) 217 | - add citation info to readthedocs [#118](https://github.com/mesa/mesa-geo/pull/118) 218 | - docs: update docstrings on how to use providers requiring registration [#141](https://github.com/mesa/mesa-geo/pull/141) 219 | 220 | - ***Front-end updates*** 221 | - add scale to Leaflet map [#123](https://github.com/mesa/mesa-geo/pull/123) 222 | - allow basemap tiles configuration [#127](https://github.com/mesa/mesa-geo/pull/127) 223 | 224 | - ***CI updates*** 225 | - add testing for python 3.11 [#122](https://github.com/mesa/mesa-geo/pull/122) 226 | - ci: replace flake8 with ruff [#132](https://github.com/mesa/mesa-geo/pull/132) 227 | - ci: update os, python versions, and dependabot configurations [#142](https://github.com/mesa/mesa-geo/pull/142) 228 | - ci: pin ruff version to v0.0.254 [#144](https://github.com/mesa/mesa-geo/pull/144) 229 | 230 | ### Fixes 231 | 232 | - fix WMSWebTile.to_dict() method [#140](https://github.com/mesa/mesa-geo/pull/140) 233 | 234 | ## 0.4.0 (2022-10-18) 235 | 236 | ### Improvements 237 | 238 | - export geoagents and raster cells [#98](https://github.com/mesa/mesa-geo/pull/98) 239 | - use ModularServer from Mesa [#109](https://github.com/mesa/mesa-geo/pull/109) 240 | - implement simpler Mesa-Geo namespace [#115](https://github.com/mesa/mesa-geo/pull/115) 241 | 242 | - ***Docs updates*** 243 | - create Read the Docs [#99](https://github.com/mesa/mesa-geo/pull/99) 244 | - update README with badges and matrix chat link [#100](https://github.com/mesa/mesa-geo/pull/100) 245 | 246 | - ***Front-end updates*** 247 | - auto zoom to geospace when view & zoom are missing [#103](https://github.com/mesa/mesa-geo/pull/103) 248 | 249 | - ***CI updates*** 250 | - add pre-commit config and run it on all files [#107](https://github.com/mesa/mesa-geo/pull/107) 251 | 252 | - ***Example updates*** 253 | - link example models to readthedocs [#101](https://github.com/mesa/mesa-geo/pull/101) 254 | - fix spatial variation of water level in rainfall example [#108](https://github.com/mesa/mesa-geo/pull/108) 255 | - fix youtube links in geo_schelling examples [#113](https://github.com/mesa/mesa-geo/pull/113) 256 | 257 | ### Fixes 258 | 259 | - replace BuildCommand & DevelopCommand with BuildPyCommand during setup [#106](https://github.com/mesa/mesa-geo/pull/106) 260 | 261 | ## 0.3.0 (2022-07-27) 262 | 263 | ### Special Notes 264 | 265 | - BREAKING: rename model.grid to model.space [#40](https://github.com/mesa/mesa-geo/pull/40) 266 | - BREAKING: rename GeoAgent's shape attribute to geometry [#57](https://github.com/mesa/mesa-geo/pull/57) 267 | 268 | ### Improvements 269 | 270 | - feat/crs [#58](https://github.com/mesa/mesa-geo/pull/58) 271 | - add GeoAgent.crs attribute 272 | - update GeoSpace with GeoAgent.crs 273 | - extract an _AgentLayer from GeoSpace [#62](https://github.com/mesa/mesa-geo/pull/62) 274 | - add layers into geospace [#67](https://github.com/mesa/mesa-geo/pull/67) 275 | - implement RasterLayer [#75](https://github.com/mesa/mesa-geo/pull/75) 276 | - create raster layer from file [#92](https://github.com/mesa/mesa-geo/pull/92) 277 | 278 | - ***Front-end updates*** 279 | - implement LeafletPortrayal dataclass for GeoAgent portrayal [#84](https://github.com/mesa/mesa-geo/pull/84) 280 | 281 | - ***CI updates*** 282 | - ci: Replace Travis with GH Actions [#47](https://github.com/mesa/mesa-geo/pull/47) 283 | - ci: Disable PyPy tests for now [#56](https://github.com/mesa/mesa-geo/pull/56) 284 | 285 | - ***Dependency updates*** 286 | - Frontend dependencies [#54](https://github.com/mesa/mesa-geo/pull/54) 287 | - remove all frontend dependencies available from mesa 288 | - create setup.cfg and pyproject.toml from setup.py 289 | - download leaflet during install [#59](https://github.com/mesa/mesa-geo/pull/59) 290 | - remove version number from leaflet filenames [#61](https://github.com/mesa/mesa-geo/pull/61) 291 | - update for Mesa v1.0.0 [#78](https://github.com/mesa/mesa-geo/pull/78) 292 | - specify mesa 1.x dependency 293 | - update for mesa css includes 294 | - remove jQuery usage in MapModule.js 295 | - use Slider instead of UserSettableParameter in examples 296 | 297 | - ***Example updates*** 298 | - update examples [#74](https://github.com/mesa/mesa-geo/pull/74) 299 | - change examples folder structure 300 | - add test for examples 301 | - add geo_schelling_points example 302 | - add rainfall and urban growth examples [#80](https://github.com/mesa/mesa-geo/pull/80) 303 | - add uganda example [#90](https://github.com/mesa/mesa-geo/pull/90) 304 | 305 | - ***Other improvements*** 306 | - add github issue templates [#38](https://github.com/mesa/mesa-geo/pull/38) 307 | - apply Black to all Python files [#50](https://github.com/mesa/mesa-geo/pull/50) 308 | - add code of conduct and contributing guide [#69](https://github.com/mesa/mesa-geo/pull/69) 309 | - update license with year and contributors [#86](https://github.com/mesa/mesa-geo/pull/86) 310 | - rename master branch to main [#89](https://github.com/mesa/mesa-geo/pull/89) 311 | 312 | ### Fixes 313 | 314 | - fix remove_agent in GeoSpace [#34](https://github.com/mesa/mesa-geo/pull/34) 315 | - remove deprecated skip_equivalent from pyproj [#43](https://github.com/mesa/mesa-geo/pull/43) 316 | - flake8: Fix errors [#51](https://github.com/mesa/mesa-geo/pull/51) 317 | - rename InstallCommand to BuildCommand [#55](https://github.com/mesa/mesa-geo/pull/55) 318 | - fix codecov and README.md [#71](https://github.com/mesa/mesa-geo/pull/71) 319 | - use shape.centroid instead of shape.center() [#73](https://github.com/mesa/mesa-geo/pull/73) 320 | - fix unique id exception for raster cells [#83](https://github.com/mesa/mesa-geo/pull/83) 321 | - fix total_bounds check in GeoSpace [#88](https://github.com/mesa/mesa-geo/pull/88) 322 | -------------------------------------------------------------------------------- /mesa_geo/geospace.py: -------------------------------------------------------------------------------- 1 | """ 2 | GeoSpace 3 | -------- 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import warnings 9 | 10 | import geopandas as gpd 11 | import numpy as np 12 | import pyproj 13 | from libpysal import weights 14 | from rtree import index 15 | from shapely.geometry import Point 16 | from shapely.prepared import prep 17 | 18 | from mesa_geo.geo_base import GeoBase 19 | from mesa_geo.geoagent import GeoAgent 20 | from mesa_geo.raster_layers import ImageLayer, RasterLayer 21 | 22 | 23 | class GeoSpace(GeoBase): 24 | """ 25 | Space used to add a geospatial component to a model. 26 | """ 27 | 28 | def __init__(self, crs="epsg:3857", *, warn_crs_conversion=True): 29 | """ 30 | Create a GeoSpace for GIS enabled mesa modeling. 31 | 32 | :param crs: The coordinate reference system of the GeoSpace. 33 | If `crs` is not set, epsg:3857 (Web Mercator) is used as default. 34 | However, this system is only accurate at the equator and errors 35 | increase with latitude. 36 | :param warn_crs_conversion: Whether to warn when converting layers and 37 | GeoAgents of different crs into the crs of GeoSpace. Default to 38 | True. 39 | """ 40 | super().__init__(crs) 41 | self._transformer = pyproj.Transformer.from_crs( 42 | crs_from=self.crs, crs_to="epsg:4326", always_xy=True 43 | ) 44 | self.warn_crs_conversion = warn_crs_conversion 45 | self._agent_layer = _AgentLayer() 46 | self._static_layers = [] 47 | self._total_bounds = None # [min_x, min_y, max_x, max_y] 48 | 49 | def to_crs(self, crs, inplace=False) -> GeoSpace | None: 50 | super()._to_crs_check(crs) 51 | 52 | if inplace: 53 | for agent in self.agents: 54 | agent.to_crs(crs, inplace=True) 55 | for layer in self.layers: 56 | layer.to_crs(crs, inplace=True) 57 | else: 58 | geospace = GeoSpace( 59 | crs=self.crs.to_string(), warn_crs_conversion=self.warn_crs_conversion 60 | ) 61 | for agent in self.agents: 62 | geospace.add_agents(agent.to_crs(crs, inplace=False)) 63 | for layer in self.layers: 64 | geospace.add_layer(layer.to_crs(crs, inplace=False)) 65 | return geospace 66 | 67 | @property 68 | def transformer(self): 69 | """ 70 | Return the pyproj.Transformer that transforms the GeoSpace into 71 | epsg:4326. Mainly used for GeoJSON serialization. 72 | """ 73 | return self._transformer 74 | 75 | @property 76 | def agents(self): 77 | """ 78 | Return a list of all agents in the Geospace. 79 | """ 80 | return self._agent_layer.agents 81 | 82 | @property 83 | def layers(self) -> list[ImageLayer | RasterLayer | gpd.GeoDataFrame]: 84 | """ 85 | Return a list of all layers in the Geospace. 86 | """ 87 | return self._static_layers 88 | 89 | @property 90 | def total_bounds(self) -> np.ndarray | None: 91 | """ 92 | Return the bounds of the GeoSpace in [min_x, min_y, max_x, max_y] format. 93 | """ 94 | if self._total_bounds is None: 95 | if len(self.agents) > 0: 96 | self._update_bounds(self._agent_layer.total_bounds) 97 | if len(self.layers) > 0: 98 | for layer in self.layers: 99 | self._update_bounds(layer.total_bounds) 100 | return self._total_bounds 101 | 102 | def _update_bounds(self, new_bounds: np.ndarray) -> None: 103 | if new_bounds is not None: 104 | if self._total_bounds is not None: 105 | new_min_x = min(self.total_bounds[0], new_bounds[0]) 106 | new_min_y = min(self.total_bounds[1], new_bounds[1]) 107 | new_max_x = max(self.total_bounds[2], new_bounds[2]) 108 | new_max_y = max(self.total_bounds[3], new_bounds[3]) 109 | self._total_bounds = np.array( 110 | [new_min_x, new_min_y, new_max_x, new_max_y] 111 | ) 112 | else: 113 | self._total_bounds = new_bounds 114 | 115 | @property 116 | def __geo_interface__(self): 117 | """ 118 | Return a GeoJSON FeatureCollection. 119 | """ 120 | features = [a.__geo_interface__() for a in self.agents] 121 | return {"type": "FeatureCollection", "features": features} 122 | 123 | def add_layer(self, layer: ImageLayer | RasterLayer | gpd.GeoDataFrame) -> None: 124 | """Add a layer to the Geospace. 125 | 126 | :param ImageLayer | RasterLayer | gpd.GeoDataFrame layer: The layer to add. 127 | """ 128 | if not self.crs.is_exact_same(layer.crs): 129 | if self.warn_crs_conversion: 130 | warnings.warn( 131 | f"Converting {layer.__class__.__name__} from crs {layer.crs.to_string()} " 132 | f"to the crs of {self.__class__.__name__} - {self.crs.to_string()}. " 133 | "Please check your crs settings if this is unintended, or set `GeoSpace.warn_crs_conversion` " 134 | "to `False` to suppress this warning message.", 135 | UserWarning, 136 | stacklevel=2, 137 | ) 138 | layer.to_crs(self.crs, inplace=True) 139 | self._total_bounds = None 140 | self._static_layers.append(layer) 141 | 142 | def _check_agent(self, agent): 143 | if hasattr(agent, "geometry"): 144 | if not self.crs.is_exact_same(agent.crs): 145 | if self.warn_crs_conversion: 146 | warnings.warn( 147 | f"Converting {agent.__class__.__name__} from crs {agent.crs.to_string()} " 148 | f"to the crs of {self.__class__.__name__} - {self.crs.to_string()}. " 149 | "Please check your crs settings if this is unintended, or set `GeoSpace.warn_crs_conversion` " 150 | "to `False` to suppress this warning message.", 151 | UserWarning, 152 | stacklevel=2, 153 | ) 154 | agent.to_crs(self.crs, inplace=True) 155 | else: 156 | raise AttributeError("GeoAgents must have a geometry attribute") 157 | 158 | def add_agents(self, agents): 159 | """Add a list of GeoAgents to the Geospace. 160 | 161 | GeoAgents must have a geometry attribute. This function may also be called 162 | with a single GeoAgent. 163 | 164 | :param agents: A list of GeoAgents or a single GeoAgent to be added into GeoSpace. 165 | :raises AttributeError: If the GeoAgents do not have a geometry attribute. 166 | """ 167 | if isinstance(agents, GeoAgent): 168 | agent = agents 169 | self._check_agent(agent) 170 | else: 171 | for agent in agents: 172 | self._check_agent(agent) 173 | self._agent_layer.add_agents(agents) 174 | self._total_bounds = None 175 | 176 | def _recreate_rtree(self, new_agents=None): 177 | """Create a new rtree index from agents geometries.""" 178 | self._agent_layer._recreate_rtree(new_agents) 179 | 180 | def remove_agent(self, agent): 181 | """Remove an agent from the GeoSpace.""" 182 | self._agent_layer.remove_agent(agent) 183 | self._total_bounds = None 184 | 185 | def get_relation(self, agent, relation): 186 | """Return a list of related agents. 187 | 188 | :param GeoAgent agent: The agent to find related agents for. 189 | :param str relation: The relation to find. Must be one of 'intersects', 190 | 'within', 'contains', 'touches'. 191 | """ 192 | yield from self._agent_layer.get_relation(agent, relation) 193 | 194 | def get_intersecting_agents(self, agent): 195 | return self._agent_layer.get_intersecting_agents(agent) 196 | 197 | def get_neighbors_within_distance( 198 | self, agent, distance, center=False, relation="intersects" 199 | ): 200 | """Return a list of agents within `distance` of `agent`. 201 | 202 | Distance is measured as a buffer around the agent's geometry, 203 | set center=True to calculate distance from center. 204 | """ 205 | yield from self._agent_layer.get_neighbors_within_distance( 206 | agent, distance, center, relation 207 | ) 208 | 209 | def agents_at(self, pos): 210 | """ 211 | Return a list of agents at given pos. 212 | """ 213 | return self._agent_layer.agents_at(pos) 214 | 215 | def distance(self, agent_a, agent_b): 216 | """ 217 | Return distance of two agents. 218 | """ 219 | return self._agent_layer.distance(agent_a, agent_b) 220 | 221 | def get_neighbors(self, agent): 222 | """ 223 | Get (touching) neighbors of an agent. 224 | """ 225 | return self._agent_layer.get_neighbors(agent) 226 | 227 | def get_agents_as_GeoDataFrame(self, agent_cls=GeoAgent) -> gpd.GeoDataFrame: 228 | """ 229 | Extract GeoAgents as a GeoDataFrame. 230 | 231 | :param agent_cls: The class of the GeoAgents to extract. Default is `GeoAgent`. 232 | :return: A GeoDataFrame of the GeoAgents. 233 | :rtype: gpd.GeoDataFrame 234 | """ 235 | 236 | return self._agent_layer.get_agents_as_GeoDataFrame(agent_cls) 237 | 238 | 239 | class _AgentLayer: 240 | """ 241 | Layer that contains the GeoAgents. Mainly for internal usage within `GeoSpace`. 242 | """ 243 | 244 | def __init__(self): 245 | # neighborhood graph for touching neighbors 246 | self._neighborhood = None 247 | # rtree index for spatial indexing (e.g., neighbors within distance, agents at pos, etc.) 248 | self._idx = None 249 | self._id_to_agent = {} 250 | # bounds of the layer in [min_x, min_y, max_x, max_y] format 251 | # While it is possible to calculate the bounds from rtree index, 252 | # total_bounds is almost always needed (e.g., for plotting), while rtree index is not. 253 | # Hence we compute total_bounds separately from rtree index. 254 | self._total_bounds = None 255 | 256 | @property 257 | def agents(self): 258 | """ 259 | Return a list of all agents in the layer. 260 | """ 261 | 262 | return list(self._id_to_agent.values()) 263 | 264 | @property 265 | def total_bounds(self): 266 | """ 267 | Return the bounds of the layer in [min_x, min_y, max_x, max_y] format. 268 | """ 269 | 270 | if self._total_bounds is None and len(self.agents) > 0: 271 | bounds = np.array([agent.geometry.bounds for agent in self.agents]) 272 | min_x, min_y = np.min(bounds[:, :2], axis=0) 273 | max_x, max_y = np.max(bounds[:, 2:], axis=0) 274 | self._total_bounds = np.array([min_x, min_y, max_x, max_y]) 275 | return self._total_bounds 276 | 277 | def _get_rtree_intersections(self, geometry): 278 | """ 279 | Calculate rtree intersections for candidate agents. 280 | """ 281 | 282 | self._ensure_index() 283 | if self._idx is None: 284 | return [] 285 | else: 286 | return [ 287 | self._id_to_agent[i] for i in self._idx.intersection(geometry.bounds) 288 | ] 289 | 290 | def _create_neighborhood(self): 291 | """ 292 | Create a neighborhood graph of all agents. 293 | """ 294 | 295 | agents = self.agents 296 | geometries = [agent.geometry for agent in agents] 297 | self._neighborhood = weights.contiguity.Queen.from_iterable(geometries) 298 | self._neighborhood.agents = agents 299 | self._neighborhood.idx = {} 300 | for agent, key in zip(agents, self._neighborhood.neighbors.keys()): 301 | self._neighborhood.idx[agent] = key 302 | 303 | def _ensure_index(self): 304 | """ 305 | Ensure that the rtree index is created. 306 | """ 307 | 308 | if self._idx is None: 309 | self._recreate_rtree() 310 | 311 | def _recreate_rtree(self, new_agents=None): 312 | """ 313 | Create a new rtree index from agents geometries. 314 | """ 315 | 316 | if new_agents is None: 317 | new_agents = [] 318 | agents = list(self.agents) + new_agents 319 | 320 | if len(agents) > 0: 321 | # Bulk insert agents 322 | index_data = ( 323 | (agent.unique_id, agent.geometry.bounds, None) for agent in agents 324 | ) 325 | self._idx = index.Index(index_data) 326 | 327 | def add_agents(self, agents): 328 | """ 329 | Add a list of GeoAgents to the layer without checking their crs. 330 | 331 | GeoAgents must have the same crs to avoid incorrect spatial indexing results. 332 | To change the crs of a GeoAgent, use `GeoAgent.to_crs()` method. Refer to 333 | `GeoSpace._check_agent()` as an example. 334 | This function may also be called with a single GeoAgent. 335 | 336 | :param agents: A list of GeoAgents or a single GeoAgent to be added into the layer. 337 | """ 338 | 339 | if isinstance(agents, GeoAgent): 340 | agent = agents 341 | self._id_to_agent[agent.unique_id] = agent 342 | if self._idx: 343 | self._idx.insert(agent.unique_id, agent.geometry.bounds, None) 344 | else: 345 | for agent in agents: 346 | self._id_to_agent[agent.unique_id] = agent 347 | if self._idx: 348 | self._recreate_rtree(agents) 349 | self._total_bounds = None 350 | 351 | def remove_agent(self, agent): 352 | """ 353 | Remove an agent from the layer. 354 | """ 355 | 356 | del self._id_to_agent[agent.unique_id] 357 | if self._idx: 358 | self._idx.delete(agent.unique_id, agent.geometry.bounds) 359 | self._total_bounds = None 360 | 361 | def get_relation(self, agent, relation): 362 | """Return a list of related agents. 363 | 364 | Args: 365 | agent: the agent for which to compute the relation 366 | relation: must be one of 'intersects', 'within', 'contains', 367 | 'touches' 368 | other_agents: A list of agents to compare against. 369 | Omit to compare against all other agents of the layer. 370 | """ 371 | 372 | self._ensure_index() 373 | possible_agents = self._get_rtree_intersections(agent.geometry) 374 | for other_agent in possible_agents: 375 | if ( 376 | getattr(agent.geometry, relation)(other_agent.geometry) 377 | and other_agent.unique_id != agent.unique_id 378 | ): 379 | yield other_agent 380 | 381 | def get_intersecting_agents(self, agent): 382 | self._ensure_index() 383 | intersecting_agents = self.get_relation(agent, "intersects") 384 | return intersecting_agents 385 | 386 | def get_neighbors_within_distance( 387 | self, agent, distance, center=False, relation="intersects" 388 | ): 389 | """Return a list of agents within `distance` of `agent`. 390 | 391 | Distance is measured as a buffer around the agent's geometry, 392 | set center=True to calculate distance from center. 393 | """ 394 | self._ensure_index() 395 | if center: 396 | geometry = agent.geometry.centroid.buffer(distance) 397 | else: 398 | geometry = agent.geometry.buffer(distance) 399 | possible_neighbors = self._get_rtree_intersections(geometry) 400 | prepared_geometry = prep(geometry) 401 | for other_agent in possible_neighbors: 402 | if getattr(prepared_geometry, relation)(other_agent.geometry): 403 | yield other_agent 404 | 405 | def agents_at(self, pos): 406 | """ 407 | Return a generator of agents at given pos. 408 | """ 409 | 410 | self._ensure_index() 411 | if not isinstance(pos, Point): 412 | pos = Point(pos) 413 | 414 | possible_agents = self._get_rtree_intersections(pos) 415 | for other_agent in possible_agents: 416 | if pos.within(other_agent.geometry): 417 | yield other_agent 418 | 419 | def distance(self, agent_a, agent_b): 420 | """ 421 | Return distance of two agents. 422 | """ 423 | 424 | return agent_a.geometry.distance(agent_b.geometry) 425 | 426 | def get_neighbors(self, agent): 427 | """ 428 | Get (touching) neighbors of an agent. 429 | """ 430 | 431 | if not self._neighborhood or self._neighborhood.agents != self.agents: 432 | self._create_neighborhood() 433 | 434 | if self._neighborhood is None: 435 | return [] 436 | else: 437 | idx = self._neighborhood.idx[agent] 438 | neighbors_idx = self._neighborhood.neighbors[idx] 439 | neighbors = [self.agents[i] for i in neighbors_idx] 440 | return neighbors 441 | 442 | def get_agents_as_GeoDataFrame(self, agent_cls=GeoAgent) -> gpd.GeoDataFrame: 443 | """ 444 | Extract GeoAgents as a GeoDataFrame. 445 | 446 | :param agent_cls: The class of the GeoAgents to extract. Default is `GeoAgent`. 447 | :return: A GeoDataFrame of the GeoAgents. 448 | :rtype: geopandas.GeoDataFrame 449 | """ 450 | 451 | agents_list = [] 452 | crs = None 453 | for agent in self.agents: 454 | if isinstance(agent, agent_cls): 455 | crs = agent.crs 456 | agent_dict = { 457 | attr: value 458 | for attr, value in vars(agent).items() 459 | if attr not in {"model", "pos", "_crs"} 460 | } 461 | agents_list.append(agent_dict) 462 | agents_gdf = gpd.GeoDataFrame.from_records(agents_list) 463 | # workaround for geometry column not being set in `from_records` 464 | # see https://github.com/geopandas/geopandas/issues/3152 465 | # may be removed when the issue is resolved 466 | agents_gdf.set_geometry("geometry", inplace=True) 467 | agents_gdf.crs = crs 468 | return agents_gdf 469 | --------------------------------------------------------------------------------