├── .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 | 
6 |
7 | [](https://github.com/sjsrey/geoplanar/actions?query=workflow%3A.github%2Fworkflows%2Funittests.yml)
8 | |[](https://geoplanar.readthedocs.io/en/latest/?badge=latest)
9 | [](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 | y a9 Shape_Leng F Shape_Area F ADM0_ES C 2 ADM0_PCODE C 2 ADM0_REF C 2 ADM0ALT1ES C 2 ADM0ALT2ES C 2 date D validOn D validTo D
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": "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",
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 | " geometry | \n",
535 | "
\n",
536 | " \n",
537 | " \n",
538 | " \n",
539 | " 0 | \n",
540 | " POLYGON ((0.00000 0.00000, 5.00000 5.00000, 10... | \n",
541 | "
\n",
542 | " \n",
543 | " 1 | \n",
544 | " POLYGON ((5.00000 5.00000, 15.00000 5.00000, 2... | \n",
545 | "
\n",
546 | " \n",
547 | "
\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": "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",
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 | " geometry | \n",
264 | "
\n",
265 | " \n",
266 | " \n",
267 | " \n",
268 | " 0 | \n",
269 | " POLYGON ((0.00000 0.00000, 0.00000 10.00000, 1... | \n",
270 | "
\n",
271 | " \n",
272 | " 1 | \n",
273 | " POLYGON ((10.00000 2.00000, 20.00000 8.00000, ... | \n",
274 | "
\n",
275 | " \n",
276 | "
\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
--------------------------------------------------------------------------------