├── .github ├── release.yml └── workflows │ ├── release.yml │ └── unittests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── ci ├── 310-latest.yaml ├── 310-oldest.yaml ├── 311-latest.yaml ├── 312-dev.yaml └── 312-latest.yaml ├── datasets ├── README.md ├── US │ └── README.md └── mexico │ ├── README.md │ └── lvl0 │ ├── mex_admbnda_adm0_govmex_20210618.CPG │ ├── mex_admbnda_adm0_govmex_20210618.dbf │ ├── mex_admbnda_adm0_govmex_20210618.prj │ ├── mex_admbnda_adm0_govmex_20210618.sbn │ ├── mex_admbnda_adm0_govmex_20210618.sbx │ ├── mex_admbnda_adm0_govmex_20210618.shp │ ├── mex_admbnda_adm0_govmex_20210618.shp.xml │ └── mex_admbnda_adm0_govmex_20210618.shx ├── docs ├── Makefile ├── _static │ └── css │ │ └── custom.css ├── _templates │ ├── docs-sidebar.html │ └── layout.html ├── conf.py ├── environment.yml ├── gaps.ipynb ├── holes.ipynb ├── index.md ├── nonplanaredges.ipynb ├── nonplanartouches.ipynb ├── overlaps.ipynb ├── reference.rst ├── snap.ipynb ├── touching.ipynb └── usmex.ipynb ├── geoplanar ├── __init__.py ├── __pycache__ │ ├── __init__.cpython-37.pyc │ ├── _version.cpython-37.pyc │ ├── gap.cpython-37.pyc │ ├── hole.cpython-37.pyc │ ├── overlap.cpython-37.pyc │ ├── planar.cpython-37.pyc │ └── valid.cpython-37.pyc ├── gap.py ├── hole.py ├── overlap.py ├── planar.py ├── tests │ ├── __init__.py │ ├── test_compact.py │ ├── test_data │ │ └── possibly_invalid_snap.gpkg │ ├── test_gap.py │ ├── test_hole.py │ ├── test_overlap.py │ └── test_planar.py └── valid.py ├── notebooks ├── Europe │ ├── EU_NUTS_only.ipynb │ └── EU_diff_data.ipynb ├── USCAN │ ├── nb1_Fixing_gaps.ipynb │ ├── nb2_Fixing_overlays.ipynb │ ├── nb3_Testing_Contiguity.ipynb │ ├── nb4_Centroid_Comparsion.ipynb │ ├── nb5_Area_Comparsion.ipynb │ └── nb_dataprocessing.ipynb └── usmex │ ├── README.md │ ├── data_processing.ipynb │ ├── usmex_0.ipynb │ ├── usmex_1.ipynb │ ├── usmex_1_changes.ipynb │ ├── usmex_1_contiguity.ipynb │ ├── usmex_2.ipynb │ ├── usmex_2_changes.ipynb │ └── usmex_2_contiguity.ipynb ├── pyproject.toml └── readthedocs.yml /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore-for-release 5 | authors: 6 | - dependabot 7 | categories: 8 | - title: Bug Fixes 9 | labels: 10 | - bug 11 | - title: Enhancements 12 | labels: 13 | - enhancement 14 | - title: Maintenance 15 | labels: 16 | - maintenance 17 | - title: Other Changes 18 | labels: 19 | - "*" -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # Release package on GitHub and publish to PyPI 2 | 3 | # Important: In order to trigger this workflow for the organization 4 | # repo (organzation-name/repo-name vs. user-name/repo-name), a tagged 5 | # commit must be made to *organzation-name/repo-name*. If the tagged 6 | # commit is made to *user-name/repo-name*, a release will be published 7 | # under the user's name, not the organzation. 8 | 9 | #-------------------------------------------------- 10 | name: Release & Publish 11 | 12 | on: 13 | push: 14 | # Sequence of patterns matched against refs/tags 15 | tags: 16 | - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 17 | workflow_dispatch: 18 | inputs: 19 | version: 20 | description: Manual Release 21 | default: test 22 | required: false 23 | 24 | jobs: 25 | build: 26 | name: Create release & publish to PyPI 27 | runs-on: ubuntu-latest 28 | 29 | steps: 30 | - name: Checkout repo 31 | uses: actions/checkout@v4 32 | with: 33 | fetch-depth: 0 # Fetch all history for all branches and tags. 34 | 35 | - name: Set up python 36 | uses: actions/setup-python@v5 37 | with: 38 | python-version: "3.x" 39 | 40 | - name: Install Dependencies 41 | run: | 42 | python -m pip install --upgrade pip build twine 43 | python -m build 44 | twine check --strict dist/* 45 | 46 | - name: Create Release Notes 47 | uses: actions/github-script@v7 48 | with: 49 | github-token: ${{secrets.GITHUB_TOKEN}} 50 | script: | 51 | await github.request(`POST /repos/${{ github.repository }}/releases`, { 52 | tag_name: "${{ github.ref }}", 53 | generate_release_notes: true 54 | }); 55 | 56 | - name: Publish distribution 📦 to PyPI 57 | uses: pypa/gh-action-pypi-publish@release/v1 58 | with: 59 | user: __token__ 60 | password: ${{ secrets.PYPI_PASSWORD }} -------------------------------------------------------------------------------- /.github/workflows/unittests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: 8 | - "*" 9 | schedule: 10 | - cron: "0 0 * * 1,4" 11 | workflow_dispatch: 12 | inputs: 13 | version: 14 | description: Manual test execution 15 | default: test 16 | required: false 17 | 18 | jobs: 19 | Test: 20 | name: ${{ matrix.os }}, ${{ matrix.environment-file }} 21 | runs-on: ${{ matrix.os }} 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | os: [ubuntu-latest] 26 | environment-file: 27 | - ci/310-oldest.yaml 28 | - ci/310-latest.yaml 29 | - ci/311-latest.yaml 30 | - ci/312-latest.yaml 31 | - ci/312-dev.yaml 32 | include: 33 | - environment-file: ci/312-latest.yaml 34 | os: macos-13 # Intel 35 | - environment-file: ci/312-latest.yaml 36 | os: macos-latest # Apple Silicon 37 | - environment-file: ci/312-latest.yaml 38 | os: windows-latest 39 | defaults: 40 | run: 41 | shell: bash -l {0} 42 | 43 | steps: 44 | - uses: actions/checkout@v4 45 | 46 | - name: setup micromamba 47 | uses: mamba-org/setup-micromamba@v1 48 | with: 49 | environment-file: ${{ matrix.environment-file }} 50 | 51 | - name: Install geoplanar 52 | run: pip install . 53 | 54 | - name: Test geoplanar 55 | run: | 56 | pytest -v --color yes --cov geoplanar --cov-append --cov-report term-missing --cov-report xml . 57 | 58 | - uses: codecov/codecov-action@v4 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | docs/generated/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | cache/ 108 | 109 | # GPKG cache 110 | .gpkg-wal 111 | .gpkg-shm 112 | 113 | # my playing files 114 | sandbox/ 115 | run/ 116 | 117 | .vscode 118 | .asv 119 | dask-worker-space 120 | .DS_Store 121 | .ruff_cache -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | files: 'geoplanar\/' 2 | repos: 3 | - repo: https://github.com/astral-sh/ruff-pre-commit 4 | rev: v0.4.2 5 | hooks: 6 | - id: ruff 7 | - id: ruff-format 8 | 9 | ci: 10 | autofix_prs: false 11 | autoupdate_schedule: quarterly 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2021, Sergio Rey 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # geoplanar 3 | Planar enforcement for polygon geoseries 4 | 5 | ![mexico-us](https://i.imgur.com/CFgnecL.png) 6 | 7 | [![unittests](https://github.com/sjsrey/geoplanar/workflows/.github/workflows/unittests.yml/badge.svg)](https://github.com/sjsrey/geoplanar/actions?query=workflow%3A.github%2Fworkflows%2Funittests.yml) 8 | |[![Documentation Status](https://readthedocs.org/projects/geoplanar/badge/?version=latest)](https://geoplanar.readthedocs.io/en/latest/?badge=latest) 9 | [![DOI](https://zenodo.org/badge/382492314.svg)](https://zenodo.org/badge/latestdoi/382492314) 10 | 11 | 12 | 13 | 14 | `geoplanar` supports the detection and correction of violations of [planar enforcement](https://ibis.geog.ubc.ca/courses/klink/gis.notes/ncgia/u12.html#SEC12.6) for polygon geoseries including: 15 | 16 | 17 | - [gaps](https://github.com/sjsrey/geoplanar/blob/main/notebooks/gaps.ipynb) 18 | - [nonplanar coincident edges](https://github.com/sjsrey/geoplanar/blob/main/notebooks/nonplanaredges.ipynb) 19 | - [nonplanar touches](https://github.com/sjsrey/geoplanar/blob/main/notebooks/nonplanartouches.ipynb) 20 | - [overlaps](https://github.com/sjsrey/geoplanar/blob/main/notebooks/overlaps.ipynb) 21 | - [holes](https://github.com/sjsrey/geoplanar/blob/main/notebooks/holes.ipynb) 22 | 23 | 24 | ## Status 25 | 26 | `geoplanar` is currently in alpha status and is open to contributions that help shape the scope of the package. It is being developed in support of [research ](https://nsf.gov/awardsearch/showAward?AWD_ID=1759746&HistoricalAwards=false) and is likely to be undergoing changes as the project evolves. 27 | 28 | ## Contributing 29 | 30 | `geoplanar` development uses a [git-flow](https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow) model. Contributions following this model are welcomed. 31 | 32 | 33 | ## Funding 34 | 35 | `geoplanar` is partially supported by [NSF Award #1759746, Comparative Regional Inequality Dynamics: Multiscalar and Multinational Perspectives](https://nsf.gov/awardsearch/showAward?AWD_ID=1759746&HistoricalAwards=false) 36 | 37 | 38 | -------------------------------------------------------------------------------- /ci/310-latest.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python=3.10 6 | - libpysal 7 | - esda 8 | - geopandas 9 | - packaging 10 | - pytest 11 | - pytest-cov 12 | - codecov 13 | -------------------------------------------------------------------------------- /ci/310-oldest.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python=3.10 6 | - libpysal=4.8.0 7 | - geopandas=0.10.2 8 | - scipy=1.8 9 | - pandas=1.5 10 | - esda 11 | - packaging 12 | - pytest 13 | - pytest-cov 14 | - codecov 15 | -------------------------------------------------------------------------------- /ci/311-latest.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python=3.11 6 | - libpysal 7 | - esda 8 | - geopandas 9 | - packaging 10 | - pytest 11 | - pytest-cov 12 | - codecov 13 | -------------------------------------------------------------------------------- /ci/312-dev.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python=3.12 6 | - shapely 7 | - pyogrio 8 | - packaging 9 | - pytest 10 | - pytest-cov 11 | - codecov 12 | - pip 13 | - pip: 14 | # dev versions of packages 15 | - --pre --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple --extra-index-url https://pypi.org/simple 16 | - pandas 17 | - git+https://github.com/geopandas/geopandas.git@main 18 | - git+https://github.com/pysal/libpysal.git@main 19 | - git+https://github.com/pysal/esda.git@main -------------------------------------------------------------------------------- /ci/312-latest.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python=3.12 6 | - libpysal 7 | - esda 8 | - packaging 9 | - pytest 10 | - pytest-cov 11 | - codecov 12 | - pip 13 | - pip: 14 | - geopandas==1.0.0a1 15 | -------------------------------------------------------------------------------- /datasets/README.md: -------------------------------------------------------------------------------- 1 | # Built-in datasets for geoplanar 2 | 3 | These are intended to demonstrate core functionality and support unit tests. 4 | 5 | 6 | | Directory | Description | 7 | |-----------|-------------| 8 | | `mexico` | Mexico Level 0 Administrative Boundaries | 9 | -------------------------------------------------------------------------------- /datasets/US/README.md: -------------------------------------------------------------------------------- 1 | # US shapefiles 2 | 3 | Source: https://gadm.org/download_country.html 4 | 5 | https://gadm.org/index.html 6 | 7 | 8 | Unzip or read in directly with geopandas 9 | 10 | 11 | -------------------------------------------------------------------------------- /datasets/mexico/README.md: -------------------------------------------------------------------------------- 1 | # Mexico Level 0-2 Administrative boundaries 2 | 3 | Source: https://data.humdata.org/dataset/9721eaf0-5663-4137-b3a2-c21dc8fac15a/resource/f151b1c1-1353-4f57-bdb2-b1b1c18a1fd1/download/mex_admbnda_govmex_20210618_shp.zip 4 | 5 | 6 | Unzip or read in directly with geopandas 7 | 8 | 9 | -------------------------------------------------------------------------------- /datasets/mexico/lvl0/mex_admbnda_adm0_govmex_20210618.CPG: -------------------------------------------------------------------------------- 1 | UTF-8 -------------------------------------------------------------------------------- /datasets/mexico/lvl0/mex_admbnda_adm0_govmex_20210618.dbf: -------------------------------------------------------------------------------- 1 | ya9Shape_LengF Shape_AreaF ADM0_ESC2ADM0_PCODEC2ADM0_REFC2ADM0ALT1ESC2ADM0ALT2ESC2dateDvalidOnDvalidToD 3.54605875572e+02 1.73513956447e+02México MX Mexico 2020062320210618  -------------------------------------------------------------------------------- /datasets/mexico/lvl0/mex_admbnda_adm0_govmex_20210618.prj: -------------------------------------------------------------------------------- 1 | GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]] -------------------------------------------------------------------------------- /datasets/mexico/lvl0/mex_admbnda_adm0_govmex_20210618.sbn: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopandas/geoplanar/371a51c12e470bcdb01f4ba2fb80b6ff95783d5e/datasets/mexico/lvl0/mex_admbnda_adm0_govmex_20210618.sbn -------------------------------------------------------------------------------- /datasets/mexico/lvl0/mex_admbnda_adm0_govmex_20210618.sbx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopandas/geoplanar/371a51c12e470bcdb01f4ba2fb80b6ff95783d5e/datasets/mexico/lvl0/mex_admbnda_adm0_govmex_20210618.sbx -------------------------------------------------------------------------------- /datasets/mexico/lvl0/mex_admbnda_adm0_govmex_20210618.shp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopandas/geoplanar/371a51c12e470bcdb01f4ba2fb80b6ff95783d5e/datasets/mexico/lvl0/mex_admbnda_adm0_govmex_20210618.shp -------------------------------------------------------------------------------- /datasets/mexico/lvl0/mex_admbnda_adm0_govmex_20210618.shx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopandas/geoplanar/371a51c12e470bcdb01f4ba2fb80b6ff95783d5e/datasets/mexico/lvl0/mex_admbnda_adm0_govmex_20210618.shx -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/_static/css/custom.css: -------------------------------------------------------------------------------- 1 | /* Override some aspects of the pydata-sphinx-theme */ 2 | 3 | :root { 4 | --color-active-navigation: 0, 91, 129; 5 | /* Use normal text color (like h3, ..) instead of primary color */ 6 | --color-h1: var(--color-text-base); 7 | --color-h2: var(--color-text-base); 8 | } 9 | 10 | 11 | body { 12 | padding-top: 0px; 13 | } 14 | 15 | @media (min-width: 768px) { 16 | @supports (position: -webkit-sticky) or (position: sticky) { 17 | .bd-sidebar { 18 | top: 40px; 19 | } 20 | } 21 | } 22 | 23 | /* no pink for code */ 24 | code { 25 | color: #3b444b; 26 | } 27 | 28 | /* Larger font size for sidebar*/ 29 | .bd-sidebar .nav > li > a { 30 | font-size: 1em; 31 | } 32 | 33 | /* New element: brand text instead of logo */ 34 | 35 | /* .navbar-brand-text { 36 | padding: 1rem; 37 | } */ 38 | 39 | a.navbar-brand-text { 40 | color: #333; 41 | font-size: xx-large; 42 | } 43 | -------------------------------------------------------------------------------- /docs/_templates/docs-sidebar.html: -------------------------------------------------------------------------------- 1 | {% if logo %} 2 | 3 | 4 | 5 | {% else %} 6 | 7 | GEOPLANAR 8 | 9 |

Planar Enforcement for Polygon Geoseries

10 | {% endif %} 11 | 12 | 13 | 17 | 18 | -------------------------------------------------------------------------------- /docs/_templates/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "pydata_sphinx_theme/layout.html" %} 2 | 3 | 4 | 5 | {# Silence the navbar #} 6 | {% block docs_navbar %} 7 | {% endblock %} 8 | 9 | 10 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | # If extensions (or modules to document with autodoc) are in another directory, 9 | # add these directories to sys.path here. If the directory is relative to the 10 | # documentation root, use os.path.abspath to make it absolute, like shown here. 11 | # 12 | import os 13 | import sys 14 | 15 | sys.path.insert(0, os.path.abspath("../")) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | import geoplanar 20 | 21 | project = "geoplanar" 22 | copyright = "2021-, Serge Rey & geoplanar contributors" 23 | author = "Serge Rey & geoplanar contributors" 24 | 25 | # The full version, including alpha/beta/rc tags 26 | release = geoplanar.__version__ 27 | version = geoplanar.__version__ 28 | 29 | 30 | # -- General configuration --------------------------------------------------- 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = ["sphinx.ext.autodoc", "numpydoc", "myst_nb"] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ["_templates"] 39 | 40 | # List of patterns, relative to source directory, that match files and 41 | # directories to ignore when looking for source files. 42 | # This pattern also affects html_static_path and html_extra_path. 43 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 44 | 45 | 46 | # -- Options for HTML output ------------------------------------------------- 47 | 48 | # The theme to use for HTML and HTML Help pages. See the documentation for 49 | # a list of builtin themes. 50 | # 51 | html_theme = "sphinx_book_theme" 52 | 53 | # Add any paths that contain custom static files (such as style sheets) here, 54 | # relative to this directory. They are copied after the builtin static files, 55 | # so a file named "default.css" will overwrite the builtin "default.css". 56 | html_static_path = ["_static"] 57 | 58 | html_css_files = [ 59 | "css/custom.css", 60 | ] 61 | 62 | html_sidebars = {} 63 | 64 | nb_execution_mode = "off" 65 | -------------------------------------------------------------------------------- /docs/environment.yml: -------------------------------------------------------------------------------- 1 | name: geoplanar_docs 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python 6 | # dependencies 7 | - geopandas 8 | - libpysal 9 | - numpy 10 | - geopy 11 | - matplotlib-base 12 | - mercantile 13 | - pillow 14 | - rasterio 15 | - requests 16 | - joblib 17 | - pip 18 | # doc dependencies 19 | - sphinx 20 | - numpydoc 21 | - ipython 22 | - sphinx-book-theme 23 | - myst-nb -------------------------------------------------------------------------------- /docs/holes.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "c72f6db1", 6 | "metadata": {}, 7 | "source": [ 8 | "# Omitted interiors" 9 | ] 10 | }, 11 | { 12 | "cell_type": "code", 13 | "execution_count": 1, 14 | "id": "cfbaa126", 15 | "metadata": {}, 16 | "outputs": [], 17 | "source": [ 18 | "import geopandas\n", 19 | "import numpy\n", 20 | "import matplotlib.pyplot as plt\n", 21 | "import geoplanar\n", 22 | "from shapely.geometry import box, Point\n" 23 | ] 24 | }, 25 | { 26 | "cell_type": "markdown", 27 | "id": "dcffc755", 28 | "metadata": {}, 29 | "source": [ 30 | "For a planar enforced polygon layer there should be no individual polygons that are contained inside other polygons.\n", 31 | "\n", 32 | "Violation of this condition can lead to a number of errors in subsequent spatial analysis.\n", 33 | "\n", 34 | "## Violation: Points within more than a single feature" 35 | ] 36 | }, 37 | { 38 | "cell_type": "markdown", 39 | "id": "8a1be122", 40 | "metadata": {}, 41 | "source": [ 42 | "If this were not the case, then it would be possible for a point to be contained inside more than a single polygon which would be a violation of planar enforcement.\n", 43 | "An example can be seen as follows:" 44 | ] 45 | }, 46 | { 47 | "cell_type": "code", 48 | "execution_count": 2, 49 | "id": "6c5beb4a", 50 | "metadata": {}, 51 | "outputs": [ 52 | { 53 | "data": { 54 | "text/plain": [ 55 | "" 56 | ] 57 | }, 58 | "execution_count": 2, 59 | "metadata": {}, 60 | "output_type": "execute_result" 61 | }, 62 | { 63 | "data": { 64 | "image/png": "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", 65 | "text/plain": [ 66 | "
" 67 | ] 68 | }, 69 | "metadata": { 70 | "needs_background": "light" 71 | }, 72 | "output_type": "display_data" 73 | } 74 | ], 75 | "source": [ 76 | "p1 = box(0,0,10,10)\n", 77 | "p2 = box(1,1, 3,3)\n", 78 | "p3 = box(7,7, 9,9)\n", 79 | "\n", 80 | "gdf = geopandas.GeoDataFrame(geometry=[p1,p2,p3])\n", 81 | "base = gdf.plot(edgecolor='k')\n", 82 | "\n", 83 | "pnt1 = geopandas.GeoDataFrame(geometry=[Point(2,2)])\n", 84 | "pnt1.plot(ax=base,color='red')" 85 | ] 86 | }, 87 | { 88 | "cell_type": "code", 89 | "execution_count": 3, 90 | "id": "90fe7df8", 91 | "metadata": {}, 92 | "outputs": [ 93 | { 94 | "data": { 95 | "text/plain": [ 96 | "0 True\n", 97 | "dtype: bool" 98 | ] 99 | }, 100 | "execution_count": 3, 101 | "metadata": {}, 102 | "output_type": "execute_result" 103 | } 104 | ], 105 | "source": [ 106 | "pnt1.within(gdf.geometry[0])" 107 | ] 108 | }, 109 | { 110 | "cell_type": "code", 111 | "execution_count": 4, 112 | "id": "df53d8fd", 113 | "metadata": { 114 | "lines_to_next_cell": 0 115 | }, 116 | "outputs": [ 117 | { 118 | "data": { 119 | "text/plain": [ 120 | "0 True\n", 121 | "dtype: bool" 122 | ] 123 | }, 124 | "execution_count": 4, 125 | "metadata": {}, 126 | "output_type": "execute_result" 127 | } 128 | ], 129 | "source": [ 130 | "pnt1.within(gdf.geometry[1])" 131 | ] 132 | }, 133 | { 134 | "cell_type": "markdown", 135 | "id": "1ce4898e", 136 | "metadata": {}, 137 | "source": [ 138 | "The violation here is that `pnt1` is `within` *both* polygon `p1` *and* `p2`." 139 | ] 140 | }, 141 | { 142 | "cell_type": "markdown", 143 | "id": "cc697779", 144 | "metadata": {}, 145 | "source": [ 146 | "## Error in area calculations\n", 147 | "\n", 148 | "A related error that arises in this case is that the area of the \"containing\" polygon will be too large, since it includes the area of the smaller polygons:" 149 | ] 150 | }, 151 | { 152 | "cell_type": "code", 153 | "execution_count": 5, 154 | "id": "4a9ff8f1", 155 | "metadata": {}, 156 | "outputs": [ 157 | { 158 | "data": { 159 | "image/svg+xml": [ 160 | "" 161 | ], 162 | "text/plain": [ 163 | "" 164 | ] 165 | }, 166 | "execution_count": 5, 167 | "metadata": {}, 168 | "output_type": "execute_result" 169 | } 170 | ], 171 | "source": [ 172 | "gdf.geometry[0]" 173 | ] 174 | }, 175 | { 176 | "cell_type": "code", 177 | "execution_count": 6, 178 | "id": "74199743", 179 | "metadata": {}, 180 | "outputs": [ 181 | { 182 | "data": { 183 | "text/plain": [ 184 | "0 100.0\n", 185 | "1 4.0\n", 186 | "2 4.0\n", 187 | "dtype: float64" 188 | ] 189 | }, 190 | "execution_count": 6, 191 | "metadata": {}, 192 | "output_type": "execute_result" 193 | } 194 | ], 195 | "source": [ 196 | "gdf.area" 197 | ] 198 | }, 199 | { 200 | "cell_type": "code", 201 | "execution_count": 7, 202 | "id": "eb4e445a", 203 | "metadata": {}, 204 | "outputs": [ 205 | { 206 | "data": { 207 | "text/plain": [ 208 | "108.0" 209 | ] 210 | }, 211 | "execution_count": 7, 212 | "metadata": {}, 213 | "output_type": "execute_result" 214 | } 215 | ], 216 | "source": [ 217 | "gdf.area.sum()" 218 | ] 219 | }, 220 | { 221 | "cell_type": "markdown", 222 | "id": "ea9863d0", 223 | "metadata": {}, 224 | "source": [ 225 | "## Missing interior rings (aka holes)\n", 226 | "\n", 227 | "The crux of the issue is that the two smaller polygons are entities in their own right, yet the large polygon was defined to have only a single external ring. It is missing two **interior rings**\n", 228 | "which would allow for the correct topological relationship between the larger polygon and the two smaller polygons.\n", 229 | "\n", 230 | "`geoplanar` can detect missing interiors:" 231 | ] 232 | }, 233 | { 234 | "cell_type": "code", 235 | "execution_count": 8, 236 | "id": "0ac7ff26", 237 | "metadata": { 238 | "lines_to_next_cell": 2 239 | }, 240 | "outputs": [ 241 | { 242 | "data": { 243 | "text/plain": [ 244 | "[(0, 1), (0, 2)]" 245 | ] 246 | }, 247 | "execution_count": 8, 248 | "metadata": {}, 249 | "output_type": "execute_result" 250 | } 251 | ], 252 | "source": [ 253 | "mi = geoplanar.missing_interiors(gdf)\n", 254 | "mi" 255 | ] 256 | }, 257 | { 258 | "cell_type": "markdown", 259 | "id": "5628ee20", 260 | "metadata": {}, 261 | "source": [ 262 | "## Adding interior rings\n", 263 | "Once we know that the problem is missing interior rings, we can correct this with `add_interiors`:" 264 | ] 265 | }, 266 | { 267 | "cell_type": "code", 268 | "execution_count": 9, 269 | "id": "dda5dc19", 270 | "metadata": {}, 271 | "outputs": [], 272 | "source": [ 273 | "gdf1 = geoplanar.add_interiors(gdf)" 274 | ] 275 | }, 276 | { 277 | "cell_type": "code", 278 | "execution_count": 10, 279 | "id": "d91a5d40", 280 | "metadata": {}, 281 | "outputs": [ 282 | { 283 | "data": { 284 | "image/svg+xml": [ 285 | "" 286 | ], 287 | "text/plain": [ 288 | "" 289 | ] 290 | }, 291 | "execution_count": 10, 292 | "metadata": {}, 293 | "output_type": "execute_result" 294 | } 295 | ], 296 | "source": [ 297 | "gdf1.geometry[0]" 298 | ] 299 | }, 300 | { 301 | "cell_type": "markdown", 302 | "id": "b01a3534", 303 | "metadata": {}, 304 | "source": [ 305 | "And we see that the resulting area of the GeoSeries is now correct:" 306 | ] 307 | }, 308 | { 309 | "cell_type": "code", 310 | "execution_count": 11, 311 | "id": "996cc225", 312 | "metadata": { 313 | "lines_to_next_cell": 0 314 | }, 315 | "outputs": [ 316 | { 317 | "data": { 318 | "text/plain": [ 319 | "0 92.0\n", 320 | "1 4.0\n", 321 | "2 4.0\n", 322 | "dtype: float64" 323 | ] 324 | }, 325 | "execution_count": 11, 326 | "metadata": {}, 327 | "output_type": "execute_result" 328 | } 329 | ], 330 | "source": [ 331 | "gdf1.area" 332 | ] 333 | }, 334 | { 335 | "cell_type": "markdown", 336 | "id": "f239eeb7", 337 | "metadata": {}, 338 | "source": [ 339 | "Additionally, a check for `missing_interiors` reveals the violation has been corrected" 340 | ] 341 | }, 342 | { 343 | "cell_type": "code", 344 | "execution_count": 12, 345 | "id": "92ff657f", 346 | "metadata": {}, 347 | "outputs": [ 348 | { 349 | "data": { 350 | "text/plain": [ 351 | "[]" 352 | ] 353 | }, 354 | "execution_count": 12, 355 | "metadata": {}, 356 | "output_type": "execute_result" 357 | } 358 | ], 359 | "source": [ 360 | "geoplanar.missing_interiors(gdf1)" 361 | ] 362 | }, 363 | { 364 | "cell_type": "markdown", 365 | "id": "63623c39", 366 | "metadata": {}, 367 | "source": [ 368 | "The addition of the interior rings also corrects the violation of the containment rule that a point should belong to at most a single polygon in a planar enforced polygon GeoSeries:\n" 369 | ] 370 | }, 371 | { 372 | "cell_type": "code", 373 | "execution_count": 13, 374 | "id": "0b639aac", 375 | "metadata": {}, 376 | "outputs": [ 377 | { 378 | "data": { 379 | "text/plain": [ 380 | "0 False\n", 381 | "dtype: bool" 382 | ] 383 | }, 384 | "execution_count": 13, 385 | "metadata": {}, 386 | "output_type": "execute_result" 387 | } 388 | ], 389 | "source": [ 390 | "pnt1.within(gdf1.geometry[0])" 391 | ] 392 | }, 393 | { 394 | "cell_type": "code", 395 | "execution_count": 14, 396 | "id": "e51e09b0", 397 | "metadata": { 398 | "lines_to_next_cell": 0 399 | }, 400 | "outputs": [ 401 | { 402 | "data": { 403 | "text/plain": [ 404 | "0 True\n", 405 | "dtype: bool" 406 | ] 407 | }, 408 | "execution_count": 14, 409 | "metadata": {}, 410 | "output_type": "execute_result" 411 | } 412 | ], 413 | "source": [ 414 | "pnt1.within(gdf1.geometry[1])" 415 | ] 416 | }, 417 | { 418 | "cell_type": "markdown", 419 | "id": "76a66d79", 420 | "metadata": {}, 421 | "source": [ 422 | "## Failure to detect contiguity\n", 423 | "\n", 424 | "A final implication of missing interiors in a non-planar enforced polygon GeoSeries is that algorithms that rely on planar enforcement to detect contiguous polygons will fail.\n", 425 | "\n", 426 | "More specifically, in [pysal](https://pysal.org), fast polygon detectors can be used to generate so called Queen neighbors, which are pairs of polygons that share at least one vertex on their exterior/interior rings." 427 | ] 428 | }, 429 | { 430 | "cell_type": "code", 431 | "execution_count": 15, 432 | "id": "764ec964", 433 | "metadata": { 434 | "lines_to_next_cell": 2 435 | }, 436 | "outputs": [], 437 | "source": [ 438 | "import libpysal" 439 | ] 440 | }, 441 | { 442 | "cell_type": "code", 443 | "execution_count": 16, 444 | "id": "b27388ca", 445 | "metadata": {}, 446 | "outputs": [ 447 | { 448 | "name": "stderr", 449 | "output_type": "stream", 450 | "text": [ 451 | "/home/serge/Documents/p/pysal/src/subpackages/libpysal/libpysal/weights/weights.py:172: UserWarning: The weights matrix is not fully connected: \n", 452 | " There are 3 disconnected components.\n", 453 | " There are 3 islands with ids: 0, 1, 2.\n", 454 | " warnings.warn(message)\n" 455 | ] 456 | } 457 | ], 458 | "source": [ 459 | "w = libpysal.weights.Queen.from_dataframe(gdf)" 460 | ] 461 | }, 462 | { 463 | "cell_type": "code", 464 | "execution_count": 17, 465 | "id": "12244594", 466 | "metadata": { 467 | "lines_to_next_cell": 0 468 | }, 469 | "outputs": [ 470 | { 471 | "data": { 472 | "text/plain": [ 473 | "{0: [], 1: [], 2: []}" 474 | ] 475 | }, 476 | "execution_count": 17, 477 | "metadata": {}, 478 | "output_type": "execute_result" 479 | } 480 | ], 481 | "source": [ 482 | "w.neighbors" 483 | ] 484 | }, 485 | { 486 | "cell_type": "markdown", 487 | "id": "74195a22", 488 | "metadata": { 489 | "lines_to_next_cell": 0 490 | }, 491 | "source": [ 492 | "The original GeoDataFrame results in fully disconnected polygons, or islands. `pysal` at least throws a warning when islands are detected, and for this particular type of planar enforcement violation, missing interiors, the contained polygons will always be reported as islands.\n", 493 | "\n", 494 | "Using the corrected GeoDataFrame with the inserted interior rings results in the correct neighbor determinations:" 495 | ] 496 | }, 497 | { 498 | "cell_type": "code", 499 | "execution_count": 18, 500 | "id": "5c09a712", 501 | "metadata": {}, 502 | "outputs": [], 503 | "source": [ 504 | "w = libpysal.weights.Queen.from_dataframe(gdf1)" 505 | ] 506 | }, 507 | { 508 | "cell_type": "code", 509 | "execution_count": 19, 510 | "id": "ae7d4c04", 511 | "metadata": {}, 512 | "outputs": [ 513 | { 514 | "data": { 515 | "text/plain": [ 516 | "{0: [1, 2], 1: [0], 2: [0]}" 517 | ] 518 | }, 519 | "execution_count": 19, 520 | "metadata": {}, 521 | "output_type": "execute_result" 522 | } 523 | ], 524 | "source": [ 525 | "w.neighbors" 526 | ] 527 | } 528 | ], 529 | "metadata": { 530 | "jupytext": { 531 | "formats": "ipynb,md" 532 | }, 533 | "kernelspec": { 534 | "display_name": "Python 3", 535 | "language": "python", 536 | "name": "python3" 537 | }, 538 | "language_info": { 539 | "codemirror_mode": { 540 | "name": "ipython", 541 | "version": 3 542 | }, 543 | "file_extension": ".py", 544 | "mimetype": "text/x-python", 545 | "name": "python", 546 | "nbconvert_exporter": "python", 547 | "pygments_lexer": "ipython3", 548 | "version": "3.8.10" 549 | } 550 | }, 551 | "nbformat": 4, 552 | "nbformat_minor": 5 553 | } 554 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ```{include} ../README.md 2 | ``` 3 | 4 | ```{toctree} 5 | :hidden: 6 | :caption: User Guide 7 | usmex 8 | snap 9 | touching 10 | ``` 11 | 12 | ```{toctree} 13 | :hidden: 14 | :caption: Violations 15 | holes 16 | gaps 17 | overlaps 18 | nonplanaredges 19 | nonplanartouches 20 | ``` 21 | 22 | ```{toctree} 23 | :hidden: 24 | :caption: API 25 | reference 26 | 27 | ``` 28 | 29 | ```{toctree} 30 | :hidden: 31 | :caption: For contributors 32 | GitHub 33 | ``` 34 | -------------------------------------------------------------------------------- /docs/nonplanaredges.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "parallel-proposition", 6 | "metadata": {}, 7 | "source": [ 8 | "# Non-planar enforced edges" 9 | ] 10 | }, 11 | { 12 | "cell_type": "code", 13 | "execution_count": 1, 14 | "id": "ancient-wheat", 15 | "metadata": {}, 16 | "outputs": [], 17 | "source": [ 18 | "import geopandas\n", 19 | "import numpy\n", 20 | "import matplotlib.pyplot as plt\n", 21 | "import geoplanar\n", 22 | "import libpysal\n", 23 | "from shapely.geometry import Polygon\n" 24 | ] 25 | }, 26 | { 27 | "cell_type": "code", 28 | "execution_count": 2, 29 | "id": "sunset-roads", 30 | "metadata": {}, 31 | "outputs": [ 32 | { 33 | "data": { 34 | "image/png": "iVBORw0KGgoAAAANSUhEUgAAAXAAAADGCAYAAADL/dvjAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjUuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/YYfK9AAAACXBIWXMAAAsTAAALEwEAmpwYAAAPgklEQVR4nO3df7BcZX3H8ffXmzi9qE2kiRZCbqOMk5kWaUN3/EVrHbFFaQTqtBlobbF1JuNMrYotLakOuTjtiE1LLB3HTqoUWhkUMUZkYoEBrNM/yJgQSMCYChZJbiLE2gRbL2MM3/6xJ3iz7CZ3d8/u3oe8XzOZu/ucs/t859nnfnL2Obv3RGYiSSrPC0ZdgCSpNwa4JBXKAJekQhngklQoA1ySCjVvmJ0tWrQoly1bNswuJal427Zt+15mLm5tH2qAL1u2jK1btw6zS0kqXkR8p127SyiSVCgDXJIKZYBLUqFOuAYeEdcDK4EnM/Osqu1U4HPAMuAxYFVm/s8gCty0fYp1d+xm38FpTl84zhXnL+fiFUsG0ZVOQs4vlWw2R+A3AG9tabsSuDszXwXcXd2v3abtU6zZuJOpg9MkMHVwmjUbd7Jp+9QgutNJxvml0p0wwDPza8D3W5ovAm6sbt8IXFxvWU3r7tjN9OEjx7RNHz7Cujt2D6I7nWScXypdrx8jfHlm7gfIzP0R8bJOO0bEamA1wMTERFed7Ds43bZ96uAPmZyc7Oq5pFZTTzeAeE57p3knzTUD/xx4Zm4ANgA0Go2u/nbt6QvHmWrzy7Rk4SlMXjlZS306ed11zT1t59fpC8dHUI3UvV4/hfJERJwGUP18sr6SfuKK85czPn/smLbx+WNccf7yQXSnk4zzS6XrNcBvAy6rbl8GfKmeco518YolfPQdr2bJwnEgWbJwnI++49V+SkC1cH6pdHGiK/JExM3Am4BFwBPAWmATcAswATwO/E5mtp7ofI5Go5G9fpV+cnLSdW8NjPNLc1lEbMvMRmv7CdfAM/PSDpvO67sqSVLP/CamJBXKAJekQhngklQoA1ySCmWAS1KhDHBJKpQBLkmFMsAlqVAGuCQVygCXpEIZ4JJUKANckgplgEtSoQxwSSqUAS5JhTLAJalQBrgkFcoAl6RCGeCSVCgDXJIKZYBLUqH6CvCIuDwiHo6IhyLi5oj4qboKk4Zixy2w/izWsh7Wn9W8L9WpmmNMLqx9jvUc4BGxBHgf0MjMs4Ax4JK6CpMGbsct8OX3waE9BMChPc37hrjqMmOOQdY+x/pdQpkHjEfEPOAUYF//JUlDcvdH4PD0sW2Hp5vtUh0GPMfm9frAzJyKiL8FHgemgTsz887W/SJiNbAaYGJiotfupPod2tu2OQ/t4erJyeHWoueltVTv7lp1mHvd6jnAI+KlwEXAK4CDwOcj4p2Z+ZmZ+2XmBmADQKPRyN5LlWq24Izqre2xYsFSJi+fHH49ev5Zf2vbOcaCM2p5+n6WUN4C/FdmHsjMw8BG4A21VCUNw3lXwfzxY9vmjzfbpToMeI71E+CPA6+LiFMiIoDzgF21VCUNw9mr4O3XwYKlPJPAgqXN+2evGnVler6YMccSap9jPQd4Zm4BbgXuB3ZWz7WhlqqkYTl7FVz+EG/+9xVw+UOGt+pXzbGrubz2OdbzGjhAZq4F1tZUiySpC34TU5IKZYBLUqEMcEkqlAEuSYUywCWpUAa4JBXKAJekQhngklQoA1ySCmWAS1KhDHBJKpQBLkmFMsAlqVAGuCQVygCXpEIZ4JJUKANckgplgEtSoQxwSSqUAS5JhTLAJalQBrgkFaqvAI+IhRFxa0R8MyJ2RcTr6ypMknR88/p8/N8D/5aZvx0RLwROqaEmSdIs9BzgEfHTwBuBdwFk5o+AH9VTliTpRPpZQnklcAD454jYHhGfiogXte4UEasjYmtEbD1w4EAf3UmSZuonwOcB5wCfzMwVwP8BV7bulJkbMrORmY3Fixf30Z0kaaZ+AnwvsDczt1T3b6UZ6JKkIeg5wDPzu8CeiFheNZ0HfKOWqiRJJ9Tvp1D+BLip+gTKt4E/7L8kSdJs9BXgmfkA0KinFElSN/wmpiQVygCXpEIZ4JJUKANckgplgEtSoQxwSSqUAS5JhTLAJalQBrgkFcoAl6RCGeCSVCgDXJIKZYBLUqEMcEkqlAEuSYUywCWpUAa4JBXKAJekQhngklQoA1ySCmWAS1Kh+g7wiBiLiO0RcXsdBUmSZqeOI/D3A7tqeB5JUhf6CvCIOAP4TeBT9ZQjSZqtfo/APw78OfBMpx0iYnVEbI2IrQcOHOizO0nSUT0HeESsBJ7MzG3H2y8zN2RmIzMbixcv7rU7SVKLfo7AzwUujIjHgM8Cb46Iz9RSlSTphHoO8Mxck5lnZOYy4BLgnsx8Z22VSZKOy8+BS1Kh5tXxJJn5VeCrdTyXJGl2PAKXpEIZ4JJUKANckgplgEtSoQxwSSqUAS5JhTLAJalQBrgkFcoAl6RCGeCSVCgDXJIKZYBLUqEMcEkqlAEuSYUywCWpUAa4JBXKAJekQhngklQoA1ySCmWAS1KhDHBJKpQBLkmF6jnAI2JpRNwbEbsi4uGIeH+dhUlDcfsH4epTuffXtsPVpzbvS3XacQusP4u1rIf1ZzXv12ReH4/9MfCnmXl/RLwE2BYRd2XmN2qqTRqs2z8IWz8NQASQR569z8prR1eXnj923AJffh8cniYADu1p3gc4e1XfT9/zEXhm7s/M+6vbPwB2AUv6rkgalm03dNcudevuj8Dh6WPbDk8322vQzxH4syJiGbAC2NJm22pgNcDExEQd3Un1yCPtm/MIV09ODrcWPS+tZU/zyLvVob21PH/fAR4RLwa+AHwgM59q3Z6ZG4ANAI1GI/vtT6pNjLUN8YgxJtdODr8ePf+sv7W5bNJqwRm1PH1fn0KJiPk0w/umzNxYS0XSsPzyu7prl7p13lUwf/zYtvnjzfYa9PMplAA+DezKTM/4qDwrr4XGuyHGSGgekTfe7QlM1efsVfD262DBUiCaP99+XS0nMKG/JZRzgd8HdkbEA1XbX2bm5r6rkoZl5bWw8lqunpx02USDcfaq2gK7Vc8Bnpn/Ae3X5yVJg+c3MSWpUAa4JBXKAJekQhngklQoA1ySCmWAS1KhDHBJKpQBLkmFMsAlqVAGuCQVygCXpEIZ4JJUKANckgplgEtSoQxwSSqUAS5JhTLAJalQBrgkFcoAl6RCGeCSVCgDXJIK1VeAR8RbI2J3RDwSEVfWVdRMH960kzPXbOaGpxucuWYzH960cxDd6CS1afsU515zDzc83eDca+5h0/apUZckzdq8Xh8YEWPAJ4BfB/YCX4+I2zLzG3UV9+FNO/nMfY8f7ZEjmc/e/6uLX11XNzpJbdo+xZqNO5k+fAQIpg5Os2Zj8wDh4hVLRlucNAv9HIG/BngkM7+dmT8CPgtcVE9ZTTdv2dNVu9SNdXfsrsL7J6YPH2HdHbtHVJHUnZ6PwIElwMwk3Qu8tnWniFgNrAaYmJjoqoMjmR3an2FycrKr55JaTT3dAOI57fsOTg+/GKkH/QT4c2c+PCdxM3MDsAGg0Wi0T+QOxiLahvhYvMAAV9/uuuYeptqE9ekLx0dQjdS9fpZQ9gJLZ9w/A9jXXznHuvS1S7tql7pxxfnLGZ8/dkzb+Pwxrjh/+YgqkrrTzxH414FXRcQrgCngEuB3a6mqcvRE5c1b9nAkk7EILn3tUk9gqhZHT1Suu2M3+w5Oc/rCca44f7knMFWMyA7rzLN6cMQFwMeBMeD6zPzr4+3faDRy69atPfcnSSejiNiWmY3W9n6OwMnMzcDmfp5DktQbv4kpSYUywCWpUH2tgXfdWcQB4Ds9PnwR8L0ay6mLdXXHurpjXd2Zq3VBf7X9XGYubm0caoD3IyK2tlvEHzXr6o51dce6ujNX64LB1OYSiiQVygCXpEKVFOAbRl1AB9bVHevqjnV1Z67WBQOorZg1cEnSsUo6ApckzWCAS1Kh5lyAn+gybdF0XbV9R0ScM4SalkbEvRGxKyIejoj3t9nnTRFxKCIeqP5dNei6qn4fi4idVZ/P+UMzIxqv5TPG4YGIeCoiPtCyz1DGKyKuj4gnI+KhGW2nRsRdEfGt6udLOzx2YJcM7FDXuoj4ZvU6fTEiFnZ47HFf8wHUNRkRUzNeqws6PHbY4/W5GTU9FhEPdHjsIMerbTYMbY5l5pz5R/OPYj0KvBJ4IfAg8PMt+1wAfIXm3yN/HbBlCHWdBpxT3X4J8J9t6noTcPsIxuwxYNFxtg99vNq8pt+l+UWEoY8X8EbgHOChGW1/A1xZ3b4S+Fgvc3EAdf0GMK+6/bF2dc3mNR9AXZPAn83idR7qeLVs/zvgqhGMV9tsGNYcm2tH4LO5TNtFwL9k033Awog4bZBFZeb+zLy/uv0DYBfNKxKVYOjj1eI84NHM7PUbuH3JzK8B329pvgi4sbp9I3Bxm4cO9JKB7erKzDsz88fV3fto/o39oeowXrMx9PE6KiICWAXcXFd/s3WcbBjKHJtrAd7uMm2tQTmbfQYmIpYBK4AtbTa/PiIejIivRMQvDKmkBO6MiG3RvHxdq5GOF82/E9/pF2sU4wXw8szcD81fQOBlbfYZ9bj9Ec13Tu2c6DUfhPdWSzvXd1gOGOV4/SrwRGZ+q8P2oYxXSzYMZY7NtQCfzWXaZnUpt0GIiBcDXwA+kJlPtWy+n+YywS8C/wBsGkZNwLmZeQ7wNuCPI+KNLdtHOV4vBC4EPt9m86jGa7ZGOW4fAn4M3NRhlxO95nX7JHAm8EvAfprLFa1GNl7ApRz/6Hvg43WCbOj4sDZtXY3ZXAvw2VymbeCXcmsnIubTfIFuysyNrdsz86nM/N/q9mZgfkQsGnRdmbmv+vkk8EWab8tmGsl4Vd4G3J+ZT7RuGNV4VZ44uoxU/XyyzT6jmmeXASuB38tqobTVLF7zWmXmE5l5JDOfAf6pQ3+jGq95wDuAz3XaZ9Dj1SEbhjLH5lqAP3uZturo7RLgtpZ9bgP+oPp0xeuAQ0ffqgxKtcb2aWBXZl7bYZ+frfYjIl5Dc2z/e8B1vSgiXnL0Ns2TYA+17Db08Zqh45HRKMZrhtuAy6rblwFfarPPbOZirSLircBfABdm5g877DOb17zuumaeM/mtDv0NfbwqbwG+mZl7220c9HgdJxuGM8cGcWa2z7O6F9A8k/so8KGq7T3Ae6rbAXyi2r4TaAyhpl+h+dZmB/BA9e+ClrreCzxM80zyfcAbhlDXK6v+Hqz6nhPjVfV7Cs1AXjCjbejjRfM/kP3AYZpHPO8Gfga4G/hW9fPUat/Tgc3Hm4sDrusRmmuiR+fYP7bW1ek1H3Bd/1rNnR00A+a0uTBeVfsNR+fUjH2HOV6dsmEoc8yv0ktSoebaEookaZYMcEkqlAEuSYUywCWpUAa4JBXKAJekQhngklSo/webB0obXTODCAAAAABJRU5ErkJggg==", 35 | "text/plain": [ 36 | "
" 37 | ] 38 | }, 39 | "metadata": { 40 | "needs_background": "light" 41 | }, 42 | "output_type": "display_data" 43 | } 44 | ], 45 | "source": [ 46 | "c1 = [[0,0], [0, 10], [10, 10], [10, 0], [0, 0]]\n", 47 | "p1 = Polygon(c1)\n", 48 | "c2 = [[10, 2], [10, 8], [20, 8], [20, 2], [10, 2]]\n", 49 | "p2 = Polygon(c2)\n", 50 | "gdf = geopandas.GeoDataFrame(geometry=[p1, p2])\n", 51 | "base = gdf.plot(edgecolor='k', facecolor=\"none\",alpha=0.5)\n", 52 | "c1 = numpy.array(c1)\n", 53 | "c2 = numpy.array(c2)\n", 54 | "_ = base.scatter(c1[:,0], c1[:,1])\n", 55 | "_ =base.scatter(c2[:,0], c2[:,1])\n", 56 | "\n" 57 | ] 58 | }, 59 | { 60 | "cell_type": "markdown", 61 | "id": "foster-letters", 62 | "metadata": {}, 63 | "source": [ 64 | "The two polygons are visually contiguous, but are not planar enforced as the right edge of the left polygon and the left edge of right polygon share no vertices. This will result in the two polygons not being Queen neighbors, since a necessary (and sufficient) condition for the latter is at least one shared vertex." 65 | ] 66 | }, 67 | { 68 | "cell_type": "code", 69 | "execution_count": 3, 70 | "id": "vietnamese-office", 71 | "metadata": {}, 72 | "outputs": [ 73 | { 74 | "name": "stderr", 75 | "output_type": "stream", 76 | "text": [ 77 | "/Users/serge/miniconda3/envs/edu_concordance/lib/python3.9/site-packages/libpysal/weights/weights.py:172: UserWarning: The weights matrix is not fully connected: \n", 78 | " There are 2 disconnected components.\n", 79 | " There are 2 islands with ids: 0, 1.\n", 80 | " warnings.warn(message)\n" 81 | ] 82 | } 83 | ], 84 | "source": [ 85 | "w = libpysal.weights.Queen.from_dataframe(gdf)" 86 | ] 87 | }, 88 | { 89 | "cell_type": "markdown", 90 | "id": "smart-hygiene", 91 | "metadata": {}, 92 | "source": [ 93 | "## Detecting nonplanar edges" 94 | ] 95 | }, 96 | { 97 | "cell_type": "markdown", 98 | "id": "selective-syracuse", 99 | "metadata": {}, 100 | "source": [ 101 | "`geoplanar` can detect and report nonplanar edges:" 102 | ] 103 | }, 104 | { 105 | "cell_type": "code", 106 | "execution_count": 4, 107 | "id": "usual-storm", 108 | "metadata": {}, 109 | "outputs": [ 110 | { 111 | "data": { 112 | "text/plain": [ 113 | "defaultdict(set, {0: {1}})" 114 | ] 115 | }, 116 | "execution_count": 4, 117 | "metadata": {}, 118 | "output_type": "execute_result" 119 | } 120 | ], 121 | "source": [ 122 | "geoplanar.non_planar_edges(gdf)" 123 | ] 124 | }, 125 | { 126 | "cell_type": "markdown", 127 | "id": "significant-penguin", 128 | "metadata": {}, 129 | "source": [ 130 | "## Fixing nonplanar edges" 131 | ] 132 | }, 133 | { 134 | "cell_type": "code", 135 | "execution_count": 5, 136 | "id": "d4b0b1ce-09ae-49dc-b083-fb909f9e1e02", 137 | "metadata": {}, 138 | "outputs": [ 139 | { 140 | "data": { 141 | "text/plain": [ 142 | "False" 143 | ] 144 | }, 145 | "execution_count": 5, 146 | "metadata": {}, 147 | "output_type": "execute_result" 148 | } 149 | ], 150 | "source": [ 151 | "geoplanar.is_planar_enforced(gdf)" 152 | ] 153 | }, 154 | { 155 | "cell_type": "code", 156 | "execution_count": 6, 157 | "id": "dimensional-gambling", 158 | "metadata": {}, 159 | "outputs": [ 160 | { 161 | "name": "stderr", 162 | "output_type": "stream", 163 | "text": [ 164 | "/Users/serge/miniconda3/envs/edu_concordance/lib/python3.9/site-packages/libpysal/weights/weights.py:172: UserWarning: The weights matrix is not fully connected: \n", 165 | " There are 2 disconnected components.\n", 166 | " There are 2 islands with ids: 0, 1.\n", 167 | " warnings.warn(message)\n" 168 | ] 169 | } 170 | ], 171 | "source": [ 172 | "gdf1 = geoplanar.fix_npe_edges(gdf)" 173 | ] 174 | }, 175 | { 176 | "cell_type": "code", 177 | "execution_count": 7, 178 | "id": "flying-computer", 179 | "metadata": {}, 180 | "outputs": [ 181 | { 182 | "data": { 183 | "text/plain": [ 184 | "defaultdict(set, {})" 185 | ] 186 | }, 187 | "execution_count": 7, 188 | "metadata": {}, 189 | "output_type": "execute_result" 190 | } 191 | ], 192 | "source": [ 193 | "geoplanar.non_planar_edges(gdf1)" 194 | ] 195 | }, 196 | { 197 | "cell_type": "code", 198 | "execution_count": 8, 199 | "id": "powered-special", 200 | "metadata": {}, 201 | "outputs": [ 202 | { 203 | "data": { 204 | "text/plain": [ 205 | "{0: [1], 1: [0]}" 206 | ] 207 | }, 208 | "execution_count": 8, 209 | "metadata": {}, 210 | "output_type": "execute_result" 211 | } 212 | ], 213 | "source": [ 214 | "w1 = libpysal.weights.Queen.from_dataframe(gdf1)\n", 215 | "w1.neighbors" 216 | ] 217 | }, 218 | { 219 | "cell_type": "code", 220 | "execution_count": 9, 221 | "id": "9a61abbe-d29a-4411-8c6b-5766a0a5e8cd", 222 | "metadata": {}, 223 | "outputs": [ 224 | { 225 | "data": { 226 | "text/plain": [ 227 | "True" 228 | ] 229 | }, 230 | "execution_count": 9, 231 | "metadata": {}, 232 | "output_type": "execute_result" 233 | } 234 | ], 235 | "source": [ 236 | "geoplanar.is_planar_enforced(gdf1)" 237 | ] 238 | }, 239 | { 240 | "cell_type": "markdown", 241 | "id": "graduate-kitty", 242 | "metadata": {}, 243 | "source": [ 244 | "## Planar Enforcement Violation: Overlapping and non-planar enforced edges" 245 | ] 246 | }, 247 | { 248 | "cell_type": "code", 249 | "execution_count": 10, 250 | "id": "crude-dating", 251 | "metadata": {}, 252 | "outputs": [], 253 | "source": [ 254 | "from shapely.geometry import Polygon" 255 | ] 256 | }, 257 | { 258 | "cell_type": "code", 259 | "execution_count": 11, 260 | "id": "distinct-button", 261 | "metadata": {}, 262 | "outputs": [], 263 | "source": [ 264 | "\n", 265 | "t1 = Polygon([[0,0],[10,10], [20,0]])" 266 | ] 267 | }, 268 | { 269 | "cell_type": "code", 270 | "execution_count": 12, 271 | "id": "static-treasury", 272 | "metadata": {}, 273 | "outputs": [ 274 | { 275 | "data": { 276 | "image/svg+xml": [ 277 | "" 278 | ], 279 | "text/plain": [ 280 | "" 281 | ] 282 | }, 283 | "execution_count": 12, 284 | "metadata": {}, 285 | "output_type": "execute_result" 286 | } 287 | ], 288 | "source": [ 289 | "t1" 290 | ] 291 | }, 292 | { 293 | "cell_type": "code", 294 | "execution_count": 13, 295 | "id": "dutch-offense", 296 | "metadata": {}, 297 | "outputs": [ 298 | { 299 | "data": { 300 | "image/svg+xml": [ 301 | "" 302 | ], 303 | "text/plain": [ 304 | "" 305 | ] 306 | }, 307 | "execution_count": 13, 308 | "metadata": {}, 309 | "output_type": "execute_result" 310 | } 311 | ], 312 | "source": [ 313 | "b1 = Polygon([[5,5], [20,5], [20,-10], [0,-10]])\n", 314 | "b1" 315 | ] 316 | }, 317 | { 318 | "cell_type": "code", 319 | "execution_count": 14, 320 | "id": "nearby-barbados", 321 | "metadata": {}, 322 | "outputs": [], 323 | "source": [ 324 | "gdf = geopandas.GeoDataFrame(geometry=[t1,b1])" 325 | ] 326 | }, 327 | { 328 | "cell_type": "code", 329 | "execution_count": 15, 330 | "id": "micro-software", 331 | "metadata": {}, 332 | "outputs": [ 333 | { 334 | "data": { 335 | "text/plain": [ 336 | "" 337 | ] 338 | }, 339 | "execution_count": 15, 340 | "metadata": {}, 341 | "output_type": "execute_result" 342 | }, 343 | { 344 | "data": { 345 | "image/png": "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", 346 | "text/plain": [ 347 | "
" 348 | ] 349 | }, 350 | "metadata": { 351 | "needs_background": "light" 352 | }, 353 | "output_type": "display_data" 354 | } 355 | ], 356 | "source": [ 357 | "gdf.plot(edgecolor='k',facecolor=\"none\",alpha=0.5) # non planar enforcement\n" 358 | ] 359 | }, 360 | { 361 | "cell_type": "markdown", 362 | "id": "proof-pendant", 363 | "metadata": {}, 364 | "source": [ 365 | "The two features overlap and would appear to share vertices, but they in fact do not share vertices. Again, because this violates planar enforcement, this results in two polygons not being Queen neighbors:" 366 | ] 367 | }, 368 | { 369 | "cell_type": "code", 370 | "execution_count": 16, 371 | "id": "medical-domestic", 372 | "metadata": {}, 373 | "outputs": [ 374 | { 375 | "name": "stderr", 376 | "output_type": "stream", 377 | "text": [ 378 | "/Users/serge/miniconda3/envs/edu_concordance/lib/python3.9/site-packages/libpysal/weights/weights.py:172: UserWarning: The weights matrix is not fully connected: \n", 379 | " There are 2 disconnected components.\n", 380 | " There are 2 islands with ids: 0, 1.\n", 381 | " warnings.warn(message)\n" 382 | ] 383 | } 384 | ], 385 | "source": [ 386 | "import libpysal\n", 387 | "\n", 388 | "w = libpysal.weights.Queen.from_dataframe(gdf)" 389 | ] 390 | }, 391 | { 392 | "cell_type": "markdown", 393 | "id": "entertaining-cement", 394 | "metadata": {}, 395 | "source": [ 396 | "## Detecting nonplanar edges" 397 | ] 398 | }, 399 | { 400 | "cell_type": "markdown", 401 | "id": "sized-dylan", 402 | "metadata": {}, 403 | "source": [ 404 | "`geoplanar` will use a failed contiguity check as part of a check for nonplanar enforced edges in the polygons of a geoseries:" 405 | ] 406 | }, 407 | { 408 | "cell_type": "code", 409 | "execution_count": 17, 410 | "id": "hawaiian-species", 411 | "metadata": {}, 412 | "outputs": [ 413 | { 414 | "data": { 415 | "text/plain": [ 416 | "defaultdict(set, {0: {1}})" 417 | ] 418 | }, 419 | "execution_count": 17, 420 | "metadata": {}, 421 | "output_type": "execute_result" 422 | } 423 | ], 424 | "source": [ 425 | "geoplanar.non_planar_edges(gdf)" 426 | ] 427 | }, 428 | { 429 | "cell_type": "markdown", 430 | "id": "caring-bacon", 431 | "metadata": {}, 432 | "source": [ 433 | "## Correcting nonplanar edges" 434 | ] 435 | }, 436 | { 437 | "cell_type": "code", 438 | "execution_count": 18, 439 | "id": "forward-paris", 440 | "metadata": {}, 441 | "outputs": [ 442 | { 443 | "name": "stderr", 444 | "output_type": "stream", 445 | "text": [ 446 | "/Users/serge/miniconda3/envs/edu_concordance/lib/python3.9/site-packages/libpysal/weights/weights.py:172: UserWarning: The weights matrix is not fully connected: \n", 447 | " There are 2 disconnected components.\n", 448 | " There are 2 islands with ids: 0, 1.\n", 449 | " warnings.warn(message)\n" 450 | ] 451 | }, 452 | { 453 | "data": { 454 | "text/plain": [ 455 | "defaultdict(set, {})" 456 | ] 457 | }, 458 | "execution_count": 18, 459 | "metadata": {}, 460 | "output_type": "execute_result" 461 | } 462 | ], 463 | "source": [ 464 | "gdf_fixed = geoplanar.fix_npe_edges(gdf)\n", 465 | "geoplanar.non_planar_edges(gdf_fixed)" 466 | ] 467 | }, 468 | { 469 | "cell_type": "markdown", 470 | "id": "level-vietnamese", 471 | "metadata": {}, 472 | "source": [ 473 | "## Default is to work on a copy" 474 | ] 475 | }, 476 | { 477 | "cell_type": "code", 478 | "execution_count": 19, 479 | "id": "sustained-queens", 480 | "metadata": {}, 481 | "outputs": [ 482 | { 483 | "name": "stderr", 484 | "output_type": "stream", 485 | "text": [ 486 | "/Users/serge/miniconda3/envs/edu_concordance/lib/python3.9/site-packages/libpysal/weights/weights.py:172: UserWarning: The weights matrix is not fully connected: \n", 487 | " There are 2 disconnected components.\n", 488 | " There are 2 islands with ids: 0, 1.\n", 489 | " warnings.warn(message)\n" 490 | ] 491 | }, 492 | { 493 | "data": { 494 | "text/plain": [ 495 | "defaultdict(set, {0: {1}})" 496 | ] 497 | }, 498 | "execution_count": 19, 499 | "metadata": {}, 500 | "output_type": "execute_result" 501 | } 502 | ], 503 | "source": [ 504 | "geoplanar.non_planar_edges(gdf)" 505 | ] 506 | }, 507 | { 508 | "cell_type": "code", 509 | "execution_count": 20, 510 | "id": "contemporary-distribution", 511 | "metadata": {}, 512 | "outputs": [ 513 | { 514 | "data": { 515 | "text/html": [ 516 | "
\n", 517 | "\n", 530 | "\n", 531 | " \n", 532 | " \n", 533 | " \n", 534 | " \n", 535 | " \n", 536 | " \n", 537 | " \n", 538 | " \n", 539 | " \n", 540 | " \n", 541 | " \n", 542 | " \n", 543 | " \n", 544 | " \n", 545 | " \n", 546 | " \n", 547 | "
geometry
0POLYGON ((0.00000 0.00000, 5.00000 5.00000, 10...
1POLYGON ((5.00000 5.00000, 15.00000 5.00000, 2...
\n", 548 | "
" 549 | ], 550 | "text/plain": [ 551 | " geometry\n", 552 | "0 POLYGON ((0.00000 0.00000, 5.00000 5.00000, 10...\n", 553 | "1 POLYGON ((5.00000 5.00000, 15.00000 5.00000, 2..." 554 | ] 555 | }, 556 | "execution_count": 20, 557 | "metadata": {}, 558 | "output_type": "execute_result" 559 | } 560 | ], 561 | "source": [ 562 | "geoplanar.fix_npe_edges(gdf, inplace=True)\n" 563 | ] 564 | }, 565 | { 566 | "cell_type": "code", 567 | "execution_count": 21, 568 | "id": "disturbed-extension", 569 | "metadata": {}, 570 | "outputs": [ 571 | { 572 | "data": { 573 | "text/plain": [ 574 | "defaultdict(set, {})" 575 | ] 576 | }, 577 | "execution_count": 21, 578 | "metadata": {}, 579 | "output_type": "execute_result" 580 | } 581 | ], 582 | "source": [ 583 | "geoplanar.non_planar_edges(gdf)" 584 | ] 585 | }, 586 | { 587 | "cell_type": "code", 588 | "execution_count": 22, 589 | "id": "juvenile-linux", 590 | "metadata": {}, 591 | "outputs": [ 592 | { 593 | "data": { 594 | "text/plain": [ 595 | "{0: [1], 1: [0]}" 596 | ] 597 | }, 598 | "execution_count": 22, 599 | "metadata": {}, 600 | "output_type": "execute_result" 601 | } 602 | ], 603 | "source": [ 604 | "w = libpysal.weights.Queen.from_dataframe(gdf)\n", 605 | "w.neighbors" 606 | ] 607 | }, 608 | { 609 | "cell_type": "markdown", 610 | "id": "5fe0d760-d0a4-4891-bc44-043deaf3ace6", 611 | "metadata": {}, 612 | "source": [ 613 | "## Handle nonplanar edges in multi polygon case" 614 | ] 615 | }, 616 | { 617 | "cell_type": "code", 618 | "execution_count": 23, 619 | "id": "8f580995-257f-4a40-bfb7-e27806a8b28b", 620 | "metadata": {}, 621 | "outputs": [], 622 | "source": [ 623 | "from shapely.geometry import MultiPolygon" 624 | ] 625 | }, 626 | { 627 | "cell_type": "code", 628 | "execution_count": 24, 629 | "id": "c61f2d0b-bd19-460d-8fc6-b14915c07432", 630 | "metadata": {}, 631 | "outputs": [ 632 | { 633 | "data": { 634 | "image/png": "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", 635 | "text/plain": [ 636 | "
" 637 | ] 638 | }, 639 | "metadata": { 640 | "needs_background": "light" 641 | }, 642 | "output_type": "display_data" 643 | } 644 | ], 645 | "source": [ 646 | "c1 = [[0,0], [0, 10], [10, 10], [10, 0], [0, 0]]\n", 647 | "p1 = Polygon(c1)\n", 648 | "c2 = [[10, 2], [10, 8], [20, 8], [20, 2], [10, 2]]\n", 649 | "p2 = Polygon(c2)\n", 650 | "p3 = Polygon([ [21, 2], [21, 4], [23,3] ])\n", 651 | "p2 = MultiPolygon([Polygon(c2), p3])\n", 652 | "gdf = geopandas.GeoDataFrame(geometry=[p1, p2])\n", 653 | "base = gdf.plot(edgecolor='k', facecolor=\"none\",alpha=0.5)\n", 654 | "c1 = numpy.array(c1)\n", 655 | "c2 = numpy.array(c2)\n", 656 | "_ = base.scatter(c1[:,0], c1[:,1])\n", 657 | "_ =base.scatter(c2[:,0], c2[:,1])\n", 658 | "\n" 659 | ] 660 | }, 661 | { 662 | "cell_type": "code", 663 | "execution_count": 25, 664 | "id": "dd80bd88-1122-4c50-ba29-5a974e5bc040", 665 | "metadata": {}, 666 | "outputs": [ 667 | { 668 | "name": "stderr", 669 | "output_type": "stream", 670 | "text": [ 671 | "/Users/serge/miniconda3/envs/edu_concordance/lib/python3.9/site-packages/libpysal/weights/_contW_lists.py:31: ShapelyDeprecationWarning: Iteration over multi-part geometries is deprecated and will be removed in Shapely 2.0. Use the `geoms` property to access the constituent parts of a multi-part geometry.\n", 672 | " return list(it.chain(*(_get_boundary_points(part.boundary) for part in shape)))\n", 673 | "/Users/serge/miniconda3/envs/edu_concordance/lib/python3.9/site-packages/libpysal/weights/weights.py:172: UserWarning: The weights matrix is not fully connected: \n", 674 | " There are 2 disconnected components.\n", 675 | " There are 2 islands with ids: 0, 1.\n", 676 | " warnings.warn(message)\n" 677 | ] 678 | } 679 | ], 680 | "source": [ 681 | "w = libpysal.weights.Queen.from_dataframe(gdf)" 682 | ] 683 | }, 684 | { 685 | "cell_type": "code", 686 | "execution_count": 26, 687 | "id": "c758c611-8825-46bb-a8ba-8c0b260102ec", 688 | "metadata": {}, 689 | "outputs": [ 690 | { 691 | "data": { 692 | "text/plain": [ 693 | "defaultdict(set, {0: {1}})" 694 | ] 695 | }, 696 | "execution_count": 26, 697 | "metadata": {}, 698 | "output_type": "execute_result" 699 | } 700 | ], 701 | "source": [ 702 | "geoplanar.non_planar_edges(gdf)" 703 | ] 704 | }, 705 | { 706 | "cell_type": "code", 707 | "execution_count": 27, 708 | "id": "4cf796c6-70a0-43d0-a773-488e194c814a", 709 | "metadata": {}, 710 | "outputs": [ 711 | { 712 | "data": { 713 | "text/plain": [ 714 | "False" 715 | ] 716 | }, 717 | "execution_count": 27, 718 | "metadata": {}, 719 | "output_type": "execute_result" 720 | } 721 | ], 722 | "source": [ 723 | "geoplanar.is_planar_enforced(gdf)" 724 | ] 725 | }, 726 | { 727 | "cell_type": "code", 728 | "execution_count": 28, 729 | "id": "1cf50c6d-195d-47da-b95c-2132f052090a", 730 | "metadata": {}, 731 | "outputs": [ 732 | { 733 | "name": "stderr", 734 | "output_type": "stream", 735 | "text": [ 736 | "/Users/serge/miniconda3/envs/edu_concordance/lib/python3.9/site-packages/libpysal/weights/_contW_lists.py:31: ShapelyDeprecationWarning: Iteration over multi-part geometries is deprecated and will be removed in Shapely 2.0. Use the `geoms` property to access the constituent parts of a multi-part geometry.\n", 737 | " return list(it.chain(*(_get_boundary_points(part.boundary) for part in shape)))\n", 738 | "/Users/serge/miniconda3/envs/edu_concordance/lib/python3.9/site-packages/libpysal/weights/weights.py:172: UserWarning: The weights matrix is not fully connected: \n", 739 | " There are 2 disconnected components.\n", 740 | " There are 2 islands with ids: 0, 1.\n", 741 | " warnings.warn(message)\n" 742 | ] 743 | } 744 | ], 745 | "source": [ 746 | "gdf1 = geoplanar.fix_npe_edges(gdf)" 747 | ] 748 | }, 749 | { 750 | "cell_type": "code", 751 | "execution_count": 29, 752 | "id": "8f8c4a4c-0713-4aa3-a487-5db5a950dfb9", 753 | "metadata": {}, 754 | "outputs": [ 755 | { 756 | "name": "stderr", 757 | "output_type": "stream", 758 | "text": [ 759 | "/Users/serge/miniconda3/envs/edu_concordance/lib/python3.9/site-packages/libpysal/weights/_contW_lists.py:31: ShapelyDeprecationWarning: Iteration over multi-part geometries is deprecated and will be removed in Shapely 2.0. Use the `geoms` property to access the constituent parts of a multi-part geometry.\n", 760 | " return list(it.chain(*(_get_boundary_points(part.boundary) for part in shape)))\n" 761 | ] 762 | }, 763 | { 764 | "data": { 765 | "text/plain": [ 766 | "True" 767 | ] 768 | }, 769 | "execution_count": 29, 770 | "metadata": {}, 771 | "output_type": "execute_result" 772 | } 773 | ], 774 | "source": [ 775 | "geoplanar.is_planar_enforced(gdf1)" 776 | ] 777 | } 778 | ], 779 | "metadata": { 780 | "jupytext": { 781 | "formats": "ipynb,md" 782 | }, 783 | "kernelspec": { 784 | "display_name": "Python 3 (ipykernel)", 785 | "language": "python", 786 | "name": "python3" 787 | }, 788 | "language_info": { 789 | "codemirror_mode": { 790 | "name": "ipython", 791 | "version": 3 792 | }, 793 | "file_extension": ".py", 794 | "mimetype": "text/x-python", 795 | "name": "python", 796 | "nbconvert_exporter": "python", 797 | "pygments_lexer": "ipython3", 798 | "version": "3.12.3" 799 | } 800 | }, 801 | "nbformat": 4, 802 | "nbformat_minor": 5 803 | } 804 | -------------------------------------------------------------------------------- /docs/nonplanartouches.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "parallel-proposition", 6 | "metadata": {}, 7 | "source": [ 8 | "# Non-planar enforced touches" 9 | ] 10 | }, 11 | { 12 | "cell_type": "code", 13 | "execution_count": 1, 14 | "id": "ancient-wheat", 15 | "metadata": {}, 16 | "outputs": [], 17 | "source": [ 18 | "import geopandas\n", 19 | "import numpy\n", 20 | "import matplotlib.pyplot as plt\n", 21 | "import geoplanar\n", 22 | "import libpysal\n", 23 | "from shapely.geometry import Polygon\n" 24 | ] 25 | }, 26 | { 27 | "cell_type": "code", 28 | "execution_count": 2, 29 | "id": "sunset-roads", 30 | "metadata": {}, 31 | "outputs": [ 32 | { 33 | "data": { 34 | "image/png": "iVBORw0KGgoAAAANSUhEUgAAAXAAAADGCAYAAADL/dvjAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjUuMCwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8/fFQqAAAACXBIWXMAAAsTAAALEwEAmpwYAAAQ40lEQVR4nO3df7BcdXnH8ffjTRwvCkRLVJJwG3SczFTEQnfwB6LMxBalUcCxDLS2WJ1JdapoVWpSLVwdHbFYVFqnbVopIAyVaozgxEYGymhnSsaEYILE+KsIuUTA2ARaLyWEp3/sRm82Z5O7u2d/nLvv10xmd7/n7H6f+e65n5z77O7dyEwkSdXztEEXIEnqjAEuSRVlgEtSRRngklRRBrgkVdS8fk523HHH5dKlS/s5pSRV3ubNm3+WmQubx/sa4EuXLmXTpk39nFKSKi8iflI0bgtFkirKAJekijLAJamijtgDj4irgRXAw5l5UmPsOcAXgaXAfcD5mfnfvShw3ZYprtiwgwf3TLNowTiXnLWMc09Z3IupNII8vlRlszkDvwZ4XdPYKuC2zHwRcFvjdunWbZli9dptTO2ZJoGpPdOsXruNdVumejGdRozHl6ruiAGemd8Eft40fA5wbeP6tcC55ZZVd8WGHUzv23/Q2PS+/VyxYUcvptOI8fhS1XX6NsLnZeYugMzcFRHPbbVjRKwEVgJMTEy0NcmDe6YLx6f2/ILJycm2HktqNvV4DYhDxlsdd9Kw6fn7wDNzDbAGoFartfW3axctGGeq4Idp8YKjmFw1WUp9Gl23Xn574fG1aMH4AKqR2tfpu1AeiojjARqXD5dX0q9cctYyxuePHTQ2Pn+MS85a1ovpNGI8vlR1nQb4zcBFjesXAV8tp5yDnXvKYj7xppeweME4kCxeMM4n3vQS3yWgUnh8qeriSN/IExE3AmcCxwEPAZcB64CbgAngfuD3MrP5hc5D1Gq17PSj9JOTk/a91TMeXxpmEbE5M2vN40fsgWfmhS02Le+6KklSx/wkpiRVlAEuSRVlgEtSRRngklRRBrgkVZQBLkkVZYBLUkUZ4JJUUQa4JFWUAS5JFWWAS1JFGeCSVFEGuCRVlAEuSRVlgEtSRRngklRRBrgkVZQBLkkVZYBLUkUZ4JJUUQa4JFVUVwEeEX8WEd+NiHsi4saIeEZZhUnSnLD1Jvj0STC5oH659abSHrrjAI+IxcDFQC0zTwLGgAvKKkySKm/rTXDLxbD3ASDrl7dcXFqId9tCmQeMR8Q84Cjgwe5LkqQ54raPwr7pg8f2TdfHS9BxgGfmFPAp4H5gF7A3M7/RvF9ErIyITRGx6ZFHHum8UkmqmNy7s3hDq/E2ddNCeTZwDnAisAh4ZkS8pXm/zFyTmbXMrC1cuLDzSiWpIjKTe++9l8fimOIdjl1SyjzdtFBeC/xXZj6SmfuAtcArS6lKkipq9+7dXH/99dxxxx3836s+CPPHD95h/jgsv7SUueZ1cd/7gZdHxFHANLAc2FRKVZJUMU888QTf+ta32Lx5M2eccQannXYaY2NjsHAhe9a+n2N5jDh2ST28Tz6/lDk7DvDM3BgRXwLuAp4EtgBrSqlKkioiM9m+fTsbNmxgYmKCd77znRx99NG/2uHk8/nbm7/PqlWrmDevm3PmQ3X1aJl5GXBZSbVIUqXs3r2b9evX89hjj3HeeeexdOnSvs5f7n8HkjQCWrZL+swAl6RZOmK7pM8McEmahUG3S4oY4JJ0GMPSLiligEtSgWFrlxQxwCWpyTC2S4oY4JLUMMztkiIGuKSRV4V2SREDXNJIq0q7pIgBLmkkVa1dUsQAlzRSqtouKWKASxoZVW6XFDHAJc15c6FdUsQAlzRnzaV2SREDXNKcNNfaJUUMcElzylxtlxQxwCXNCXO9XVLEAJdUeaPQLiligEuqrFFqlxQxwCVVzii2S4oY4JIqZVTbJUUMcEmVMOrtkiJdBXhELAD+CTgJSOBtmfmfJdQlSYDtksPp9gz8s8C/ZeabI+LpwFEl1CRJgO2SI+k4wCPiGODVwFsBMvMJ4IlyypI0ymyXzE43Z+AvAB4B/jkiXgpsBt6Tmf87c6eIWAmsBJiYmOhiOklzne2S9nQT4POAU4F3Z+bGiPgssAr4y5k7ZeYaYA1ArVbLLuaTNIfZLmlfNwG+E9iZmRsbt79EPcAladZsl3Su4wDPzJ9GxAMRsSwzdwDLgXvLK03SXGa7pHvdvgvl3cANjXeg/Bj44+5LkjTX2S4pR1cBnpl3A7VySpE019kuKZefxJTUc7ZLesMAl9RTtkt6xwCX1BO2S3rPAJdUKtsl/WOASyqN7ZL+MsAldc12yWAY4JI6ZrtksAxwSR2xXTJ4BrikttguGR4GuKRZsV0yfAxwSUdku2Q4GeCSWrJdMtwMcEmHsF1SDQa4pIPYLqkOA1wSYLukigxwacTZLqkuA1waYbZLqs0Al0aQ7ZK5wQCXRojtkrnFAJdGhO2SuccAl+Y42yVzV9cBHhFjwCZgKjNXdF+SpDLYLpn7yjgDfw+wHTimhMeSVALbJaOhqwCPiCXA7wIfB95XSkWSOma7ZLR0ewb+GeDPgZa/l0XESmAlwMTERJfTSSpiu2Q0dRzgEbECeDgzN0fEma32y8w1wBqAWq2Wnc4nqZjtktHVzRn46cAbI+Js4BnAMRFxfWa+pZzSJB2O7RJ1HOCZuRpYDdA4A/+A4S31nu0SHeD7wKUKsV2imUoJ8My8A7ijjMeSdCjbJSriGbg0xGyX6HAMcGlI2S7RkRjg0pCxXaLZMsClIWG7RO0ywKUhYLtEnTDApQGyXaJuGODSANguURkMcKnPbJeoLAa41Ce2S1Q2A1zqMdsl6hUDXOoh2yXqJQNc6gHbJeoHA1wqke0S9ZMBLpXEdon6zQCXumS7RINigEsdsl2iQTPApQ7YLtEwMMClNtgu0TAxwKVZsF2iYWSAS0dgu0TDygCXWrBdomFngEtNbJeoKjoO8Ig4AbgOeD7wFLAmMz9bVmFSX3ztfbD5Gi5jP3zkKqZffAFfmn6F7RKVZ+tNvOvJf2DsY5+CY5fA8kvh5PNLeehuzsCfBN6fmXdFxNHA5oi4NTPvLaUyqde+9j7Y9HkAAiD384x7buC1S/by3D+5znaJurf1JrjlYhYwXb+99wG45eL69RJCvOMAz8xdwK7G9cciYjuwGDDAVQ2brzlkKIDn7VzPdV/4Qt/L0dzz5gcmedb+6YMH903DbR8dbIDPFBFLgVOAjQXbVgIrASYmJsqYTipH7i8cDp7iNa95TZ+L0Vz0zGv3FG/Yu7OUx+86wCPiWcCXgfdm5qPN2zNzDbAGoFarZbfzSaWJscIQjxjjxBNPHEBBmnOOXVJvmxSNl+Bp3dw5IuZTD+8bMnNtKRVJ/fJbb21vXGrX8kth/vjBY/PH6+Ml6DjAIyKAzwPbM/PKUqqR+mnFlVB7O8QYCfUz8trb6+NSGU4+H95wFRx7AhD1yzdcNRTvQjkd+ENgW0Tc3Rj7i8xc33VVUr+suBJWXMlHJieZvGxy0NVoLjr5/NICu1k370L5DxrvvpIk9V9XPXBJ0uAY4JJUUQa4JFWUAS5JFWWAS1JFGeCSVFEGuCRVlAEuSRVlgEtSRRngklRRBrgkVZQBLkkVZYBLUkUZ4JJUUQa4JFWUAS5JFWWAS1JFGeCSVFEGuCRVlAEuSRVlgEtSRXUV4BHxuojYERE/jIhVZRU104fXbeOFq9dzzeM1Xrh6PR9et60X02hErdsyxemX3841j9c4/fLbWbdlatAlSbM2r9M7RsQY8Dngt4GdwLcj4ubMvLes4j68bhvX33n/gRnZn/nL2x879yVlTaMRtW7LFKvXbmN6334gmNozzeq19ROEc09ZPNjipFno5gz8NOCHmfnjzHwC+BfgnHLKqrtx4wNtjUvtuGLDjkZ4/8r0vv1csWHHgCqS2tPxGTiwGJiZpDuBlzXvFBErgZUAExMTbU2wP7PF+FNMTk629VhSs6nHa0AcMv7gnun+FyN1oJsAP/TIh0MSNzPXAGsAarVacSK3MBZRGOJj8TQDXF279fLbmSoI60ULxgdQjdS+blooO4ETZtxeAjzYXTkHu/BlJ7Q1LrXjkrOWMT5/7KCx8fljXHLWsgFVJLWnmzPwbwMviogTgSngAuD3S6mq4cALlTdufID9mYxFcOHLTvAFTJXiwAuVV2zYwYN7plm0YJxLzlrmC5iqjMgWfeZZ3TnibOAzwBhwdWZ+/HD712q13LRpU8fzSdIoiojNmVlrHu/mDJzMXA+s7+YxJEmd8ZOYklRRBrgkVVRXPfC2J4t4BPhJh3c/DvhZieWUxbraY13tsa72DGtd0F1tv56ZC5sH+xrg3YiITUVN/EGzrvZYV3usqz3DWhf0pjZbKJJUUQa4JFVUlQJ8zaALaMG62mNd7bGu9gxrXdCD2irTA5ckHaxKZ+CSpBkMcEmqqKEL8CN9TVvUXdXYvjUiTu1DTSdExL9HxPaI+G5EvKdgnzMjYm9E3N34d2mv62rMe19EbGvMecgfmhnQei2bsQ53R8SjEfHepn36sl4RcXVEPBwR98wYe05E3BoRP2hcPrvFfXv2lYEt6roiIr7XeJ6+EhELWtz3sM95D+qajIipGc/V2S3u2+/1+uKMmu6LiLtb3LeX61WYDX07xjJzaP5R/6NYPwJeADwd+A7wG037nA18nfrfI385sLEPdR0PnNq4fjTw/YK6zgS+NoA1uw847jDb+75eBc/pT6l/EKHv6wW8GjgVuGfG2F8BqxrXVwGf7ORY7EFdvwPMa1z/ZFFds3nOe1DXJPCBWTzPfV2vpu1/DVw6gPUqzIZ+HWPDdgY+m69pOwe4LuvuBBZExPG9LCozd2XmXY3rjwHbqX8jURX0fb2aLAd+lJmdfgK3K5n5TeDnTcPnANc2rl8LnFtw155+ZWBRXZn5jcx8snHzTup/Y7+vWqzXbPR9vQ6IiADOB24sa77ZOkw29OUYG7YAL/qatuagnM0+PRMRS4FTgI0Fm18REd+JiK9HxIv7VFIC34iIzVH/+rpmA10v6n8nvtUP1iDWC+B5mbkL6j+AwHML9hn0ur2N+m9ORY70nPfCuxqtnatbtAMGuV5nAA9l5g9abO/LejVlQ1+OsWEL8Nl8TdusvsqtFyLiWcCXgfdm5qNNm++i3iZ4KfA3wLp+1AScnpmnAq8H/jQiXt20fZDr9XTgjcC/Fmwe1HrN1iDX7UPAk8ANLXY50nNetr8DXgj8JrCLerui2cDWC7iQw59993y9jpANLe9WMNbWmg1bgM/ma9p6/lVuRSJiPvUn6IbMXNu8PTMfzcz/aVxfD8yPiON6XVdmPti4fBj4CvVfy2YayHo1vB64KzMfat4wqPVqeOhAG6lx+XDBPoM6zi4CVgB/kI1GabNZPOelysyHMnN/Zj4F/GOL+Qa1XvOANwFfbLVPr9erRTb05RgbtgD/5de0Nc7eLgBubtrnZuCPGu+ueDmw98CvKr3S6LF9HtiemVe22Of5jf2IiNOor+3uHtf1zIg4+sB16i+C3dO0W9/Xa4aWZ0aDWK8ZbgYualy/CPhqwT6zORZLFRGvAz4IvDEzf9Fin9k852XXNfM1k/NazNf39Wp4LfC9zNxZtLHX63WYbOjPMdaLV2a7fFX3bOqv5P4I+FBj7B3AOxrXA/hcY/s2oNaHml5F/VebrcDdjX9nN9X1LuC71F9JvhN4ZR/qekFjvu805h6K9WrMexT1QD52xljf14v6fyC7gH3Uz3jeDvwacBvwg8blcxr7LgLWH+5Y7HFdP6TeEz1wjP19c12tnvMe1/WFxrGzlXrAHD8M69UYv+bAMTVj336uV6ts6Msx5kfpJamihq2FIkmaJQNckirKAJekijLAJamiDHBJqigDXJIqygCXpIr6fxtLSdhTr1YVAAAAAElFTkSuQmCC", 35 | "text/plain": [ 36 | "
" 37 | ] 38 | }, 39 | "metadata": { 40 | "needs_background": "light" 41 | }, 42 | "output_type": "display_data" 43 | } 44 | ], 45 | "source": [ 46 | "c1 = [[0,0], [0, 10], [10, 10], [10, 0], [0, 0]]\n", 47 | "p1 = Polygon(c1)\n", 48 | "c2 = [[10, 2], [20, 8], [20, 2], [10, 2]]\n", 49 | "p2 = Polygon(c2)\n", 50 | "gdf = geopandas.GeoDataFrame(geometry=[p1, p2])\n", 51 | "base = gdf.plot(edgecolor='k', facecolor=\"none\",alpha=0.5)\n", 52 | "c1 = numpy.array(c1)\n", 53 | "c2 = numpy.array(c2)\n", 54 | "_ = base.scatter(c1[:,0], c1[:,1])\n", 55 | "_ =base.scatter(c2[:,0], c2[:,1])\n", 56 | "\n" 57 | ] 58 | }, 59 | { 60 | "cell_type": "markdown", 61 | "id": "foster-letters", 62 | "metadata": {}, 63 | "source": [ 64 | "The two polygons are visually contiguous share no vertices. This will result in the two polygons not being Queen neighbors, since a necessary (and sufficient) condition for the latter is at least one shared vertex." 65 | ] 66 | }, 67 | { 68 | "cell_type": "code", 69 | "execution_count": 3, 70 | "id": "vietnamese-office", 71 | "metadata": {}, 72 | "outputs": [ 73 | { 74 | "name": "stderr", 75 | "output_type": "stream", 76 | "text": [ 77 | "/home/serge/anaconda3/envs/dev39/lib/python3.10/site-packages/libpysal/weights/weights.py:172: UserWarning: The weights matrix is not fully connected: \n", 78 | " There are 2 disconnected components.\n", 79 | " There are 2 islands with ids: 0, 1.\n", 80 | " warnings.warn(message)\n" 81 | ] 82 | } 83 | ], 84 | "source": [ 85 | "w = libpysal.weights.Queen.from_dataframe(gdf)" 86 | ] 87 | }, 88 | { 89 | "cell_type": "markdown", 90 | "id": "smart-hygiene", 91 | "metadata": {}, 92 | "source": [ 93 | "## Detecting nonplanar touches" 94 | ] 95 | }, 96 | { 97 | "cell_type": "markdown", 98 | "id": "selective-syracuse", 99 | "metadata": {}, 100 | "source": [ 101 | "`geoplanar` can detect and report nonplanar edges:" 102 | ] 103 | }, 104 | { 105 | "cell_type": "code", 106 | "execution_count": 4, 107 | "id": "usual-storm", 108 | "metadata": {}, 109 | "outputs": [ 110 | { 111 | "data": { 112 | "text/plain": [ 113 | "defaultdict(set, {0: {1}})" 114 | ] 115 | }, 116 | "execution_count": 4, 117 | "metadata": {}, 118 | "output_type": "execute_result" 119 | } 120 | ], 121 | "source": [ 122 | "geoplanar.non_planar_edges(gdf)" 123 | ] 124 | }, 125 | { 126 | "cell_type": "markdown", 127 | "id": "significant-penguin", 128 | "metadata": {}, 129 | "source": [ 130 | "## Fixing nonplanar edges" 131 | ] 132 | }, 133 | { 134 | "cell_type": "code", 135 | "execution_count": 5, 136 | "id": "dimensional-gambling", 137 | "metadata": {}, 138 | "outputs": [ 139 | { 140 | "name": "stderr", 141 | "output_type": "stream", 142 | "text": [ 143 | "/home/serge/anaconda3/envs/dev39/lib/python3.10/site-packages/libpysal/weights/weights.py:172: UserWarning: The weights matrix is not fully connected: \n", 144 | " There are 2 disconnected components.\n", 145 | " There are 2 islands with ids: 0, 1.\n", 146 | " warnings.warn(message)\n" 147 | ] 148 | } 149 | ], 150 | "source": [ 151 | "gdf1 = geoplanar.fix_npe_edges(gdf)" 152 | ] 153 | }, 154 | { 155 | "cell_type": "code", 156 | "execution_count": 6, 157 | "id": "flying-computer", 158 | "metadata": {}, 159 | "outputs": [ 160 | { 161 | "data": { 162 | "text/plain": [ 163 | "defaultdict(set, {})" 164 | ] 165 | }, 166 | "execution_count": 6, 167 | "metadata": {}, 168 | "output_type": "execute_result" 169 | } 170 | ], 171 | "source": [ 172 | "geoplanar.non_planar_edges(gdf1)" 173 | ] 174 | }, 175 | { 176 | "cell_type": "code", 177 | "execution_count": 7, 178 | "id": "powered-special", 179 | "metadata": {}, 180 | "outputs": [ 181 | { 182 | "data": { 183 | "text/plain": [ 184 | "{0: [1], 1: [0]}" 185 | ] 186 | }, 187 | "execution_count": 7, 188 | "metadata": {}, 189 | "output_type": "execute_result" 190 | } 191 | ], 192 | "source": [ 193 | "w1 = libpysal.weights.Queen.from_dataframe(gdf1)\n", 194 | "w1.neighbors" 195 | ] 196 | }, 197 | { 198 | "cell_type": "markdown", 199 | "id": "level-vietnamese", 200 | "metadata": {}, 201 | "source": [ 202 | "## Default is to work on a copy" 203 | ] 204 | }, 205 | { 206 | "cell_type": "code", 207 | "execution_count": 8, 208 | "id": "sustained-queens", 209 | "metadata": {}, 210 | "outputs": [ 211 | { 212 | "name": "stderr", 213 | "output_type": "stream", 214 | "text": [ 215 | "/home/serge/anaconda3/envs/dev39/lib/python3.10/site-packages/libpysal/weights/weights.py:172: UserWarning: The weights matrix is not fully connected: \n", 216 | " There are 2 disconnected components.\n", 217 | " There are 2 islands with ids: 0, 1.\n", 218 | " warnings.warn(message)\n" 219 | ] 220 | }, 221 | { 222 | "data": { 223 | "text/plain": [ 224 | "defaultdict(set, {0: {1}})" 225 | ] 226 | }, 227 | "execution_count": 8, 228 | "metadata": {}, 229 | "output_type": "execute_result" 230 | } 231 | ], 232 | "source": [ 233 | "geoplanar.non_planar_edges(gdf)" 234 | ] 235 | }, 236 | { 237 | "cell_type": "code", 238 | "execution_count": 9, 239 | "id": "contemporary-distribution", 240 | "metadata": {}, 241 | "outputs": [ 242 | { 243 | "data": { 244 | "text/html": [ 245 | "
\n", 246 | "\n", 259 | "\n", 260 | " \n", 261 | " \n", 262 | " \n", 263 | " \n", 264 | " \n", 265 | " \n", 266 | " \n", 267 | " \n", 268 | " \n", 269 | " \n", 270 | " \n", 271 | " \n", 272 | " \n", 273 | " \n", 274 | " \n", 275 | " \n", 276 | "
geometry
0POLYGON ((0.00000 0.00000, 0.00000 10.00000, 1...
1POLYGON ((10.00000 2.00000, 20.00000 8.00000, ...
\n", 277 | "
" 278 | ], 279 | "text/plain": [ 280 | " geometry\n", 281 | "0 POLYGON ((0.00000 0.00000, 0.00000 10.00000, 1...\n", 282 | "1 POLYGON ((10.00000 2.00000, 20.00000 8.00000, ..." 283 | ] 284 | }, 285 | "execution_count": 9, 286 | "metadata": {}, 287 | "output_type": "execute_result" 288 | } 289 | ], 290 | "source": [ 291 | "geoplanar.fix_npe_edges(gdf, inplace=True)\n" 292 | ] 293 | }, 294 | { 295 | "cell_type": "code", 296 | "execution_count": 10, 297 | "id": "disturbed-extension", 298 | "metadata": {}, 299 | "outputs": [ 300 | { 301 | "data": { 302 | "text/plain": [ 303 | "defaultdict(set, {})" 304 | ] 305 | }, 306 | "execution_count": 10, 307 | "metadata": {}, 308 | "output_type": "execute_result" 309 | } 310 | ], 311 | "source": [ 312 | "geoplanar.non_planar_edges(gdf)" 313 | ] 314 | }, 315 | { 316 | "cell_type": "code", 317 | "execution_count": 11, 318 | "id": "juvenile-linux", 319 | "metadata": {}, 320 | "outputs": [ 321 | { 322 | "data": { 323 | "text/plain": [ 324 | "{0: [1], 1: [0]}" 325 | ] 326 | }, 327 | "execution_count": 11, 328 | "metadata": {}, 329 | "output_type": "execute_result" 330 | } 331 | ], 332 | "source": [ 333 | "w = libpysal.weights.Queen.from_dataframe(gdf)\n", 334 | "w.neighbors" 335 | ] 336 | }, 337 | { 338 | "cell_type": "markdown", 339 | "id": "c4a8f147-2065-4ccd-8254-a6db63f21cd1", 340 | "metadata": {}, 341 | "source": [ 342 | "## Handle MultiPolygons" 343 | ] 344 | }, 345 | { 346 | "cell_type": "code", 347 | "execution_count": 12, 348 | "id": "961d1051-9b27-4a5e-9d2f-033e13b097bc", 349 | "metadata": {}, 350 | "outputs": [ 351 | { 352 | "data": { 353 | "image/png": "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", 354 | "text/plain": [ 355 | "
" 356 | ] 357 | }, 358 | "metadata": { 359 | "needs_background": "light" 360 | }, 361 | "output_type": "display_data" 362 | } 363 | ], 364 | "source": [ 365 | "from shapely.geometry import MultiPolygon\n", 366 | "c1 = [[0,0], [0, 10], [10, 10], [10, 0], [0, 0]]\n", 367 | "p1 = Polygon(c1)\n", 368 | "c2 = [[10, 2], [20, 8], [20, 2], [10, 2]]\n", 369 | "p3 = Polygon([ [21, 2], [21, 4], [23,3] ])\n", 370 | "\n", 371 | "#p2 = Polygon(c2)\n", 372 | "p2 = MultiPolygon([Polygon(c2), p3])\n", 373 | "\n", 374 | "gdf = geopandas.GeoDataFrame(geometry=[p1, p2])\n", 375 | "base = gdf.plot(edgecolor='k', facecolor=\"none\",alpha=0.5)\n", 376 | "c1 = numpy.array(c1)\n", 377 | "c2 = numpy.array(c2)\n", 378 | "_ = base.scatter(c1[:,0], c1[:,1])\n", 379 | "_ =base.scatter(c2[:,0], c2[:,1])\n", 380 | "\n" 381 | ] 382 | }, 383 | { 384 | "cell_type": "code", 385 | "execution_count": 13, 386 | "id": "85926d2f-af5e-47c8-8dff-c4ddebd0463e", 387 | "metadata": {}, 388 | "outputs": [ 389 | { 390 | "name": "stderr", 391 | "output_type": "stream", 392 | "text": [ 393 | "/home/serge/anaconda3/envs/dev39/lib/python3.10/site-packages/libpysal/weights/weights.py:172: UserWarning: The weights matrix is not fully connected: \n", 394 | " There are 2 disconnected components.\n", 395 | " There are 2 islands with ids: 0, 1.\n", 396 | " warnings.warn(message)\n" 397 | ] 398 | } 399 | ], 400 | "source": [ 401 | "res = geoplanar.non_planar_edges(gdf)" 402 | ] 403 | }, 404 | { 405 | "cell_type": "code", 406 | "execution_count": 14, 407 | "id": "5a6dece0-42fe-4d9c-ad87-8c8b2394f114", 408 | "metadata": {}, 409 | "outputs": [ 410 | { 411 | "data": { 412 | "text/plain": [ 413 | "defaultdict(set, {0: {1}})" 414 | ] 415 | }, 416 | "execution_count": 14, 417 | "metadata": {}, 418 | "output_type": "execute_result" 419 | } 420 | ], 421 | "source": [ 422 | "res" 423 | ] 424 | }, 425 | { 426 | "cell_type": "code", 427 | "execution_count": 14, 428 | "id": "7499cf0c-26d2-4c6b-ae36-cb281898c763", 429 | "metadata": {}, 430 | "outputs": [ 431 | { 432 | "name": "stderr", 433 | "output_type": "stream", 434 | "text": [ 435 | "/home/serge/anaconda3/envs/edu_concordance/lib/python3.9/site-packages/libpysal/weights/weights.py:172: UserWarning: The weights matrix is not fully connected: \n", 436 | " There are 2 disconnected components.\n", 437 | " There are 2 islands with ids: 0, 1.\n", 438 | " warnings.warn(message)\n" 439 | ] 440 | } 441 | ], 442 | "source": [ 443 | "gdf1 = geoplanar.fix_npe_edges(gdf)\n" 444 | ] 445 | }, 446 | { 447 | "cell_type": "code", 448 | "execution_count": 15, 449 | "id": "28641dfc-4016-4e9a-8c6f-e0c977864561", 450 | "metadata": {}, 451 | "outputs": [ 452 | { 453 | "data": { 454 | "text/plain": [ 455 | "'POLYGON ((0 0, 0 10, 10 10, 10 2, 10 0, 0 0))'" 456 | ] 457 | }, 458 | "execution_count": 15, 459 | "metadata": {}, 460 | "output_type": "execute_result" 461 | } 462 | ], 463 | "source": [ 464 | "gdf1.geometry[0].wkt" 465 | ] 466 | }, 467 | { 468 | "cell_type": "code", 469 | "execution_count": 16, 470 | "id": "617337be-c55c-4337-a1ba-a120b6ea3feb", 471 | "metadata": {}, 472 | "outputs": [ 473 | { 474 | "data": { 475 | "text/plain": [ 476 | "'POLYGON ((0 0, 0 10, 10 10, 10 0, 0 0))'" 477 | ] 478 | }, 479 | "execution_count": 16, 480 | "metadata": {}, 481 | "output_type": "execute_result" 482 | } 483 | ], 484 | "source": [ 485 | "gdf.geometry[0].wkt" 486 | ] 487 | }, 488 | { 489 | "cell_type": "code", 490 | "execution_count": null, 491 | "id": "cac07120-687d-44bf-b4eb-9851a5f16527", 492 | "metadata": {}, 493 | "outputs": [], 494 | "source": [] 495 | } 496 | ], 497 | "metadata": { 498 | "kernelspec": { 499 | "display_name": "Python 3 (ipykernel)", 500 | "language": "python", 501 | "name": "python3" 502 | }, 503 | "language_info": { 504 | "codemirror_mode": { 505 | "name": "ipython", 506 | "version": 3 507 | }, 508 | "file_extension": ".py", 509 | "mimetype": "text/x-python", 510 | "name": "python", 511 | "nbconvert_exporter": "python", 512 | "pygments_lexer": "ipython3", 513 | "version": "3.12.3" 514 | } 515 | }, 516 | "nbformat": 4, 517 | "nbformat_minor": 5 518 | } 519 | -------------------------------------------------------------------------------- /docs/reference.rst: -------------------------------------------------------------------------------- 1 | .. _reference: 2 | 3 | Reference Guide 4 | =============== 5 | 6 | Nonplanar Edges 7 | ---------------- 8 | 9 | .. autofunction:: geoplanar.non_planar_edges 10 | 11 | .. autofunction:: geoplanar.is_planar_enforced 12 | 13 | .. autofunction:: geoplanar.fix_npe_edges 14 | 15 | .. autofunction:: geoplanar.planar_enforce 16 | 17 | .. autofunction:: geoplanar.insert_intersections 18 | 19 | .. autofunction:: geoplanar.self_intersecting_rings 20 | 21 | .. autofunction:: geoplanar.check_validity 22 | 23 | Gaps 24 | ---- 25 | 26 | .. autofunction:: geoplanar.gaps 27 | 28 | .. autofunction:: geoplanar.fill_gaps 29 | 30 | .. autofunction:: geoplanar.snap 31 | 32 | 33 | Holes 34 | ----- 35 | 36 | .. autofunction:: geoplanar.missing_interiors 37 | 38 | .. autofunction:: geoplanar.add_interiors 39 | 40 | Overlaps 41 | -------- 42 | 43 | .. autofunction:: geoplanar.is_overlapping 44 | 45 | .. autofunction:: geoplanar.overlaps 46 | 47 | .. autofunction:: geoplanar.trim_overlaps 48 | 49 | .. autofunction:: geoplanar.merge_overlaps 50 | 51 | .. autofunction:: geoplanar.merge_touching -------------------------------------------------------------------------------- /geoplanar/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | GeoPlanar 3 | 4 | A module for handling planar enforcement for polygon geoseries. 5 | """ 6 | 7 | import contextlib 8 | from importlib.metadata import PackageNotFoundError, version 9 | 10 | from geoplanar.gap import * 11 | from geoplanar.hole import * 12 | from geoplanar.overlap import * 13 | from geoplanar.planar import * 14 | from geoplanar.valid import * 15 | 16 | with contextlib.suppress(PackageNotFoundError): 17 | __version__ = version("geoplanar") 18 | -------------------------------------------------------------------------------- /geoplanar/__pycache__/__init__.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopandas/geoplanar/371a51c12e470bcdb01f4ba2fb80b6ff95783d5e/geoplanar/__pycache__/__init__.cpython-37.pyc -------------------------------------------------------------------------------- /geoplanar/__pycache__/_version.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopandas/geoplanar/371a51c12e470bcdb01f4ba2fb80b6ff95783d5e/geoplanar/__pycache__/_version.cpython-37.pyc -------------------------------------------------------------------------------- /geoplanar/__pycache__/gap.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopandas/geoplanar/371a51c12e470bcdb01f4ba2fb80b6ff95783d5e/geoplanar/__pycache__/gap.cpython-37.pyc -------------------------------------------------------------------------------- /geoplanar/__pycache__/hole.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopandas/geoplanar/371a51c12e470bcdb01f4ba2fb80b6ff95783d5e/geoplanar/__pycache__/hole.cpython-37.pyc -------------------------------------------------------------------------------- /geoplanar/__pycache__/overlap.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopandas/geoplanar/371a51c12e470bcdb01f4ba2fb80b6ff95783d5e/geoplanar/__pycache__/overlap.cpython-37.pyc -------------------------------------------------------------------------------- /geoplanar/__pycache__/planar.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopandas/geoplanar/371a51c12e470bcdb01f4ba2fb80b6ff95783d5e/geoplanar/__pycache__/planar.cpython-37.pyc -------------------------------------------------------------------------------- /geoplanar/__pycache__/valid.cpython-37.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopandas/geoplanar/371a51c12e470bcdb01f4ba2fb80b6ff95783d5e/geoplanar/__pycache__/valid.cpython-37.pyc -------------------------------------------------------------------------------- /geoplanar/gap.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | import geopandas 4 | import numpy as np 5 | import pandas as pd 6 | import shapely 7 | from packaging.version import Version 8 | from collections import defaultdict 9 | from esda.shape import isoperimetric_quotient 10 | 11 | 12 | __all__ = ["gaps", "fill_gaps", "snap"] 13 | 14 | GPD_GE_014 = Version(geopandas.__version__) >= Version("0.14.0") 15 | GPD_GE_100 = Version(geopandas.__version__) >= Version("1.0.0dev") 16 | 17 | 18 | def gaps(gdf): 19 | """Find gaps in a geodataframe. 20 | 21 | A gap (emply sliver polygon) is a set of points that: 22 | 23 | - are not contained by any of the geometries in the geoseries 24 | - are not contained by the external polygon 25 | 26 | Parameters 27 | ---------- 28 | 29 | gdf : GeoDataFrame with polygon (multipolygon) GeoSeries 30 | 31 | 32 | Returns 33 | ------- 34 | 35 | _gaps : GeoDataFrame with gap polygons 36 | 37 | Examples 38 | -------- 39 | >>> p1 = box(0,0,10,10) 40 | >>> p2 = Polygon([(10,10), (12,8), (10,6), (12,4), (10,2), (20,5)]) 41 | >>> gdf = geopandas.GeoDataFrame(geometry=[p1,p2]) 42 | >>> h = geoplanar.gaps(gdf) 43 | >>> h.area 44 | array([4., 4.]) 45 | """ 46 | 47 | polygons = geopandas.GeoSeries( 48 | shapely.get_parts( 49 | shapely.polygonize( 50 | [gdf.boundary.union_all() if GPD_GE_100 else gdf.boundary.unary_union] 51 | ) 52 | ), 53 | crs=gdf.crs, 54 | ) 55 | if GPD_GE_014: 56 | poly_idx, _ = gdf.sindex.query(polygons, predicate="covers") 57 | else: 58 | poly_idx, _ = gdf.sindex.query_bulk(polygons, predicate="covers") 59 | 60 | return polygons.drop(poly_idx).reset_index(drop=True) 61 | 62 | 63 | def fill_gaps(gdf, gap_df=None, strategy='largest', inplace=False): 64 | """Fill gaps in a GeoDataFrame by merging them with neighboring polygons. 65 | 66 | Parameters 67 | ---------- 68 | gdf : GeoDataFrame 69 | A GeoDataFrame containing polygon or multipolygon geometries. 70 | 71 | gap_df : GeoDataFrame, optional 72 | A GeoDataFrame containing the gaps to be filled. If None, gaps will be 73 | automatically detected within `gdf`. 74 | 75 | strategy : {'smallest', 'largest', 'compact', None}, default 'largest' 76 | Strategy to determine how gaps are merged with neighboring polygons: 77 | - 'smallest': Merge each gap with the smallest neighboring polygon. 78 | - 'largest' : Merge each gap with the largest neighboring polygon. 79 | - 'compact' : Merge each gap with the neighboring polygon that results in 80 | the new polygon having the highest compactness 81 | (isoperimetric quotient). 82 | - None : Merge each gap with the first available neighboring polygon 83 | (order is indeterminate). 84 | 85 | inplace : bool, default False 86 | If True, modify the input GeoDataFrame in place. Otherwise, return a new 87 | GeoDataFrame with the gaps filled. 88 | 89 | Returns 90 | ------- 91 | GeoDataFrame or None 92 | A new GeoDataFrame with gaps filled if `inplace` is False. Otherwise, 93 | modifies `gdf` in place and returns None. 94 | """ 95 | if gap_df is None: 96 | gap_df = gaps(gdf) 97 | 98 | if not inplace: 99 | gdf = gdf.copy() 100 | 101 | if not GPD_GE_014: 102 | gap_idx, gdf_idx = gdf.sindex.query_bulk( 103 | gap_df.geometry, predicate="intersects" 104 | ) 105 | else: 106 | gap_idx, gdf_idx = gdf.sindex.query(gap_df.geometry, predicate="intersects") 107 | 108 | to_merge = defaultdict(set) 109 | 110 | for g_ix in range(len(gap_df)): 111 | neighbors = gdf_idx[gap_idx == g_ix] 112 | 113 | if strategy == 'compact': 114 | # Find the neighbor that results in the highest IQ 115 | gap_geom = shapely.make_valid(gap_df.geometry.iloc[g_ix]) 116 | best_iq = -1 117 | best_neighbor = None 118 | neighbor_geometries = gdf.geometry.iloc[neighbors].apply(shapely.make_valid) 119 | for neighbor, neighbor_geom in zip(neighbors, neighbor_geometries): 120 | combined_geom = shapely.union_all( 121 | [neighbor_geom, gap_geom] 122 | ) 123 | iq = isoperimetric_quotient(combined_geom) 124 | if iq > best_iq: 125 | best_iq = iq 126 | best_neighbor = neighbor 127 | to_merge[best_neighbor].add(g_ix) 128 | elif strategy is None: # don't care which polygon we attach cap to 129 | to_merge[gdf.index[neighbors[0]]].add(g_ix) 130 | elif strategy == 'largest': 131 | # Attach to the largest neighbor 132 | to_merge[gdf.iloc[neighbors].area.idxmax()].add(g_ix) 133 | else: 134 | # Attach to the smallest neighbor 135 | to_merge[gdf.iloc[neighbors].area.idxmin()].add(g_ix) 136 | 137 | new_geom = [] 138 | for k, v in to_merge.items(): 139 | new_geom.append( 140 | shapely.union_all( 141 | [gdf.geometry.loc[k]] + [gap_df.geometry.iloc[i] for i in v] 142 | ) 143 | ) 144 | gdf.loc[list(to_merge.keys()), gdf.geometry.name] = new_geom 145 | 146 | return gdf 147 | 148 | 149 | def _get_parts(geom): 150 | """Get parts recursively to explode multi-part geoms in collections 151 | 152 | Parameters 153 | ---------- 154 | geom : shapely.Geometry 155 | 156 | Returns 157 | ------- 158 | np.array[shapely.Geometry] 159 | """ 160 | parts = shapely.get_parts(geom) 161 | if (shapely.get_type_id(parts) > 3).any(): 162 | return _get_parts(parts) 163 | return parts 164 | 165 | 166 | def _snap(geometry, reference, threshold, segment_length): 167 | """Snap g1 to g2 within threshold 168 | 169 | Parameters 170 | ---------- 171 | geometry : shapely.Polygon 172 | geometry to snap 173 | reference : shapely.Polygon 174 | geometry to snap to 175 | threshold : float 176 | max distance between vertices to snap 177 | segment_length : float 178 | max segment length parameter in segmentize 179 | 180 | Returns 181 | ------- 182 | shapely.Polygon 183 | snapped geometry 184 | """ 185 | 186 | # extract the shell and holes from the first geometry 187 | shell, holes = geometry.exterior, geometry.interiors 188 | # segmentize the shell and extract coordinates 189 | coords = shapely.get_coordinates(shapely.segmentize(shell, segment_length)) 190 | # create a point geometry from the coordinates 191 | points = shapely.points(coords) 192 | # find the shortest line between the points and the second geometry 193 | lines = shapely.shortest_line(points, reference) 194 | # mask the coordinates where the distance is less than the threshold 195 | distance_mask = shapely.length(lines) < threshold 196 | 197 | # return the original geometry if no coordinates are within the threshold 198 | if not any(distance_mask): 199 | return geometry 200 | 201 | # update the coordinates with the snapped coordinates 202 | coords[distance_mask] = shapely.get_coordinates(lines)[1::2][distance_mask] 203 | # re-create the polygon with new coordinates and original holes and simplify 204 | # to remove any extra vertices. 205 | polygon = shapely.Polygon(coords, holes=holes) 206 | simplified = shapely.make_valid(shapely.simplify(polygon, segment_length / 100)) 207 | # the function may return invalid and make_valid may return non-polygons 208 | # the largest polygon is the most likely the one we want 209 | if simplified.geom_type != "Polygon": 210 | parts = _get_parts(simplified) 211 | simplified = parts[np.argmax(shapely.area(parts))] 212 | 213 | return simplified 214 | 215 | 216 | def snap(geometry, threshold): 217 | """Snap geometries that are within threshold to each other 218 | 219 | Only one of the pair of geometries identified as nearby will be snapped, 220 | the one with the lower index. 221 | 222 | If the snapping heuristics leads to an invalid geometry, the function attempts 223 | to fix it using :func:`shapely.make_valid`, which may lead to multi-part geometries. 224 | If that happens, only the largest component is returned. Occasionally, this may 225 | lead to improper snapping. 226 | 227 | Parameters 228 | ---------- 229 | geometry : GeoDataFrame | GeoSeries 230 | geometries to snap. Geometry type needs to be Polygon for all of them. 231 | threshold : float 232 | max distance between geometries to snap 233 | threshold should be ~10% larger than the distance between polygon edges to 234 | ensure snapping 235 | 236 | Returns 237 | ------- 238 | GeoSeries 239 | GeoSeries with snapped geometries 240 | """ 241 | if not GPD_GE_100: 242 | raise ImportError("geopandas 1.0.0 or higher is required.") 243 | 244 | nearby_a, nearby_b = geometry.sindex.query( 245 | geometry.geometry, predicate="dwithin", distance=threshold 246 | ) 247 | overlap_a, overlap_b = geometry.boundary.sindex.query( 248 | geometry.boundary, predicate="overlaps" 249 | ) 250 | 251 | self_mask = nearby_a != nearby_b 252 | nearby_a = nearby_a[self_mask] 253 | nearby_b = nearby_b[self_mask] 254 | 255 | self_mask = overlap_a != overlap_b 256 | overlap_a = overlap_a[self_mask] 257 | overlap_b = overlap_b[self_mask] 258 | 259 | nearby = pd.MultiIndex.from_arrays([nearby_a, nearby_b], names=("source", "target")) 260 | overlap = pd.MultiIndex.from_arrays( 261 | [overlap_a, overlap_b], names=("source", "target") 262 | ) 263 | nearby_not_overlap = nearby.difference(overlap) 264 | if not nearby_not_overlap.empty: 265 | duplicated = pd.DataFrame( 266 | np.sort(np.array(nearby_not_overlap.to_list()), axis=1) 267 | ).duplicated() 268 | pairs_to_snap = nearby_not_overlap[~duplicated] 269 | 270 | new_geoms = [] 271 | previous_geom = None 272 | snapped_geom = None 273 | for geom, ref in zip( 274 | geometry.geometry.iloc[pairs_to_snap.get_level_values("source")], 275 | geometry.geometry.iloc[pairs_to_snap.get_level_values("target")], 276 | strict=True, 277 | ): 278 | if previous_geom == geom: 279 | new_geoms.append( 280 | _snap( 281 | snapped_geom, ref, threshold=threshold, segment_length=threshold 282 | ) 283 | ) 284 | else: 285 | snapped_geom = _snap( 286 | geom, ref, threshold=threshold, segment_length=threshold 287 | ) 288 | new_geoms.append(snapped_geom) 289 | previous_geom = geom 290 | 291 | snapped = geometry.geometry.copy() 292 | snapped.iloc[pairs_to_snap.get_level_values("source")] = new_geoms 293 | else: 294 | snapped = geometry.geometry.copy() 295 | return snapped 296 | -------------------------------------------------------------------------------- /geoplanar/hole.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | import geopandas 4 | import pandas as pd 5 | from packaging.version import Version 6 | 7 | __all__ = ["add_interiors", "missing_interiors"] 8 | 9 | GPD_GE_014 = Version(geopandas.__version__) >= Version("0.14.0") 10 | 11 | 12 | def missing_interiors(gdf): 13 | """Find any missing interiors. 14 | 15 | For a planar enforced polygon layer, there should be no cases of a polygon 16 | being contained in another polygon. Instead the "contained" polygon is a 17 | hole in the "containing" polygon. 18 | 19 | 20 | Parameters 21 | ---------- 22 | 23 | gdf : GeoDataFrame with polygon (multipolygon) GeoSeries 24 | 25 | 26 | Returns 27 | ------- 28 | 29 | pairs : list 30 | tuples for each violation (i,j), where i is the index of the 31 | containing polygon, j is the index of the contained polygon 32 | 33 | Examples 34 | -------- 35 | >>> p1 = box(0,0,10,10) 36 | >>> p2 = box(1,1, 3,3) 37 | >>> p3 = box(7,7, 9,9) 38 | >>> gdf = geopandas.GeoDataFrame(geometry=[p1,p2,p3]) 39 | >>> mi = geoplanar.missing_interiors(gdf) 40 | >>> mi 41 | [(0, 1), (0, 2)] 42 | """ 43 | if GPD_GE_014: 44 | i, j = gdf.geometry.sindex.query(gdf.geometry, predicate="contains") 45 | else: 46 | i, j = gdf.geometry.sindex.query_bulk(gdf.geometry, predicate="contains") 47 | 48 | mask = i != j 49 | 50 | return list(zip(i[mask], j[mask], strict=True)) 51 | 52 | 53 | def add_interiors(gdf, inplace=False): 54 | """Add any missing interiors. 55 | 56 | For a planar enforced polygon layer, there should be no cases of a polygon 57 | being contained in another polygon. Instead the "contained" polygon is a 58 | hole in the "containing" polygon. This function finds and corrects any such 59 | violations. 60 | 61 | 62 | Parameters 63 | ---------- 64 | 65 | gdf : GeoDataFrame with polygon (multipolygon) GeoSeries 66 | 67 | 68 | inplace: boolean (default: False) 69 | Change the geoseries of current dataframe 70 | 71 | 72 | Returns 73 | ------- 74 | 75 | gdf : GeoDataFrame 76 | 77 | 78 | Examples 79 | -------- 80 | >>> p1 = box(0,0,10,10) 81 | >>> p2 = box(1,1, 3,3) 82 | >>> p3 = box(7,7, 9,9) 83 | >>> gdf = geopandas.GeoDataFrame(geometry=[p1,p2,p3]) 84 | >>> gdf.area 85 | 0 100.0 86 | 1 4.0 87 | 2 4.0 88 | >>> mi = geoplanar.missing_interiors(gdf) 89 | >>> mi 90 | [(0, 1), (0, 2)] 91 | >>> gdf1 = geoplanar.add_interiors(gdf) 92 | >>> gdf1.area 93 | 0 92.0 94 | 1 4.0 95 | 2 4.0 96 | """ 97 | if not inplace: 98 | gdf = gdf.copy() 99 | 100 | geom_col_idx = gdf.columns.get_loc(gdf.geometry.name) 101 | 102 | if GPD_GE_014: 103 | contained = gdf.geometry.sindex.query(gdf.geometry, predicate="contains") 104 | else: 105 | contained = gdf.geometry.sindex.query_bulk(gdf.geometry, predicate="contains") 106 | k = contained.shape[1] 107 | 108 | if k > gdf.shape[0]: 109 | to_add = contained[:, contained[0] != contained[1]].T 110 | for add in to_add: 111 | i, j = add 112 | gdf.iloc[i, geom_col_idx] = gdf.geometry.iloc[i].difference( 113 | gdf.geometry.iloc[j] 114 | ) 115 | return gdf 116 | -------------------------------------------------------------------------------- /geoplanar/overlap.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from collections import defaultdict 3 | 4 | import geopandas 5 | import libpysal 6 | import numpy as np 7 | from packaging.version import Version 8 | from esda.shape import isoperimetric_quotient 9 | 10 | __all__ = [ 11 | "overlaps", 12 | "trim_overlaps", 13 | "is_overlapping", 14 | "merge_overlaps", 15 | "merge_touching", 16 | ] 17 | 18 | GPD_GE_014 = Version(geopandas.__version__) >= Version("0.14.0") 19 | 20 | 21 | def overlaps(gdf): 22 | """Check for overlapping geometries in the GeoDataFrame. 23 | 24 | Parameters 25 | ---------- 26 | gdf: GeoDataFrame with polygon geometries 27 | 28 | Returns: 29 | array-like: Pairs of indices with overlapping geometries. 30 | """ 31 | if GPD_GE_014: 32 | return gdf.sindex.query(gdf.geometry, predicate="overlaps") 33 | return gdf.sindex.query_bulk(gdf.geometry, predicate="overlaps") 34 | 35 | 36 | def trim_overlaps(gdf, strategy='largest', inplace=False): 37 | """Trim overlapping polygons 38 | 39 | Note 40 | ---- 41 | Under certain circumstances, the output may result in MultiPolygons. This is 42 | typically a result of a complex relationship between geometries and is expected. 43 | Just note, that it may require further treatment if simple Polygons are needed. 44 | 45 | Parameters 46 | ---------- 47 | 48 | gdf: geodataframe with polygon geometries 49 | 50 | strategy : {'smallest', 'largest', 'compact', None}, default 'largest' 51 | Strategy to determine which polygon to trim. 52 | - 'smallest': Trim the smallest polygon. 53 | - 'largest' : Trim the largest polygon. 54 | - 'compact' : Trim the polygon yielding the most compact modified polygon. 55 | (isoperimetric quotient). 56 | - None : Trim either polygon non-deterministically but performantly. 57 | 58 | Returns 59 | ------- 60 | 61 | gdf: geodataframe with corrected geometries 62 | 63 | """ 64 | if GPD_GE_014: 65 | intersections = gdf.sindex.query(gdf.geometry, predicate="overlaps").T 66 | else: 67 | intersections = gdf.sindex.query_bulk(gdf.geometry, predicate="overlaps").T 68 | 69 | if not inplace: 70 | gdf = gdf.copy() 71 | 72 | geom_col_idx = gdf.columns.get_loc(gdf.geometry.name) 73 | 74 | if strategy is None: # don't care which polygon to trim 75 | for i, j in intersections: 76 | if i != j: 77 | left = gdf.geometry.iloc[i] 78 | right = gdf.geometry.iloc[j] 79 | gdf.iloc[j, geom_col_idx] = right.difference(left) 80 | elif strategy=='largest': 81 | for i, j in intersections: 82 | if i != j: 83 | left = gdf.geometry.iloc[i] 84 | right = gdf.geometry.iloc[j] 85 | if left.area > right.area: # trim left 86 | gdf.iloc[i, geom_col_idx] = left.difference(right) 87 | else: 88 | gdf.iloc[j, geom_col_idx] = right.difference(left) 89 | elif strategy=='smallest': 90 | for i, j in intersections: 91 | if i != j: 92 | left = gdf.geometry.iloc[i] 93 | right = gdf.geometry.iloc[j] 94 | if left.area < right.area: # trim left 95 | gdf.iloc[i, geom_col_idx] = left.difference(right) 96 | else: 97 | gdf.iloc[j, geom_col_idx] = right.difference(left) 98 | elif strategy=='compact': 99 | for i, j in intersections: 100 | if i != j: 101 | left = gdf.geometry.iloc[i] 102 | right = gdf.geometry.iloc[j] 103 | left_c = left.difference(right) 104 | right_c = right.difference(left) 105 | iq_left = isoperimetric_quotient(left_c) 106 | iq_right = isoperimetric_quotient(right_c) 107 | if iq_left > iq_right: # trimming left is more compact than right 108 | gdf.iloc[i, geom_col_idx] = left_c 109 | else: 110 | gdf.iloc[j, geom_col_idx] = right_c 111 | return gdf 112 | 113 | 114 | def is_overlapping(gdf): 115 | "Test for overlapping features in geoseries." 116 | 117 | if GPD_GE_014: 118 | overlaps = gdf.sindex.query(gdf.geometry, predicate="overlaps") 119 | else: 120 | overlaps = gdf.sindex.query_bulk(gdf.geometry, predicate="overlaps") 121 | 122 | if overlaps.shape[1] > 0: 123 | return True 124 | return False 125 | 126 | 127 | def merge_overlaps(gdf, merge_limit, overlap_limit): 128 | """Merge overlapping polygons based on a set of conditions. 129 | 130 | Overlapping polygons smaller than ``merge_limit`` are merged to a neighboring 131 | polygon. 132 | 133 | Polygons larger than ``merge_limit`` are merged to neighboring if they share area 134 | larger than ``area * overlap_limit``. 135 | 136 | Notes 137 | ----- 138 | The original index is not preserved. 139 | 140 | Parameters 141 | ---------- 142 | gdf : GeoDataFrame 143 | GeoDataFrame with polygon or mutli polygon geometry 144 | merge_limit : float 145 | area of overlapping polygons that are to be merged with neighbors no matter the 146 | size of the overlap 147 | overlap_limit : float (0-1) 148 | ratio of area of an overlapping polygon that has to be shared with other polygon 149 | to merge both into one 150 | 151 | Returns 152 | ------- 153 | 154 | GeoDataFrame 155 | """ 156 | if GPD_GE_014: 157 | overlap_a, overlap_b = gdf.sindex.query(gdf.geometry, predicate="overlaps") 158 | contains_a, contains_b = gdf.sindex.query(gdf.geometry, predicate="contains") 159 | else: 160 | overlap_a, overlap_b = gdf.sindex.query_bulk(gdf.geometry, predicate="overlaps") 161 | contains_a, contains_b = gdf.sindex.query_bulk( 162 | gdf.geometry, predicate="contains" 163 | ) 164 | 165 | self_mask = contains_a != contains_b 166 | contains_a = contains_a[self_mask] 167 | contains_b = contains_b[self_mask] 168 | 169 | self_mask = overlap_a != overlap_b 170 | overlap_a = overlap_a[self_mask] 171 | overlap_b = overlap_b[self_mask] 172 | 173 | source = gdf.index[np.concatenate([overlap_a, contains_a])] 174 | target = gdf.index[np.concatenate([overlap_b, contains_b])] 175 | 176 | neighbors = defaultdict(list) 177 | for key, value in zip(source, target, strict=False): 178 | neighbors[key].append(value) 179 | 180 | neighbors_final = {} 181 | 182 | for i, poly in gdf.geometry.items(): 183 | if i in neighbors: 184 | if poly.area < merge_limit: 185 | neighbors_final[i] = neighbors[i] 186 | else: 187 | sub = gdf.geometry.loc[neighbors[i]] 188 | inters = sub.intersection(poly) 189 | include = sub.index[inters.area > (sub.area * overlap_limit)] 190 | neighbors_final[i] = list(include) 191 | else: 192 | neighbors_final[i] = [] 193 | 194 | w = libpysal.graph.Graph.from_dicts(neighbors_final) 195 | dissolved_gdf = gdf.dissolve(w.component_labels) 196 | dissolved_gdf.index = w.component_labels.drop_duplicates().index 197 | dissolved_gdf = dissolved_gdf.rename_axis(index=gdf.index.name) 198 | return dissolved_gdf 199 | 200 | 201 | def merge_touching(gdf, index, largest=None): 202 | """Merge or remove polygons based on a set of conditions. 203 | 204 | If polygon does not share any boundary with another polygon, remove. If it shares 205 | some boundary with a neighbouring polygon, join to that polygon. If ``largest=None`` 206 | it picks one randomly, otherwise it picks the polygon with which it shares the 207 | largest (True) or the smallest (False) boundary. 208 | 209 | Notes 210 | ----- 211 | The original index is not preserved. 212 | 213 | Parameters 214 | ---------- 215 | gdf : GeoDataFrame 216 | GeoDataFrame with polygon or mutli polygon geometry 217 | index : list of indexes 218 | list of indexes of polygons in gdf to merge or remove 219 | largest : bool (default None) 220 | Merge with the polygon with the largest (True) or smallest (False) shared 221 | boundary. If None, merge with any neighbor non-deterministically but 222 | performantly. 223 | 224 | Returns 225 | ------- 226 | 227 | GeoDataFrame 228 | """ 229 | 230 | merge_gdf = gdf.loc[index] 231 | 232 | if GPD_GE_014: 233 | source, target = gdf.boundary.sindex.query( 234 | merge_gdf.boundary, predicate="overlaps" 235 | ) 236 | else: 237 | source, target = gdf.boundary.sindex.query_bulk( 238 | merge_gdf.boundary, predicate="overlaps" 239 | ) 240 | 241 | source = merge_gdf.index[source] 242 | target = gdf.index[target] 243 | 244 | neighbors = {} 245 | delete = [] 246 | for i, poly in gdf.geometry.items(): 247 | if i in merge_gdf.index: 248 | if i in source: 249 | if largest is None: 250 | neighbors[i] = [target[source == i][0]] 251 | else: 252 | sub = gdf.geometry.loc[target[source == i]] 253 | inters = sub.intersection(poly.exterior) 254 | if largest: 255 | neighbors[i] = [inters.length.idxmax()] 256 | else: 257 | neighbors[i] = [inters.length.idxmin()] 258 | else: 259 | delete.append(i) 260 | else: 261 | neighbors[i] = [] 262 | 263 | w = libpysal.graph.Graph.from_dicts(neighbors) 264 | dissolved_gdf = gdf.drop(delete).dissolve(w.component_labels) 265 | dissolved_gdf.index = w.component_labels.drop_duplicates().index 266 | dissolved_gdf = dissolved_gdf.rename_axis(index=gdf.index.name) 267 | return dissolved_gdf 268 | -------------------------------------------------------------------------------- /geoplanar/planar.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import geopandas 3 | import numpy 4 | import pandas 5 | from libpysal.graph import Graph 6 | from shapely import ( 7 | GeometryCollection, 8 | LineString, 9 | MultiLineString, 10 | MultiPoint, 11 | MultiPolygon, 12 | Point, 13 | Polygon, 14 | ) 15 | from shapely.ops import linemerge, polygonize, split 16 | 17 | from .gap import gaps 18 | from .hole import missing_interiors 19 | from .overlap import is_overlapping, overlaps 20 | 21 | __all__ = [ 22 | "non_planar_edges", 23 | "planar_enforce", 24 | "is_planar_enforced", 25 | "fix_npe_edges", 26 | "insert_intersections", 27 | "self_intersecting_rings", 28 | "check_validity", 29 | ] 30 | 31 | 32 | def non_planar_edges(gdf): 33 | """Find coincident nonplanar edges 34 | 35 | Parameters 36 | ---------- 37 | 38 | gdf : GeoDataFrame with polygon (multipolygon) GeoSeries 39 | 40 | 41 | Returns 42 | ------- 43 | 44 | missing : libpysal.graph.Graph 45 | graph encoding nonplanar relationships between polygons 46 | 47 | Examples 48 | -------- 49 | >>> c1 = [[0,0], [0, 10], [10, 10], [10, 0], [0, 0]] 50 | >>> p1 = Polygon(c1) 51 | >>> c2 = [[10, 2], [10, 8], [20, 8], [20, 2], [10, 2]] 52 | >>> p2 = Polygon(c2) 53 | >>> gdf = geopandas.GeoDataFrame(geometry=[p1, p2]) 54 | >>> geoplanar.non_planar_edges(gdf).adjacency 55 | focal neighbor 56 | 0 0 0 57 | 1 1 0 58 | Name: weight, dtype: int64 59 | 60 | """ 61 | vertex_queen = Graph.build_contiguity(gdf, rook=False, strict=False) 62 | strict_queen = Graph.build_fuzzy_contiguity(gdf) 63 | return strict_queen.difference(vertex_queen) 64 | 65 | 66 | def planar_enforce(gdf): 67 | uu = gdf.unary_union 68 | geoms = [uu.intersection(row.geometry) for index, row in gdf.iterrows()] 69 | return geopandas.GeoDataFrame(geometry=geoms) 70 | 71 | 72 | def is_planar_enforced(gdf, allow_gaps=False): 73 | """Test if a geodataframe has any planar enforcement violations 74 | 75 | Parameters 76 | ---------- 77 | gdf: GeoDataFrame with polygon geoseries for geometry 78 | allow_gaps: boolean 79 | If True, allow gaps in the polygonal coverage 80 | 81 | Returns 82 | ------- 83 | boolean 84 | """ 85 | 86 | if is_overlapping(gdf): 87 | return False 88 | if non_planar_edges(gdf): 89 | return False 90 | if not allow_gaps: 91 | _gaps = gaps(gdf) 92 | if _gaps.shape[0] > 0: 93 | return False 94 | return True 95 | 96 | 97 | def fix_npe_edges(gdf, inplace=False): 98 | """Fix all npe intersecting edges in geoseries. 99 | 100 | Arguments 101 | --------- 102 | 103 | gdf: GeoDataFrame with polygon geoseries for geometry 104 | 105 | 106 | Returns 107 | ------- 108 | gdf: GeoDataFrame with geometries respected planar edges. 109 | 110 | Examples 111 | -------- 112 | >>> c1 = [[0,0], [0, 10], [10, 10], [10, 0], [0, 0]] 113 | >>> p1 = Polygon(c1) 114 | >>> c2 = [[10, 2], [10, 8], [20, 8], [20, 2], [10, 2]] 115 | >>> p2 = Polygon(c2) 116 | >>> gdf = geopandas.GeoDataFrame(geometry=[p1, p2]) 117 | >>> geoplanar.non_planar_edges(gdf) 118 | defaultdict(set, {0: {1}}) 119 | 120 | >>> gdf1 = geoplanar.fix_npe_edges(gdf) 121 | >>> geoplanar.non_planar_edges(gdf1) 122 | defaultdict(set, {}) 123 | 124 | 125 | """ 126 | if not inplace: 127 | gdf = gdf.copy() 128 | 129 | edges = non_planar_edges(gdf) 130 | unique_edges = edges.adjacency[ 131 | ~pandas.DataFrame(numpy.sort(edges.adjacency.index.to_frame(), axis=1)) 132 | .duplicated() 133 | .values 134 | ].index 135 | for i, j in unique_edges: 136 | poly_a = gdf.geometry.loc[i] 137 | poly_b = gdf.geometry.loc[j] 138 | new_a, new_b = insert_intersections(poly_a, poly_b) 139 | poly_a = new_a 140 | gdf.loc[i, gdf.geometry.name] = new_a 141 | gdf.loc[j, gdf.geometry.name] = new_b 142 | return gdf 143 | 144 | 145 | def insert_intersections(poly_a, poly_b): 146 | """Correct two npe intersecting polygons by inserting intersection points 147 | on intersecting edges 148 | """ 149 | overlapping_msg = ( 150 | "Polygons are overlapping. Fix overlaps before fixing nonplanar edges." 151 | ) 152 | pint = poly_a.intersection(poly_b) 153 | if isinstance(pint, LineString | MultiLineString | GeometryCollection): 154 | if isinstance(pint, GeometryCollection): 155 | for geom in pint.geoms: 156 | if isinstance(geom, Polygon | MultiPolygon): 157 | raise ValueError(overlapping_msg) 158 | return poly_a.union(pint), poly_b.union(pint) 159 | elif isinstance(pint, Point | MultiPoint): 160 | new_polys = [] 161 | for poly in [poly_a, poly_b]: 162 | if isinstance(poly, MultiPolygon): 163 | new_parts = [] 164 | for part in poly.geoms: 165 | exterior = LineString(list(part.exterior.coords)) 166 | splits = split(exterior, pint).geoms 167 | if len(splits) > 1: 168 | left, right = splits 169 | exterior = linemerge([left, right]) 170 | part = Polygon(exterior) 171 | new_parts.append(part) 172 | new_poly = MultiPolygon(new_parts) 173 | new_polys.append(new_poly) 174 | else: 175 | exterior = LineString(list(poly.exterior.coords)) 176 | splits = split(exterior, pint).geoms 177 | if len(splits) > 1: 178 | left, right = splits 179 | exterior = linemerge([left, right]) 180 | new_poly = Polygon(exterior) 181 | new_polys.append(Polygon(new_poly)) 182 | else: 183 | new_polys.append(poly) 184 | return new_polys 185 | else: # intersection is Polygon 186 | raise ValueError(overlapping_msg) 187 | 188 | 189 | def self_intersecting_rings(gdf): 190 | sirs = [] 191 | for i, geom in enumerate(gdf.geometry): 192 | if not geom.is_valid: 193 | sirs.append(i) 194 | return sirs 195 | 196 | 197 | def fix_self_intersecting_ring(polygon): 198 | p0 = polygon.exterior 199 | mls = p0.intersection(p0) 200 | polys = polygonize(mls) 201 | return MultiPolygon(polys) 202 | 203 | 204 | def check_validity(gdf): 205 | gdfv = gdf.copy() 206 | sirs = self_intersecting_rings(gdf) 207 | if sirs: 208 | for i in sirs: 209 | fixed_i = fix_self_intersecting_ring(gdfv.geometry.iloc[i]) 210 | gdfv.geometry.iloc[i] = fixed_i 211 | 212 | _gaps = gaps(gdfv) 213 | _overlaps = overlaps(gdfv) 214 | violations = {} 215 | violations["selfintersectingrings"] = sirs 216 | violations["gaps"] = _gaps 217 | violations["overlaps"] = _overlaps 218 | violations["nonplanaredges"] = non_planar_edges(gdfv) 219 | violations["missinginteriors"] = missing_interiors(gdfv) 220 | return violations 221 | -------------------------------------------------------------------------------- /geoplanar/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopandas/geoplanar/371a51c12e470bcdb01f4ba2fb80b6ff95783d5e/geoplanar/tests/__init__.py -------------------------------------------------------------------------------- /geoplanar/tests/test_compact.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from shapely.geometry import box 3 | import geopandas as gpd 4 | from geoplanar import fill_gaps # Updated module name 5 | from packaging.version import Version 6 | import pytest 7 | 8 | @pytest.mark.skipif(Version(gpd.__version__) == Version("0.10.2"), reason="Missing pygeos") 9 | def test_fill_gaps_compact_visual_case(): 10 | """Test fill_gaps behavior with compact=True and compact=False using specific geometries.""" 11 | # Define four polygons with a visible gap 12 | p1 = box(0, 0, 10, 10) # Left rectangle 13 | p2 = box(10, 0, 40, 2) # Bottom thin rectangle 14 | p3 = box(10, 3, 40, 10) # Top thin rectangle 15 | p4 = box(40, 0, 100, 10) # Right rectangle 16 | 17 | # Create GeoDataFrame 18 | gdf = gpd.GeoDataFrame(geometry=[p1, p2, p3, p4]) 19 | 20 | # Fill gaps with compact 21 | filled_gdf_compact = fill_gaps(gdf, strategy='compact') 22 | compact_geom_count = len(filled_gdf_compact) 23 | 24 | # Fill gaps with largest (default) 25 | filled_gdf_default = fill_gaps(gdf, strategy='largest') 26 | default_geom_count = len(filled_gdf_default) 27 | 28 | # Assert the number of geometries is the same after filling gaps 29 | assert compact_geom_count == default_geom_count, ( 30 | "The number of geometries after filling gaps should remain consistent." 31 | ) 32 | 33 | # Assert the geometries differ when compact=True vs largest=True 34 | assert not filled_gdf_compact.equals(filled_gdf_default), ( 35 | "The resulting geometries should differ between compact=True and largest=True." 36 | ) 37 | 38 | # Verify that gaps are filled completely 39 | filled_gaps_compact = filled_gdf_compact.unary_union 40 | filled_gaps_default = filled_gdf_default.unary_union 41 | assert filled_gaps_compact.is_valid, "The geometries with compact=True must be valid." 42 | assert filled_gaps_default.is_valid, "The geometries with largest=True must be valid." 43 | 44 | -------------------------------------------------------------------------------- /geoplanar/tests/test_data/possibly_invalid_snap.gpkg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopandas/geoplanar/371a51c12e470bcdb01f4ba2fb80b6ff95783d5e/geoplanar/tests/test_data/possibly_invalid_snap.gpkg -------------------------------------------------------------------------------- /geoplanar/tests/test_gap.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os.path 3 | 4 | import geopandas 5 | import numpy 6 | import pytest 7 | from numpy.testing import assert_equal 8 | from packaging.version import Version 9 | from shapely.geometry import Polygon, box 10 | 11 | from geoplanar import fill_gaps, gaps, snap 12 | 13 | HERE = os.path.abspath(os.path.dirname(__file__)) 14 | PACKAGE_DIR = os.path.dirname(os.path.dirname(HERE)) 15 | _TEST_DATA_DIR = os.path.join(PACKAGE_DIR, "geoplanar", "tests", "test_data") 16 | 17 | 18 | class TestGap: 19 | def setup_method(self): 20 | self.p1 = box(0, 0, 10, 10) 21 | self.p2 = Polygon([(10, 10), (12, 8), (10, 6), (12, 4), (10, 2), (20, 5)]) 22 | self.gdf = geopandas.GeoDataFrame(geometry=[self.p1, self.p2]) 23 | self.gdf_crs = self.gdf.set_crs(3857) 24 | self.gdf_str = self.gdf_crs.set_index(numpy.array(["foo", "bar"])) 25 | 26 | def test_gaps(self): 27 | h = gaps(self.gdf_crs) 28 | assert_equal(h.area.values, numpy.array([4.0, 4.0])) 29 | assert self.gdf_crs.crs.equals(h.crs) 30 | 31 | def test_fill_gaps(self): 32 | gdf1 = fill_gaps(self.gdf) 33 | assert_equal(gdf1.area.values, numpy.array([108.0, 32.0])) 34 | 35 | gdf1 = fill_gaps(self.gdf_str) 36 | assert_equal(gdf1.area.values, numpy.array([108.0, 32.0])) 37 | 38 | def test_fill_gaps_smallest(self): 39 | gdf1 = fill_gaps(self.gdf, strategy='smallest') 40 | assert_equal(gdf1.area.values, numpy.array([100.0, 40.0])) 41 | 42 | def test_fill_gaps_none(self): 43 | gdf1 = fill_gaps(self.gdf, strategy=None) 44 | assert_equal(gdf1.area.values, numpy.array([108.0, 32.0])) 45 | 46 | def test_fill_gaps_gaps_df(self): 47 | gaps_df = gaps(self.gdf).loc[[0]] 48 | filled = fill_gaps(self.gdf, gaps_df) 49 | assert_equal(filled.area, numpy.array([104, 32])) 50 | 51 | filled = fill_gaps(self.gdf_str, gaps_df) 52 | assert_equal(filled.area, numpy.array([104, 32])) 53 | 54 | 55 | @pytest.mark.skipif( 56 | Version(geopandas.__version__) < Version("1.0.0dev"), 57 | reason="requires geopandas 1.0", 58 | ) 59 | class TestSnap: 60 | def setup_method(self): 61 | self.p1 = Polygon([[0, 0], [10, 0], [10, 10], [0, 10]]) 62 | self.p2 = Polygon([(11, 0), (21, 0), (21, 20), (11, 20)]) 63 | self.gdf = geopandas.GeoDataFrame(geometry=[self.p1, self.p2]) 64 | self.gdf_str = self.gdf.set_index(numpy.array(["foo", "bar"])) 65 | 66 | self.p3 = Polygon([[0, 0], [10, 0], [13, 13], [3, 10]]) 67 | self.p4 = Polygon([(10.7, 2), (23, 10), (15, 20)]) 68 | self.p5 = Polygon([(10.7, 1.5), (23, 9), (10.3, 0)]) 69 | 70 | def test_snap_below_threshold(self): 71 | gdf1 = snap(self.gdf, 0.5) 72 | assert_equal(gdf1.area.values, numpy.array([100.0, 200.0])) 73 | 74 | gdf1 = snap(self.gdf_str, 0.5) 75 | assert_equal(gdf1.area.values, numpy.array([100.0, 200.0])) 76 | 77 | def test_snap_above_threshold(self): 78 | gdf1 = snap(self.gdf, 1.1) 79 | assert_equal(gdf1.area.values, numpy.array([110.0, 200.0])) 80 | 81 | gdf1 = snap(self.gdf_str, 1.1) 82 | assert_equal(gdf1.area.values, numpy.array([110.0, 200.0])) 83 | 84 | def test_snap_reverse_order(self): 85 | gdf = geopandas.GeoDataFrame(geometry=[self.p2, self.p1]) 86 | gdf1 = snap(gdf, 1.1) 87 | assert_equal(gdf1.area.values, numpy.array([210.0, 100.0])) 88 | 89 | def test_snap_complex_shapes(self): 90 | gdf = geopandas.GeoDataFrame(geometry=[self.p3, self.p4]) 91 | gdf1 = snap(gdf, 0.5) 92 | assert_equal( 93 | numpy.round(gdf1.area.values, decimals=1), numpy.array([113.6, 93.5]) 94 | ) 95 | 96 | def test_snap_3shapes(self): 97 | gdf = geopandas.GeoDataFrame(geometry=[self.p3, self.p4, self.p5]) 98 | gdf1 = snap(gdf, 1) 99 | assert_equal( 100 | numpy.round(gdf1.area.values, decimals=1), numpy.array([114.1, 102.3, 7.7]) 101 | ) 102 | 103 | def test_validity(self): 104 | df = geopandas.read_file( 105 | os.path.join(_TEST_DATA_DIR, "possibly_invalid_snap.gpkg") 106 | ) 107 | snapped = snap(df, 0.5) 108 | assert snapped.is_valid.all() 109 | -------------------------------------------------------------------------------- /geoplanar/tests/test_hole.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import geopandas 4 | import numpy 5 | from shapely.geometry import box 6 | 7 | from geoplanar.hole import add_interiors, missing_interiors 8 | 9 | 10 | class TestHole: 11 | def setup_method(self): 12 | p1 = box(0, 0, 10, 10) 13 | p2 = box(1, 1, 3, 3) 14 | p3 = box(7, 7, 9, 9) 15 | 16 | self.gdf = geopandas.GeoDataFrame(geometry=[p1, p2, p3]) 17 | self.gdf_str = self.gdf.set_index(numpy.array(["foo", "bar", "baz"])) 18 | 19 | def test_missing_interiors(self): 20 | mi = missing_interiors(self.gdf) 21 | assert mi == [(0, 1), (0, 2)] 22 | 23 | def test_add_interiors(self): 24 | gdf1 = add_interiors(self.gdf, inplace=True) 25 | mi = missing_interiors(gdf1) 26 | assert mi == [] 27 | 28 | gdf1 = add_interiors(self.gdf_str, inplace=True) 29 | mi = missing_interiors(gdf1) 30 | assert mi == [] 31 | -------------------------------------------------------------------------------- /geoplanar/tests/test_overlap.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import geopandas 4 | import numpy 5 | from numpy.testing import assert_equal 6 | from shapely.geometry import box 7 | from packaging.version import Version 8 | import pytest 9 | 10 | from geoplanar.overlap import ( 11 | is_overlapping, 12 | merge_overlaps, 13 | merge_touching, 14 | trim_overlaps, 15 | ) 16 | 17 | 18 | class TestOverlap: 19 | def setup_method(self): 20 | self.p1 = box(0, 0, 10, 10) 21 | self.p2 = box(8, 4, 12, 6) 22 | self.p3 = box(10, 0, 20, 10) 23 | self.gdf = geopandas.GeoDataFrame(geometry=[self.p1, self.p2]) 24 | self.gdf2 = geopandas.GeoDataFrame(geometry=[self.p1, self.p3, self.p2]) 25 | self.gdf_str = self.gdf.set_index(numpy.array(["foo", "bar"])) 26 | 27 | def test_is_overlapping(self): 28 | assert is_overlapping(self.gdf) 29 | assert is_overlapping(self.gdf_str) 30 | 31 | def test_trim_overlaps(self): 32 | gdf1 = trim_overlaps(self.gdf) 33 | assert_equal(gdf1.area.values, numpy.array([96.0, 8.0])) 34 | 35 | gdf1 = trim_overlaps(self.gdf_str) 36 | assert_equal(gdf1.area.values, numpy.array([96.0, 8.0])) 37 | 38 | def test_trim_overlaps_smallest(self): 39 | gdf1 = trim_overlaps(self.gdf, strategy='smallest') 40 | assert_equal(gdf1.area.values, numpy.array([100.0, 4.0])) 41 | 42 | def test_trim_overlaps_random(self): 43 | gdf1 = trim_overlaps(self.gdf, strategy=None) 44 | assert_equal(gdf1.area.values, numpy.array([100.0, 4.0])) 45 | 46 | @pytest.mark.skipif(Version(geopandas.__version__) == Version("0.10.2"), reason="Missing pygeos") 47 | def test_trim_overlaps_multiple(self): 48 | gdf1 = trim_overlaps(self.gdf2, strategy='largest') 49 | assert_equal(gdf1.area.values, numpy.array([96, 96.0, 8.0])) 50 | 51 | gdf1 = trim_overlaps(self.gdf2, strategy=None) 52 | assert_equal(gdf1.area.values, numpy.array([100.0, 100.0, 0.0])) 53 | 54 | gdf1 = trim_overlaps(self.gdf2) 55 | assert_equal(gdf1.area.values, numpy.array([96.0, 96.0, 8.0])) 56 | 57 | gdf1 = trim_overlaps(self.gdf2, strategy='smallest') 58 | assert_equal(gdf1.area.values, numpy.array([100.0, 100.0, 0.0])) 59 | 60 | gdf = trim_overlaps(self.gdf2, strategy='compact') 61 | assert_equal(gdf1.area.values, numpy.array([100.0, 100.0, 0.0])) 62 | 63 | def test_merge_overlaps(self): 64 | gdf1 = merge_overlaps(self.gdf, 10, 0) 65 | assert_equal(gdf1.area.values, numpy.array([104])) 66 | 67 | gdf1 = merge_overlaps(self.gdf, 10, 1) 68 | assert_equal(gdf1.area.values, numpy.array([104])) 69 | 70 | gdf1 = merge_overlaps(self.gdf, 1, 0) 71 | assert_equal(gdf1.area.values, numpy.array([104])) 72 | 73 | gdf1 = merge_overlaps(self.gdf, 1, 1) 74 | assert_equal(gdf1.area.values, numpy.array([100, 8])) 75 | 76 | gdf1 = merge_overlaps(self.gdf_str, 10, 0) 77 | assert_equal(gdf1.area.values, numpy.array([104])) 78 | assert_equal(gdf1.index.to_list(), ['foo']) 79 | 80 | def test_merge_overlaps_multiple(self): 81 | gdf1 = merge_overlaps(self.gdf2, 10, 0) 82 | assert_equal(gdf1.area.values, numpy.array([200])) 83 | 84 | 85 | class TestTouching: 86 | def setup_method(self): 87 | self.p1 = box(0, 0, 1, 1) 88 | self.p2 = box(1, 0, 11, 10) 89 | self.p3 = box(15, 0, 25, 10) 90 | self.p4 = box(0, 15, 1, 16) 91 | self.p5 = box(0.5, 1, 1, 8) 92 | self.gdf = geopandas.GeoDataFrame( 93 | geometry=[self.p1, self.p2, self.p3, self.p4, self.p5] 94 | ) 95 | self.index = [0, 3] 96 | self.gdf_str = self.gdf.set_index( 97 | numpy.array(["foo", "bar", "baz", "qux", "quux"]) 98 | ) 99 | self.index_str = ["foo", "qux"] 100 | 101 | def test_merge_touching_largest(self): 102 | gdf1 = merge_touching(self.gdf, self.index, largest=True) 103 | assert_equal(gdf1.area.values, numpy.array([101, 100, 3.5])) 104 | 105 | gdf1 = merge_touching(self.gdf_str, self.index_str, largest=True) 106 | assert_equal(gdf1.area.values, numpy.array([101, 100, 3.5])) 107 | assert_equal(gdf1.index.to_list(), ['foo', 'baz', 'quux']) 108 | 109 | def test_merge_touching_smallest(self): 110 | gdf2 = merge_touching(self.gdf, self.index, largest=False) 111 | assert_equal(gdf2.area.values, numpy.array([4.5, 100, 100])) 112 | 113 | gdf2 = merge_touching(self.gdf_str, self.index_str, largest=False) 114 | assert_equal(gdf2.area.values, numpy.array([4.5, 100, 100])) 115 | assert_equal(gdf2.index.to_list(), ['foo', 'bar', 'baz']) 116 | 117 | def test_merge_touching_none(self): 118 | gdf3 = merge_touching(self.gdf, self.index, largest=None) 119 | assert_equal(gdf3.area.values, numpy.array([4.5, 100, 100])) 120 | 121 | gdf3 = merge_touching(self.gdf_str, self.index_str, largest=None) 122 | assert_equal(gdf3.area.values, numpy.array([4.5, 100, 100])) 123 | assert_equal(gdf3.index.to_list(), ['foo', 'bar', 'baz']) 124 | -------------------------------------------------------------------------------- /geoplanar/tests/test_planar.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import geopandas as gpd 3 | import numpy as np 4 | import pytest 5 | from libpysal.graph import Graph 6 | from numpy.testing import assert_equal 7 | from shapely.geometry import MultiPolygon, Polygon 8 | 9 | import geoplanar 10 | 11 | 12 | @pytest.fixture 13 | def test_polygons(): 14 | # Polygon A: square 15 | coords1 = [[0, 0], [0, 10], [10, 10], [10, 0], [0, 0]] 16 | # Polygon B: touches Polygon A at an edge but not properly snapped (non-planar) 17 | coords2 = [[10, 2], [10, 8], [20, 8], [20, 2], [10, 2]] 18 | poly1 = Polygon(coords1) 19 | poly2 = Polygon(coords2) 20 | gdf = gpd.GeoDataFrame(geometry=[poly1, poly2]) 21 | return gdf 22 | 23 | 24 | class TestPlanar: 25 | def setup_method(self): 26 | # Setup multipolygon with potential non-planar edge 27 | c1 = [[0, 0], [0, 10], [10, 10], [10, 0], [0, 0]] 28 | p1 = Polygon(c1) 29 | 30 | c2 = [[10, 2], [20, 8], [20, 2], [10, 2]] 31 | p2a = Polygon(c2) 32 | p2b = Polygon([[21, 2], [21, 4], [23, 3]]) 33 | p2 = MultiPolygon([p2a, p2b]) 34 | 35 | self.gdf = gpd.GeoDataFrame(geometry=[p1, p2]) 36 | self.gdf_str = self.gdf.set_index(np.array(["foo", "bar"])) 37 | 38 | def test_non_planar_edges(self): 39 | res = geoplanar.non_planar_edges(self.gdf) 40 | assert res.equals(Graph.from_dicts({0: [1], 1: [0]})) 41 | 42 | gdf1 = geoplanar.fix_npe_edges(self.gdf) 43 | assert_equal( 44 | gdf1.geometry[0].wkt, 45 | "POLYGON ((0 0, 0 10, 10 10, 10 2, 10 0, 0 0))" 46 | ) 47 | 48 | res_str = geoplanar.non_planar_edges(self.gdf_str) 49 | assert res_str.equals(Graph.from_dicts({"foo": ["bar"], "bar": ["foo"]})) 50 | 51 | gdf1_str = geoplanar.fix_npe_edges(self.gdf_str) 52 | assert_equal( 53 | gdf1_str.geometry.iloc[0].wkt, 54 | "POLYGON ((0 0, 0 10, 10 10, 10 2, 10 0, 0 0))" 55 | ) 56 | 57 | def test_check_validity(self, test_polygons): 58 | violations = geoplanar.check_validity(test_polygons) 59 | assert "nonplanaredges" in violations 60 | assert len(violations["nonplanaredges"].adjacency) > 0 61 | assert isinstance(violations["gaps"], gpd.GeoSeries) 62 | assert isinstance(violations["overlaps"], np.ndarray) 63 | assert violations["overlaps"].shape[1] == 0 or violations["overlaps"].shape[1] == 2 64 | 65 | 66 | -------------------------------------------------------------------------------- /geoplanar/valid.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from shapely import validation 4 | 5 | __all__ = ["isvalid"] 6 | 7 | 8 | def isvalid(obj): 9 | return validation.explain_validity(obj) 10 | -------------------------------------------------------------------------------- /notebooks/usmex/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## US-Mexico geoplanar notebooks 4 | 5 | For our US/MEX, the notebook workflow is organized in the following way: 6 | 7 | 8 | #### geoplanar notebooks 9 | 10 | - data_processing.ipynb *must be run first before running level 1 and 2 notebooks only. 11 | 12 | - usmex_0.ipynb mexico/us example at level 0. country to country 13 | - usmex_1.ipynb mexico/us example at level 1. states to states 14 | - usmex_2.ipynb mexico/us example at level 2. counties to municipios 15 | 16 | 17 | #### Contiguity and Changes notebooks 18 | Must run level 1 and 2 notebooks before running the following: 19 | 20 | #### Level 1 States/States 21 | - usmex_1_changes.ipynb 22 | - usmex_1_contiguity.ipynb 23 | 24 | #### Level 2 - Municipios/Counties 25 | - usmex_2_changes.ipynb 26 | - usmex_2_contiguity.ipynb 27 | 28 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "setuptools_scm[toml]>=6.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools_scm] 6 | 7 | [project] 8 | name = "geoplanar" 9 | dynamic = ["version"] 10 | authors = [ 11 | {name = "Serge Rey", email = "sjsrey@gmail.com"}, 12 | ] 13 | license = {text = "3-Clause BSD"} 14 | description = "Geographic planar enforcement of polygon geoseries" 15 | readme = "README.md" 16 | classifiers = [ 17 | "Intended Audience :: Science/Research", 18 | "License :: OSI Approved :: BSD License", 19 | "Operating System :: OS Independent", 20 | "Programming Language :: Python :: 3", 21 | "Topic :: Scientific/Engineering :: GIS", 22 | ] 23 | requires-python = ">=3.10" 24 | dependencies = [ 25 | "geopandas", 26 | "esda", 27 | "libpysal >=4.8.0", 28 | "packaging", 29 | ] 30 | 31 | [project.urls] 32 | Home = "https://geoplanar.readthedocs.io" 33 | Repository = "https://github.com/sjsrey/geoplanar" 34 | 35 | [tool.setuptools.packages.find] 36 | include = [ 37 | "geoplanar", 38 | "geoplanar.*", 39 | ] 40 | 41 | [tool.coverage.run] 42 | source = ['geoplanar'] 43 | omit = ["geoplanar/tests/*"] 44 | 45 | [tool.ruff] 46 | line-length = 88 47 | lint.select = ["E", "F", "W", "I", "UP", "N", "B", "A", "C4", "SIM", "ARG"] 48 | lint.ignore = ["B006", "F403", "SIM108"] 49 | exclude = ["docs"] 50 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-22.04 4 | tools: 5 | python: mambaforge-latest 6 | python: 7 | install: 8 | - method: pip 9 | path: . 10 | conda: 11 | environment: docs/environment.yml 12 | formats: [] 13 | sphinx: 14 | configuration: docs/conf.py --------------------------------------------------------------------------------