diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c8b3a6a7d..ce26e95ae 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -19,7 +19,7 @@ body: id: documentation_examples attributes: label: Documentation - description: Before you proceed, review the [documentation](https://osmnx.readthedocs.io/) and [OSMnx examples](https://github.com/gboeing/osmnx-examples) gallery, which cover key concepts, installation, and package usage. + description: Before you proceed, review the [documentation](https://osmnx.readthedocs.io/) and [examples gallery](https://github.com/gboeing/osmnx-examples), which cover key concepts, installation, and package usage. options: - label: My problem is not addressed by the documentation or examples required: true diff --git a/.github/ISSUE_TEMPLATE/feature_proposal.yml b/.github/ISSUE_TEMPLATE/feature_proposal.yml index 467f496c0..cdbbedad9 100644 --- a/.github/ISSUE_TEMPLATE/feature_proposal.yml +++ b/.github/ISSUE_TEMPLATE/feature_proposal.yml @@ -19,7 +19,7 @@ body: id: documentation_examples attributes: label: Documentation - description: Before you proceed, review the [documentation](https://osmnx.readthedocs.io/) and [OSMnx examples](https://github.com/gboeing/osmnx-examples) gallery, which cover key concepts, installation, and package usage. + description: Before you proceed, review the [documentation](https://osmnx.readthedocs.io/) and [examples gallery](https://github.com/gboeing/osmnx-examples), which cover key concepts, installation, and package usage. options: - label: My proposal is not addressed by the documentation or examples required: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6fddca0d6..5ace4600a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,5 @@ version: 2 updates: - # Maintain dependencies for GitHub Actions - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f05f31659..3a6d1bfec 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,4 +1,4 @@ -**Read these instructions carefully** +# Read these instructions carefully Before you proceed, review the contributing guidelines in the CONTRIBUTING.md file, especially the sections on project coding standards and tests. Please edit the changelog to reflect your changes. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f19f7bbb1..9c238f45c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,8 @@ on: pull_request: branches: [main] schedule: - - cron: "0 6 * * 1" # Every Monday at 06:00 UTC + - cron: "0 5 * * 1" # every monday at 05:00 UTC + workflow_dispatch: jobs: build: @@ -15,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12"] os: [ubuntu-latest] defaults: @@ -34,7 +35,7 @@ jobs: cache-downloads: true cache-environment: true create-args: python=${{ matrix.python-version }} - environment-file: tests/environments/env-ci.yml + environment-file: environments/tests/env-ci.yml post-cleanup: none - name: Install OSMnx @@ -43,14 +44,16 @@ jobs: conda list conda info --all - - name: Lint code + - name: Run pre-commit checks run: SKIP=no-commit-to-branch pre-commit run --all-files - name: Test docs - run: make -C ./docs html + run: make -C ./docs html SPHINXOPTS="-E -W --keep-going" - - name: Test code - run: pytest --cov=./osmnx --cov-report=xml --cov-report=term-missing --verbose + - name: Test code and coverage + run: pytest --verbose --maxfail=1 --typeguard-packages=osmnx --cov=osmnx --cov-report=xml - name: Upload coverage report uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml new file mode 100644 index 000000000..be5e8c26f --- /dev/null +++ b/.github/workflows/test-build.yml @@ -0,0 +1,41 @@ +name: Build and check package/docs + +on: + schedule: + - cron: "0 6 * * 1" # every monday at 06:00 UTC + workflow_dispatch: + +jobs: + build: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + + defaults: + run: + shell: bash -elo pipefail {0} + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Create environment with Micromamba + uses: mamba-org/setup-micromamba@v1 + with: + cache-downloads: true + cache-environment: true + environment-file: environments/tests/env-test-build.yml + post-cleanup: none + + - name: Build package and twine check + run: | + hatch build --clean + twine check --strict ./dist/* + + - name: Build docs and check links + run: python -m sphinx -E -W --keep-going -b linkcheck ./docs/source ./docs/build/linkcheck diff --git a/.github/workflows/test-minimal.yml b/.github/workflows/test-minimal.yml index 25f8caeae..b3aec4574 100644 --- a/.github/workflows/test-minimal.yml +++ b/.github/workflows/test-minimal.yml @@ -2,7 +2,7 @@ name: Test minimal versions on: schedule: - - cron: "0 7 * * 1" # Every Monday at 07:00 UTC + - cron: "0 4 * * 1" # every monday at 04:00 UTC workflow_dispatch: jobs: @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest] + os: [macos-latest, ubuntu-latest, windows-latest] defaults: run: @@ -29,7 +29,7 @@ jobs: with: cache-downloads: true cache-environment: true - environment-file: tests/environments/env-test-minimal.yml + environment-file: environments/tests/env-test-minimal.yml post-cleanup: none - name: Install OSMnx @@ -38,16 +38,11 @@ jobs: conda list conda info --all - - name: Lint code - run: | - SKIP=no-commit-to-branch pre-commit run --all-files - hatch build --clean - twine check --strict ./dist/* + - name: Run pre-commit checks + run: SKIP=no-commit-to-branch pre-commit run --all-files - name: Test docs - run: | - make -C ./docs html - python -m sphinx -b linkcheck ./docs/source ./docs/build/linkcheck + run: make -C ./docs html SPHINXOPTS="-E -W --keep-going" - name: Test code - run: pytest --cov=./osmnx --cov-report=term-missing --verbose + run: pytest --verbose --maxfail=1 --typeguard-packages=osmnx diff --git a/.gitignore b/.gitignore index 3a1299c5e..b085b4d2a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ .temp .pytest_cache -tests/run_tests.bat *.vrt .DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b005d31ca..fc17aa18d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,20 +1,19 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.5.0" + rev: "v4.6.0" hooks: - id: check-added-large-files args: [--maxkb=50] - id: check-ast - - id: check-builtin-literals - id: check-case-conflict - - id: check-docstring-first + - id: check-executables-have-shebangs - id: check-json - id: check-merge-conflict args: [--assume-in-merge] + - id: check-shebang-scripts-are-executable - id: check-toml - id: check-xml - id: check-yaml - - id: debug-statements - id: detect-private-key - id: end-of-file-fixer - id: fix-byte-order-marker @@ -29,15 +28,27 @@ repos: - id: prettier types_or: [markdown, yaml] + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: "v0.39.0" + hooks: + - id: markdownlint + args: [--disable=MD013] + + - repo: https://github.com/abravalheri/validate-pyproject + rev: "v0.16" + hooks: + - id: validate-pyproject + - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.1.15" + rev: "v0.4.1" hooks: - id: ruff args: [--fix] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v1.8.0" + rev: "v1.9.0" hooks: - id: mypy - additional_dependencies: [types-requests] + additional_dependencies: + [matplotlib, pandas-stubs, pytest, types-requests] diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cbc22653..1721043b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog +## 2.0.0 (in development) + +Read the v2 [migration guide](https://github.com/gboeing/osmnx/issues/1123) + +- add type annotations to all public and private functions throughout package (#1107) +- remove all functionality previously deprecated in v1 (#1113 #1122 #1135 #1148) +- drop Python 3.8 support (#1106) +- bump minimum required numpy version to 1.21 for typing support (#1133) +- improve docstrings throughout package (#1116) +- improve logging and warnings throughout package (#1125) +- improve error messages throughout package (#1131) +- refactor features module for speed improvement and memory efficiency (#1157) +- refactor save_graph_xml function and \_osm_xml module for speed improvement and bug fixes (#1135) +- make save_graph_xml function accept only an unsimplified MultiDiGraph as its input data (#1135) +- replace save_graph_xml function's edge_tag_aggs tuple parameter with way_tag_aggs dict parameter (#1135) +- add OSM junction and railway tags to the default settings.useful_tags_node (#1144) +- add node_attrs_include argument to simplification.simplify_graph function to flexibly relax strictness (#1145) +- add edge_attr_aggs argument to simplify_graph function to specify aggregation behavior (#1155) +- add node_attr_aggs argument to the consolidate_intersections function to specify aggregation behavior (#1155) +- allow per-node tolerance values for intersection consolidation (#1160) +- make consolidate_intersections function retain unique attribute values when consolidating nodes (#1144) +- make which_result function parameters consistently able to accept a list throughout package (#1113) +- handle implicit maxspeed values in add_edge_speeds function (#1153) +- change add_node_elevations_google default batch_size to 512 to match Google's limit (#1115) +- allow analysis of MultiDiGraph directional edge bearings and orientation (#1139) +- fix graph projection creating useless lat and lon node attributes (#1144) +- fix bug in \_downloader.\_save_to_cache function usage (#1107) +- fix bug in handling requests ConnectionError when querying Overpass status endpoint (#1113) +- fix minor bugs throughout to address inconsistencies revealed by type enforcement (#1107 #1114) +- make optional function parameters keyword-only throughout package (#1134) +- make dist function parameters required rather than optional throughout package (#1134) +- make utils_geo.bbox_from_point function return a tuple of floats for consistency with rest of package (#1113) +- rename truncate.truncate_graph_dist max_dist argument to dist for consistency with rest of package (#1134) +- remove retain_all argument from all truncate module functions (#1148) +- remove settings module's deprecated and now replaced settings (#1129 #1136) +- rename osm_xml module to \_osm_xml to make it private, as all its functions are private (#1113) +- rename private \_downloader module to \_http (#1114) +- remove unnecessary private \_api module (#1114) + ## 1.9.3 (2024-05-01) - update the official package reference paper (#1169) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dbc4c17ae..1549e23e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,29 +1,28 @@ # Contributing guidelines -Thanks for using OSMnx and for considering contributing to it by opening an issue or pull request. Every piece of software is a work in progress. This project is the result of many hours of work contributed freely its contributors and the many people that build the projects it depends on. Thank you for contributing! +Thanks for using OSMnx and for considering contributing to it by opening an issue or pull request. Every piece of software is a work in progress. This project is the result of many hours of work contributed freely its contributors and the many people that build the projects on which it depends. Thank you for contributing! -#### If you have a "how-to" or usage question: +## If you have a "how-to" or usage question -- please ask your question on [StackOverflow](https://stackoverflow.com/search?q=osmnx), as we reserve the issue tracker for bug reports and new feature development. Any such questions asked in the issue tracker will be automatically closed. +Please ask your question on [StackOverflow](https://stackoverflow.com/search?q=osmnx), as we reserve the issue tracker for bug reports and new feature development. Any such questions asked in the issue tracker will be automatically closed. -#### If you're having an installation problem: +## If you have an installation problem -- make sure you've followed the installation instructions in the [documentation](https://osmnx.readthedocs.io/) -- if you installed OSMnx via conda-forge, please open an issue at its [feedstock](https://github.com/conda-forge/osmnx-feedstock/issues) +Ensure you have followed the installation instructions in the [documentation](https://osmnx.readthedocs.io/). If you installed OSMnx via conda-forge, please open an issue at its [feedstock](https://github.com/conda-forge/osmnx-feedstock/issues). -#### If you've found a bug: +## If you found a bug -- read the error message, then review the [documentation](https://osmnx.readthedocs.io/) and [OSMnx examples](https://github.com/gboeing/osmnx-examples) gallery, which cover key concepts, installation, and package usage -- search through the [open issues](https://github.com/gboeing/osmnx/issues?q=is%3Aopen+is%3Aissue) and [closed issues](https://github.com/gboeing/osmnx/issues?q=is%3Aissue+is%3Aclosed) to see if the problem has already been reported -- if the problem is with a dependency of OSMnx, open an issue in the dependency's repo -- if the problem is with OSMnx itself and you can fix it simply, please open a pull request -- if the problem persists, please open an issue in the [issue tracker](https://github.com/gboeing/osmnx/issues), and _provide all the information requested in the template_, including a minimal working example so others can independently and completely reproduce the problem +- Read the error message, then review the [documentation](https://osmnx.readthedocs.io/) and OSMnx [Examples Gallery](https://github.com/gboeing/osmnx-examples), which cover key concepts, installation, and package usage. +- Search through the open and closed [issues](https://github.com/gboeing/osmnx/issues) to see if the problem has already been reported. +- If the problem is with a dependency of OSMnx, open an issue in that dependency's repo. +- If the problem is with OSMnx itself and you can fix it simply, please open a pull request. +- If the problem persists, please open an issue in the [issue tracker](https://github.com/gboeing/osmnx/issues), and _provide all the information requested in the template_, including a minimal working example so others can independently and completely reproduce the bug. -#### If you have a feature proposal or want to contribute: +## If you have a feature proposal -- post your proposal on the [issue tracker](https://github.com/gboeing/osmnx/issues), and _provide all the information requested in the template_, so we can review it together (some proposals may not be a good fit for the project) -- fork the repo, make your change, update the [changelog](./CHANGELOG.md), run the [tests](./tests), and submit a PR -- adhere to the project's code and docstring standards by running its [pre-commit](.pre-commit-config.yaml) hooks -- respond to code review +- Post your proposal on the [issue tracker](https://github.com/gboeing/osmnx/issues), and _provide all the information requested in the template_, so we can review it together (some proposals may not be a good fit for the project). +- Fork the repo, make your change, update the [changelog](./CHANGELOG.md), run the [tests](./tests), and submit a pull request. +- Adhere to the project's code and docstring standards by running its [pre-commit](.pre-commit-config.yaml) hooks. +- Respond to code review. -This project requires minimum Python and NumPy versions in accordance with [NEP 29](https://numpy.org/neps/nep-0029-deprecation_policy.html). +The OSMnx project follows three principles when adding new functionality: 1) it is useful for a broad set of users, 2) it generalizes well, and 3) it is not trivially easy for users to implement themselves. diff --git a/docs/requirements.txt b/docs/requirements.txt index c84399e04..532390375 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,3 @@ furo sphinx == 7.* # same value as needs_sphinx in /docs/source/conf.py +sphinx-autodoc-typehints diff --git a/docs/source/conf.py b/docs/source/conf.py index 0008f262d..14a5233e5 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,3 +1,4 @@ +# ruff: noqa: INP001 """ Configuration file for the Sphinx documentation builder. @@ -5,19 +6,18 @@ https://www.sphinx-doc.org/en/master/usage/configuration.html """ -# -- Project information ----------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information import sys from pathlib import Path -# go up two levels from current working dir (/docs/source) to package root -pkg_root_path = str(Path.cwd().parent.parent) -sys.path.insert(0, pkg_root_path) - +# project info author = "Geoff Boeing" copyright = "2016-2024, Geoff Boeing" # noqa: A001 project = "OSMnx" +# go up two levels from current working dir (/docs/source) to package root +pkg_root_path = str(Path.cwd().parent.parent) +sys.path.insert(0, pkg_root_path) + # dynamically load version from /osmnx/_version.py with Path.open(Path("../../osmnx/_version.py")) as f: version = release = f.read().split(" = ")[1].replace('"', "") @@ -41,17 +41,22 @@ # linkcheck for stackoverflow gets HTTP 403 in CI environment linkcheck_ignore = [r"https://stackoverflow\.com/.*"] -# -- General configuration --------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration +# type annotations configuration +autodoc_typehints = "description" +napoleon_use_param = True +napoleon_use_rtype = False +typehints_document_rtype = True +typehints_use_rtype = False +typehints_fully_qualified = False + +# general configuration and options for HTML output +# see https://www.sphinx-doc.org/en/master/usage/configuration.html exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] -extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon"] +extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx_autodoc_typehints"] +html_static_path: list[str] = [] +html_theme = "furo" language = "en" needs_sphinx = "7" # same value as pinned in /docs/requirements.txt root_doc = "index" source_suffix = ".rst" -templates_path: list = [] - -# -- Options for HTML output ------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -html_static_path: list = [] -html_theme = "furo" +templates_path: list[str] = [] diff --git a/docs/source/internals-reference.rst b/docs/source/internals-reference.rst index e8c9181d8..bbea80c90 100644 --- a/docs/source/internals-reference.rst +++ b/docs/source/internals-reference.rst @@ -3,14 +3,6 @@ Internals Reference This is the complete OSMnx internals reference for developers, including private internal modules and functions. If you are instead looking for a user guide to OSMnx's public API, see the :doc:`user-reference`. -osmnx._api module ------------------ - -.. automodule:: osmnx._api - :members: - :private-members: - :noindex: - osmnx.bearing module -------------------- @@ -20,7 +12,7 @@ osmnx.bearing module :noindex: osmnx.convert module --------------------- +--------------------- .. automodule:: osmnx.convert :members: @@ -35,14 +27,6 @@ osmnx.distance module :private-members: :noindex: -osmnx._downloader module ------------------------- - -.. automodule:: osmnx._downloader - :members: - :private-members: - :noindex: - osmnx.elevation module ---------------------- @@ -67,14 +51,6 @@ osmnx.features module :private-members: :noindex: -osmnx.folium module -------------------- - -.. automodule:: osmnx.folium - :members: - :private-members: - :noindex: - osmnx.geocoder module --------------------- @@ -83,18 +59,18 @@ osmnx.geocoder module :private-members: :noindex: -osmnx.geometries module ------------------------ +osmnx.graph module +------------------ -.. automodule:: osmnx.geometries +.. automodule:: osmnx.graph :members: :private-members: :noindex: -osmnx.graph module +osmnx._http module ------------------ -.. automodule:: osmnx.graph +.. automodule:: osmnx._http :members: :private-members: :noindex: @@ -115,10 +91,10 @@ osmnx._nominatim module :private-members: :noindex: -osmnx.osm_xml module --------------------- +osmnx._osm_xml module +--------------------- -.. automodule:: osmnx.osm_xml +.. automodule:: osmnx._osm_xml :members: :private-members: :noindex: @@ -171,14 +147,6 @@ osmnx.simplification module :private-members: :noindex: -osmnx.speed module ------------------- - -.. automodule:: osmnx.speed - :members: - :private-members: - :noindex: - osmnx.stats module ------------------ @@ -211,14 +179,6 @@ osmnx.utils_geo module :private-members: :noindex: -osmnx.utils_graph module ------------------------- - -.. automodule:: osmnx.utils_graph - :members: - :private-members: - :noindex: - osmnx._version module --------------------- diff --git a/docs/source/user-reference.rst b/docs/source/user-reference.rst index 88aade266..77ead8ff5 100644 --- a/docs/source/user-reference.rst +++ b/docs/source/user-reference.rst @@ -1,122 +1,110 @@ -User Reference -============== - -This is the User Reference for the OSMnx package. If you are looking for an introduction to OSMnx, read the :doc:`getting-started` guide. This guide describes the usage of OSMnx's public API. - -OSMnx 2.0 is in beta: read the `migration guide`_. - -.. _migration guide: https://github.com/gboeing/osmnx/issues/1123 - -osmnx.bearing module --------------------- - -.. automodule:: osmnx.bearing - :members: - -osmnx.convert module --------------------- - -.. automodule:: osmnx.convert - :members: - -osmnx.distance module ---------------------- - -.. automodule:: osmnx.distance - :members: - -osmnx.elevation module ----------------------- - -.. automodule:: osmnx.elevation - :members: - -osmnx.features module ---------------------- - -.. automodule:: osmnx.features - :members: - -osmnx.geocoder module ---------------------- - -.. automodule:: osmnx.geocoder - :members: - -osmnx.graph module ------------------- - -.. automodule:: osmnx.graph - :members: - -osmnx.io module ---------------- - -.. automodule:: osmnx.io - :members: - -osmnx.plot module ------------------ - -.. automodule:: osmnx.plot - :members: - -osmnx.projection module ------------------------ - -.. automodule:: osmnx.projection - :members: - -osmnx.routing module ------------------------ - -.. automodule:: osmnx.routing - :members: - -osmnx.settings module ---------------------- - -.. automodule:: osmnx.settings - :members: - -osmnx.simplification module ---------------------------- - -.. automodule:: osmnx.simplification - :members: - -osmnx.speed module ------------------- - -.. automodule:: osmnx.speed - :members: - -osmnx.stats module ------------------- - -.. automodule:: osmnx.stats - :members: - -osmnx.truncate module ---------------------- - -.. automodule:: osmnx.truncate - :members: - -osmnx.utils module ------------------- - -.. automodule:: osmnx.utils - :members: - -osmnx.utils_geo module ----------------------- - -.. automodule:: osmnx.utils_geo - :members: - -osmnx.utils_graph module ------------------------- - -.. automodule:: osmnx.utils_graph - :members: +User Reference +============== + +This is the User Reference for the OSMnx package. If you are looking for an introduction to OSMnx, read the :doc:`getting-started` guide. This guide describes the usage of OSMnx's public API. + +OSMnx 2.0 is in beta: read the `migration guide`_. + +.. _migration guide: https://github.com/gboeing/osmnx/issues/1123 + +osmnx.bearing module +-------------------- + +.. automodule:: osmnx.bearing + :members: + +osmnx.convert module +-------------------- + +.. automodule:: osmnx.convert + :members: + +osmnx.distance module +--------------------- + +.. automodule:: osmnx.distance + :members: + +osmnx.elevation module +---------------------- + +.. automodule:: osmnx.elevation + :members: + +osmnx.features module +--------------------- + +.. automodule:: osmnx.features + :members: + +osmnx.geocoder module +--------------------- + +.. automodule:: osmnx.geocoder + :members: + +osmnx.graph module +------------------ + +.. automodule:: osmnx.graph + :members: + +osmnx.io module +--------------- + +.. automodule:: osmnx.io + :members: + +osmnx.plot module +----------------- + +.. automodule:: osmnx.plot + :members: + +osmnx.projection module +----------------------- + +.. automodule:: osmnx.projection + :members: + +osmnx.routing module +----------------------- + +.. automodule:: osmnx.routing + :members: + +osmnx.settings module +--------------------- + +.. automodule:: osmnx.settings + :members: + +osmnx.simplification module +--------------------------- + +.. automodule:: osmnx.simplification + :members: + +osmnx.stats module +------------------ + +.. automodule:: osmnx.stats + :members: + +osmnx.truncate module +--------------------- + +.. automodule:: osmnx.truncate + :members: + +osmnx.utils module +------------------ + +.. automodule:: osmnx.utils + :members: + +osmnx.utils_geo module +---------------------- + +.. automodule:: osmnx.utils_geo + :members: diff --git a/environments/docker/docker-build-single_platform.sh b/environments/docker/docker-build-single_platform.sh old mode 100644 new mode 100755 diff --git a/environments/docker/docker-build.sh b/environments/docker/docker-build.sh old mode 100644 new mode 100755 diff --git a/environments/docker/environment.yml b/environments/docker/environment.yml index 5c3ebb715..a3e9215a7 100644 --- a/environments/docker/environment.yml +++ b/environments/docker/environment.yml @@ -10,7 +10,7 @@ dependencies: - alembic=1.12.0=pyhd8ed1ab_0 - amply=0.1.6=pyhd8ed1ab_0 - anyio=4.0.0=pyhd8ed1ab_0 - - archspec=0.2.3=pyhd8ed1ab_0 + - archspec=0.2.2=pyhd8ed1ab_0 - argon2-cffi=23.1.0=pyhd8ed1ab_0 - argon2-cffi-bindings=21.2.0=py311h459d7ec_4 - arpack=3.8.0=nompi_h0baa96a_101 @@ -19,7 +19,7 @@ dependencies: - async-lru=2.0.4=pyhd8ed1ab_0 - async_generator=1.10=py_0 - attrs=23.1.0=pyh71513ae_1 - - autopep8=2.1.0=pyhd8ed1ab_0 + - autopep8=2.0.4=pyhd8ed1ab_0 - babel=2.13.0=pyhd8ed1ab_0 - backcall=0.2.0=pyh9f0ad1d_0 - backports=1.0=pyhd8ed1ab_3 @@ -28,22 +28,22 @@ dependencies: - bleach=6.1.0=pyhd8ed1ab_0 - blinker=1.6.3=pyhd8ed1ab_0 - blosc=1.21.5=h0f2a231_0 - - bokeh=3.4.0=pyhd8ed1ab_0 + - bokeh=3.3.4=pyhd8ed1ab_0 - boltons=23.0.0=pyhd8ed1ab_0 - boolean.py=4.0=pyhd8ed1ab_0 - - bottleneck=1.3.8=py311h1f0f07a_0 + - bottleneck=1.3.7=py311h1f0f07a_1 - branca=0.7.1=pyhd8ed1ab_0 - brotli=1.1.0=hd590300_1 - brotli-bin=1.1.0=hd590300_1 - brotli-python=1.1.0=py311hb755f60_1 - bzip2=1.0.8=h7f98852_4 - - c-ares=1.28.1=hd590300_0 - - ca-certificates=2024.2.2=hbcca054_0 + - c-ares=1.26.0=hd590300_0 + - ca-certificates=2023.11.17=hbcca054_0 - cached-property=1.5.2=hd8ed1ab_1 - cached_property=1.5.2=pyha770c72_1 - cairo=1.18.0=h3faef2a_0 - cartopy=0.22.0=py311h320fe9a_1 - - certifi=2024.2.2=pyhd8ed1ab_0 + - certifi=2023.11.17=pyhd8ed1ab_0 - certipy=0.1.3=py_0 - cffi=1.16.0=py311hb3a22ac_0 - cfgv=3.3.1=pyhd8ed1ab_0 @@ -63,22 +63,22 @@ dependencies: - coincbc=2.10.10=0_metapackage - colorama=0.4.6=pyhd8ed1ab_0 - comm=0.1.4=pyhd8ed1ab_0 - - conda=24.3.0=py311h38be061_0 - - conda-build=24.3.0=py311h38be061_1 - - conda-forge-pinning=2024.04.03.08.04.29=hd8ed1ab_0 - - conda-index=0.4.0=pyhd8ed1ab_0 + - conda=23.11.0=py311h38be061_1 + - conda-build=3.28.4=py311h38be061_0 + - conda-forge-pinning=2024.02.01.14.05.18=hd8ed1ab_0 + - conda-index=0.3.0=pyhd8ed1ab_1 - conda-libmamba-solver=24.1.0=pyhd8ed1ab_0 - conda-package-handling=2.2.0=pyh38be061_0 - conda-package-streaming=0.9.0=pyhd8ed1ab_0 - - conda-smithy=3.34.1=pyhd8ed1ab_0 + - conda-smithy=3.30.4=pyhd8ed1ab_0 - configurable-http-proxy=4.5.6=h92b4e83_1 - - contextily=1.6.0=pyhd8ed1ab_0 + - contextily=1.5.0=pyhd8ed1ab_0 - contourpy=1.2.0=py311h9547e67_0 - - coverage=7.4.4=py311h459d7ec_0 + - coverage=7.4.1=py311h459d7ec_0 - cryptography=41.0.4=py311h63ff55d_0 - - curl=8.7.1=hca28451_0 + - curl=8.5.0=hca28451_0 - cycler=0.12.1=pyhd8ed1ab_0 - - cython=3.0.10=py311hb755f60_0 + - cython=3.0.8=py311hb755f60_0 - dbus=1.13.6=h5008d03_3 - debugpy=1.8.0=py311hb755f60_1 - decorator=5.1.1=pyhd8ed1ab_0 @@ -95,10 +95,10 @@ dependencies: - exceptiongroup=1.1.3=pyhd8ed1ab_0 - executing=1.2.0=pyhd8ed1ab_0 - expat=2.5.0=hcb278e6_1 - - filelock=3.13.3=pyhd8ed1ab_0 + - filelock=3.13.1=pyhd8ed1ab_0 - fiona=1.9.5=py311hbac4ec9_0 - - fmt=10.2.1=h00ab1b0_0 - - folium=0.16.0=pyhd8ed1ab_0 + - fmt=10.1.1=h00ab1b0_0 + - folium=0.15.1=pyhd8ed1ab_0 - font-ttf-dejavu-sans-mono=2.37=hab24e00_0 - font-ttf-inconsolata=3.000=h77eed37_0 - font-ttf-source-code-pro=2.038=h77eed37_0 @@ -106,16 +106,16 @@ dependencies: - fontconfig=2.14.2=h14ed4e7_0 - fonts-conda-ecosystem=1=0 - fonts-conda-forge=1=0 - - fonttools=4.50.0=py311h459d7ec_0 + - fonttools=4.47.2=py311h459d7ec_0 - fqdn=1.5.1=pyhd8ed1ab_0 - freetype=2.12.1=h267a509_2 - freexl=2.0.0=h743c826_0 - furo=2024.1.29=pyhd8ed1ab_0 - gdal=3.7.2=py311h815a124_7 - - geographiclib=2.0=pyhd8ed1ab_0 + - geographiclib=1.52=pyhd8ed1ab_0 - geopandas=0.14.3=pyhd8ed1ab_0 - geopandas-base=0.14.3=pyha770c72_0 - - geopy=2.4.1=pyhd8ed1ab_1 + - geopy=2.4.1=pyhd8ed1ab_0 - geos=3.12.0=h59595ed_0 - geotiff=1.7.1=hf074850_14 - gettext=0.21.1=h27087fc_0 @@ -123,24 +123,24 @@ dependencies: - giflib=5.2.1=h0b41bf4_3 - git=2.42.0=pl5321h86e50cf_0 - gitdb=4.0.11=pyhd8ed1ab_0 - - gitpython=3.1.43=pyhd8ed1ab_0 + - gitpython=3.1.41=pyhd8ed1ab_0 - glpk=5.0=h445213a_0 - - gmp=6.3.0=h59595ed_1 + - gmp=6.3.0=h59595ed_0 - gmpy2=2.1.2=py311h6a5fa03_1 - greenlet=3.0.0=py311hb755f60_1 - h11=0.14.0=pyhd8ed1ab_0 - h2=4.1.0=pyhd8ed1ab_0 - - hatch=1.9.4=pyhd8ed1ab_0 + - hatch=1.9.2=pyhd8ed1ab_0 - hatchling=1.21.1=pyhd8ed1ab_0 - hdf4=4.2.15=h2a13503_7 - hdf5=1.14.3=nompi_h4f84152_100 - hpack=4.0.0=pyh9f0ad1d_0 - - httpcore=1.0.5=pyhd8ed1ab_0 - - httpx=0.27.0=pyhd8ed1ab_0 + - httpcore=1.0.2=pyhd8ed1ab_0 + - httpx=0.26.0=pyhd8ed1ab_0 - hyperframe=6.0.1=pyhd8ed1ab_0 - hyperlink=21.0.0=pyhd3deb0d_0 - icu=73.2=h59595ed_0 - - identify=2.5.35=pyhd8ed1ab_0 + - identify=2.5.33=pyhd8ed1ab_0 - idna=3.4=pyhd8ed1ab_0 - imagesize=1.4.1=pyhd8ed1ab_0 - importlib-metadata=6.8.0=pyha770c72_0 @@ -151,12 +151,10 @@ dependencies: - ipykernel=6.25.2=pyh2140261_0 - ipython=8.16.1=pyh0d859eb_0 - ipython_genutils=0.2.0=py_1 - - ipywidgets=8.1.2=pyhd8ed1ab_0 + - ipywidgets=8.1.1=pyhd8ed1ab_0 - isodate=0.6.1=pyhd8ed1ab_0 - isoduration=20.11.0=pyhd8ed1ab_0 - - jaraco.classes=3.4.0=pyhd8ed1ab_0 - - jaraco.context=4.3.0=pyhd8ed1ab_0 - - jaraco.functools=4.0.0=pyhd8ed1ab_0 + - jaraco.classes=3.3.0=pyhd8ed1ab_0 - jedi=0.19.1=pyhd8ed1ab_0 - jeepney=0.8.0=pyhd8ed1ab_0 - jinja2=3.1.2=pyhd8ed1ab_1 @@ -181,9 +179,9 @@ dependencies: - jupyterlab=4.0.7=pyhd8ed1ab_0 - jupyterlab_pygments=0.2.2=pyhd8ed1ab_0 - jupyterlab_server=2.25.0=pyhd8ed1ab_0 - - jupyterlab_widgets=3.0.10=pyhd8ed1ab_0 + - jupyterlab_widgets=3.0.9=pyhd8ed1ab_0 - kealib=1.5.3=h2f55d51_0 - - keyring=25.1.0=pyha804496_0 + - keyring=24.3.0=py311h38be061_0 - keyutils=1.6.1=h166bdaf_0 - kiwisolver=1.4.5=py311h9547e67_1 - krb5=1.21.2=h659d440_0 @@ -191,16 +189,16 @@ dependencies: - ld_impl_linux-64=2.40=h41732ed_0 - lerc=4.0.0=h27087fc_0 - libabseil=20230802.1=cxx17_h59595ed_0 - - libaec=1.1.3=h59595ed_0 + - libaec=1.1.2=h59595ed_1 - libarchive=3.7.2=h2aa1ff5_1 - libblas=3.9.0=21_linux64_openblas - - libboost-headers=1.84.0=ha770c72_2 + - libboost-headers=1.84.0=ha770c72_0 - libbrotlicommon=1.1.0=hd590300_1 - libbrotlidec=1.1.0=hd590300_1 - libbrotlienc=1.1.0=hd590300_1 - libcblas=3.9.0=21_linux64_openblas - libcrc32c=1.1.2=h9c3ff4c_0 - - libcurl=8.7.1=hca28451_0 + - libcurl=8.5.0=hca28451_0 - libdeflate=1.19=hd590300_0 - libedit=3.1.20191231=he28a2e2_2 - libev=4.33=h516909a_1 @@ -208,30 +206,30 @@ dependencies: - libffi=3.4.2=h7f98852_5 - libgcc-ng=13.2.0=h807b86a_2 - libgdal=3.7.2=h6f3d308_7 - - libgfortran-ng=13.2.0=h69a702a_5 - - libgfortran5=13.2.0=ha4646dd_5 + - libgfortran-ng=13.2.0=h69a702a_4 + - libgfortran5=13.2.0=ha4646dd_4 - libglib=2.78.1=hebfc3b9_0 - libgomp=13.2.0=h807b86a_2 - libgoogle-cloud=2.12.0=hef10d8f_5 - - libgrpc=1.60.1=h74775cd_0 + - libgrpc=1.60.0=h74775cd_1 - libiconv=1.17=h166bdaf_0 - libjpeg-turbo=3.0.0=hd590300_1 - libkml=1.3.0=h01aab08_1018 - liblapack=3.9.0=21_linux64_openblas - liblapacke=3.9.0=21_linux64_openblas - - liblief=0.14.1=hac33072_1 + - liblief=0.12.3=h27087fc_0 - libllvm14=14.0.6=hcd5def8_4 - - libmamba=1.5.8=had39da4_0 - - libmambapy=1.5.8=py311hf2555c7_0 + - libmamba=1.5.6=had39da4_0 + - libmambapy=1.5.6=py311hf2555c7_0 - libnetcdf=4.9.2=nompi_h9612171_113 - libnghttp2=1.58.0=h47da74e_1 - libnsl=2.0.1=hd590300_0 - libopenblas=0.3.26=pthreads_h413a1c8_0 - - libpng=1.6.43=h2797004_0 - - libpq=16.2=h33b98f1_1 - - libprotobuf=4.25.1=hf27288f_2 - - libpysal=4.10=pyhd8ed1ab_0 - - libre2-11=2023.09.01=h7a70373_1 + - libpng=1.6.42=h2797004_0 + - libpq=16.1=h33b98f1_7 + - libprotobuf=4.25.1=hf27288f_0 + - libpysal=4.9.2=pyhd8ed1ab_1 + - libre2-11=2023.06.02=h7a70373_0 - librttopo=1.1.0=hb58d41b_14 - libsodium=1.0.18=h36c2ea0_1 - libsolv=0.7.25=hfc55251_0 @@ -246,25 +244,25 @@ dependencies: - libwebp-base=1.3.2=hd590300_0 - libxcb=1.15=h0b41bf4_0 - libxcrypt=4.4.36=hd590300_1 - - libxml2=2.12.6=h232c23b_1 + - libxml2=2.12.4=h232c23b_1 - libzip=1.10.1=h2629f0a_3 - libzlib=1.2.13=hd590300_5 - license-expression=30.1.1=pyhd8ed1ab_0 - - llvmlite=0.42.0=py311ha6695c7_1 + - llvmlite=0.41.1=py311ha6695c7_0 - lz4-c=1.9.4=hcb278e6_0 - lzo=2.10=h516909a_1000 - mako=1.2.4=pyhd8ed1ab_0 - - mamba=1.5.8=py311h3072747_0 + - mamba=1.5.6=py311h3072747_0 - mapclassify=2.6.1=pyhd8ed1ab_0 - markdown-it-py=3.0.0=pyhd8ed1ab_0 - markupsafe=2.1.3=py311h459d7ec_1 - - matplotlib-base=3.8.3=py311h54ef318_0 + - matplotlib-base=3.8.2=py311h54ef318_0 - matplotlib-inline=0.1.6=pyhd8ed1ab_0 - mdurl=0.1.2=pyhd8ed1ab_0 - menuinst=2.0.2=py311h38be061_0 - mercantile=1.2.1=pyhd8ed1ab_0 - mgwr=2.2.1=pyhd8ed1ab_0 - - minizip=4.0.5=h0ab5242_0 + - minizip=4.0.4=h0ab5242_0 - mistune=3.0.1=pyhd8ed1ab_0 - momepy=0.7.0=pyhd8ed1ab_0 - more-itertools=10.2.0=pyhd8ed1ab_0 @@ -281,11 +279,11 @@ dependencies: - nbconvert-pandoc=7.9.2=pyhd8ed1ab_0 - nbdime=4.0.1=pyhd8ed1ab_0 - nbformat=5.9.2=pyhd8ed1ab_0 - - nbqa=1.8.5=pyhd8ed1ab_0 + - nbqa=1.7.1=pyhd8ed1ab_0 - ncurses=6.4=hcb278e6_0 - nest-asyncio=1.5.8=pyhd8ed1ab_0 - networkx=3.2.1=pyhd8ed1ab_0 - - nh3=0.2.17=py311h46250e7_0 + - nh3=0.2.15=py311h46250e7_0 - nodeenv=1.8.0=pyhd8ed1ab_0 - nodejs=20.8.1=h1990674_0 - nomkl=1.0=h5ca1d4c_0 @@ -293,17 +291,17 @@ dependencies: - notebook-shim=0.2.3=pyhd8ed1ab_0 - nspr=4.35=h27087fc_0 - nss=3.94=h1d7d5a4_0 - - numba=0.59.1=py311h96b013e_0 - - numexpr=2.9.0=py311h039bad6_100 - - numpy=1.26.4=py311h64a7726_0 + - numba=0.58.1=py311h96b013e_0 + - numexpr=2.8.8=py311h039bad6_100 + - numpy=1.26.3=py311h64a7726_0 - oauthlib=3.2.2=pyhd8ed1ab_0 - - openjpeg=2.5.2=h488ebb8_0 - - openssl=3.2.1=hd590300_1 - - osmnx=1.9.2=pyhd8ed1ab_0 + - openjpeg=2.5.0=h488ebb8_3 + - openssl=3.2.1=hd590300_0 + - osmnx=1.9.1=pyhd8ed1ab_0 - overrides=7.4.0=pyhd8ed1ab_0 - packaging=23.2=pyhd8ed1ab_0 - pamela=1.1.0=pyh1a96a4e_0 - - pandas=2.2.1=py311h320fe9a_0 + - pandas=2.2.0=py311h320fe9a_0 - pandoc=3.1.3=h32600fe_0 - pandocfilters=1.5.0=pyhd8ed1ab_0 - parso=0.8.3=pyhd8ed1ab_0 @@ -318,15 +316,15 @@ dependencies: - pillow=10.2.0=py311ha6c5da5_0 - pip=23.3=pyhd8ed1ab_0 - pixman=0.43.2=h59595ed_0 - - pkginfo=1.10.0=pyhd8ed1ab_0 + - pkginfo=1.9.6=pyhd8ed1ab_0 - pkgutil-resolve-name=1.3.10=pyhd8ed1ab_1 - platformdirs=3.11.0=pyhd8ed1ab_0 - pluggy=1.4.0=pyhd8ed1ab_0 - pointpats=2.4.0=pyhd8ed1ab_0 - poppler=23.10.0=h590f24d_0 - poppler-data=0.4.12=hd8ed1ab_0 - - postgresql=16.2=h82ecc9d_1 - - pre-commit=3.7.0=pyha770c72_0 + - postgresql=16.1=h7387d8b_7 + - pre-commit=3.6.0=pyha770c72_0 - proj=9.3.0=h1d62c97_2 - prometheus_client=0.17.1=pyhd8ed1ab_0 - prompt-toolkit=3.0.39=pyha770c72_0 @@ -337,61 +335,61 @@ dependencies: - ptyprocess=0.7.0=pyhd3deb0d_0 - pulp=2.8.0=py311h38be061_0 - pure_eval=0.2.2=pyhd8ed1ab_0 - - py-lief=0.14.1=py311h4332511_1 + - py-lief=0.12.3=py311ha362b79_0 - pybind11-abi=4=hd8ed1ab_3 - pycodestyle=2.11.1=pyhd8ed1ab_0 - pycosat=0.6.6=py311h459d7ec_0 - pycparser=2.21=pyhd8ed1ab_0 - pycryptodome=3.20.0=py311h6601440_0 - pycurl=7.45.1=py311hae980a4_3 - - pygithub=2.3.0=pyhd8ed1ab_0 + - pygithub=2.2.0=pyhd8ed1ab_0 - pygments=2.16.1=pyhd8ed1ab_0 - pyjwt=2.8.0=pyhd8ed1ab_0 - pynacl=1.5.0=py311h459d7ec_3 - pyopenssl=23.2.0=pyhd8ed1ab_1 - - pyparsing=3.1.2=pyhd8ed1ab_0 + - pyparsing=3.1.1=pyhd8ed1ab_0 - pyproj=3.6.1=py311h1facc83_4 - pysal=23.7=pyhd8ed1ab_0 - pyshp=2.3.1=pyhd8ed1ab_0 - pysocks=1.7.1=pyha2e5f31_6 - - pytest=8.1.1=pyhd8ed1ab_0 - - pytest-cov=5.0.0=pyhd8ed1ab_0 + - pytest=8.0.0=pyhd8ed1ab_0 + - pytest-cov=4.1.0=pyhd8ed1ab_0 - python=3.11.6=hab00c5b_0_cpython - python-dateutil=2.8.2=pyhd8ed1ab_0 - python-fastjsonschema=2.18.1=pyhd8ed1ab_0 - - python-igraph=0.11.4=py311hce45d13_0 + - python-igraph=0.11.3=py311h8290d24_1 - python-json-logger=2.0.7=pyhd8ed1ab_0 - - python-libarchive-c=5.1=py311h38be061_0 - - python-tzdata=2024.1=pyhd8ed1ab_0 + - python-libarchive-c=5.0=py311h38be061_2 + - python-tzdata=2023.4=pyhd8ed1ab_0 - python_abi=3.11=4_cp311 - pytz=2023.3.post1=pyhd8ed1ab_0 - pyyaml=6.0.1=py311h459d7ec_1 - pyzmq=25.1.1=py311h75c88c4_1 - - quantecon=0.7.2=pyhd8ed1ab_0 + - quantecon=0.5.3=pyhd8ed1ab_0 - rasterio=1.3.9=py311h40fbdff_0 - rasterstats=0.19.0=pyhd8ed1ab_0 - - re2=2023.09.01=h7f4b329_1 + - re2=2023.06.02=h2873b5e_0 - readline=8.2=h8228510_1 - readme_renderer=42.0=pyhd8ed1ab_0 - referencing=0.30.2=pyhd8ed1ab_0 - reproc=14.2.4.post0=hd590300_0 - reproc-cpp=14.2.4.post0=h59595ed_0 - requests=2.31.0=pyhd8ed1ab_0 - - requests-oauthlib=2.0.0=pyhd8ed1ab_0 + - requests-oauthlib=1.3.1=pyhd8ed1ab_0 - requests-toolbelt=1.0.0=pyhd8ed1ab_0 - rfc3339-validator=0.1.4=pyhd8ed1ab_0 - rfc3986=2.0.0=pyhd8ed1ab_0 - rfc3986-validator=0.1.1=pyh9f0ad1d_0 - - rich=13.7.1=pyhd8ed1ab_0 + - rich=13.7.0=pyhd8ed1ab_0 - ripgrep=14.1.0=he8a937b_0 - rpds-py=0.10.6=py311h46250e7_0 - rtree=1.2.0=py311h3bb2b0f_0 - ruamel.yaml=0.17.39=py311h459d7ec_0 - ruamel.yaml.clib=0.2.7=py311h459d7ec_2 - - ruff=0.3.5=py311h7145743_0 - - scikit-learn=1.4.1.post1=py311hc009520_0 + - ruff=0.1.15=py311h7145743_0 + - scikit-learn=1.4.0=py311hc009520_0 - scipy=1.12.0=py311h64a7726_2 - - scrypt=0.8.24=py311h6965a6d_0 + - scrypt=0.8.20=py311h6965a6d_1 - seaborn=0.13.2=hd8ed1ab_0 - seaborn-base=0.13.2=pyhd8ed1ab_0 - secretstorage=3.3.3=py311h38be061_2 @@ -430,7 +428,7 @@ dependencies: - sympy=1.12=pypyh9d50eac_103 - terminado=0.17.1=pyh41d4057_0 - texttable=1.7.0=pyhd8ed1ab_0 - - threadpoolctl=3.4.0=pyhc1e730c_0 + - threadpoolctl=3.2.0=pyha21a80b_0 - tiledb=2.16.3=h8c794c1_3 - tinycss2=1.2.1=pyhd8ed1ab_0 - tk=8.6.13=h2797004_0 @@ -439,34 +437,34 @@ dependencies: - toml=0.10.2=pyhd8ed1ab_0 - tomli=2.0.1=pyhd8ed1ab_0 - tomli-w=1.0.0=pyhd8ed1ab_0 - - tomlkit=0.12.4=pyha770c72_0 + - tomlkit=0.12.3=pyha770c72_0 - toolz=0.12.1=pyhd8ed1ab_0 - tornado=6.3.3=py311h459d7ec_1 - tqdm=4.66.1=pyhd8ed1ab_0 - traitlets=5.11.2=pyhd8ed1ab_0 - - trove-classifiers=2024.3.25=pyhd8ed1ab_0 + - trove-classifiers=2024.1.8=pyhd8ed1ab_0 - truststore=0.8.0=pyhd8ed1ab_0 - - twine=5.0.0=pyhd8ed1ab_0 - - typer=0.11.1=pyhd8ed1ab_0 + - twine=4.0.2=pyhd8ed1ab_0 + - typer=0.9.0=pyhd8ed1ab_0 - types-python-dateutil=2.8.19.14=pyhd8ed1ab_0 - typing-extensions=4.8.0=hd8ed1ab_0 - typing_extensions=4.8.0=pyha770c72_0 - typing_utils=0.1.0=pyhd8ed1ab_0 - - tzcode=2024a=h3f72095_0 + - tzcode=2023d=h3f72095_0 - tzdata=2023c=h71feb2d_0 - ukkonen=1.0.1=py311h9547e67_4 - uri-template=1.3.0=pyhd8ed1ab_0 - - uriparser=0.9.7=h59595ed_1 + - uriparser=0.9.7=hcb278e6_1 - urllib3=2.0.7=pyhd8ed1ab_0 - userpath=1.7.0=pyhd8ed1ab_0 - - virtualenv=20.25.1=pyhd8ed1ab_0 + - virtualenv=20.25.0=pyhd8ed1ab_0 - vsts-python-api=0.1.25=pyhd8ed1ab_1 - wcwidth=0.2.8=pyhd8ed1ab_0 - webcolors=1.13=pyhd8ed1ab_0 - webencodings=0.5.1=pyhd8ed1ab_2 - websocket-client=1.6.4=pyhd8ed1ab_0 - wheel=0.41.2=pyhd8ed1ab_0 - - widgetsnbextension=4.0.10=pyhd8ed1ab_0 + - widgetsnbextension=4.0.9=pyhd8ed1ab_0 - wrapt=1.16.0=py311h459d7ec_0 - xerces-c=3.2.5=hac6953d_0 - xorg-kbproto=1.0.7=h7f98852_1002 diff --git a/environments/docker/requirements.txt b/environments/docker/requirements.txt index 833625a0d..bc16ca656 100644 --- a/environments/docker/requirements.txt +++ b/environments/docker/requirements.txt @@ -2,19 +2,13 @@ osmnx python == 3.11.* -# helpful -beautifulsoup4 -bokeh +# helpful extras bottleneck cartopy -contextily -cython folium jupyterlab -mapclassify numexpr pillow -psycopg2 pysal python-igraph seaborn @@ -23,6 +17,7 @@ statsmodels # docs furo sphinx +sphinx-autodoc-typehints # packaging conda-smithy @@ -30,7 +25,14 @@ hatch pip twine +# typing +mypy +pandas-stubs +typeguard +types-requests + # linting/testing +lxml nbdime nbqa pre-commit diff --git a/environments/linux/create-environment.sh b/environments/linux/create-environment.sh old mode 100644 new mode 100755 index 602e50589..3daa11696 --- a/environments/linux/create-environment.sh +++ b/environments/linux/create-environment.sh @@ -3,7 +3,7 @@ set -e ENV=ox PACKAGE=osmnx eval "$(conda shell.bash hook)" -conda deactivate +conda activate base mamba env remove -n $ENV --yes mamba clean --all --yes --quiet --no-banner mamba create -c conda-forge --strict-channel-priority -n $ENV --file "../docker/requirements.txt" --yes --no-banner diff --git a/environments/linux/environment.yml b/environments/linux/environment.yml index 31f363b02..88c441408 100644 --- a/environments/linux/environment.yml +++ b/environments/linux/environment.yml @@ -8,8 +8,8 @@ dependencies: - affine=2.4.0=pyhd8ed1ab_0 - alabaster=0.7.16=pyhd8ed1ab_0 - amply=0.1.6=pyhd8ed1ab_0 - - anyio=4.3.0=pyhd8ed1ab_0 - - archspec=0.2.3=pyhd8ed1ab_0 + - anyio=4.2.0=pyhd8ed1ab_0 + - archspec=0.2.2=pyhd8ed1ab_0 - argon2-cffi=23.1.0=pyhd8ed1ab_0 - argon2-cffi-bindings=21.2.0=py311h459d7ec_4 - arpack=3.8.0=nompi_h0baa96a_101 @@ -17,47 +17,34 @@ dependencies: - asttokens=2.4.1=pyhd8ed1ab_0 - async-lru=2.0.4=pyhd8ed1ab_0 - attrs=23.2.0=pyh71513ae_0 - - autopep8=2.1.0=pyhd8ed1ab_0 - - aws-c-auth=0.7.16=haed3651_8 - - aws-c-cal=0.6.10=ha9bf9b1_2 - - aws-c-common=0.9.14=hd590300_0 - - aws-c-compression=0.2.18=h4466546_2 - - aws-c-event-stream=0.4.2=he635cd5_6 - - aws-c-http=0.8.1=hbfc29b2_7 - - aws-c-io=0.14.6=h96cd748_2 - - aws-c-mqtt=0.10.3=hffff1cc_2 - - aws-c-s3=0.5.4=h4893938_0 - - aws-c-sdkutils=0.1.15=h4466546_2 - - aws-checksums=0.1.18=h4466546_2 - - aws-crt-cpp=0.26.4=hba3594f_2 - - aws-sdk-cpp=1.11.267=hb1af6a8_4 - - azure-core-cpp=1.11.1=h91d86a7_1 - - azure-storage-blobs-cpp=12.10.0=h00ab1b0_1 - - azure-storage-common-cpp=12.5.0=h94269e2_4 + - autopep8=2.0.4=pyhd8ed1ab_0 + - azure-core-cpp=1.10.3=h91d86a7_1 + - azure-storage-blobs-cpp=12.10.0=h00ab1b0_0 + - azure-storage-common-cpp=12.5.0=hb858b4b_2 - babel=2.14.0=pyhd8ed1ab_0 - beautifulsoup4=4.12.3=pyha770c72_0 - bleach=6.1.0=pyhd8ed1ab_0 - blinker=1.7.0=pyhd8ed1ab_0 - blosc=1.21.5=h0f2a231_0 - - bokeh=3.4.0=pyhd8ed1ab_0 - - boltons=24.0.0=pyhd8ed1ab_0 + - bokeh=3.3.4=pyhd8ed1ab_0 + - boltons=23.1.1=pyhd8ed1ab_0 - boolean.py=4.0=pyhd8ed1ab_0 - - bottleneck=1.3.8=py311h1f0f07a_0 + - bottleneck=1.3.7=py311h1f0f07a_1 - branca=0.7.1=pyhd8ed1ab_0 - brotli=1.1.0=hd590300_1 - brotli-bin=1.1.0=hd590300_1 - brotli-python=1.1.0=py311hb755f60_1 - bzip2=1.0.8=hd590300_5 - - c-ares=1.28.1=hd590300_0 - - ca-certificates=2024.2.2=hbcca054_0 + - c-ares=1.26.0=hd590300_0 + - ca-certificates=2023.11.17=hbcca054_0 - cached-property=1.5.2=hd8ed1ab_1 - cached_property=1.5.2=pyha770c72_1 - cairo=1.18.0=h3faef2a_0 - cartopy=0.22.0=py311h320fe9a_1 - - certifi=2024.2.2=pyhd8ed1ab_0 + - certifi=2023.11.17=pyhd8ed1ab_0 - cffi=1.16.0=py311hb3a22ac_0 - cfgv=3.3.1=pyhd8ed1ab_0 - - cfitsio=4.4.0=hbdc6101_0 + - cfitsio=4.3.1=hbdc6101_0 - chardet=5.2.0=py311h38be061_1 - charset-normalizer=3.3.2=pyhd8ed1ab_0 - cirun=0.30=pyhd8ed1ab_0 @@ -72,24 +59,24 @@ dependencies: - coin-or-utils=2.11.9=hee58242_0 - coincbc=2.10.10=0_metapackage - colorama=0.4.6=pyhd8ed1ab_0 - - comm=0.2.2=pyhd8ed1ab_0 - - conda=24.3.0=py311h38be061_0 - - conda-build=24.3.0=py311h38be061_1 - - conda-forge-pinning=2024.04.03.08.04.29=hd8ed1ab_0 - - conda-index=0.4.0=pyhd8ed1ab_0 + - comm=0.2.1=pyhd8ed1ab_0 + - conda=23.11.0=py311h38be061_1 + - conda-build=3.28.4=py311h38be061_0 + - conda-forge-pinning=2024.02.01.14.05.18=hd8ed1ab_0 + - conda-index=0.3.0=pyhd8ed1ab_1 - conda-libmamba-solver=24.1.0=pyhd8ed1ab_0 - conda-package-handling=2.2.0=pyh38be061_0 - conda-package-streaming=0.9.0=pyhd8ed1ab_0 - - conda-smithy=3.34.1=pyhd8ed1ab_0 - - contextily=1.6.0=pyhd8ed1ab_0 + - conda-smithy=3.30.4=pyhd8ed1ab_0 + - contextily=1.5.0=pyhd8ed1ab_0 - contourpy=1.2.0=py311h9547e67_0 - - coverage=7.4.4=py311h459d7ec_0 - - cryptography=42.0.5=py311h63ff55d_0 - - curl=8.7.1=hca28451_0 + - coverage=7.4.1=py311h459d7ec_0 + - cryptography=42.0.2=py311hcb13ee4_0 + - curl=8.5.0=hca28451_0 - cycler=0.12.1=pyhd8ed1ab_0 - - cython=3.0.10=py311hb755f60_0 + - cython=3.0.8=py311hb755f60_0 - dbus=1.13.6=h5008d03_3 - - debugpy=1.8.1=py311hb755f60_0 + - debugpy=1.8.0=py311hb755f60_1 - decorator=5.1.1=pyhd8ed1ab_0 - defusedxml=0.7.1=pyhd8ed1ab_0 - deprecated=1.2.14=pyh1a96a4e_0 @@ -103,11 +90,11 @@ dependencies: - esda=2.5.1=pyhd8ed1ab_0 - exceptiongroup=1.2.0=pyhd8ed1ab_2 - executing=2.0.1=pyhd8ed1ab_0 - - expat=2.6.2=h59595ed_0 - - filelock=3.13.3=pyhd8ed1ab_0 - - fiona=1.9.6=py311hf8e0aa6_0 + - expat=2.5.0=hcb278e6_1 + - filelock=3.13.1=pyhd8ed1ab_0 + - fiona=1.9.5=py311hf8e0aa6_3 - fmt=10.2.1=h00ab1b0_0 - - folium=0.16.0=pyhd8ed1ab_0 + - folium=0.15.1=pyhd8ed1ab_0 - font-ttf-dejavu-sans-mono=2.37=hab24e00_0 - font-ttf-inconsolata=3.000=h77eed37_0 - font-ttf-source-code-pro=2.038=h77eed37_0 @@ -115,158 +102,155 @@ dependencies: - fontconfig=2.14.2=h14ed4e7_0 - fonts-conda-ecosystem=1=0 - fonts-conda-forge=1=0 - - fonttools=4.50.0=py311h459d7ec_0 + - fonttools=4.47.2=py311h459d7ec_0 - fqdn=1.5.1=pyhd8ed1ab_0 - freetype=2.12.1=h267a509_2 - freexl=2.0.0=h743c826_0 - furo=2024.1.29=pyhd8ed1ab_0 - - gdal=3.8.4=py311h8be719e_5 - - geographiclib=2.0=pyhd8ed1ab_0 + - gdal=3.8.3=py311h39b4e0e_0 + - geographiclib=1.52=pyhd8ed1ab_0 - geopandas=0.14.3=pyhd8ed1ab_0 - geopandas-base=0.14.3=pyha770c72_0 - - geopy=2.4.1=pyhd8ed1ab_1 + - geopy=2.4.1=pyhd8ed1ab_0 - geos=3.12.1=h59595ed_0 - geotiff=1.7.1=h6b2125f_15 - gettext=0.21.1=h27087fc_0 - giddy=2.3.5=pyhd8ed1ab_0 - giflib=5.2.1=h0b41bf4_3 - - git=2.44.0=pl5321h709897a_0 + - git=2.43.0=pl5321h7bc287a_0 - gitdb=4.0.11=pyhd8ed1ab_0 - - gitpython=3.1.43=pyhd8ed1ab_0 + - gitpython=3.1.41=pyhd8ed1ab_0 - glpk=5.0=h445213a_0 - - gmp=6.3.0=h59595ed_1 + - gmp=6.3.0=h59595ed_0 - gmpy2=2.1.2=py311h6a5fa03_1 - h11=0.14.0=pyhd8ed1ab_0 - h2=4.1.0=pyhd8ed1ab_0 - - hatch=1.9.4=pyhd8ed1ab_0 + - hatch=1.9.2=pyhd8ed1ab_0 - hatchling=1.21.1=pyhd8ed1ab_0 - hdf4=4.2.15=h2a13503_7 - hdf5=1.14.3=nompi_h4f84152_100 - hpack=4.0.0=pyh9f0ad1d_0 - - httpcore=1.0.5=pyhd8ed1ab_0 - - httpx=0.27.0=pyhd8ed1ab_0 + - httpcore=1.0.2=pyhd8ed1ab_0 + - httpx=0.26.0=pyhd8ed1ab_0 - hyperframe=6.0.1=pyhd8ed1ab_0 - hyperlink=21.0.0=pyhd3deb0d_0 - icu=73.2=h59595ed_0 - - identify=2.5.35=pyhd8ed1ab_0 + - identify=2.5.33=pyhd8ed1ab_0 - idna=3.6=pyhd8ed1ab_0 - imagesize=1.4.1=pyhd8ed1ab_0 - - importlib-metadata=7.1.0=pyha770c72_0 - - importlib_metadata=7.1.0=hd8ed1ab_0 - - importlib_resources=6.4.0=pyhd8ed1ab_0 + - importlib-metadata=7.0.1=pyha770c72_0 + - importlib_metadata=7.0.1=hd8ed1ab_0 + - importlib_resources=6.1.1=pyhd8ed1ab_0 - inequality=1.0.1=pyhd8ed1ab_0 - iniconfig=2.0.0=pyhd8ed1ab_0 - - ipykernel=6.29.3=pyhd33586a_0 - - ipython=8.22.2=pyh707e725_0 - - ipywidgets=8.1.2=pyhd8ed1ab_0 + - ipykernel=6.29.0=pyhd33586a_0 + - ipython=8.21.0=pyh707e725_0 + - ipywidgets=8.1.1=pyhd8ed1ab_0 - isodate=0.6.1=pyhd8ed1ab_0 - isoduration=20.11.0=pyhd8ed1ab_0 - - jaraco.classes=3.4.0=pyhd8ed1ab_0 - - jaraco.context=4.3.0=pyhd8ed1ab_0 - - jaraco.functools=4.0.0=pyhd8ed1ab_0 + - jaraco.classes=3.3.0=pyhd8ed1ab_0 - jedi=0.19.1=pyhd8ed1ab_0 - jeepney=0.8.0=pyhd8ed1ab_0 - jinja2=3.1.3=pyhd8ed1ab_0 - joblib=1.3.2=pyhd8ed1ab_0 - json-c=0.17=h7ab15ed_0 - - json5=0.9.24=pyhd8ed1ab_0 + - json5=0.9.14=pyhd8ed1ab_0 - jsonpatch=1.33=pyhd8ed1ab_0 - jsonpointer=2.4=py311h38be061_3 - jsonschema=4.21.1=pyhd8ed1ab_0 - jsonschema-specifications=2023.12.1=pyhd8ed1ab_0 - jsonschema-with-format-nongpl=4.21.1=pyhd8ed1ab_0 - - jupyter-lsp=2.2.4=pyhd8ed1ab_0 + - jupyter-lsp=2.2.2=pyhd8ed1ab_0 - jupyter-server-mathjax=0.2.6=pyh5bfe37b_1 - - jupyter_client=8.6.1=pyhd8ed1ab_0 - - jupyter_core=5.7.2=py311h38be061_0 - - jupyter_events=0.10.0=pyhd8ed1ab_0 - - jupyter_server=2.13.0=pyhd8ed1ab_0 - - jupyter_server_terminals=0.5.3=pyhd8ed1ab_0 - - jupyterlab=4.1.5=pyhd8ed1ab_0 - - jupyterlab_pygments=0.3.0=pyhd8ed1ab_1 - - jupyterlab_server=2.25.4=pyhd8ed1ab_0 - - jupyterlab_widgets=3.0.10=pyhd8ed1ab_0 + - jupyter_client=8.6.0=pyhd8ed1ab_0 + - jupyter_core=5.7.1=py311h38be061_0 + - jupyter_events=0.9.0=pyhd8ed1ab_0 + - jupyter_server=2.12.5=pyhd8ed1ab_0 + - jupyter_server_terminals=0.5.2=pyhd8ed1ab_0 + - jupyterlab=4.0.12=pyhd8ed1ab_0 + - jupyterlab_pygments=0.3.0=pyhd8ed1ab_0 + - jupyterlab_server=2.25.2=pyhd8ed1ab_0 + - jupyterlab_widgets=3.0.9=pyhd8ed1ab_0 - kealib=1.5.3=h2f55d51_0 - - keyring=25.1.0=pyha804496_0 + - keyring=24.3.0=py311h38be061_0 - keyutils=1.6.1=h166bdaf_0 - kiwisolver=1.4.5=py311h9547e67_1 - krb5=1.21.2=h659d440_0 - lcms2=2.16=hb7c19ff_0 - ld_impl_linux-64=2.40=h41732ed_0 - lerc=4.0.0=h27087fc_0 - - libabseil=20240116.1=cxx17_h59595ed_2 - - libaec=1.1.3=h59595ed_0 + - libabseil=20230802.1=cxx17_h59595ed_0 + - libaec=1.1.2=h59595ed_1 - libarchive=3.7.2=h2aa1ff5_1 - libblas=3.9.0=21_linux64_openblas - - libboost-headers=1.84.0=ha770c72_2 + - libboost-headers=1.84.0=ha770c72_0 - libbrotlicommon=1.1.0=hd590300_1 - libbrotlidec=1.1.0=hd590300_1 - libbrotlienc=1.1.0=hd590300_1 - libcblas=3.9.0=21_linux64_openblas - libcrc32c=1.1.2=h9c3ff4c_0 - - libcurl=8.7.1=hca28451_0 - - libdeflate=1.20=hd590300_0 + - libcurl=8.5.0=hca28451_0 + - libdeflate=1.19=hd590300_0 - libedit=3.1.20191231=he28a2e2_2 - libev=4.33=hd590300_2 - - libexpat=2.6.2=h59595ed_0 + - libexpat=2.5.0=hcb278e6_1 - libffi=3.4.2=h7f98852_5 - - libgcc-ng=13.2.0=h807b86a_5 - - libgdal=3.8.4=h7c88fdf_5 - - libgfortran-ng=13.2.0=h69a702a_5 - - libgfortran5=13.2.0=ha4646dd_5 - - libglib=2.80.0=hf2295e7_1 - - libgomp=13.2.0=h807b86a_5 - - libgoogle-cloud=2.22.0=h9be4e54_1 - - libgoogle-cloud-storage=2.22.0=hc7a4891_1 - - libgrpc=1.62.1=h15f2491_0 + - libgcc-ng=13.2.0=h807b86a_4 + - libgdal=3.8.3=hcd1fc54_0 + - libgfortran-ng=13.2.0=h69a702a_4 + - libgfortran5=13.2.0=ha4646dd_4 + - libglib=2.78.3=h783c2da_0 + - libgomp=13.2.0=h807b86a_4 + - libgoogle-cloud=2.12.0=hef10d8f_5 + - libgrpc=1.60.0=h74775cd_1 - libiconv=1.17=hd590300_2 - libjpeg-turbo=3.0.0=hd590300_1 - libkml=1.3.0=h01aab08_1018 - liblapack=3.9.0=21_linux64_openblas - liblapacke=3.9.0=21_linux64_openblas - - liblief=0.14.1=hac33072_1 + - liblief=0.12.3=h27087fc_0 - libllvm14=14.0.6=hcd5def8_4 - - libmamba=1.5.8=had39da4_0 - - libmambapy=1.5.8=py311hf2555c7_0 + - libmamba=1.5.6=had39da4_0 + - libmambapy=1.5.6=py311hf2555c7_0 - libnetcdf=4.9.2=nompi_h9612171_113 - libnghttp2=1.58.0=h47da74e_1 - libnsl=2.0.1=hd590300_0 - libopenblas=0.3.26=pthreads_h413a1c8_0 - - libpng=1.6.43=h2797004_0 - - libpq=16.2=h33b98f1_1 - - libprotobuf=4.25.3=h08a7969_0 - - libpysal=4.10=pyhd8ed1ab_0 - - libre2-11=2023.09.01=h5a48ba9_2 + - libpng=1.6.42=h2797004_0 + - libpq=16.1=h33b98f1_7 + - libprotobuf=4.25.1=hf27288f_0 + - libpysal=4.9.2=pyhd8ed1ab_1 + - libre2-11=2023.06.02=h7a70373_0 - librttopo=1.1.0=h8917695_15 - libsodium=1.0.18=h36c2ea0_1 - - libsolv=0.7.28=hfc55251_2 + - libsolv=0.7.27=hfc55251_0 - libspatialindex=1.9.3=h9c3ff4c_4 - libspatialite=5.1.0=h7bd4643_4 - - libsqlite=3.45.2=h2797004_0 + - libsqlite=3.44.2=h2797004_0 - libssh2=1.11.0=h0841786_0 - - libstdcxx-ng=13.2.0=h7e041cc_5 - - libtiff=4.6.0=h1dd3fc0_3 + - libstdcxx-ng=13.2.0=h7e041cc_4 + - libtiff=4.6.0=ha9c0a0a_2 - libuuid=2.38.1=h0b41bf4_0 - libwebp-base=1.3.2=hd590300_0 - libxcb=1.15=h0b41bf4_0 - libxcrypt=4.4.36=hd590300_1 - - libxml2=2.12.6=h232c23b_1 + - libxml2=2.12.4=h232c23b_1 - libzip=1.10.1=h2629f0a_3 - libzlib=1.2.13=hd590300_5 - license-expression=30.1.1=pyhd8ed1ab_0 - - llvmlite=0.42.0=py311ha6695c7_1 + - llvmlite=0.41.1=py311ha6695c7_0 - lz4-c=1.9.4=hcb278e6_0 - lzo=2.10=h516909a_1000 - mapclassify=2.6.1=pyhd8ed1ab_0 - markdown-it-py=3.0.0=pyhd8ed1ab_0 - - markupsafe=2.1.5=py311h459d7ec_0 - - matplotlib-base=3.8.3=py311h54ef318_0 + - markupsafe=2.1.4=py311h459d7ec_0 + - matplotlib-base=3.8.2=py311h54ef318_0 - matplotlib-inline=0.1.6=pyhd8ed1ab_0 - mdurl=0.1.2=pyhd8ed1ab_0 - menuinst=2.0.2=py311h38be061_0 - mercantile=1.2.1=pyhd8ed1ab_0 - mgwr=2.2.1=pyhd8ed1ab_0 - - minizip=4.0.5=h0ab5242_0 + - minizip=4.0.4=h0ab5242_0 - mistune=3.0.2=pyhd8ed1ab_0 - momepy=0.7.0=pyhd8ed1ab_0 - more-itertools=10.2.0=pyhd8ed1ab_0 @@ -275,54 +259,54 @@ dependencies: - mpmath=1.3.0=pyhd8ed1ab_0 - msrest=0.6.21=pyh44b312d_0 - munkres=1.1.4=pyh9f0ad1d_0 - - nbclient=0.10.0=pyhd8ed1ab_0 - - nbconvert-core=7.16.3=pyhd8ed1ab_0 + - nbclient=0.8.0=pyhd8ed1ab_0 + - nbconvert-core=7.14.2=pyhd8ed1ab_0 - nbdime=4.0.1=pyhd8ed1ab_0 - - nbformat=5.10.3=pyhd8ed1ab_0 - - nbqa=1.8.5=pyhd8ed1ab_0 - - ncurses=6.4.20240210=h59595ed_0 + - nbformat=5.9.2=pyhd8ed1ab_0 + - nbqa=1.7.1=pyhd8ed1ab_0 + - ncurses=6.4=h59595ed_2 - nest-asyncio=1.6.0=pyhd8ed1ab_0 - networkx=3.2.1=pyhd8ed1ab_0 - - nh3=0.2.17=py311h46250e7_0 + - nh3=0.2.15=py311h46250e7_0 - nodeenv=1.8.0=pyhd8ed1ab_0 - nomkl=1.0=h5ca1d4c_0 - - notebook-shim=0.2.4=pyhd8ed1ab_0 + - notebook-shim=0.2.3=pyhd8ed1ab_0 - nspr=4.35=h27087fc_0 - - nss=3.98=h1d7d5a4_0 - - numba=0.59.1=py311h96b013e_0 - - numexpr=2.9.0=py311h039bad6_100 - - numpy=1.26.4=py311h64a7726_0 + - nss=3.97=h1d7d5a4_0 + - numba=0.58.1=py311h96b013e_0 + - numexpr=2.8.8=py311h039bad6_100 + - numpy=1.26.3=py311h64a7726_0 - oauthlib=3.2.2=pyhd8ed1ab_0 - - openjpeg=2.5.2=h488ebb8_0 - - openssl=3.2.1=hd590300_1 - - osmnx=1.9.2=pyhd8ed1ab_0 + - openjpeg=2.5.0=h488ebb8_3 + - openssl=3.2.1=hd590300_0 + - osmnx=1.9.1=pyhd8ed1ab_0 - overrides=7.7.0=pyhd8ed1ab_0 - - packaging=24.0=pyhd8ed1ab_0 - - pandas=2.2.1=py311h320fe9a_0 + - packaging=23.2=pyhd8ed1ab_0 + - pandas=2.2.0=py311h320fe9a_0 - pandocfilters=1.5.0=pyhd8ed1ab_0 - parso=0.8.3=pyhd8ed1ab_0 - patch=2.7.6=h7f98852_1002 - patchelf=0.17.2=h58526e2_0 - pathspec=0.12.1=pyhd8ed1ab_0 - patsy=0.5.6=pyhd8ed1ab_0 - - pcre2=10.43=hcad00b1_0 + - pcre2=10.42=hcad00b1_0 - perl=5.32.1=7_hd590300_perl5 - pexpect=4.9.0=pyhd8ed1ab_0 - pickleshare=0.7.5=py_1003 - pillow=10.2.0=py311ha6c5da5_0 - - pip=24.0=pyhd8ed1ab_0 + - pip=23.3.2=pyhd8ed1ab_0 - pixman=0.43.2=h59595ed_0 - - pkginfo=1.10.0=pyhd8ed1ab_0 + - pkginfo=1.9.6=pyhd8ed1ab_0 - pkgutil-resolve-name=1.3.10=pyhd8ed1ab_1 - platformdirs=4.2.0=pyhd8ed1ab_0 - pluggy=1.4.0=pyhd8ed1ab_0 - pointpats=2.4.0=pyhd8ed1ab_0 - - poppler=24.03.0=h590f24d_0 + - poppler=23.12.0=h590f24d_0 - poppler-data=0.4.12=hd8ed1ab_0 - - postgresql=16.2=h82ecc9d_1 - - pre-commit=3.7.0=pyha770c72_0 + - postgresql=16.1=h7387d8b_7 + - pre-commit=3.6.0=pyha770c72_0 - proj=9.3.1=h1d62c97_0 - - prometheus_client=0.20.0=pyhd8ed1ab_0 + - prometheus_client=0.19.0=pyhd8ed1ab_0 - prompt-toolkit=3.0.42=pyha770c72_0 - psutil=5.9.8=py311h459d7ec_0 - psycopg2=2.9.9=py311h03dec38_0 @@ -330,78 +314,76 @@ dependencies: - ptyprocess=0.7.0=pyhd3deb0d_0 - pulp=2.8.0=py311h38be061_0 - pure_eval=0.2.2=pyhd8ed1ab_0 - - py-lief=0.14.1=py311h4332511_1 + - py-lief=0.12.3=py311ha362b79_0 - pybind11-abi=4=hd8ed1ab_3 - pycodestyle=2.11.1=pyhd8ed1ab_0 - pycosat=0.6.6=py311h459d7ec_0 - - pycparser=2.22=pyhd8ed1ab_0 + - pycparser=2.21=pyhd8ed1ab_0 - pycryptodome=3.20.0=py311h6601440_0 - - pygithub=2.3.0=pyhd8ed1ab_0 + - pygithub=2.2.0=pyhd8ed1ab_0 - pygments=2.17.2=pyhd8ed1ab_0 - - pyjwt=2.8.0=pyhd8ed1ab_1 + - pyjwt=2.8.0=pyhd8ed1ab_0 - pynacl=1.5.0=py311h459d7ec_3 - - pyparsing=3.1.2=pyhd8ed1ab_0 + - pyparsing=3.1.1=pyhd8ed1ab_0 - pyproj=3.6.1=py311hca0b8b9_5 - pysal=23.7=pyhd8ed1ab_0 - pyshp=2.3.1=pyhd8ed1ab_0 - pysocks=1.7.1=pyha2e5f31_6 - - pytest=8.1.1=pyhd8ed1ab_0 - - pytest-cov=5.0.0=pyhd8ed1ab_0 - - python=3.11.8=hab00c5b_0_cpython - - python-dateutil=2.9.0=pyhd8ed1ab_0 + - pytest=8.0.0=pyhd8ed1ab_0 + - pytest-cov=4.1.0=pyhd8ed1ab_0 + - python=3.11.7=hab00c5b_1_cpython + - python-dateutil=2.8.2=pyhd8ed1ab_0 - python-fastjsonschema=2.19.1=pyhd8ed1ab_0 - - python-igraph=0.11.4=py311hce45d13_0 + - python-igraph=0.11.3=py311h8290d24_1 - python-json-logger=2.0.7=pyhd8ed1ab_0 - - python-libarchive-c=5.1=py311h38be061_0 - - python-tzdata=2024.1=pyhd8ed1ab_0 + - python-libarchive-c=5.0=py311h38be061_2 + - python-tzdata=2023.4=pyhd8ed1ab_0 - python_abi=3.11=4_cp311 - - pytz=2024.1=pyhd8ed1ab_0 + - pytz=2023.4=pyhd8ed1ab_0 - pyyaml=6.0.1=py311h459d7ec_1 - pyzmq=25.1.2=py311h34ded2d_0 - - quantecon=0.7.2=pyhd8ed1ab_0 + - quantecon=0.5.3=pyhd8ed1ab_0 - rasterio=1.3.9=py311ha38370a_2 - rasterstats=0.19.0=pyhd8ed1ab_0 - - re2=2023.09.01=h7f4b329_2 + - re2=2023.06.02=h2873b5e_0 - readline=8.2=h8228510_1 - readme_renderer=42.0=pyhd8ed1ab_0 - - referencing=0.34.0=pyhd8ed1ab_0 + - referencing=0.33.0=pyhd8ed1ab_0 - reproc=14.2.4.post0=hd590300_1 - reproc-cpp=14.2.4.post0=h59595ed_1 - requests=2.31.0=pyhd8ed1ab_0 - - requests-oauthlib=2.0.0=pyhd8ed1ab_0 + - requests-oauthlib=1.3.1=pyhd8ed1ab_0 - requests-toolbelt=1.0.0=pyhd8ed1ab_0 - rfc3339-validator=0.1.4=pyhd8ed1ab_0 - rfc3986=2.0.0=pyhd8ed1ab_0 - rfc3986-validator=0.1.1=pyh9f0ad1d_0 - - rich=13.7.1=pyhd8ed1ab_0 + - rich=13.7.0=pyhd8ed1ab_0 - ripgrep=14.1.0=he8a937b_0 - - rpds-py=0.18.0=py311h46250e7_0 + - rpds-py=0.17.1=py311h46250e7_0 - rtree=1.2.0=py311h3bb2b0f_0 - - ruamel.yaml=0.18.6=py311h459d7ec_0 - - ruamel.yaml.clib=0.2.8=py311h459d7ec_0 - - ruff=0.3.5=py311h7145743_0 - - s2n=1.4.8=h06160fa_0 - - scikit-learn=1.4.1.post1=py311hc009520_0 + - ruamel.yaml=0.18.5=py311h459d7ec_0 + - ruamel.yaml.clib=0.2.7=py311h459d7ec_2 + - ruff=0.1.15=py311h7145743_0 + - scikit-learn=1.4.0=py311hc009520_0 - scipy=1.12.0=py311h64a7726_2 - - scrypt=0.8.24=py311h6965a6d_0 + - scrypt=0.8.20=py311h6965a6d_1 - seaborn=0.13.2=hd8ed1ab_0 - seaborn-base=0.13.2=pyhd8ed1ab_0 - secretstorage=3.3.3=py311h38be061_2 - segregation=2.5=pyhd8ed1ab_1 - send2trash=1.8.2=pyh41d4057_0 - - setuptools=69.2.0=pyhd8ed1ab_0 - - shapely=2.0.3=py311h2032efe_0 + - setuptools=69.0.3=pyhd8ed1ab_0 + - shapely=2.0.2=py311h2032efe_1 - shellingham=1.5.4=pyhd8ed1ab_0 - simplejson=3.19.2=py311h459d7ec_0 - six=1.16.0=pyh6c4a22f_0 - smmap=5.0.0=pyhd8ed1ab_0 - snappy=1.1.10=h9fff704_0 - - sniffio=1.3.1=pyhd8ed1ab_0 + - sniffio=1.3.0=pyhd8ed1ab_0 - snowballstemmer=2.2.0=pyhd8ed1ab_0 - snuggs=1.4.7=py_0 - soupsieve=2.5=pyhd8ed1ab_1 - spaghetti=1.7.5.post1=pyhd8ed1ab_0 - - spdlog=1.12.0=hd2e6256_2 - spglm=1.1.0=pyhd8ed1ab_1 - sphinx=7.2.6=pyhd8ed1ab_0 - sphinx-basic-ng=1.0.0b2=pyhd8ed1ab_1 @@ -416,14 +398,14 @@ dependencies: - spopt=0.6.0=pyhd8ed1ab_0 - spreg=1.4.2=pyhd8ed1ab_0 - spvcm=0.3.0=pyhd8ed1ab_1 - - sqlite=3.45.2=h2c6b66d_0 + - sqlite=3.44.2=h2c6b66d_0 - stack_data=0.6.2=pyhd8ed1ab_0 - statsmodels=0.14.1=py311h1f0f07a_0 - sympy=1.12=pypyh9d50eac_103 - - terminado=0.18.1=pyh0d859eb_0 + - terminado=0.18.0=pyh0d859eb_0 - texttable=1.7.0=pyhd8ed1ab_0 - - threadpoolctl=3.4.0=pyhc1e730c_0 - - tiledb=2.21.1=ha9641ad_1 + - threadpoolctl=3.2.0=pyha21a80b_0 + - tiledb=2.19.1=h4386cac_0 - tinycss2=1.2.1=pyhd8ed1ab_0 - tk=8.6.13=noxft_h4845f30_101 - tobler=0.11.2=pyhd8ed1ab_2 @@ -431,34 +413,34 @@ dependencies: - toml=0.10.2=pyhd8ed1ab_0 - tomli=2.0.1=pyhd8ed1ab_0 - tomli-w=1.0.0=pyhd8ed1ab_0 - - tomlkit=0.12.4=pyha770c72_0 + - tomlkit=0.12.3=pyha770c72_0 - toolz=0.12.1=pyhd8ed1ab_0 - - tornado=6.4=py311h459d7ec_0 - - tqdm=4.66.2=pyhd8ed1ab_0 - - traitlets=5.14.2=pyhd8ed1ab_0 - - trove-classifiers=2024.3.25=pyhd8ed1ab_0 + - tornado=6.3.3=py311h459d7ec_1 + - tqdm=4.66.1=pyhd8ed1ab_0 + - traitlets=5.14.1=pyhd8ed1ab_0 + - trove-classifiers=2024.1.8=pyhd8ed1ab_0 - truststore=0.8.0=pyhd8ed1ab_0 - - twine=5.0.0=pyhd8ed1ab_0 - - typer=0.11.1=pyhd8ed1ab_0 - - types-python-dateutil=2.9.0.20240316=pyhd8ed1ab_0 - - typing-extensions=4.10.0=hd8ed1ab_0 - - typing_extensions=4.10.0=pyha770c72_0 + - twine=4.0.2=pyhd8ed1ab_0 + - typer=0.9.0=pyhd8ed1ab_0 + - types-python-dateutil=2.8.19.20240106=pyhd8ed1ab_0 + - typing-extensions=4.9.0=hd8ed1ab_0 + - typing_extensions=4.9.0=pyha770c72_0 - typing_utils=0.1.0=pyhd8ed1ab_0 - - tzcode=2024a=h3f72095_0 - - tzdata=2024a=h0c530f3_0 + - tzcode=2023d=h3f72095_0 + - tzdata=2023d=h0c530f3_0 - ukkonen=1.0.1=py311h9547e67_4 - uri-template=1.3.0=pyhd8ed1ab_0 - - uriparser=0.9.7=h59595ed_1 - - urllib3=2.2.1=pyhd8ed1ab_0 + - uriparser=0.9.7=hcb278e6_1 + - urllib3=2.2.0=pyhd8ed1ab_0 - userpath=1.7.0=pyhd8ed1ab_0 - - virtualenv=20.25.1=pyhd8ed1ab_0 + - virtualenv=20.25.0=pyhd8ed1ab_0 - vsts-python-api=0.1.25=pyhd8ed1ab_1 - wcwidth=0.2.13=pyhd8ed1ab_0 - webcolors=1.13=pyhd8ed1ab_0 - webencodings=0.5.1=pyhd8ed1ab_2 - websocket-client=1.7.0=pyhd8ed1ab_0 - - wheel=0.43.0=pyhd8ed1ab_1 - - widgetsnbextension=4.0.10=pyhd8ed1ab_0 + - wheel=0.42.0=pyhd8ed1ab_0 + - widgetsnbextension=4.0.9=pyhd8ed1ab_0 - wrapt=1.16.0=py311h459d7ec_0 - xerces-c=3.2.5=hac6953d_0 - xorg-kbproto=1.0.7=h7f98852_1002 @@ -476,7 +458,7 @@ dependencies: - xz=5.2.6=h166bdaf_0 - yaml=0.2.5=h7f98852_2 - yaml-cpp=0.8.0=h59595ed_0 - - zeromq=4.3.5=h59595ed_1 + - zeromq=4.3.5=h59595ed_0 - zipp=3.17.0=pyhd8ed1ab_0 - zlib=1.2.13=hd590300_5 - zstandard=0.22.0=py311haa97af0_0 diff --git a/tests/environments/env-ci.yml b/environments/tests/env-ci.yml similarity index 85% rename from tests/environments/env-ci.yml rename to environments/tests/env-ci.yml index 9e327a48b..e147cbc9e 100644 --- a/tests/environments/env-ci.yml +++ b/environments/tests/env-ci.yml @@ -14,7 +14,6 @@ dependencies: - shapely # extras - - folium - gdal - matplotlib - rasterio @@ -22,12 +21,13 @@ dependencies: - scipy # testing - - hatch + - lxml - pre-commit - pytest - pytest-cov - - twine + - typeguard # docs - furo - sphinx=7 + - sphinx-autodoc-typehints diff --git a/environments/tests/env-test-build.yml b/environments/tests/env-test-build.yml new file mode 100644 index 000000000..c65ad1bd8 --- /dev/null +++ b/environments/tests/env-test-build.yml @@ -0,0 +1,14 @@ +name: env-test-build + +channels: + - conda-forge + +dependencies: + # build + - hatch + - twine + + # docs + - furo + - sphinx=7 + - sphinx-autodoc-typehints diff --git a/tests/environments/env-test-minimal.yml b/environments/tests/env-test-minimal.yml similarity index 61% rename from tests/environments/env-test-minimal.yml rename to environments/tests/env-test-minimal.yml index 2fdb7c6b3..7e011189a 100644 --- a/tests/environments/env-test-minimal.yml +++ b/environments/tests/env-test-minimal.yml @@ -7,17 +7,16 @@ channels: - conda-forge dependencies: - # required dependencies pinned to minimum versions from /pyproject.toml + # required (pinned to min versions from /pyproject.toml) - geopandas=0.12 - networkx=2.5 - - numpy=1.20 + - numpy=1.21 - pandas=1.1 - - python=3.8 # pin to minimum version from /pyproject.toml + - python=3.9 - requests=2.27 - shapely=2.0 - # extras pinned to minimum versions from /pyproject.toml - - folium + # extras (pinned to min versions from /pyproject.toml) - gdal - matplotlib=3.5 - rasterio=1.3 @@ -25,12 +24,13 @@ dependencies: - scipy=1.5 # testing - - hatch + - lxml - pre-commit - pytest - pytest-cov - - twine + - typeguard - # docs + # docs (sphinx pinned to version from /docs/requirements.txt) - furo - - sphinx=7 # pin to version from /docs/requirements.txt + - sphinx=7 + - sphinx-autodoc-typehints diff --git a/environments/windows/environment.yml b/environments/windows/environment.yml index 568aaeecc..0b4dc3761 100644 --- a/environments/windows/environment.yml +++ b/environments/windows/environment.yml @@ -6,55 +6,42 @@ dependencies: - affine=2.4.0=pyhd8ed1ab_0 - alabaster=0.7.16=pyhd8ed1ab_0 - amply=0.1.6=pyhd8ed1ab_0 - - anyio=4.3.0=pyhd8ed1ab_0 - - archspec=0.2.3=pyhd8ed1ab_0 + - anyio=4.2.0=pyhd8ed1ab_0 + - archspec=0.2.2=pyhd8ed1ab_0 - argon2-cffi=23.1.0=pyhd8ed1ab_0 - argon2-cffi-bindings=21.2.0=py311ha68e1ae_4 - arrow=1.3.0=pyhd8ed1ab_0 - asttokens=2.4.1=pyhd8ed1ab_0 - async-lru=2.0.4=pyhd8ed1ab_0 - attrs=23.2.0=pyh71513ae_0 - - autopep8=2.1.0=pyhd8ed1ab_0 - - aws-c-auth=0.7.16=h7613915_8 - - aws-c-cal=0.6.10=hf6fcf4e_2 - - aws-c-common=0.9.14=hcfcfb64_0 - - aws-c-compression=0.2.18=hf6fcf4e_2 - - aws-c-event-stream=0.4.2=h3df98b0_6 - - aws-c-http=0.8.1=h4e3df0f_7 - - aws-c-io=0.14.6=hf0b8b6f_2 - - aws-c-mqtt=0.10.3=h96fac68_2 - - aws-c-s3=0.5.4=h08df315_0 - - aws-c-sdkutils=0.1.15=hf6fcf4e_2 - - aws-checksums=0.1.18=hf6fcf4e_2 - - aws-crt-cpp=0.26.4=hbe739fa_2 - - aws-sdk-cpp=1.11.267=hfaf0dd0_4 - - azure-core-cpp=1.11.1=h249a519_1 - - azure-storage-blobs-cpp=12.10.0=h91493d7_1 - - azure-storage-common-cpp=12.5.0=h91493d7_4 + - autopep8=2.0.4=pyhd8ed1ab_0 + - azure-core-cpp=1.10.3=h249a519_1 + - azure-storage-blobs-cpp=12.10.0=h91493d7_0 + - azure-storage-common-cpp=12.5.0=h91493d7_2 - babel=2.14.0=pyhd8ed1ab_0 - beautifulsoup4=4.12.3=pyha770c72_0 - bleach=6.1.0=pyhd8ed1ab_0 - blinker=1.7.0=pyhd8ed1ab_0 - blosc=1.21.5=hdccc3a2_0 - - bokeh=3.4.0=pyhd8ed1ab_0 - - boltons=24.0.0=pyhd8ed1ab_0 + - bokeh=3.3.4=pyhd8ed1ab_0 + - boltons=23.1.1=pyhd8ed1ab_0 - boolean.py=4.0=pyhd8ed1ab_0 - - bottleneck=1.3.8=py311h59ca53f_0 + - bottleneck=1.3.7=py311h59ca53f_1 - branca=0.7.1=pyhd8ed1ab_0 - brotli=1.1.0=hcfcfb64_1 - brotli-bin=1.1.0=hcfcfb64_1 - brotli-python=1.1.0=py311h12c1d0e_1 - bzip2=1.0.8=hcfcfb64_5 - - c-ares=1.28.1=hcfcfb64_0 - - ca-certificates=2024.2.2=h56e8100_0 + - c-ares=1.26.0=hcfcfb64_0 + - ca-certificates=2023.11.17=h56e8100_0 - cached-property=1.5.2=hd8ed1ab_1 - cached_property=1.5.2=pyha770c72_1 - cairo=1.18.0=h1fef639_0 - cartopy=0.22.0=py311hf63dbb6_1 - - certifi=2024.2.2=pyhd8ed1ab_0 + - certifi=2023.11.17=pyhd8ed1ab_0 - cffi=1.16.0=py311ha68e1ae_0 - cfgv=3.3.1=pyhd8ed1ab_0 - - cfitsio=4.4.0=h9b0cee5_0 + - cfitsio=4.3.1=h9b0cee5_0 - chardet=5.2.0=py311h1ea47a8_1 - charset-normalizer=3.3.2=pyhd8ed1ab_0 - cirun=0.30=pyhd8ed1ab_0 @@ -63,22 +50,22 @@ dependencies: - cligj=0.7.2=pyhd8ed1ab_1 - cmarkgfm=0.8.0=py311ha68e1ae_3 - colorama=0.4.6=pyhd8ed1ab_0 - - comm=0.2.2=pyhd8ed1ab_0 - - conda=24.3.0=py311h1ea47a8_0 - - conda-build=24.3.0=py311h1ea47a8_1 - - conda-forge-pinning=2024.04.03.08.04.29=hd8ed1ab_0 - - conda-index=0.4.0=pyhd8ed1ab_0 + - comm=0.2.1=pyhd8ed1ab_0 + - conda=23.11.0=py311h1ea47a8_1 + - conda-build=3.28.4=py311h1ea47a8_0 + - conda-forge-pinning=2024.02.01.14.05.18=hd8ed1ab_0 + - conda-index=0.3.0=pyhd8ed1ab_1 - conda-libmamba-solver=24.1.0=pyhd8ed1ab_0 - conda-package-handling=2.2.0=pyh38be061_0 - conda-package-streaming=0.9.0=pyhd8ed1ab_0 - - conda-smithy=3.34.1=pyhd8ed1ab_0 - - contextily=1.6.0=pyhd8ed1ab_0 + - conda-smithy=3.30.4=pyhd8ed1ab_0 + - contextily=1.5.0=pyhd8ed1ab_0 - contourpy=1.2.0=py311h005e61a_0 - - coverage=7.4.4=py311ha68e1ae_0 - - cryptography=42.0.5=py311h28e9c30_0 + - coverage=7.4.1=py311ha68e1ae_0 + - cryptography=42.0.2=py311h7cb4080_0 - cycler=0.12.1=pyhd8ed1ab_0 - - cython=3.0.10=py311h12c1d0e_0 - - debugpy=1.8.1=py311h12c1d0e_0 + - cython=3.0.8=py311h12c1d0e_0 + - debugpy=1.8.0=py311h12c1d0e_1 - decorator=5.1.1=pyhd8ed1ab_0 - defusedxml=0.7.1=pyhd8ed1ab_0 - deprecated=1.2.14=pyh1a96a4e_0 @@ -92,11 +79,11 @@ dependencies: - esda=2.5.1=pyhd8ed1ab_0 - exceptiongroup=1.2.0=pyhd8ed1ab_2 - executing=2.0.1=pyhd8ed1ab_0 - - expat=2.6.2=h63175ca_0 - - filelock=3.13.3=pyhd8ed1ab_0 - - fiona=1.9.6=py311hbcf8545_0 + - expat=2.5.0=h63175ca_1 + - filelock=3.13.1=pyhd8ed1ab_0 + - fiona=1.9.5=py311hbcf8545_3 - fmt=10.2.1=h181d51b_0 - - folium=0.16.0=pyhd8ed1ab_0 + - folium=0.15.1=pyhd8ed1ab_0 - font-ttf-dejavu-sans-mono=2.37=hab24e00_0 - font-ttf-inconsolata=3.000=h77eed37_0 - font-ttf-source-code-pro=2.038=h77eed37_0 @@ -104,127 +91,125 @@ dependencies: - fontconfig=2.14.2=hbde0cde_0 - fonts-conda-ecosystem=1=0 - fonts-conda-forge=1=0 - - fonttools=4.50.0=py311ha68e1ae_0 + - fonttools=4.47.2=py311ha68e1ae_0 - fqdn=1.5.1=pyhd8ed1ab_0 - freetype=2.12.1=hdaf720e_2 - freexl=2.0.0=h8276f4a_0 - furo=2024.1.29=pyhd8ed1ab_0 - - gdal=3.8.4=py311h21a6730_5 - - geographiclib=2.0=pyhd8ed1ab_0 + - gdal=3.8.3=py311hff9a05f_0 + - geographiclib=1.52=pyhd8ed1ab_0 - geopandas=0.14.3=pyhd8ed1ab_0 - geopandas-base=0.14.3=pyha770c72_0 - - geopy=2.4.1=pyhd8ed1ab_1 + - geopy=2.4.1=pyhd8ed1ab_0 - geos=3.12.1=h1537add_0 - geotiff=1.7.1=hbf5ca3a_15 + - gettext=0.21.1=h5728263_0 - giddy=2.3.5=pyhd8ed1ab_0 - - git=2.44.0=h57928b3_0 + - git=2.43.0=h57928b3_0 - gitdb=4.0.11=pyhd8ed1ab_0 - - gitpython=3.1.43=pyhd8ed1ab_0 + - gitpython=3.1.41=pyhd8ed1ab_0 - glpk=5.0=h8ffe710_0 - h11=0.14.0=pyhd8ed1ab_0 - h2=4.1.0=pyhd8ed1ab_0 - - hatch=1.9.4=pyhd8ed1ab_0 + - hatch=1.9.2=pyhd8ed1ab_0 - hatchling=1.21.1=pyhd8ed1ab_0 - hdf4=4.2.15=h5557f11_7 - hdf5=1.14.3=nompi_h73e8ff5_100 - hpack=4.0.0=pyh9f0ad1d_0 - - httpcore=1.0.5=pyhd8ed1ab_0 - - httpx=0.27.0=pyhd8ed1ab_0 + - httpcore=1.0.2=pyhd8ed1ab_0 + - httpx=0.26.0=pyhd8ed1ab_0 - hyperframe=6.0.1=pyhd8ed1ab_0 - hyperlink=21.0.0=pyhd3deb0d_0 - icu=73.2=h63175ca_0 - - identify=2.5.35=pyhd8ed1ab_0 + - identify=2.5.33=pyhd8ed1ab_0 - idna=3.6=pyhd8ed1ab_0 - imagesize=1.4.1=pyhd8ed1ab_0 - - importlib-metadata=7.1.0=pyha770c72_0 - - importlib_metadata=7.1.0=hd8ed1ab_0 - - importlib_resources=6.4.0=pyhd8ed1ab_0 + - importlib-metadata=7.0.1=pyha770c72_0 + - importlib_metadata=7.0.1=hd8ed1ab_0 + - importlib_resources=6.1.1=pyhd8ed1ab_0 - inequality=1.0.1=pyhd8ed1ab_0 - iniconfig=2.0.0=pyhd8ed1ab_0 - - ipykernel=6.29.3=pyha63f2e9_0 - - ipython=8.22.2=pyh7428d3b_0 - - ipywidgets=8.1.2=pyhd8ed1ab_0 + - ipykernel=6.29.0=pyha63f2e9_0 + - ipython=8.21.0=pyh7428d3b_0 + - ipywidgets=8.1.1=pyhd8ed1ab_0 - isodate=0.6.1=pyhd8ed1ab_0 - isoduration=20.11.0=pyhd8ed1ab_0 - - jaraco.classes=3.4.0=pyhd8ed1ab_0 - - jaraco.context=4.3.0=pyhd8ed1ab_0 - - jaraco.functools=4.0.0=pyhd8ed1ab_0 + - jaraco.classes=3.3.0=pyhd8ed1ab_0 - jedi=0.19.1=pyhd8ed1ab_0 - jinja2=3.1.3=pyhd8ed1ab_0 - joblib=1.3.2=pyhd8ed1ab_0 - - json5=0.9.24=pyhd8ed1ab_0 + - json5=0.9.14=pyhd8ed1ab_0 - jsonpatch=1.33=pyhd8ed1ab_0 - jsonpointer=2.4=py311h1ea47a8_3 - jsonschema=4.21.1=pyhd8ed1ab_0 - jsonschema-specifications=2023.12.1=pyhd8ed1ab_0 - jsonschema-with-format-nongpl=4.21.1=pyhd8ed1ab_0 - - jupyter-lsp=2.2.4=pyhd8ed1ab_0 + - jupyter-lsp=2.2.2=pyhd8ed1ab_0 - jupyter-server-mathjax=0.2.6=pyh5bfe37b_1 - - jupyter_client=8.6.1=pyhd8ed1ab_0 - - jupyter_core=5.7.2=py311h1ea47a8_0 - - jupyter_events=0.10.0=pyhd8ed1ab_0 - - jupyter_server=2.13.0=pyhd8ed1ab_0 - - jupyter_server_terminals=0.5.3=pyhd8ed1ab_0 - - jupyterlab=4.1.5=pyhd8ed1ab_0 - - jupyterlab_pygments=0.3.0=pyhd8ed1ab_1 - - jupyterlab_server=2.25.4=pyhd8ed1ab_0 - - jupyterlab_widgets=3.0.10=pyhd8ed1ab_0 + - jupyter_client=8.6.0=pyhd8ed1ab_0 + - jupyter_core=5.7.1=py311h1ea47a8_0 + - jupyter_events=0.9.0=pyhd8ed1ab_0 + - jupyter_server=2.12.5=pyhd8ed1ab_0 + - jupyter_server_terminals=0.5.2=pyhd8ed1ab_0 + - jupyterlab=4.0.12=pyhd8ed1ab_0 + - jupyterlab_pygments=0.3.0=pyhd8ed1ab_0 + - jupyterlab_server=2.25.2=pyhd8ed1ab_0 + - jupyterlab_widgets=3.0.9=pyhd8ed1ab_0 - kealib=1.5.3=hd248416_0 - - keyring=25.1.0=pyh7428d3b_0 + - keyring=24.3.0=py311h1ea47a8_0 - kiwisolver=1.4.5=py311h005e61a_1 - krb5=1.21.2=heb0366b_0 - lcms2=2.16=h67d730c_0 - lerc=4.0.0=h63175ca_0 - - libabseil=20240116.1=cxx17_h63175ca_2 - - libaec=1.1.3=h63175ca_0 + - libabseil=20230802.1=cxx17_h63175ca_0 + - libaec=1.1.2=h63175ca_1 - libarchive=3.7.2=h313118b_1 - libblas=3.9.0=21_win64_openblas - - libboost-headers=1.84.0=h57928b3_2 + - libboost-headers=1.84.0=h57928b3_0 - libbrotlicommon=1.1.0=hcfcfb64_1 - libbrotlidec=1.1.0=hcfcfb64_1 - libbrotlienc=1.1.0=hcfcfb64_1 - libcblas=3.9.0=21_win64_openblas - libcrc32c=1.1.2=h0e60522_0 - - libcurl=8.7.1=hd5e4a3a_0 - - libdeflate=1.20=hcfcfb64_0 - - libexpat=2.6.2=h63175ca_0 + - libcurl=8.5.0=hd5e4a3a_0 + - libdeflate=1.19=hcfcfb64_0 + - libexpat=2.5.0=h63175ca_1 - libffi=3.4.2=h8ffe710_5 - libflang=5.0.0=h6538335_20180525 - - libgdal=3.8.4=hf83a0e2_5 - - libglib=2.80.0=h39d0aa6_1 - - libgoogle-cloud=2.22.0=h9cad5c0_1 - - libgoogle-cloud-storage=2.22.0=hb581fae_1 - - libgrpc=1.62.1=h5273850_0 + - libgdal=3.8.3=h576f4c1_0 + - libglib=2.78.3=h16e383f_0 + - libgoogle-cloud=2.12.0=hc7cbac0_5 + - libgrpc=1.60.0=h0bf0bfa_1 - libiconv=1.17=hcfcfb64_2 - libjpeg-turbo=3.0.0=hcfcfb64_1 - libkml=1.3.0=haf3e7a6_1018 - liblapack=3.9.0=21_win64_openblas - - liblief=0.14.1=he0c23c2_1 - - libmamba=1.5.8=h3f09ed1_0 - - libmambapy=1.5.8=py311h0317a69_0 + - liblief=0.12.3=h63175ca_0 + - libmamba=1.5.6=h3f09ed1_0 + - libmambapy=1.5.6=py311h0317a69_0 - libnetcdf=4.9.2=nompi_h07c049d_113 - libopenblas=0.3.26=pthreads_hc140b1d_0 - - libpng=1.6.43=h19919ed_0 - - libpq=16.2=hdb24f17_1 - - libprotobuf=4.25.3=h503648d_0 - - libpysal=4.10=pyhd8ed1ab_0 - - libre2-11=2023.09.01=hf8d8778_2 + - libpng=1.6.42=h19919ed_0 + - libpq=16.1=hdb24f17_7 + - libprotobuf=4.25.1=hb8276f3_0 + - libpysal=4.9.2=pyhd8ed1ab_1 + - libre2-11=2023.06.02=h8c5ae5e_0 - librttopo=1.1.0=h94c4f80_15 - libsodium=1.0.18=h8d14728_1 - - libsolv=0.7.28=h12be248_2 + - libsolv=0.7.27=h12be248_0 - libspatialindex=1.9.3=h39d44d4_4 - libspatialite=5.1.0=hf2f0abc_4 - - libsqlite=3.45.2=hcfcfb64_0 + - libsqlite=3.44.2=hcfcfb64_0 - libssh2=1.11.0=h7dfc565_0 - - libtiff=4.6.0=hddb2be6_3 + - libtiff=4.6.0=h6e2ebb7_2 - libwebp-base=1.3.2=hcfcfb64_0 - libxcb=1.15=hcd874cb_0 - - libxml2=2.12.6=hc3477c8_1 + - libxml2=2.12.4=hc3477c8_1 - libzip=1.10.1=h1d365fa_3 - libzlib=1.2.13=hcfcfb64_5 - license-expression=30.1.1=pyhd8ed1ab_0 - llvm-meta=5.0.0=0 - - llvmlite=0.42.0=py311h5bc0dda_1 + - llvmlite=0.41.1=py311h5bc0dda_0 - lz4-c=1.9.4=hcfcfb64_0 - lzo=2.10=he774522_1000 - m2-msys2-runtime=2.5.0.17080.65c939c=3 @@ -236,14 +221,14 @@ dependencies: - m2w64-libwinpthread-git=5.0.0.4634.697f757=2 - mapclassify=2.6.1=pyhd8ed1ab_0 - markdown-it-py=3.0.0=pyhd8ed1ab_0 - - markupsafe=2.1.5=py311ha68e1ae_0 - - matplotlib-base=3.8.3=py311h6e989c2_0 + - markupsafe=2.1.4=py311ha68e1ae_0 + - matplotlib-base=3.8.2=py311h6e989c2_0 - matplotlib-inline=0.1.6=pyhd8ed1ab_0 - mdurl=0.1.2=pyhd8ed1ab_0 - menuinst=2.0.2=py311h12c1d0e_0 - mercantile=1.2.1=pyhd8ed1ab_0 - mgwr=2.2.1=pyhd8ed1ab_0 - - minizip=4.0.5=h5bed578_0 + - minizip=4.0.4=h5bed578_0 - mistune=3.0.2=pyhd8ed1ab_0 - momepy=0.7.0=pyhd8ed1ab_0 - more-itertools=10.2.0=pyhd8ed1ab_0 @@ -252,49 +237,49 @@ dependencies: - msrest=0.6.21=pyh44b312d_0 - msys2-conda-epoch=20160418=1 - munkres=1.1.4=pyh9f0ad1d_0 - - nbclient=0.10.0=pyhd8ed1ab_0 - - nbconvert-core=7.16.3=pyhd8ed1ab_0 + - nbclient=0.8.0=pyhd8ed1ab_0 + - nbconvert-core=7.14.2=pyhd8ed1ab_0 - nbdime=4.0.1=pyhd8ed1ab_0 - - nbformat=5.10.3=pyhd8ed1ab_0 - - nbqa=1.8.5=pyhd8ed1ab_0 + - nbformat=5.9.2=pyhd8ed1ab_0 + - nbqa=1.7.1=pyhd8ed1ab_0 - nest-asyncio=1.6.0=pyhd8ed1ab_0 - networkx=3.2.1=pyhd8ed1ab_0 - - nh3=0.2.17=py311h633b200_0 + - nh3=0.2.15=py311h633b200_0 - nodeenv=1.8.0=pyhd8ed1ab_0 - nomkl=1.0=h5ca1d4c_0 - - notebook-shim=0.2.4=pyhd8ed1ab_0 - - numba=0.59.1=py311h2c0921f_0 - - numexpr=2.9.0=py311h0aebda5_100 - - numpy=1.26.4=py311h0b4df5a_0 + - notebook-shim=0.2.3=pyhd8ed1ab_0 + - numba=0.58.1=py311h2c0921f_0 + - numexpr=2.8.8=py311h0aebda5_100 + - numpy=1.26.3=py311h0b4df5a_0 - oauthlib=3.2.2=pyhd8ed1ab_0 - - openjpeg=2.5.2=h3d672ee_0 + - openjpeg=2.5.0=h3d672ee_3 - openmp=5.0.0=vc14_1 - - openssl=3.2.1=hcfcfb64_1 - - osmnx=1.9.2=pyhd8ed1ab_0 + - openssl=3.2.1=hcfcfb64_0 + - osmnx=1.9.1=pyhd8ed1ab_0 - overrides=7.7.0=pyhd8ed1ab_0 - - packaging=24.0=pyhd8ed1ab_0 - - pandas=2.2.1=py311hf63dbb6_0 + - packaging=23.2=pyhd8ed1ab_0 + - pandas=2.2.0=py311hf63dbb6_0 - pandocfilters=1.5.0=pyhd8ed1ab_0 - parso=0.8.3=pyhd8ed1ab_0 - pathspec=0.12.1=pyhd8ed1ab_0 - patsy=0.5.6=pyhd8ed1ab_0 - - pcre2=10.43=h17e33f8_0 + - pcre2=10.42=h17e33f8_0 - pexpect=4.9.0=pyhd8ed1ab_0 - pickleshare=0.7.5=py_1003 - pillow=10.2.0=py311h4dd8a23_0 - - pip=24.0=pyhd8ed1ab_0 - - pixman=0.43.4=h63175ca_0 - - pkginfo=1.10.0=pyhd8ed1ab_0 + - pip=23.3.2=pyhd8ed1ab_0 + - pixman=0.43.2=h63175ca_0 + - pkginfo=1.9.6=pyhd8ed1ab_0 - pkgutil-resolve-name=1.3.10=pyhd8ed1ab_1 - platformdirs=4.2.0=pyhd8ed1ab_0 - pluggy=1.4.0=pyhd8ed1ab_0 - pointpats=2.4.0=pyhd8ed1ab_0 - - poppler=24.03.0=hc2f3c52_0 + - poppler=23.12.0=hc2f3c52_0 - poppler-data=0.4.12=hd8ed1ab_0 - - postgresql=16.2=h94c9ec1_1 - - pre-commit=3.7.0=pyha770c72_0 + - postgresql=16.1=h1beaf6b_7 + - pre-commit=3.6.0=pyha770c72_0 - proj=9.3.1=he13c7e8_0 - - prometheus_client=0.20.0=pyhd8ed1ab_0 + - prometheus_client=0.19.0=pyhd8ed1ab_0 - prompt-toolkit=3.0.42=pyha770c72_0 - psutil=5.9.8=py311ha68e1ae_0 - psycopg2=2.9.9=py311h2abc067_0 @@ -302,78 +287,77 @@ dependencies: - ptyprocess=0.7.0=pyhd3deb0d_0 - pulp=2.8.0=py311h1ea47a8_0 - pure_eval=0.2.2=pyhd8ed1ab_0 - - py-lief=0.14.1=py311hda3d55a_1 + - py-lief=0.12.3=py311h12c1d0e_0 - pybind11-abi=4=hd8ed1ab_3 - pycodestyle=2.11.1=pyhd8ed1ab_0 - pycosat=0.6.6=py311ha68e1ae_0 - - pycparser=2.22=pyhd8ed1ab_0 + - pycparser=2.21=pyhd8ed1ab_0 - pycryptodome=3.20.0=py311ha68e1ae_0 - - pygithub=2.3.0=pyhd8ed1ab_0 + - pygithub=2.2.0=pyhd8ed1ab_0 - pygments=2.17.2=pyhd8ed1ab_0 - - pyjwt=2.8.0=pyhd8ed1ab_1 + - pyjwt=2.8.0=pyhd8ed1ab_0 - pynacl=1.5.0=py311hd53affc_3 - - pyparsing=3.1.2=pyhd8ed1ab_0 + - pyparsing=3.1.1=pyhd8ed1ab_0 - pyproj=3.6.1=py311h82130bc_5 - pysal=23.7=pyhd8ed1ab_0 - pyshp=2.3.1=pyhd8ed1ab_0 - pysocks=1.7.1=pyh0701188_6 - - pytest=8.1.1=pyhd8ed1ab_0 - - pytest-cov=5.0.0=pyhd8ed1ab_0 - - python=3.11.8=h2628c8c_0_cpython - - python-dateutil=2.9.0=pyhd8ed1ab_0 + - pytest=8.0.0=pyhd8ed1ab_0 + - pytest-cov=4.1.0=pyhd8ed1ab_0 + - python=3.11.7=h2628c8c_1_cpython + - python-dateutil=2.8.2=pyhd8ed1ab_0 - python-fastjsonschema=2.19.1=pyhd8ed1ab_0 - - python-igraph=0.11.4=py311h8ea321e_0 + - python-igraph=0.11.3=py311h9092318_1 - python-json-logger=2.0.7=pyhd8ed1ab_0 - - python-libarchive-c=5.1=py311h1ea47a8_0 - - python-tzdata=2024.1=pyhd8ed1ab_0 + - python-libarchive-c=5.0=py311h1ea47a8_2 + - python-tzdata=2023.4=pyhd8ed1ab_0 - python_abi=3.11=4_cp311 - - pytz=2024.1=pyhd8ed1ab_0 + - pytz=2023.4=pyhd8ed1ab_0 - pywin32=306=py311h12c1d0e_2 - pywin32-ctypes=0.2.2=py311h1ea47a8_1 - - pywinpty=2.0.13=py311h12c1d0e_0 + - pywinpty=2.0.12=py311h12c1d0e_0 - pyyaml=6.0.1=py311ha68e1ae_1 - pyzmq=25.1.2=py311h9250fbb_0 - - quantecon=0.7.2=pyhd8ed1ab_0 + - quantecon=0.5.3=pyhd8ed1ab_0 - rasterio=1.3.9=py311h02f6225_2 - rasterstats=0.19.0=pyhd8ed1ab_0 - - re2=2023.09.01=hd3b24a8_2 + - re2=2023.06.02=hcbb65ff_0 - readme_renderer=42.0=pyhd8ed1ab_0 - - referencing=0.34.0=pyhd8ed1ab_0 + - referencing=0.33.0=pyhd8ed1ab_0 - reproc=14.2.4.post0=hcfcfb64_1 - reproc-cpp=14.2.4.post0=h63175ca_1 - requests=2.31.0=pyhd8ed1ab_0 - - requests-oauthlib=2.0.0=pyhd8ed1ab_0 + - requests-oauthlib=1.3.1=pyhd8ed1ab_0 - requests-toolbelt=1.0.0=pyhd8ed1ab_0 - rfc3339-validator=0.1.4=pyhd8ed1ab_0 - rfc3986=2.0.0=pyhd8ed1ab_0 - rfc3986-validator=0.1.1=pyh9f0ad1d_0 - - rich=13.7.1=pyhd8ed1ab_0 + - rich=13.7.0=pyhd8ed1ab_0 - ripgrep=14.1.0=h7f3b576_0 - - rpds-py=0.18.0=py311hc37eb10_0 + - rpds-py=0.17.1=py311hc37eb10_0 - rtree=1.2.0=py311hcacb13a_0 - - ruamel.yaml=0.18.6=py311ha68e1ae_0 - - ruamel.yaml.clib=0.2.8=py311ha68e1ae_0 - - ruff=0.3.5=py311hc14472d_0 - - scikit-learn=1.4.1.post1=py311h142b183_0 + - ruamel.yaml=0.18.5=py311ha68e1ae_0 + - ruamel.yaml.clib=0.2.7=py311ha68e1ae_2 + - ruff=0.1.15=py311hc14472d_0 + - scikit-learn=1.4.0=py311h142b183_0 - scipy=1.12.0=py311h0b4df5a_2 - - scrypt=0.8.24=py311h4be8fce_0 + - scrypt=0.8.20=py311h4be8fce_1 - seaborn=0.13.2=hd8ed1ab_0 - seaborn-base=0.13.2=pyhd8ed1ab_0 - segregation=2.5=pyhd8ed1ab_1 - send2trash=1.8.2=pyh08f2357_0 - - setuptools=69.2.0=pyhd8ed1ab_0 - - shapely=2.0.3=py311h16bee0b_0 + - setuptools=69.0.3=pyhd8ed1ab_0 + - shapely=2.0.2=py311h16bee0b_1 - shellingham=1.5.4=pyhd8ed1ab_0 - simplejson=3.19.2=py311ha68e1ae_0 - six=1.16.0=pyh6c4a22f_0 - smmap=5.0.0=pyhd8ed1ab_0 - snappy=1.1.10=hfb803bf_0 - - sniffio=1.3.1=pyhd8ed1ab_0 + - sniffio=1.3.0=pyhd8ed1ab_0 - snowballstemmer=2.2.0=pyhd8ed1ab_0 - snuggs=1.4.7=py_0 - soupsieve=2.5=pyhd8ed1ab_1 - spaghetti=1.7.5.post1=pyhd8ed1ab_0 - - spdlog=1.12.0=h64d2f7d_2 - spglm=1.1.0=pyhd8ed1ab_1 - sphinx=7.2.6=pyhd8ed1ab_0 - sphinx-basic-ng=1.0.0b2=pyhd8ed1ab_1 @@ -388,14 +372,14 @@ dependencies: - spopt=0.6.0=pyhd8ed1ab_0 - spreg=1.4.2=pyhd8ed1ab_0 - spvcm=0.3.0=pyhd8ed1ab_1 - - sqlite=3.45.2=hcfcfb64_0 + - sqlite=3.44.2=hcfcfb64_0 - stack_data=0.6.2=pyhd8ed1ab_0 - statsmodels=0.14.1=py311h59ca53f_0 - sympy=1.12=pyh04b8f61_3 - - terminado=0.18.1=pyh5737063_0 + - terminado=0.18.0=pyh5737063_0 - texttable=1.7.0=pyhd8ed1ab_0 - - threadpoolctl=3.4.0=pyhc1e730c_0 - - tiledb=2.21.1=h25b666a_1 + - threadpoolctl=3.2.0=pyha21a80b_0 + - tiledb=2.19.1=h2657894_0 - tinycss2=1.2.1=pyhd8ed1ab_0 - tk=8.6.13=h5226925_1 - tobler=0.11.2=pyhd8ed1ab_2 @@ -403,37 +387,37 @@ dependencies: - toml=0.10.2=pyhd8ed1ab_0 - tomli=2.0.1=pyhd8ed1ab_0 - tomli-w=1.0.0=pyhd8ed1ab_0 - - tomlkit=0.12.4=pyha770c72_0 + - tomlkit=0.12.3=pyha770c72_0 - toolz=0.12.1=pyhd8ed1ab_0 - - tornado=6.4=py311ha68e1ae_0 - - tqdm=4.66.2=pyhd8ed1ab_0 - - traitlets=5.14.2=pyhd8ed1ab_0 - - trove-classifiers=2024.3.25=pyhd8ed1ab_0 + - tornado=6.3.3=py311ha68e1ae_1 + - tqdm=4.66.1=pyhd8ed1ab_0 + - traitlets=5.14.1=pyhd8ed1ab_0 + - trove-classifiers=2024.1.8=pyhd8ed1ab_0 - truststore=0.8.0=pyhd8ed1ab_0 - - twine=5.0.0=pyhd8ed1ab_0 - - typer=0.11.1=pyhd8ed1ab_0 - - types-python-dateutil=2.9.0.20240316=pyhd8ed1ab_0 - - typing-extensions=4.10.0=hd8ed1ab_0 - - typing_extensions=4.10.0=pyha770c72_0 + - twine=4.0.2=pyhd8ed1ab_0 + - typer=0.9.0=pyhd8ed1ab_0 + - types-python-dateutil=2.8.19.20240106=pyhd8ed1ab_0 + - typing-extensions=4.9.0=hd8ed1ab_0 + - typing_extensions=4.9.0=pyha770c72_0 - typing_utils=0.1.0=pyhd8ed1ab_0 - - tzdata=2024a=h0c530f3_0 + - tzdata=2023d=h0c530f3_0 - ucrt=10.0.22621.0=h57928b3_0 - ukkonen=1.0.1=py311h005e61a_4 - uri-template=1.3.0=pyhd8ed1ab_0 - uriparser=0.9.7=h1537add_1 - - urllib3=2.2.1=pyhd8ed1ab_0 + - urllib3=2.2.0=pyhd8ed1ab_0 - userpath=1.7.0=pyhd8ed1ab_0 - vc=14.3=hcf57466_18 - vc14_runtime=14.38.33130=h82b7239_18 - - virtualenv=20.25.1=pyhd8ed1ab_0 + - virtualenv=20.25.0=pyhd8ed1ab_0 - vs2015_runtime=14.38.33130=hcb4865c_18 - vsts-python-api=0.1.25=pyhd8ed1ab_1 - wcwidth=0.2.13=pyhd8ed1ab_0 - webcolors=1.13=pyhd8ed1ab_0 - webencodings=0.5.1=pyhd8ed1ab_2 - websocket-client=1.7.0=pyhd8ed1ab_0 - - wheel=0.43.0=pyhd8ed1ab_1 - - widgetsnbextension=4.0.10=pyhd8ed1ab_0 + - wheel=0.42.0=pyhd8ed1ab_0 + - widgetsnbextension=4.0.9=pyhd8ed1ab_0 - win_inet_pton=1.1.0=pyhd8ed1ab_6 - winpty=0.4.3=4 - wrapt=1.16.0=py311ha68e1ae_0 @@ -444,7 +428,7 @@ dependencies: - xz=5.2.6=h8d14728_0 - yaml=0.2.5=h8ffe710_2 - yaml-cpp=0.8.0=h63175ca_0 - - zeromq=4.3.5=h63175ca_1 + - zeromq=4.3.5=h63175ca_0 - zipp=3.17.0=pyhd8ed1ab_0 - zlib=1.2.13=hcfcfb64_5 - zstandard=0.22.0=py311he5d195f_0 diff --git a/osmnx/__init__.py b/osmnx/__init__.py index e21f2e708..0b6e82aea 100644 --- a/osmnx/__init__.py +++ b/osmnx/__init__.py @@ -1,4 +1,53 @@ -"""OSMnx init.""" +# ruff: noqa: F401,PLC0414 +"""Define the OSMnx package's namespace.""" -from ._api import * # noqa: F401, F403 -from ._version import __version__ # noqa: F401 +# expose the package version +from ._version import __version__ as __version__ + +# expose the old pre-v2 API for backwards compatibility. this allows common +# functionality to be accessed directly via the ox.function_name() shortcut +# by exposing these functions directly in the package's namespace. +from .bearing import add_edge_bearings as add_edge_bearings +from .bearing import orientation_entropy as orientation_entropy +from .convert import graph_from_gdfs as graph_from_gdfs +from .convert import graph_to_gdfs as graph_to_gdfs +from .distance import nearest_edges as nearest_edges +from .distance import nearest_nodes as nearest_nodes +from .elevation import add_edge_grades as add_edge_grades +from .elevation import add_node_elevations_google as add_node_elevations_google +from .elevation import add_node_elevations_raster as add_node_elevations_raster +from .features import features_from_address as features_from_address +from .features import features_from_bbox as features_from_bbox +from .features import features_from_place as features_from_place +from .features import features_from_point as features_from_point +from .features import features_from_polygon as features_from_polygon +from .features import features_from_xml as features_from_xml +from .geocoder import geocode as geocode +from .geocoder import geocode_to_gdf as geocode_to_gdf +from .graph import graph_from_address as graph_from_address +from .graph import graph_from_bbox as graph_from_bbox +from .graph import graph_from_place as graph_from_place +from .graph import graph_from_point as graph_from_point +from .graph import graph_from_polygon as graph_from_polygon +from .graph import graph_from_xml as graph_from_xml +from .io import load_graphml as load_graphml +from .io import save_graph_geopackage as save_graph_geopackage +from .io import save_graph_xml as save_graph_xml +from .io import save_graphml as save_graphml +from .plot import plot_figure_ground as plot_figure_ground +from .plot import plot_footprints as plot_footprints +from .plot import plot_graph as plot_graph +from .plot import plot_graph_route as plot_graph_route +from .plot import plot_graph_routes as plot_graph_routes +from .plot import plot_orientation as plot_orientation +from .projection import project_graph as project_graph +from .routing import add_edge_speeds as add_edge_speeds +from .routing import add_edge_travel_times as add_edge_travel_times +from .routing import k_shortest_paths as k_shortest_paths +from .routing import shortest_path as shortest_path +from .simplification import consolidate_intersections as consolidate_intersections +from .simplification import simplify_graph as simplify_graph +from .stats import basic_stats as basic_stats +from .utils import citation as citation +from .utils import log as log +from .utils import ts as ts diff --git a/osmnx/_api.py b/osmnx/_api.py deleted file mode 100644 index 74e0c1fd8..000000000 --- a/osmnx/_api.py +++ /dev/null @@ -1,61 +0,0 @@ -# ruff: noqa: F401 -"""Expose most common parts of public API directly in package namespace.""" - -from . import speed -from .bearing import add_edge_bearings -from .bearing import orientation_entropy -from .convert import graph_from_gdfs -from .convert import graph_to_gdfs -from .distance import nearest_edges -from .distance import nearest_nodes -from .elevation import add_edge_grades -from .elevation import add_node_elevations_google -from .elevation import add_node_elevations_raster -from .features import features_from_address -from .features import features_from_bbox -from .features import features_from_place -from .features import features_from_point -from .features import features_from_polygon -from .features import features_from_xml -from .folium import plot_graph_folium -from .folium import plot_route_folium -from .geocoder import geocode -from .geocoder import geocode_to_gdf -from .geometries import geometries_from_address -from .geometries import geometries_from_bbox -from .geometries import geometries_from_place -from .geometries import geometries_from_point -from .geometries import geometries_from_polygon -from .geometries import geometries_from_xml -from .graph import graph_from_address -from .graph import graph_from_bbox -from .graph import graph_from_place -from .graph import graph_from_point -from .graph import graph_from_polygon -from .graph import graph_from_xml -from .io import load_graphml -from .io import save_graph_geopackage -from .io import save_graph_shapefile -from .io import save_graph_xml -from .io import save_graphml -from .plot import plot_figure_ground -from .plot import plot_footprints -from .plot import plot_graph -from .plot import plot_graph_route -from .plot import plot_graph_routes -from .plot import plot_orientation -from .projection import project_gdf -from .projection import project_graph -from .routing import add_edge_speeds -from .routing import add_edge_travel_times -from .routing import k_shortest_paths -from .routing import shortest_path -from .simplification import consolidate_intersections -from .simplification import simplify_graph -from .stats import basic_stats -from .utils import citation -from .utils import config -from .utils import log -from .utils import ts -from .utils_graph import get_digraph -from .utils_graph import get_undirected diff --git a/osmnx/_errors.py b/osmnx/_errors.py index 40b2fd9d7..216ca6066 100644 --- a/osmnx/_errors.py +++ b/osmnx/_errors.py @@ -2,7 +2,7 @@ class CacheOnlyInterruptError(InterruptedError): - """Exception for settings.cache_only_mode=True interruption.""" + """Exception for `settings.cache_only_mode=True` interruption.""" class GraphSimplificationError(ValueError): diff --git a/osmnx/_downloader.py b/osmnx/_http.py similarity index 50% rename from osmnx/_downloader.py rename to osmnx/_http.py index 14e5e0305..cecdf75e9 100644 --- a/osmnx/_downloader.py +++ b/osmnx/_http.py @@ -1,12 +1,14 @@ """Handle HTTP requests to web APIs.""" +from __future__ import annotations + import json import logging as lg import socket from hashlib import sha1 from pathlib import Path +from typing import Any from urllib.parse import urlparse -from warnings import warn import requests from requests.exceptions import JSONDecodeError @@ -20,15 +22,19 @@ _original_getaddrinfo = socket.getaddrinfo -def _save_to_cache(url, response_json, ok): +def _save_to_cache( + url: str, + response_json: dict[str, Any] | list[dict[str, Any]], + ok: bool, # noqa: FBT001 +) -> None: """ Save a HTTP response JSON object to a file in the cache folder. - Function calculates the checksum of url to generate the cache file's name. - If the request was sent to server via POST instead of GET, then URL should - be a GET-style representation of request. Response is only saved to a - cache file if settings.use_cache is True, response_json is not None, and - ok is True. + This calculates the checksum of `url` to generate the cache file name. If + the request was sent to server via POST instead of GET, then `url` should + be a GET-style representation of the request. Response is only saved to a + cache file if `settings.use_cache` is True, `response_json` is not None, + and `ok` is True. Users should always pass OrderedDicts instead of dicts of parameters into request functions, so the parameters remain in the same order each time, @@ -38,12 +44,12 @@ def _save_to_cache(url, response_json, ok): Parameters ---------- - url : string - the URL of the request - response_json : dict - the JSON response - ok : bool - requests response.ok value + url + The URL of the request. + response_json + The JSON response from the server. + ok + A `requests.response.ok` value. Returns ------- @@ -51,11 +57,8 @@ def _save_to_cache(url, response_json, ok): """ if settings.use_cache: if not ok: # pragma: no cover - utils.log("Did not save to cache because response status_code is not OK") - - elif response_json is None: # pragma: no cover - utils.log("Did not save to cache because response_json is None") - + msg = "Did not save to cache because HTTP status code is not OK" + utils.log(msg, level=lg.WARNING) else: # create the folder on the disk if it doesn't already exist cache_folder = Path(settings.cache_folder) @@ -63,142 +66,119 @@ def _save_to_cache(url, response_json, ok): # hash the url to make the filename succinct but unique # sha1 digest is 160 bits = 20 bytes = 40 hexadecimal characters - filename = sha1(url.encode("utf-8")).hexdigest() + ".json" - cache_filepath = cache_folder / filename + checksum = sha1(url.encode("utf-8")).hexdigest() # noqa: S324 + cache_filepath = cache_folder / f"{checksum}.json" # dump to json, and save to file cache_filepath.write_text(json.dumps(response_json), encoding="utf-8") - utils.log(f"Saved response to cache file {str(cache_filepath)!r}") + msg = f"Saved response to cache file {str(cache_filepath)!r}" + utils.log(msg, level=lg.INFO) -def _url_in_cache(url): +def _url_in_cache(url: str) -> Path | None: """ Determine if a URL's response exists in the cache. - Calculates the checksum of url to determine the cache file's name. + Calculates the checksum of `url` to determine the cache file's name. + Returns None if it cannot be found in the cache. Parameters ---------- - url : string - the URL to look for in the cache + url + The URL to look for in the cache. Returns ------- - filepath : pathlib.Path - path to cached response for url if it exists, otherwise None + cache_filepath + Path to cached response for `url` if it exists, otherwise None. """ # hash the url to generate the cache filename - filename = sha1(url.encode("utf-8")).hexdigest() + ".json" - filepath = Path(settings.cache_folder) / filename + checksum = sha1(url.encode("utf-8")).hexdigest() # noqa: S324 + cache_filepath = Path(settings.cache_folder) / f"{checksum}.json" # if this file exists in the cache, return its full path - return filepath if filepath.is_file() else None + return cache_filepath if cache_filepath.is_file() else None -def _retrieve_from_cache(url, check_remark=True): +def _retrieve_from_cache(url: str) -> dict[str, Any] | list[dict[str, Any]] | None: """ - Retrieve a HTTP response JSON object from the cache, if it exists. + Retrieve a HTTP response JSON object from the cache if it exists. + + Returns None if there is a server remark in the cached response. Parameters ---------- - url : string - the URL of the request - check_remark : string - if True, only return filepath if cached response does not have a - remark key indicating a server warning + url + The URL of the request. Returns ------- - response_json : dict - cached response for url if it exists in the cache, otherwise None + response_json + Cached response for `url` if it exists in the cache and does not + contain a server remark, otherwise None. """ # if the tool is configured to use the cache if settings.use_cache: # return cached response for this url if exists, otherwise return None cache_filepath = _url_in_cache(url) if cache_filepath is not None: - response_json = json.loads(cache_filepath.read_text(encoding="utf-8")) + response_json: dict[str, Any] | list[dict[str, Any]] = json.loads( + cache_filepath.read_text(encoding="utf-8"), + ) - # return None if check_remark is True and there is a server - # remark in the cached response - if check_remark and "remark" in response_json: # pragma: no cover - utils.log( + # return None if there is a server remark in the cached response + if isinstance(response_json, dict) and ("remark" in response_json): # pragma: no cover + msg = ( f"Ignoring cache file {str(cache_filepath)!r} because " f"it contains a remark: {response_json['remark']!r}" ) + utils.log(msg, lg.WARNING) return None - utils.log(f"Retrieved response from cache file {str(cache_filepath)!r}") + msg = f"Retrieved response from cache file {str(cache_filepath)!r}" + utils.log(msg, lg.INFO) return response_json + return None -def _get_http_headers(user_agent=None, referer=None, accept_language=None): +def _get_http_headers( + *, + user_agent: str | None = None, + referer: str | None = None, + accept_language: str | None = None, +) -> dict[str, str]: """ - Update the default requests HTTP headers with OSMnx info. + Update the default requests HTTP headers with OSMnx information. Parameters ---------- - user_agent : string - the user agent string, if None will set with OSMnx default - referer : string - the referer string, if None will set with OSMnx default - accept_language : string - make accept-language explicit e.g. for consistent nominatim result - sorting + user_agent + The user agent. If None, use `settings.http_user_agent` value. + referer + The referer. If None, use `settings.http_referer` value. + accept_language + The accept language. If None, use `settings.http_accept_language` + value. Returns ------- - headers : dict + headers """ - if settings.default_accept_language is None: - default_accept_language = settings.http_accept_language - else: - default_accept_language = settings.default_accept_language - msg = ( - "`settings.default_accept_language` is deprecated and will be removed " - "in the v2.0.0 release: use `settings.http_accept_language` instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - - if settings.default_referer is None: - default_referer = settings.http_referer - else: - default_referer = settings.default_referer - msg = ( - "`settings.default_referer` is deprecated and will be removed in the " - "v2.0.0 release: use `settings.http_referer` instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - - if settings.default_user_agent is None: - default_user_agent = settings.http_user_agent - else: - default_user_agent = settings.default_user_agent - msg = ( - "`settings.default_user_agent` is deprecated and will be removed in " - "the v2.0.0 release: use `settings.http_user_agent` instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - if user_agent is None: - user_agent = default_user_agent + user_agent = settings.http_user_agent if referer is None: - referer = default_referer + referer = settings.http_referer if accept_language is None: - accept_language = default_accept_language + accept_language = settings.http_accept_language - headers = requests.utils.default_headers() - headers.update( - {"User-Agent": user_agent, "referer": referer, "Accept-Language": accept_language} - ) + info = {"User-Agent": user_agent, "referer": referer, "Accept-Language": accept_language} + headers = dict(requests.utils.default_headers()) + headers.update(info) return headers -def _resolve_host_via_doh(hostname): +def _resolve_host_via_doh(hostname: str) -> str: """ Resolve hostname to IP address via Google's public DNS-over-HTTPS API. @@ -211,34 +191,24 @@ def _resolve_host_via_doh(hostname): Parameters ---------- - hostname : string - the hostname to consistently resolve the IP address of + hostname + The hostname to consistently resolve the IP address of. Returns ------- - ip_address : string - resolved IP address of host, or hostname itself if resolution failed + ip_address + Resolved IP address of host, or hostname itself if resolution failed. """ - if settings.timeout is None: - timeout = settings.requests_timeout - else: - timeout = settings.timeout - msg = ( - "`settings.timeout` is deprecated and will be removed in the v2.0.0 " - "release: use `settings.requests_timeout` instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - if settings.doh_url_template is None: # if user has set the url template to None, return hostname itself - utils.log("User set `doh_url_template=None`, requesting host by name", level=lg.WARNING) + msg = "User set `doh_url_template=None`, requesting host by name" + utils.log(msg, level=lg.WARNING) return hostname err_msg = f"Failed to resolve {hostname!r} IP via DoH, requesting host by name" try: url = settings.doh_url_template.format(hostname=hostname) - response = requests.get(url, timeout=timeout) + response = requests.get(url, timeout=settings.requests_timeout) data = response.json() # if we cannot reach DoH server or resolve host, return hostname itself @@ -246,11 +216,12 @@ def _resolve_host_via_doh(hostname): utils.log(err_msg, level=lg.ERROR) return hostname - # if there were no exceptions, return + # if there were no request exceptions, return else: if response.ok and data["Status"] == 0: # status 0 means NOERROR, so return the IP address - return data["Answer"][0]["data"] + ip_address: str = data["Answer"][0]["data"] + return ip_address # otherwise, if we cannot reach DoH server or cannot resolve host # just return the hostname itself @@ -258,7 +229,7 @@ def _resolve_host_via_doh(hostname): return hostname -def _config_dns(url): +def _config_dns(url: str) -> None: """ Force socket.getaddrinfo to use IP address instead of hostname. @@ -277,8 +248,8 @@ def _config_dns(url): Parameters ---------- - url : string - the URL to consistently resolve the IP address of + url + The URL to consistently resolve the IP address of. Returns ------- @@ -289,16 +260,15 @@ def _config_dns(url): ip = socket.gethostbyname(hostname) except socket.gaierror: # pragma: no cover # may occur when using a proxy, so instead resolve IP address via DoH - utils.log( - f"Encountered gaierror while trying to resolve {hostname!r}, trying again via DoH...", - level=lg.ERROR, - ) + msg = f"Encountered gaierror while trying to resolve {hostname!r}, trying again via DoH..." + utils.log(msg, level=lg.ERROR) ip = _resolve_host_via_doh(hostname) # mutate socket.getaddrinfo to map hostname -> IP address - def _getaddrinfo(*args, **kwargs): + def _getaddrinfo(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401 if args[0] == hostname: - utils.log(f"Resolved {hostname!r} to {ip!r}") + msg = f"Resolved {hostname!r} to {ip!r}" + utils.log(msg, level=lg.INFO) return _original_getaddrinfo(ip, *args[1:], **kwargs) # otherwise @@ -307,44 +277,47 @@ def _getaddrinfo(*args, **kwargs): socket.getaddrinfo = _getaddrinfo -def _hostname_from_url(url): +def _hostname_from_url(url: str) -> str: """ Extract the hostname (domain) from a URL. Parameters ---------- - url : string - the url from which to extract the hostname + url + The url from which to extract the hostname. Returns ------- - hostname : string - the extracted hostname (domain) + hostname + The extracted hostname (domain). """ return urlparse(url).netloc.split(":")[0] -def _parse_response(response): +def _parse_response(response: requests.Response) -> dict[str, Any] | list[dict[str, Any]]: """ Parse JSON from a requests response and log the details. Parameters ---------- - response : requests.response - the response object + response + The response object. Returns ------- - response_json : dict + response_json + Value will be a dict if the response is from the Google or Overpass + APIs, and a list if the response is from the Nominatim API. """ # log the response size and domain domain = _hostname_from_url(response.url) size_kb = len(response.content) / 1000 - utils.log(f"Downloaded {size_kb:,.1f}kB from {domain!r} with status {response.status_code}") + msg = f"Downloaded {size_kb:,.1f}kB from {domain!r} with status {response.status_code}" + utils.log(msg, level=lg.INFO) # parse the response to JSON and log/raise exceptions try: - response_json = response.json() + response_json: dict[str, Any] | list[dict[str, Any]] = response.json() except JSONDecodeError as e: # pragma: no cover msg = f"{domain!r} responded: {response.status_code} {response.reason} {response.text}" utils.log(msg, level=lg.ERROR) @@ -353,7 +326,13 @@ def _parse_response(response): raise ResponseStatusCodeError(msg) from e # log any remarks if they exist - if "remark" in response_json: # pragma: no cover - utils.log(f'{domain!r} remarked: {response_json["remark"]!r}', level=lg.WARNING) + if isinstance(response_json, dict) and "remark" in response_json: # pragma: no cover + msg = f"{domain!r} remarked: {response_json['remark']!r}" + utils.log(msg, level=lg.WARNING) + + # log if the response status_code is not OK + if not response.ok: + msg = f"{domain!r} returned HTTP status code {response.status_code}" + utils.log(msg, level=lg.WARNING) return response_json diff --git a/osmnx/_nominatim.py b/osmnx/_nominatim.py index 0a8fb1c8a..3c12ff2b1 100644 --- a/osmnx/_nominatim.py +++ b/osmnx/_nominatim.py @@ -1,44 +1,55 @@ """Tools to work with the Nominatim API.""" +from __future__ import annotations + import logging as lg import time from collections import OrderedDict -from warnings import warn +from typing import Any import requests -from . import _downloader +from . import _http from . import settings from . import utils +from ._errors import InsufficientResponseError -def _download_nominatim_element(query, by_osmid=False, limit=1, polygon_geojson=1): +def _download_nominatim_element( + query: str | dict[str, str], + *, + by_osmid: bool = False, + limit: int = 1, + polygon_geojson: bool = True, +) -> list[dict[str, Any]]: """ Retrieve an OSM element from the Nominatim API. Parameters ---------- - query : string or dict - query string or structured query dict - by_osmid : bool - if True, treat query as an OSM ID lookup rather than text search - limit : int - max number of results to return - polygon_geojson : int - retrieve the place's geometry from the API, 0=no, 1=yes + query + Query string or structured query dict. + by_osmid + If True, treat `query` as an OSM ID lookup rather than text search. + limit + Max number of results to return. + polygon_geojson + Whether to retrieve the place's geometry from the API. Returns ------- - response_json : dict - JSON response from the Nominatim server + response_json """ # define the parameters - params = OrderedDict() + params: OrderedDict[str, int | str] = OrderedDict() params["format"] = "json" - params["polygon_geojson"] = polygon_geojson + params["polygon_geojson"] = int(polygon_geojson) # bool -> int if by_osmid: # if querying by OSM ID, use the lookup endpoint + if not isinstance(query, str): + msg = "`query` must be a string if `by_osmid` is True." + raise TypeError(msg) request_type = "lookup" params["osm_ids"] = query @@ -58,79 +69,69 @@ def _download_nominatim_element(query, by_osmid=False, limit=1, polygon_geojson= for key in sorted(query): params[key] = query[key] else: # pragma: no cover - msg = "query must be a dict or a string" + msg = "Each query must be a dict or a string." # type: ignore[unreachable] raise TypeError(msg) # request the URL, return the JSON return _nominatim_request(params=params, request_type=request_type) -def _nominatim_request(params, request_type="search", pause=1, error_pause=60): +def _nominatim_request( + params: OrderedDict[str, int | str], + *, + request_type: str = "search", + pause: float = 1, + error_pause: float = 60, +) -> list[dict[str, Any]]: """ Send a HTTP GET request to the Nominatim API and return response. Parameters ---------- - params : OrderedDict - key-value pairs of parameters - request_type : string {"search", "reverse", "lookup"} - which Nominatim API endpoint to query - pause : float - how long to pause before request, in seconds. per the nominatim usage - policy: "an absolute maximum of 1 request per second" is allowed - error_pause : float - how long to pause in seconds before re-trying request if error + params + Key-value pairs of parameters. + request_type + {"search", "reverse", "lookup"} + Which Nominatim API endpoint to query. + pause + How long to pause before request, in seconds. Per the Nominatim usage + policy: "an absolute maximum of 1 request per second" is allowed. + error_pause + How long to pause in seconds before re-trying request if error. Returns ------- - response_json : dict + response_json """ - if settings.timeout is None: - timeout = settings.requests_timeout - else: - timeout = settings.timeout - msg = ( - "`settings.timeout` is deprecated and will be removed in the v2.0.0 " - "release: use `settings.requests_timeout` instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - - if settings.nominatim_endpoint is None: - nominatim_endpoint = settings.nominatim_url - else: - nominatim_endpoint = settings.nominatim_endpoint - msg = ( - "`settings.nominatim_endpoint` is deprecated and will be removed in the " - "v2.0.0 release: use `settings.nominatim_url` instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - if request_type not in {"search", "reverse", "lookup"}: # pragma: no cover - msg = 'Nominatim request_type must be "search", "reverse", or "lookup"' + msg = "Nominatim `request_type` must be 'search', 'reverse', or 'lookup'." raise ValueError(msg) + # add nominatim API key to params if one has been provided in settings + if settings.nominatim_key is not None: + params["key"] = settings.nominatim_key + # prepare Nominatim API URL and see if request already exists in cache - url = nominatim_endpoint.rstrip("/") + "/" + request_type - params["key"] = settings.nominatim_key - prepared_url = requests.Request("GET", url, params=params).prepare().url - cached_response_json = _downloader._retrieve_from_cache(prepared_url) - if cached_response_json is not None: + url = settings.nominatim_url.rstrip("/") + "/" + request_type + prepared_url = str(requests.Request("GET", url, params=params).prepare().url) + cached_response_json = _http._retrieve_from_cache(prepared_url) + if isinstance(cached_response_json, list): return cached_response_json # pause then request this URL - domain = _downloader._hostname_from_url(url) - utils.log(f"Pausing {pause} second(s) before making HTTP GET request to {domain!r}") + domain = _http._hostname_from_url(url) + msg = f"Pausing {pause} second(s) before making HTTP GET request to {domain!r}" + utils.log(msg, level=lg.INFO) time.sleep(pause) # transmit the HTTP GET request - utils.log(f"Get {prepared_url} with timeout={timeout}") + msg = f"Get {prepared_url} with timeout={settings.requests_timeout}" + utils.log(msg, level=lg.INFO) response = requests.get( url, params=params, - timeout=timeout, - headers=_downloader._get_http_headers(), + timeout=settings.requests_timeout, + headers=_http._get_http_headers(), **settings.requests_kwargs, ) @@ -142,8 +143,16 @@ def _nominatim_request(params, request_type="search", pause=1, error_pause=60): ) utils.log(msg, level=lg.WARNING) time.sleep(error_pause) - return _nominatim_request(params, request_type, pause, error_pause) + return _nominatim_request( + params, + request_type=request_type, + pause=pause, + error_pause=error_pause, + ) - response_json = _downloader._parse_response(response) - _downloader._save_to_cache(prepared_url, response_json, response.status_code) + response_json = _http._parse_response(response) + if not isinstance(response_json, list): + msg = "Nominatim API did not return a list of results." + raise InsufficientResponseError(msg) + _http._save_to_cache(prepared_url, response_json, response.ok) return response_json diff --git a/osmnx/_osm_xml.py b/osmnx/_osm_xml.py new file mode 100644 index 000000000..75a808f00 --- /dev/null +++ b/osmnx/_osm_xml.py @@ -0,0 +1,425 @@ +""" +Read/write OSM XML files. + +For file format information see https://wiki.openstreetmap.org/wiki/OSM_XML +""" + +from __future__ import annotations + +import bz2 +import logging as lg +from pathlib import Path +from typing import TYPE_CHECKING +from typing import Any +from typing import TextIO +from warnings import warn +from xml.etree.ElementTree import Element +from xml.etree.ElementTree import ElementTree +from xml.etree.ElementTree import SubElement +from xml.etree.ElementTree import parse as etree_parse +from xml.sax import parse as sax_parse +from xml.sax.handler import ContentHandler + +import networkx as nx +import pandas as pd + +from . import convert +from . import projection +from . import settings +from . import truncate +from . import utils +from ._errors import GraphSimplificationError +from ._version import __version__ as osmnx_version + +if TYPE_CHECKING: + from xml.sax.xmlreader import AttributesImpl + + import geopandas as gpd + + +# default values for standard "node" and "way" XML subelement attributes +# see: https://wiki.openstreetmap.org/wiki/Elements#Common_attributes +ATTR_DEFAULTS = { + "changeset": "1", + "timestamp": utils.ts(style="iso8601"), + "uid": "1", + "user": "OSMnx", + "version": "1", + "visible": "true", +} + +# default values for standard "osm" root XML element attributes +# current OSM editing API version: https://wiki.openstreetmap.org/wiki/API +ROOT_ATTR_DEFAULTS = { + "attribution": "https://www.openstreetmap.org/copyright", + "copyright": "OpenStreetMap and contributors", + "generator": f"OSMnx {osmnx_version}", + "license": "https://opendatacommons.org/licenses/odbl/1-0/", + "version": "0.6", +} + + +class _OSMContentHandler(ContentHandler): + """ + SAX content handler for OSM XML. + + Builds an Overpass-like response JSON object in self.object. For format + notes, see https://wiki.openstreetmap.org/wiki/OSM_XML and + https://overpass-api.de + """ + + def __init__(self) -> None: # noqa: ANN101 + self._element: dict[str, Any] | None = None + self.object: dict[str, Any] = {"elements": []} + + def startElement(self, name: str, attrs: AttributesImpl) -> None: # noqa: ANN101,N802 + # identify node/way/relation attrs to convert from string to numeric + float_attrs = {"lat", "lon"} + int_attrs = {"changeset", "id", "uid", "version"} + + if name == "osm": + self.object.update({k: v for k, v in attrs.items() if k in ROOT_ATTR_DEFAULTS}) + + elif name in {"node", "way"}: + self._element = dict(type=name, tags={}, **attrs) + if name == "way": + self._element["nodes"] = [] + self._element.update({k: float(v) for k, v in attrs.items() if k in float_attrs}) + self._element.update({k: int(v) for k, v in attrs.items() if k in int_attrs}) + + elif name == "relation": + self._element = dict(type=name, tags={}, members=[], **attrs) + self._element.update({k: int(v) for k, v in attrs.items() if k in int_attrs}) + + elif name == "tag": + self._element["tags"].update({attrs["k"]: attrs["v"]}) # type: ignore[index] + + elif name == "nd": + self._element["nodes"].append(int(attrs["ref"])) # type: ignore[index] + + elif name == "member": + self._element["members"].append( # type: ignore[index] + {k: (int(v) if k == "ref" else v) for k, v in attrs.items()}, + ) + + def endElement(self, name: str) -> None: # noqa: ANN101,N802 + if name in {"node", "way", "relation"}: + self.object["elements"].append(self._element) + + +def _overpass_json_from_xml(filepath: str | Path, encoding: str) -> dict[str, Any]: + """ + Read OSM XML data from file and return Overpass-like JSON. + + Parameters + ---------- + filepath + Path to file containing OSM XML data. + encoding + The XML file's character encoding. + + Returns + ------- + response_json + A parsed JSON response from the Overpass API. + """ + + # open the XML file, handling bz2 or regular XML + def _opener(filepath: Path, encoding: str) -> TextIO: + if filepath.suffix == ".bz2": + return bz2.open(filepath, mode="rt", encoding=encoding) + + # otherwise just open it if it's not bz2 + return filepath.open(encoding=encoding) + + # warn if this XML file was generated by OSMnx itself + with _opener(Path(filepath), encoding) as f: + root_attrs = etree_parse(f).getroot().attrib # noqa: S314 + if "generator" in root_attrs and "OSMnx" in root_attrs["generator"]: + msg = ( + "The XML file you are loading appears to have been generated " + "by OSMnx: this use case is not supported and may not behave " + "as expected. To save/load graphs to/from disk for later use " + "in OSMnx, use the `io.save_graphml` and `io.load_graphml` " + "functions instead. Refer to the documentation for details." + ) + warn(msg, category=UserWarning, stacklevel=2) + + # parse the XML to Overpass-like JSON + with _opener(Path(filepath), encoding) as f: + handler = _OSMContentHandler() + sax_parse(f, handler) # noqa: S317 + return handler.object + + +def _save_graph_xml( + G: nx.MultiDiGraph, + filepath: str | Path | None, + way_tag_aggs: dict[str, Any] | None, + encoding: str = "utf-8", +) -> None: + """ + Save graph to disk as an OSM XML file. + + Parameters + ---------- + G + Unsimplified, unprojected graph to save as an OSM XML file. + filepath + Path to the saved file including extension. If None, use default + `settings.data_folder/graph.osm`. + way_tag_aggs + Keys are OSM way tag keys and values are aggregation functions + (anything accepted as an argument by `pandas.agg`). Allows user to + aggregate graph edge attribute values into single OSM way values. If + None, or if some tag's key does not exist in the dict, the way + attribute will be assigned the value of the first edge of the way. + encoding + The character encoding of the saved OSM XML file. + + Returns + ------- + None + """ + # default "oneway" value used to fill this tag where missing + ONEWAY = False + + # round lat/lon coordinates to 7 decimals (approx 5 to 10 mm resolution) + PRECISION = 7 + + # warn user if ox.settings.all_oneway is not currently True (but maybe it + # was when they created the graph) + if not settings.all_oneway: + msg = "Make sure graph was created with `ox.settings.all_oneway=True` to save as OSM XML." + warn(msg, category=UserWarning, stacklevel=2) + + # warn user if graph is projected + if projection.is_projected(G.graph["crs"]): + msg = ( + "Graph should be unprojected to save as OSM XML: the existing " + "projected x-y coordinates will be saved as lat-lon node attributes. " + "Project your graph back to lat-lon to avoid this." + ) + warn(msg, category=UserWarning, stacklevel=2) + + # raise error if graph has been simplified + if G.graph.get("simplified", False): + msg = "Graph must be unsimplified to save as OSM XML." + raise GraphSimplificationError(msg) + + # set default filepath if None was provided + filepath = Path(settings.data_folder) / "graph.osm" if filepath is None else Path(filepath) + filepath.parent.mkdir(parents=True, exist_ok=True) + + # convert graph to node/edge gdfs and create dict of spatial bounds + gdf_nodes, gdf_edges = convert.graph_to_gdfs(G, fill_edge_geometry=False) + coords = [str(round(c, PRECISION)) for c in gdf_nodes.unary_union.bounds] + bounds = dict(zip(["minlon", "minlat", "maxlon", "maxlat"], coords)) + + # add default values (if missing) for standard attrs + for gdf in (gdf_nodes, gdf_edges): + for col, value in ATTR_DEFAULTS.items(): + if col not in gdf.columns: + gdf[col] = value + else: + gdf[col] = gdf[col].fillna(value) + + # transform nodes gdf to meet OSM XML spec + # 1) reset index (osmid) then rename osmid, x, and y columns + # 2) round lat/lon coordinates + # 3) drop unnecessary geometry column + gdf_nodes = gdf_nodes.reset_index().rename(columns={"osmid": "id", "x": "lon", "y": "lat"}) + gdf_nodes[["lon", "lat"]] = gdf_nodes[["lon", "lat"]].round(PRECISION) + gdf_nodes = gdf_nodes.drop(columns=["geometry"]) + + # transform edges gdf to meet OSM XML spec + # 1) fill and convert oneway bools to strings + # 2) rename osmid column (but keep (u, v, k) index for processing) + # 3) drop unnecessary geometry column + if "oneway" in gdf_edges.columns: + gdf_edges["oneway"] = gdf_edges["oneway"].fillna(ONEWAY).replace({True: "yes", False: "no"}) + gdf_edges = gdf_edges.rename(columns={"osmid": "id"}).drop(columns=["geometry"]) + + # create parent XML element then add bounds, nodes, ways as subelements + element = Element("osm", attrib=ROOT_ATTR_DEFAULTS) + _ = SubElement(element, "bounds", attrib=bounds) + _add_nodes_xml(element, gdf_nodes) + _add_ways_xml(element, gdf_edges, way_tag_aggs) + + # write to disk + ElementTree(element).write(filepath, encoding=encoding, xml_declaration=True) + msg = f"Saved graph as OSM XML file at {str(filepath)!r}" + utils.log(msg, level=lg.INFO) + + +def _add_nodes_xml( + parent: Element, + gdf_nodes: gpd.GeoDataFrame, +) -> None: + """ + Add graph nodes as subelements of an XML parent element. + + Parameters + ---------- + parent + The XML parent element. + gdf_nodes + A GeoDataFrame of graph nodes. + + Returns + ------- + None + """ + node_tags = set(settings.useful_tags_node) + node_attrs = {"id", "lat", "lon"}.union(ATTR_DEFAULTS) + + # add each node attrs dict as a SubElement of parent + for node in gdf_nodes.to_dict(orient="records"): + attrs = {k: str(node[k]) for k in node_attrs if pd.notna(node[k])} + node_element = SubElement(parent, "node", attrib=attrs) + + # add each node tag dict as its own SubElement of the node SubElement + # for vals that are non-null (or list if node consolidation was done) + tags = ( + {"k": k, "v": str(node[k])} + for k in node_tags & node.keys() + if isinstance(node[k], list) or pd.notna(node[k]) + ) + for tag in tags: + _ = SubElement(node_element, "tag", attrib=tag) + + +def _add_ways_xml( + parent: Element, + gdf_edges: gpd.GeoDataFrame, + way_tag_aggs: dict[str, Any] | None, +) -> None: + """ + Add graph edges (grouped as ways) as subelements of an XML parent element. + + Parameters + ---------- + parent + The XML parent element. + gdf_edges + A GeoDataFrame of graph edges with OSM way "id" column for grouping + edges into ways. + way_tag_aggs + Keys are OSM way tag keys and values are aggregation functions + (anything accepted as an argument by `pandas.agg`). Allows user to + aggregate graph edge attribute values into single OSM way values. If + None, or if some tag's key does not exist in the dict, the way + attribute will be assigned the value of the first edge of the way. + + Returns + ------- + None + """ + way_tags = set(settings.useful_tags_way) + way_attrs = list({"id"}.union(ATTR_DEFAULTS)) + + for osmid, way in gdf_edges.groupby("id"): + # STEP 1: add the way and its attrs as a "way" subelement of the + # parent element + attrs = way[way_attrs].iloc[0].astype(str).to_dict() + way_element = SubElement(parent, "way", attrib=attrs) + + # STEP 2: add the way's edges' node IDs as "nd" subelements of the + # "way" subelement. if way contains more than 1 edge, sort the nodes + # topologically, otherwise just add node "u" then "v" from index. + if len(way) == 1: + nodes = way.index[0][:2] + else: + nodes = _sort_nodes(nx.MultiDiGraph(way.index.to_list()), osmid) + for node in nodes: + _ = SubElement(way_element, "nd", attrib={"ref": str(node)}) + + # STEP 3: add way's edges' tags as "tag" subelements of the "way" + # subelement. if an agg function was provided for a tag, apply it to + # the values of the edges in the way. if no agg function was provided + # for a tag, just use the value from first edge in way. + for tag in way_tags.intersection(way.columns): + if way_tag_aggs is not None and tag in way_tag_aggs: + value = way[tag].agg(way_tag_aggs[tag]) + else: + value = way[tag].iloc[0] + if pd.notna(value): + _ = SubElement(way_element, "tag", attrib={"k": tag, "v": str(value)}) + + +def _sort_nodes(G: nx.MultiDiGraph, osmid: int) -> list[int]: + """ + Topologically sort the nodes of an OSM way. + + Parameters + ---------- + G + The graph representing the OSM way. + osmid + The OSM way ID. + + Returns + ------- + ordered_nodes + The way's node IDs in topologically sorted order. + """ + try: + ordered_nodes = list(nx.topological_sort(G)) + + except nx.NetworkXUnfeasible: + # if it couldn't topologically sort the nodes, the way probably + # contains a cycle. try removing an edge to break the cycle. first, + # look for multiple edges emanating from the same source node + insert_before = True + edges = [ + edge + for source in [node for node, degree in G.out_degree() if degree > 1] + for edge in G.out_edges(source, keys=True) + ] + + # if none found, then look for multiple edges pointing at the same + # target node instead + if len(edges) == 0: + insert_before = False + edges = [ + edge + for target in [node for node, degree in G.in_degree() if degree > 1] + for edge in G.in_edges(target, keys=True) + ] + + # if still none, then take the first edge of the way: the entire + # way could just be a cycle in which each node appears once + if len(edges) == 0: + edges = [next(iter(G.edges))] + + # remove one edge at a time and, if the graph remains connected, exit + # the loop and check if we are able to topologically sort the nodes + for edge in edges: + G_ = G.copy() + G_.remove_edge(*edge) + if nx.is_weakly_connected(G_): + break + + try: + ordered_nodes = list(nx.topological_sort(G_)) + + # re-insert (before or after its neighbor as needed) the duplicate + # source or target node from the edge we removed + dupe_node = edge[0] if insert_before else edge[1] + neighbor = edge[1] if insert_before else edge[0] + position = ordered_nodes.index(neighbor) + position = position if insert_before else position + 1 + ordered_nodes.insert(position, dupe_node) + + except nx.NetworkXUnfeasible: + # if it failed again, this way probably contains multiple cycles, + # so remove a cycle then try to sort the nodes again, recursively. + # note this is destructive and will be missing in the saved data. + G_ = G.copy() + G_.remove_edges_from(nx.find_cycle(G_)) + G_ = truncate.largest_component(G_) + ordered_nodes = _sort_nodes(G_, osmid) + msg = f"Had to remove a cycle from way {str(osmid)!r} for topological sort" + utils.log(msg, level=lg.WARNING) + + return ordered_nodes diff --git a/osmnx/_overpass.py b/osmnx/_overpass.py index cccdfb146..333b651f7 100644 --- a/osmnx/_overpass.py +++ b/osmnx/_overpass.py @@ -1,51 +1,77 @@ """Tools to work with the Overpass API.""" +from __future__ import annotations + import datetime as dt import logging as lg import time -from warnings import warn +from collections import OrderedDict +from typing import TYPE_CHECKING +from typing import Any import numpy as np import requests from requests.exceptions import ConnectionError -from . import _downloader +from . import _http from . import projection from . import settings from . import utils from . import utils_geo +from ._errors import InsufficientResponseError + +if TYPE_CHECKING: + from collections.abc import Iterator + from shapely import MultiPolygon + from shapely import Polygon -def _get_osm_filter(network_type): + +def _get_network_filter(network_type: str) -> str: """ - Create a filter to query OSM for the specified network type. + Create a filter to query Overpass for the specified network type. + + The filter queries Overpass for every OSM way with a "highway" tag but + excludes ways that are incompatible with the requested network type. You + can choose from the following types: + + "all" retrieves all public and private-access ways currently in use. + + "all_public" retrieves all public ways currently in use. + + "bike" retrieves public bikeable ways and excludes foot ways, motor ways, + and anything tagged biking=no. + + "drive" retrieves public drivable streets and excludes service roads, + anything tagged motor=no, and certain non-service roads tagged as + providing certain services (such as alleys or driveways). + + "drive_service" retrieves public drivable streets including service roads + but excludes certain services (such as parking or emergency access). + + "walk" retrieves public walkable ways and excludes cycle ways, motor ways, + and anything tagged foot=no. It includes service roads like parking lot + aisles and alleys that you can walk on even if they are unpleasant walks. Parameters ---------- - network_type : string {"all", "all_public", "bike", "drive", "drive_service", "walk"} - what type of street network to get + network_type + {"all", "all_public", "bike", "drive", "drive_service", "walk"} + What type of street network to retrieve. Returns ------- - string + way_filter + The Overpass query filter. """ - if network_type == "all_private": - network_type = "all" - msg = ( - "The 'all_private' network type has been renamed 'all'. The old " - "'all_private' naming is deprecated and will be removed in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - # define built-in queries to send to the API. specifying way["highway"] # means that all ways returned must have a highway tag. the filters then # remove ways by tag/value. filters = {} # driving: filter out un-drivable roads, service roads, private ways, and - # anything specifying motor=no. also filter out any non-service roads that - # are tagged as providing certain services + # anything tagged motor=no. also filter out any non-service roads that are + # tagged as providing certain services filters["drive"] = ( f'["highway"]["area"!~"yes"]{settings.default_access}' f'["highway"!~"abandoned|bridleway|bus_guideway|construction|corridor|cycleway|elevator|' @@ -66,10 +92,10 @@ def _get_osm_filter(network_type): ) # walking: filter out cycle ways, motor ways, private ways, and anything - # specifying foot=no. allow service roads, permitting things like parking - # lot lanes, alleys, etc that you *can* walk on even if they're not - # exactly pleasant walks. some cycleways may allow pedestrians, but this - # filter ignores such cycleways. + # tagged foot=no. allow service roads, permitting things like parking lot + # aisles, alleys, etc that you *can* walk on even if they're not exactly + # pleasant walks. some cycleways may allow pedestrians, but this filter + # ignores such cycleways. filters["walk"] = ( f'["highway"]["area"!~"yes"]{settings.default_access}' f'["highway"!~"abandoned|bus_guideway|construction|cycleway|motor|no|planned|platform|' @@ -78,7 +104,7 @@ def _get_osm_filter(network_type): ) # biking: filter out foot ways, motor ways, private ways, and anything - # specifying biking=no + # tagged biking=no filters["bike"] = ( f'["highway"]["area"!~"yes"]{settings.default_access}' f'["highway"!~"abandoned|bus_guideway|construction|corridor|elevator|escalator|footway|' @@ -86,8 +112,8 @@ def _get_osm_filter(network_type): f'["bicycle"!~"no"]["service"!~"private"]' ) - # to download all ways, just filter out everything not currently in use or - # that is private-access only + # to download all public ways, just filter out everything not currently in + # use or that is private-access only filters["all_public"] = ( f'["highway"]["area"!~"yes"]{settings.default_access}' f'["highway"!~"abandoned|construction|no|planned|platform|proposed|raceway|razed"]' @@ -102,15 +128,20 @@ def _get_osm_filter(network_type): ) if network_type in filters: - osm_filter = filters[network_type] + way_filter = filters[network_type] else: # pragma: no cover - msg = f"Unrecognized network_type {network_type!r}" + msg = f"Unrecognized network_type {network_type!r}." raise ValueError(msg) - return osm_filter + return way_filter -def _get_overpass_pause(base_endpoint, recursive_delay=5, default_duration=60): +def _get_overpass_pause( + base_endpoint: str, + *, + recursive_delay: float = 5, + default_duration: float = 60, +) -> float: """ Retrieve a pause duration from the Overpass API status endpoint. @@ -120,29 +151,19 @@ def _get_overpass_pause(base_endpoint, recursive_delay=5, default_duration=60): Parameters ---------- - base_endpoint : string - base Overpass API url (without "/status" at the end) - recursive_delay : int - how long to wait between recursive calls if the server is currently - running a query - default_duration : int - if fatal error, fall back on returning this value + base_endpoint + Base Overpass API URL (without "/status" at the end). + recursive_delay + How long to wait between recursive calls if the server is currently + running a query. + default_duration + If a fatal error occurs, fall back on returning this value. Returns ------- - pause : int + pause + The current pause duration specified by the Overpass status endpoint. """ - if settings.timeout is None: - timeout = settings.requests_timeout - else: - timeout = settings.timeout - msg = ( - "`settings.timeout` is deprecated and will be removed in the " - "v2.0.0 release: use `settings.requests_timeout` instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - if not settings.overpass_rate_limit: # if overpass rate limiting is False, then there is zero pause return 0 @@ -151,30 +172,32 @@ def _get_overpass_pause(base_endpoint, recursive_delay=5, default_duration=60): url = base_endpoint.rstrip("/") + "/status" response = requests.get( url, - headers=_downloader._get_http_headers(), - timeout=timeout, + headers=_http._get_http_headers(), + timeout=settings.requests_timeout, **settings.requests_kwargs, ) status = response.text.split("\n")[4] - status_first_token = status.split(" ")[0] - except ConnectionError: # pragma: no cover + status_first_part = status.split(" ")[0] + except ConnectionError as e: # pragma: no cover # cannot reach status endpoint, log error and return default duration - utils.log(f"Unable to query {url}, got status {response.status_code}", level=lg.ERROR) + msg = f"Unable to query {url}, {e}" + utils.log(msg, level=lg.ERROR) return default_duration except (AttributeError, IndexError, ValueError): # pragma: no cover # cannot parse output, log error and return default duration - utils.log(f"Unable to parse {url} response: {response.text}", level=lg.ERROR) + msg = f"Unable to parse {url} response: {response.text}" + utils.log(msg, level=lg.ERROR) return default_duration try: # if first token is numeric, it's how many slots you have available, # no wait required - _ = int(status_first_token) # number of available slots - pause = 0 + _ = int(status_first_part) # number of available slots + pause: float = 0 except ValueError: # pragma: no cover # if first token is 'Slot', it tells you when your slot will be free - if status_first_token == "Slot": + if status_first_part == "Slot": utc_time_str = status.split(" ")[3] pattern = "%Y-%m-%dT%H:%M:%SZ," utc_time = dt.datetime.strptime(utc_time_str, pattern).astimezone(dt.timezone.utc) @@ -184,69 +207,50 @@ def _get_overpass_pause(base_endpoint, recursive_delay=5, default_duration=60): # if first token is 'Currently', it is currently running a query so # check back in recursive_delay seconds - elif status_first_token == "Currently": + elif status_first_part == "Currently": time.sleep(recursive_delay) pause = _get_overpass_pause(base_endpoint) # any other status is unrecognized: log error, return default duration else: - utils.log(f"Unrecognized server status: {status!r}", level=lg.ERROR) + msg = f"Unrecognized server status: {status!r}" + utils.log(msg, level=lg.ERROR) return default_duration return pause -def _make_overpass_settings(): +def _make_overpass_settings() -> str: """ Make settings string to send in Overpass query. Returns ------- - string + overpass_settings + The `settings.overpass_settings` string formatted with "timeout" and + "maxsize" values. """ - if settings.timeout is None: - timeout = settings.requests_timeout - else: - timeout = settings.timeout - msg = ( - "`settings.timeout` is deprecated and will be removed in the " - "v2.0.0 release: use `settings.requests_timeout` instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - - if settings.memory is None: - memory = settings.overpass_memory - else: - memory = settings.memory - msg = ( - "`settings.memory` is deprecated and will be removed in the " - " v2.0.0 release: use `settings.overpass_memory` instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) + maxsize = "" if settings.overpass_memory is None else f"[maxsize:{settings.overpass_memory}]" + return settings.overpass_settings.format(timeout=settings.requests_timeout, maxsize=maxsize) - maxsize = "" if memory is None else f"[maxsize:{memory}]" - return settings.overpass_settings.format(timeout=timeout, maxsize=maxsize) - -def _make_overpass_polygon_coord_strs(polygon): +def _make_overpass_polygon_coord_strs(polygon: Polygon | MultiPolygon) -> list[str]: """ Subdivide query polygon and return list of coordinate strings. - Project to utm, divide polygon up into sub-polygons if area exceeds a + Project to UTM, divide `polygon` up into sub-polygons if area exceeds a max size (in meters), project back to lat-lon, then get a list of polygon(s) exterior coordinates. Ignore interior ("holes") coordinates. Parameters ---------- - polygon : shapely.geometry.Polygon or shapely.geometry.MultiPolygon - polygon to convert to exterior coordinate strings + polygon + The (Multi)Polygon to convert to exterior coordinate strings. Returns ------- - coord_strs : list - list of strings of exterior coordinates of polygon(s) + coord_strs + Exterior coordinates of polygon(s). """ # first subdivide the polygon if its area exceeds max size # this results in a multipolygon of 1+ constituent polygons @@ -255,41 +259,44 @@ def _make_overpass_polygon_coord_strs(polygon): multi_poly, _ = projection.project_geometry(multi_poly_proj, crs=crs_proj, to_latlong=True) # then extract each's exterior coords to the string format Overpass - # expects, rounding lats and lons to 6 decimals (ie, ~100 mm) so we - # can hash and cache URL strings consistently + # expects, rounding lats and lons to 6 decimals (approx 5 to 10 cm + # resolution) so we can hash and cache URL strings consistently coord_strs = [] for geom in multi_poly.geoms: x, y = geom.exterior.xy - coord_list = [f'{xy[1]:.6f}{" "}{xy[0]:.6f}' for xy in zip(x, y)] + coord_list = [f"{xy[1]:.6f}{' '}{xy[0]:.6f}" for xy in zip(x, y)] coord_strs.append(" ".join(coord_list)) return coord_strs -def _create_overpass_query(polygon_coord_str, tags): +def _create_overpass_features_query( # noqa: PLR0912 + polygon_coord_str: str, + tags: dict[str, bool | str | list[str]], +) -> str: """ - Create an Overpass features query string based on passed tags. + Create an Overpass features query string based on tags. Parameters ---------- - polygon_coord_str : list - list of lat lon coordinates - tags : dict - dict of tags used for finding elements in the search area + polygon_coord_str + The lat lon coordinates. + tags + Tags used for finding elements in the search area. Returns ------- - query : string + query """ # create overpass settings string overpass_settings = _make_overpass_settings() # make sure every value in dict is bool, str, or list of str - err_msg = "tags must be a dict with values of bool, str, or list of str" + err_msg = "`tags` must be a dict with values of bool, str, or list of str." if not isinstance(tags, dict): # pragma: no cover raise TypeError(err_msg) - tags_dict = {} + tags_dict: dict[str, bool | str | list[str]] = {} for key, value in tags.items(): if isinstance(value, bool): tags_dict[key] = value @@ -306,7 +313,7 @@ def _create_overpass_query(polygon_coord_str, tags): raise TypeError(err_msg) # convert the tags dict into a list of {tag:value} dicts - tags_list = [] + tags_list: list[dict[str, bool | str | list[str]]] = [] for key, value in tags_dict.items(): if isinstance(value, bool): tags_list.append({key: value}) @@ -329,151 +336,148 @@ def _create_overpass_query(polygon_coord_str, tags): components.append(f"({kind}{tag_str});") # noqa: PERF401 # finalize query and return - components = "".join(components) - return f"{overpass_settings};({components});out;" + components_str = "".join(components) + return f"{overpass_settings};({components_str});out;" -def _download_overpass_network(polygon, network_type, custom_filter): +def _download_overpass_network( + polygon: Polygon | MultiPolygon, + network_type: str, + custom_filter: str | None, +) -> Iterator[dict[str, Any]]: """ Retrieve networked ways and nodes within boundary from the Overpass API. Parameters ---------- - polygon : shapely.geometry.Polygon or shapely.geometry.MultiPolygon - boundary to fetch the network ways/nodes within - network_type : string - what type of street network to get if custom_filter is None - custom_filter : string - a custom "ways" filter to be used instead of the network_type presets + polygon + The boundary to fetch the network ways/nodes within. + network_type + What type of street network to get if `custom_filter` is None. + custom_filter + A custom "ways" filter to be used instead of `network_type` presets. Yields ------ - response_json : dict - a generator of JSON responses from the Overpass server + response_json + JSON response from the Overpass server. """ # create a filter to exclude certain kinds of ways based on the requested # network_type, if provided, otherwise use custom_filter - osm_filter = custom_filter if custom_filter is not None else _get_osm_filter(network_type) + way_filter = custom_filter if custom_filter is not None else _get_network_filter(network_type) # create overpass settings string overpass_settings = _make_overpass_settings() # subdivide query polygon to get list of sub-divided polygon coord strings polygon_coord_strs = _make_overpass_polygon_coord_strs(polygon) - utils.log(f"Requesting data from API in {len(polygon_coord_strs)} request(s)") + msg = f"Requesting data from API in {len(polygon_coord_strs)} request(s)" + utils.log(msg, level=lg.INFO) # pass exterior coordinates of each polygon in list to API, one at a time # the '>' makes it recurse so we get ways and the ways' nodes. for polygon_coord_str in polygon_coord_strs: - query_str = f"{overpass_settings};(way{osm_filter}(poly:{polygon_coord_str!r});>;);out;" - yield _overpass_request(data={"data": query_str}) + query_str = f"{overpass_settings};(way{way_filter}(poly:{polygon_coord_str!r});>;);out;" + yield _overpass_request(OrderedDict(data=query_str)) -def _download_overpass_features(polygon, tags): +def _download_overpass_features( + polygon: Polygon, + tags: dict[str, bool | str | list[str]], +) -> Iterator[dict[str, Any]]: """ - Retrieve OSM features within boundary from the Overpass API. + Retrieve OSM features within some boundary polygon from the Overpass API. Parameters ---------- - polygon : shapely.geometry.Polygon - boundaries to fetch elements within - tags : dict - dict of tags used for finding elements in the selected area + polygon + Boundary to retrieve elements within. + tags + Tags used for finding elements in the selected area. Yields ------ - response_json : dict - a generator of JSON responses from the Overpass server + response_json + JSON response from the Overpass server. """ # subdivide query polygon to get list of sub-divided polygon coord strings polygon_coord_strs = _make_overpass_polygon_coord_strs(polygon) - utils.log(f"Requesting data from API in {len(polygon_coord_strs)} request(s)") + msg = f"Requesting data from API in {len(polygon_coord_strs)} request(s)" + utils.log(msg, level=lg.INFO) # pass exterior coordinates of each polygon in list to API, one at a time for polygon_coord_str in polygon_coord_strs: - query_str = _create_overpass_query(polygon_coord_str, tags) - yield _overpass_request(data={"data": query_str}) + query_str = _create_overpass_features_query(polygon_coord_str, tags) + yield _overpass_request(OrderedDict(data=query_str)) -def _overpass_request(data, pause=None, error_pause=60): +def _overpass_request( + data: OrderedDict[str, Any], + *, + pause: float | None = None, + error_pause: float = 60, +) -> dict[str, Any]: """ Send a HTTP POST request to the Overpass API and return response. Parameters ---------- - data : OrderedDict - key-value pairs of parameters - pause : float - how long to pause in seconds before request, if None, will query API - status endpoint to find when next slot is available - error_pause : float - how long to pause in seconds (in addition to `pause`) before re-trying - request if error + data + Key-value pairs of parameters. + pause + How long to pause in seconds before request. If None, will query API + status endpoint to find when next slot is available. + error_pause + How long to pause in seconds (in addition to `pause`) before re-trying + request if error. Returns ------- - response_json : dict + response_json """ - if settings.timeout is None: - timeout = settings.requests_timeout - else: - timeout = settings.timeout - msg = ( - "`settings.timeout` is deprecated and will be removed in the " - "v2.0.0 release: use `settings.requests_timeout` instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - - if settings.overpass_endpoint is None: - overpass_endpoint = settings.overpass_url - else: - overpass_endpoint = settings.overpass_endpoint - msg = ( - "`settings.overpass_endpoint` is deprecated and will be removed in the " - "v2.0.0 release: use `settings.overpass_url` instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - # resolve url to same IP even if there is server round-robin redirecting - _downloader._config_dns(overpass_endpoint) + _http._config_dns(settings.overpass_url) # prepare the Overpass API URL and see if request already exists in cache - url = overpass_endpoint.rstrip("/") + "/interpreter" - prepared_url = requests.Request("GET", url, params=data).prepare().url - cached_response_json = _downloader._retrieve_from_cache(prepared_url) - if cached_response_json is not None: + url = settings.overpass_url.rstrip("/") + "/interpreter" + prepared_url = str(requests.Request("GET", url, params=data).prepare().url) + cached_response_json = _http._retrieve_from_cache(prepared_url) + if isinstance(cached_response_json, dict): return cached_response_json # pause then request this URL if pause is None: - this_pause = _get_overpass_pause(overpass_endpoint) - domain = _downloader._hostname_from_url(url) - utils.log(f"Pausing {this_pause} second(s) before making HTTP POST request to {domain!r}") + this_pause = _get_overpass_pause(settings.overpass_url) + domain = _http._hostname_from_url(url) + msg = f"Pausing {this_pause} second(s) before making HTTP POST request to {domain!r}" + utils.log(msg, level=lg.INFO) time.sleep(this_pause) # transmit the HTTP POST request - utils.log(f"Post {prepared_url} with timeout={timeout}") + msg = f"Post {prepared_url} with timeout={settings.requests_timeout}" + utils.log(msg, level=lg.INFO) response = requests.post( url, data=data, - timeout=timeout, - headers=_downloader._get_http_headers(), + timeout=settings.requests_timeout, + headers=_http._get_http_headers(), **settings.requests_kwargs, ) # handle 429 and 504 errors by pausing then recursively re-trying request if response.status_code in {429, 504}: # pragma: no cover - this_pause = error_pause + _get_overpass_pause(overpass_endpoint) + this_pause = error_pause + _get_overpass_pause(settings.overpass_url) msg = ( f"{domain!r} responded {response.status_code} {response.reason}: " f"we'll retry in {this_pause} secs" ) utils.log(msg, level=lg.WARNING) time.sleep(this_pause) - return _overpass_request(data, pause, error_pause) + return _overpass_request(data, pause=pause, error_pause=error_pause) - response_json = _downloader._parse_response(response) - _downloader._save_to_cache(prepared_url, response_json, response.status_code) + response_json = _http._parse_response(response) + if not isinstance(response_json, dict): # pragma: no cover + msg = "Overpass API did not return a dict of results." + raise InsufficientResponseError(msg) + _http._save_to_cache(prepared_url, response_json, response.ok) return response_json diff --git a/osmnx/_version.py b/osmnx/_version.py index c999ff7e8..d70708e51 100644 --- a/osmnx/_version.py +++ b/osmnx/_version.py @@ -1,3 +1,3 @@ """OSMnx package version information.""" -__version__ = "1.9.3" +__version__ = "2.0.0-dev" diff --git a/osmnx/bearing.py b/osmnx/bearing.py index e12bf2d47..3f6fe8b8c 100644 --- a/osmnx/bearing.py +++ b/osmnx/bearing.py @@ -1,11 +1,14 @@ -"""Calculate graph edge bearings.""" +"""Calculate graph edge bearings and orientation entropy.""" +from __future__ import annotations + +from typing import overload from warnings import warn import networkx as nx import numpy as np +import numpy.typing as npt -from . import plot from . import projection # scipy is an optional dependency for entropy calculation @@ -15,30 +18,55 @@ scipy = None -def calculate_bearing(lat1, lon1, lat2, lon2): +# if coords are all floats, return float +@overload +def calculate_bearing( + lat1: float, + lon1: float, + lat2: float, + lon2: float, +) -> float: ... + + +# if coords are all arrays, return array +@overload +def calculate_bearing( + lat1: npt.NDArray[np.float64], + lon1: npt.NDArray[np.float64], + lat2: npt.NDArray[np.float64], + lon2: npt.NDArray[np.float64], +) -> npt.NDArray[np.float64]: ... + + +def calculate_bearing( + lat1: float | npt.NDArray[np.float64], + lon1: float | npt.NDArray[np.float64], + lat2: float | npt.NDArray[np.float64], + lon2: float | npt.NDArray[np.float64], +) -> float | npt.NDArray[np.float64]: """ Calculate the compass bearing(s) between pairs of lat-lon points. Vectorized function to calculate initial bearings between two points' coordinates or between arrays of points' coordinates. Expects coordinates - in decimal degrees. Bearing represents the clockwise angle in degrees - between north and the geodesic line from (lat1, lon1) to (lat2, lon2). + in decimal degrees. The bearing represents the clockwise angle in degrees + between north and the geodesic line from `(lat1, lon1)` to `(lat2, lon2)`. Parameters ---------- - lat1 : float or numpy.array of float - first point's latitude coordinate - lon1 : float or numpy.array of float - first point's longitude coordinate - lat2 : float or numpy.array of float - second point's latitude coordinate - lon2 : float or numpy.array of float - second point's longitude coordinate + lat1 + First point's latitude coordinate(s). + lon1 + First point's longitude coordinate(s). + lat2 + Second point's latitude coordinate(s). + lon2 + Second point's longitude coordinate(s). Returns ------- - bearing : float or numpy.array of float - the bearing(s) in decimal degrees + bearing + The bearing(s) in decimal degrees. """ # get the latitudes and the difference in longitudes, all in radians lat1 = np.radians(lat1) @@ -51,44 +79,33 @@ def calculate_bearing(lat1, lon1, lat2, lon2): initial_bearing = np.degrees(np.arctan2(y, x)) # normalize to 0-360 degrees to get compass bearing - return initial_bearing % 360 + bearing: float | npt.NDArray[np.float64] = initial_bearing % 360 + return bearing -def add_edge_bearings(G, precision=None): +def add_edge_bearings(G: nx.MultiDiGraph) -> nx.MultiDiGraph: """ - Add compass `bearing` attributes to all graph edges. + Calculate and add compass `bearing` attributes to all graph edges. Vectorized function to calculate (initial) bearing from origin node to destination node for each edge in a directed, unprojected graph then add - these bearings as new edge attributes. Bearing represents angle in degrees - (clockwise) between north and the geodesic line from the origin node to - the destination node. Ignores self-loop edges as their bearings are - undefined. + these bearings as new `bearing` edge attributes. Bearing represents angle + in degrees (clockwise) between north and the geodesic line from the origin + node to the destination node. Ignores self-loop edges as their bearings + are undefined. Parameters ---------- - G : networkx.MultiDiGraph - unprojected graph - precision : int - deprecated, do not use + G + Unprojected graph. Returns ------- - G : networkx.MultiDiGraph - graph with edge bearing attributes + G + Graph with `bearing` attributes on the edges. """ - if precision is None: - precision = 1 - else: - warn( - "The `precision` parameter is deprecated and will be removed in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - if projection.is_projected(G.graph["crs"]): # pragma: no cover - msg = "graph must be unprojected to add edge bearings" + msg = "Graph must be unprojected to add edge bearings." raise ValueError(msg) # extract edge IDs and corresponding coordinates from their nodes @@ -99,19 +116,28 @@ def add_edge_bearings(G, precision=None): # calculate bearings then set as edge attributes bearings = calculate_bearing(coords[:, 0], coords[:, 1], coords[:, 2], coords[:, 3]) - values = zip(uvk, bearings.round(precision)) + values = zip(uvk, bearings) nx.set_edge_attributes(G, dict(values), name="bearing") return G -def orientation_entropy(Gu, num_bins=36, min_length=0, weight=None): +def orientation_entropy( + G: nx.MultiGraph | nx.MultiDiGraph, + *, + num_bins: int = 36, + min_length: float = 0, + weight: str | None = None, +) -> float: """ - Calculate undirected graph's orientation entropy. + Calculate graph's orientation entropy. - Orientation entropy is the entropy of its edges' bidirectional bearings + Orientation entropy is the Shannon entropy of the graphs' edges' bearings across evenly spaced bins. Ignores self-loop edges as their bearings are - undefined. + undefined. If `G` is a MultiGraph, all edge bearings will be bidirectional + (ie, two reciprocal bearings per undirected edge). If `G` is a + MultiDiGraph, all edge bearings will be directional (ie, one bearing per + directed edge). For more info see: Boeing, G. 2019. "Urban Spatial Order: Street Network Orientation, Configuration, and Entropy." Applied Network Science, 4 (1), @@ -119,80 +145,104 @@ def orientation_entropy(Gu, num_bins=36, min_length=0, weight=None): Parameters ---------- - Gu : networkx.MultiGraph - undirected, unprojected graph with `bearing` attributes on each edge - num_bins : int - number of bins; for example, if `num_bins=36` is provided, then each - bin will represent 10 degrees around the compass - min_length : float - ignore edges with `length` attributes less than `min_length`; useful - to ignore the noise of many very short edges - weight : string - if not None, weight edges' bearings by this (non-null) edge attribute. - for example, if "length" is provided, this will return 1 bearing - observation per meter per street, which could result in a very large - `bearings` array. + G + Unprojected graph with `bearing` attributes on each edge. + num_bins + Number of bins. For example, if `num_bins=36` is provided, then each + bin will represent 10 degrees around the compass. + min_length + Ignore edges with "length" attributes less than `min_length`. Useful + to ignore the noise of many very short edges. + weight + If None, apply equal weight for each bearing. Otherwise, weight edges' + bearings by this (non-null) edge attribute. For example, if "length" + is provided, each edge's bearing observation will be weighted by its + "length" attribute value. Returns ------- - entropy : float - the graph's orientation entropy + entropy + The orientation entropy of `G`. """ # check if we were able to import scipy if scipy is None: # pragma: no cover - msg = "scipy must be installed to calculate entropy" + msg = "scipy must be installed as an optional dependency to calculate entropy." raise ImportError(msg) - bin_counts, _ = _bearings_distribution(Gu, num_bins, min_length, weight) - return scipy.stats.entropy(bin_counts) + bin_counts, _ = _bearings_distribution(G, num_bins, min_length, weight) + entropy: float = scipy.stats.entropy(bin_counts) + return entropy -def _extract_edge_bearings(Gu, min_length=0, weight=None): +def _extract_edge_bearings( + G: nx.MultiGraph | nx.MultiDiGraph, + min_length: float, + weight: str | None, +) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]: """ - Extract undirected graph's bidirectional edge bearings. + Extract graph's edge bearings. - For example, if an edge has a bearing of 90 degrees then we will record + Ignores self-loop edges as their bearings are undefined. If `G` is a + MultiGraph, all edge bearings will be bidirectional (ie, two reciprocal + bearings per undirected edge). If `G` is a MultiDiGraph, all edge bearings + will be directional (ie, one bearing per directed edge). For example, if + an undirected edge has a bearing of 90 degrees then we will record bearings of both 90 degrees and 270 degrees for this edge. Parameters ---------- - Gu : networkx.MultiGraph - undirected, unprojected graph with `bearing` attributes on each edge - min_length : float - ignore edges with `length` attributes less than `min_length`; useful - to ignore the noise of many very short edges - weight : string - if not None, weight edges' bearings by this (non-null) edge attribute. - for example, if "length" is provided, this will return 1 bearing - observation per meter per street, which could result in a very large - `bearings` array. + G + Unprojected graph with `bearing` attributes on each edge. + min_length + Ignore edges with `length` attributes less than `min_length`. Useful + to ignore the noise of many very short edges. + weight + If None, apply equal weight for each bearing. Otherwise, weight edges' + bearings by this (non-null) edge attribute. For example, if "length" + is provided, each edge's bearing observation will be weighted by its + "length" attribute value. Returns ------- - bearings : numpy.array - the graph's bidirectional edge bearings + bearings, weights + The edge bearings of `G` and their corresponding weights. """ - if nx.is_directed(Gu) or projection.is_projected(Gu.graph["crs"]): # pragma: no cover - msg = "graph must be undirected and unprojected to analyze edge bearings" + if projection.is_projected(G.graph["crs"]): # pragma: no cover + msg = "Graph must be unprojected to analyze edge bearings." raise ValueError(msg) bearings = [] - for u, v, data in Gu.edges(data=True): + weights = [] + for u, v, data in G.edges(data=True): # ignore self-loops and any edges below min_length if u != v and data["length"] >= min_length: - if weight: - # weight edges' bearings by some edge attribute value - bearings.extend([data["bearing"]] * int(data[weight])) - else: - # don't weight bearings, just take one value per edge - bearings.append(data["bearing"]) - - # drop any nulls, calculate reverse bearings, concatenate and return - bearings = np.array(bearings) - bearings = bearings[~np.isnan(bearings)] - bearings_r = (bearings - 180) % 360 - return np.concatenate([bearings, bearings_r]) - - -def _bearings_distribution(Gu, num_bins, min_length=0, weight=None): + bearings.append(data["bearing"]) + weights.append(data[weight] if weight is not None else 1.0) + + # drop any nulls + bearings_array = np.array(bearings) + weights_array = np.array(weights) + keep_idx = ~np.isnan(bearings_array) + bearings_array = bearings_array[keep_idx] + weights_array = weights_array[keep_idx] + if nx.is_directed(G): + msg = ( + "`G` is a MultiDiGraph, so edge bearings will be directional (one per " + "edge). If you want bidirectional edge bearings (two reciprocal bearings " + "per edge), pass a MultiGraph instead. Use `convert.to_undirected`." + ) + warn(msg, category=UserWarning, stacklevel=2) + return bearings_array, weights_array + # for undirected graphs, add reverse bearings + bearings_array = np.concatenate([bearings_array, (bearings_array - 180) % 360]) + weights_array = np.concatenate([weights_array, weights_array]) + return bearings_array, weights_array + + +def _bearings_distribution( + G: nx.MultiGraph | nx.MultiDiGraph, + num_bins: int, + min_length: float, + weight: str | None, +) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]: """ Compute distribution of bearings across evenly spaced bins. @@ -204,122 +254,43 @@ def _bearings_distribution(Gu, num_bins, min_length=0, weight=None): Parameters ---------- - Gu : networkx.MultiGraph - undirected, unprojected graph with `bearing` attributes on each edge - num_bins : int - number of bins for the bearings histogram - min_length : float - ignore edges with `length` attributes less than `min_length`; useful - to ignore the noise of many very short edges - weight : string - if not None, weight edges' bearings by this (non-null) edge attribute. - for example, if "length" is provided, this will return 1 bearing - observation per meter per street, which could result in a very large - `bearings` array. + G + Unprojected graph with `bearing` attributes on each edge. + num_bins + Number of bins for the bearing histogram. + min_length + Ignore edges with `length` attributes less than `min_length`. Useful + to ignore the noise of many very short edges. + weight + If None, apply equal weight for each bearing. Otherwise, weight edges' + bearings by this (non-null) edge attribute. For example, if "length" + is provided, each edge's bearing observation will be weighted by its + "length" attribute value. Returns ------- - bin_counts, bin_edges : tuple of numpy.array - counts of bearings per bin and the bins edges - """ - n = num_bins * 2 - bins = np.arange(n + 1) * 360 / n - - bearings = _extract_edge_bearings(Gu, min_length, weight) - count, bin_edges = np.histogram(bearings, bins=bins) - - # move last bin to front, so eg 0.01 degrees and 359.99 degrees will be - # binned together - count = np.roll(count, 1) - bin_counts = count[::2] + count[1::2] - - # because we merged the bins, their edges are now only every other one - bin_edges = bin_edges[range(0, len(bin_edges), 2)] - return bin_counts, bin_edges - - -def plot_orientation( - Gu, - num_bins=36, - min_length=0, - weight=None, - ax=None, - figsize=(5, 5), - area=True, - color="#003366", - edgecolor="k", - linewidth=0.5, - alpha=0.7, - title=None, - title_y=1.05, - title_font=None, - xtick_font=None, -): + bin_counts, bin_centers + Counts of bearings per bin and the bins' centers in degrees. Both + arrays are of length `num_bins`. """ - Do not use: deprecated. + # Split bins in half to prevent bin-edge effects around common values. + # Bins will be merged in pairs after the histogram is computed. The last + # bin edge is the same as the first (i.e., 0 degrees = 360 degrees). + num_split_bins = num_bins * 2 + split_bin_edges = np.arange(num_split_bins + 1) * 360 / num_split_bins + + bearings, weights = _extract_edge_bearings(G, min_length, weight) + split_bin_counts, split_bin_edges = np.histogram( + bearings, + bins=split_bin_edges, + weights=weights, + ) - The plot_orientation function moved to the plot module. Calling it via the - bearing module will raise an error starting in the v2.0.0 release. + # Move last bin to front, so eg 0.01 degrees and 359.99 degrees will be + # binned together. Then combine counts from pairs of split bins. + split_bin_counts = np.roll(split_bin_counts, 1) + bin_counts = split_bin_counts[::2] + split_bin_counts[1::2] - Parameters - ---------- - Gu : networkx.MultiGraph - deprecated, do not use - num_bins : int - deprecated, do not use - min_length : float - deprecated, do not use - weight : string - deprecated, do not use - ax : matplotlib.axes.PolarAxesSubplot - deprecated, do not use - figsize : tuple - deprecated, do not use - area : bool - deprecated, do not use - color : string - deprecated, do not use - edgecolor : string - deprecated, do not use - linewidth : float - deprecated, do not use - alpha : float - deprecated, do not use - title : string - deprecated, do not use - title_y : float - deprecated, do not use - title_font : dict - deprecated, do not use - xtick_font : dict - deprecated, do not use - - Returns - ------- - fig, ax : tuple - matplotlib figure, axis - """ - warn( - "The `plot_orientation` function moved to the `plot` module. Calling it " - "via the `bearing` module will raise an exception starting with the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - return plot.plot_orientation( - Gu, - num_bins=num_bins, - min_length=min_length, - weight=weight, - ax=ax, - figsize=figsize, - area=area, - color=color, - edgecolor=edgecolor, - linewidth=linewidth, - alpha=alpha, - title=title, - title_y=title_y, - title_font=title_font, - xtick_font=xtick_font, - ) + # Every other edge of the split bins is the center of a merged bin. + bin_centers = split_bin_edges[range(0, num_split_bins - 1, 2)] + return bin_counts, bin_centers diff --git a/osmnx/convert.py b/osmnx/convert.py index 095c82b2f..742ba399a 100644 --- a/osmnx/convert.py +++ b/osmnx/convert.py @@ -1,48 +1,153 @@ """Convert spatial graphs to/from different data types.""" +from __future__ import annotations + import itertools +import logging as lg +from typing import Any +from typing import Literal +from typing import overload from warnings import warn import geopandas as gpd import networkx as nx import pandas as pd -from shapely.geometry import LineString -from shapely.geometry import Point +from shapely import LineString +from shapely import Point from . import utils -def graph_to_gdfs(G, nodes=True, edges=True, node_geometry=True, fill_edge_geometry=True): +# nodes and edges are both missing (therefore both default true) +@overload +def graph_to_gdfs( + G: nx.MultiGraph | nx.MultiDiGraph, + *, + node_geometry: bool = True, + fill_edge_geometry: bool = True, +) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: ... + + +# both present/True +@overload +def graph_to_gdfs( + G: nx.MultiGraph | nx.MultiDiGraph, + *, + nodes: Literal[True], + edges: Literal[True], + node_geometry: bool = True, + fill_edge_geometry: bool = True, +) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: ... + + +# both present, nodes true, edges false +@overload +def graph_to_gdfs( + G: nx.MultiGraph | nx.MultiDiGraph, + *, + nodes: Literal[True], + edges: Literal[False], + node_geometry: bool = True, + fill_edge_geometry: bool = True, +) -> gpd.GeoDataFrame: ... + + +# both present, nodes false, edges true +@overload +def graph_to_gdfs( + G: nx.MultiGraph | nx.MultiDiGraph, + *, + nodes: Literal[False], + edges: Literal[True], + node_geometry: bool = True, + fill_edge_geometry: bool = True, +) -> gpd.GeoDataFrame: ... + + +# nodes missing (therefore default true), edges present/true +@overload +def graph_to_gdfs( + G: nx.MultiGraph | nx.MultiDiGraph, + *, + edges: Literal[True], + node_geometry: bool = True, + fill_edge_geometry: bool = True, +) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: ... + + +# nodes missing (therefore default true), edges present/false +@overload +def graph_to_gdfs( + G: nx.MultiGraph | nx.MultiDiGraph, + *, + edges: Literal[False], + node_geometry: bool = True, + fill_edge_geometry: bool = True, +) -> gpd.GeoDataFrame: ... + + +# nodes present/true, edges missing (therefore default true) +@overload +def graph_to_gdfs( + G: nx.MultiGraph | nx.MultiDiGraph, + *, + nodes: Literal[True], + edges: bool = True, + node_geometry: bool = True, + fill_edge_geometry: bool = True, +) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: ... + + +# nodes present/false, edges missing (therefore default true) +@overload +def graph_to_gdfs( + G: nx.MultiGraph | nx.MultiDiGraph, + *, + nodes: Literal[False], + edges: bool = True, + node_geometry: bool = True, + fill_edge_geometry: bool = True, +) -> gpd.GeoDataFrame: ... + + +def graph_to_gdfs( + G: nx.MultiGraph | nx.MultiDiGraph, + *, + nodes: bool = True, + edges: bool = True, + node_geometry: bool = True, + fill_edge_geometry: bool = True, +) -> gpd.GeoDataFrame | tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: """ - Convert a MultiDiGraph to node and/or edge GeoDataFrames. + Convert a MultiGraph or MultiDiGraph to node and/or edge GeoDataFrames. This function is the inverse of `graph_from_gdfs`. Parameters ---------- - G : networkx.MultiDiGraph - input graph - nodes : bool - if True, convert graph nodes to a GeoDataFrame and return it - edges : bool - if True, convert graph edges to a GeoDataFrame and return it - node_geometry : bool - if True, create a geometry column from node x and y attributes - fill_edge_geometry : bool - if True, fill in missing edge geometry fields using nodes u and v + G + Input graph. + nodes + If True, convert graph nodes to a GeoDataFrame and return it. + edges + If True, convert graph edges to a GeoDataFrame and return it. + node_geometry + If True, create a geometry column from node "x" and "y" attributes. + fill_edge_geometry + If True, fill missing edge geometry fields using endpoint nodes' + coordinates to create a LineString. Returns ------- - geopandas.GeoDataFrame or tuple - gdf_nodes or gdf_edges or tuple of (gdf_nodes, gdf_edges). gdf_nodes - is indexed by osmid and gdf_edges is multi-indexed by u, v, key - following normal MultiDiGraph structure. + gdf_nodes or gdf_edges or (gdf_nodes, gdf_edges) + `gdf_nodes` is indexed by `osmid` and `gdf_edges` is multi-indexed by + `(u, v, key)` following normal MultiGraph/MultiDiGraph structure. """ crs = G.graph["crs"] if nodes: - if not G.nodes: # pragma: no cover - msg = "graph contains no nodes" + if len(G.nodes) == 0: # pragma: no cover + msg = "Graph contains no nodes." raise ValueError(msg) uvk, data = zip(*G.nodes(data=True)) @@ -55,11 +160,12 @@ def graph_to_gdfs(G, nodes=True, edges=True, node_geometry=True, fill_edge_geome gdf_nodes = gpd.GeoDataFrame(data, index=uvk) gdf_nodes.index = gdf_nodes.index.rename("osmid") - utils.log("Created nodes GeoDataFrame from graph") + msg = "Created nodes GeoDataFrame from graph" + utils.log(msg, level=lg.INFO) if edges: - if not G.edges: # pragma: no cover - msg = "Graph contains no edges" + if len(G.edges) == 0: # pragma: no cover + msg = "Graph contains no edges." raise ValueError(msg) u, v, k, data = zip(*G.edges(keys=True, data=True)) @@ -70,7 +176,13 @@ def graph_to_gdfs(G, nodes=True, edges=True, node_geometry=True, fill_edge_geome x_lookup = nx.get_node_attributes(G, "x") y_lookup = nx.get_node_attributes(G, "y") - def _make_edge_geometry(u, v, data, x=x_lookup, y=y_lookup): + def _make_edge_geometry( + u: int, + v: int, + data: dict[str, Any], + x: dict[int, float] = x_lookup, + y: dict[int, float] = y_lookup, + ) -> LineString: if "geometry" in data: return data["geometry"] @@ -93,7 +205,8 @@ def _make_edge_geometry(u, v, data, x=x_lookup, y=y_lookup): gdf_edges["key"] = k gdf_edges = gdf_edges.set_index(["u", "v", "key"]) - utils.log("Created edges GeoDataFrame from graph") + msg = "Created edges GeoDataFrame from graph" + utils.log(msg, level=lg.INFO) if nodes and edges: return gdf_nodes, gdf_edges @@ -105,62 +218,71 @@ def _make_edge_geometry(u, v, data, x=x_lookup, y=y_lookup): return gdf_edges # otherwise - msg = "you must request nodes or edges or both" + msg = "You must request nodes or edges or both." raise ValueError(msg) -def graph_from_gdfs(gdf_nodes, gdf_edges, graph_attrs=None): +def graph_from_gdfs( + gdf_nodes: gpd.GeoDataFrame, + gdf_edges: gpd.GeoDataFrame, + *, + graph_attrs: dict[str, Any] | None = None, +) -> nx.MultiDiGraph: """ Convert node and edge GeoDataFrames to a MultiDiGraph. This function is the inverse of `graph_to_gdfs` and is designed to work in - conjunction with it. + conjunction with it. However, you can convert arbitrary node and edge + GeoDataFrames as long as 1) `gdf_nodes` is uniquely indexed by `osmid`, 2) + `gdf_nodes` contains `x` and `y` coordinate columns representing node + geometries, 3) `gdf_edges` is uniquely multi-indexed by `(u, v, key)` + (following normal MultiDiGraph structure). This allows you to load any + node/edge Shapefiles or GeoPackage layers as GeoDataFrames then convert + them to a MultiDiGraph for network analysis. - However, you can convert arbitrary node and edge GeoDataFrames as long as - 1) `gdf_nodes` is uniquely indexed by `osmid`, 2) `gdf_nodes` contains `x` - and `y` coordinate columns representing node geometries, 3) `gdf_edges` is - uniquely multi-indexed by `u`, `v`, `key` (following normal MultiDiGraph - structure). This allows you to load any node/edge shapefiles or GeoPackage - layers as GeoDataFrames then convert them to a MultiDiGraph for graph - analysis. Note that any `geometry` attribute on `gdf_nodes` is discarded - since `x` and `y` provide the necessary node geometry information instead. + Note that any `geometry` attribute on `gdf_nodes` is discarded, since `x` + and `y` provide the necessary node geometry information instead. Parameters ---------- - gdf_nodes : geopandas.GeoDataFrame - GeoDataFrame of graph nodes uniquely indexed by osmid - gdf_edges : geopandas.GeoDataFrame - GeoDataFrame of graph edges uniquely multi-indexed by u, v, key - graph_attrs : dict - the new G.graph attribute dict. if None, use crs from gdf_edges as the - only graph-level attribute (gdf_edges must have crs attribute set) + gdf_nodes + GeoDataFrame of graph nodes uniquely indexed by `osmid`. + gdf_edges + GeoDataFrame of graph edges uniquely multi-indexed by `(u, v, key)`. + graph_attrs + The new `G.graph` attribute dictionary. If None, use `gdf_edges`'s CRS + as the only graph-level attribute (`gdf_edges` must have its `crs` + attribute set). Returns ------- - G : networkx.MultiDiGraph + G """ if not ("x" in gdf_nodes.columns and "y" in gdf_nodes.columns): # pragma: no cover - msg = "gdf_nodes must contain x and y columns" + msg = "`gdf_nodes` must contain 'x' and 'y' columns." + raise ValueError(msg) + + if not hasattr(gdf_nodes, "geometry"): + msg = "`gdf_nodes` must have a 'geometry' attribute." raise ValueError(msg) - # if gdf_nodes has a geometry attribute set, drop that column (as we use x - # and y for geometry information) and warn the user if the geometry values - # differ from the coordinates in the x and y columns - if hasattr(gdf_nodes, "geometry"): - try: - all_x_match = (gdf_nodes.geometry.x == gdf_nodes["x"]).all() - all_y_match = (gdf_nodes.geometry.y == gdf_nodes["y"]).all() - assert all_x_match - assert all_y_match - except (AssertionError, ValueError): # pragma: no cover - # AssertionError if x/y coords don't match geometry column - # ValueError if geometry column contains non-point geometry types - warn( - "discarding the gdf_nodes geometry column, though its " - "values differ from the coordinates in the x and y columns", - stacklevel=2, - ) - gdf_nodes = gdf_nodes.drop(columns=gdf_nodes.geometry.name) + # drop geometry column from gdf_nodes (as we use x and y for geometry + # information), but warn the user if the geometry values differ from the + # coordinates in the x and y columns. this results in a df instead of gdf. + msg = ( + "Discarding the `gdf_nodes` 'geometry' column, though its values " + "differ from the coordinates in the 'x' and 'y' columns." + ) + try: + all_x_match = (gdf_nodes.geometry.x == gdf_nodes["x"]).all() + all_y_match = (gdf_nodes.geometry.y == gdf_nodes["y"]).all() + if not (all_x_match and all_y_match): + # warn if x/y coords don't match geometry column + warn(msg, category=UserWarning, stacklevel=2) + except ValueError: # pragma: no cover + # warn if geometry column contains non-point geometry types + warn(msg, category=UserWarning, stacklevel=2) + df_nodes = gdf_nodes.drop(columns=gdf_nodes.geometry.name) # create graph and add graph-level attribute dict if graph_attrs is None: @@ -176,40 +298,41 @@ def graph_from_gdfs(gdf_nodes, gdf_edges, graph_attrs=None): G.add_edge(u, v, key=k, **data) # add any nodes with no incident edges, since they wouldn't be added above - G.add_nodes_from(set(gdf_nodes.index) - set(G.nodes)) + G.add_nodes_from(set(df_nodes.index) - set(G.nodes)) # now all nodes are added, so set nodes' attributes - for col in gdf_nodes.columns: - nx.set_node_attributes(G, name=col, values=gdf_nodes[col].dropna()) + for col in df_nodes.columns: + nx.set_node_attributes(G, name=col, values=df_nodes[col].dropna()) - utils.log("Created graph from node/edge GeoDataFrames") + msg = "Created graph from node/edge GeoDataFrames" + utils.log(msg, level=lg.INFO) return G -def to_digraph(G, weight="length"): +def to_digraph(G: nx.MultiDiGraph, *, weight: str = "length") -> nx.DiGraph: """ Convert MultiDiGraph to DiGraph. - Chooses between parallel edges by minimizing `weight` attribute value. - Note: see also `to_undirected` to convert MultiDiGraph to MultiGraph. + Chooses between parallel edges by minimizing `weight` attribute value. See + also `to_undirected` to convert MultiDiGraph to MultiGraph. Parameters ---------- - G : networkx.MultiDiGraph - input graph - weight : string - attribute value to minimize when choosing between parallel edges + G + Input graph. + weight + Attribute value to minimize when choosing between parallel edges. Returns ------- - networkx.DiGraph + G """ # make a copy to not mutate original graph object caller passed in G = G.copy() - to_remove = [] + to_remove: list[tuple[int, int, int]] = [] # identify all the parallel edges in the MultiDiGraph - parallels = ((u, v) for u, v in G.edges(keys=False) if len(G.get_edge_data(u, v)) > 1) + parallels = ((u, v) for u, v in G.edges(keys=False) if G.number_of_edges(u, v) > 1) # among all sets of parallel edges, remove all except the one with the # minimum "weight" attribute value @@ -218,26 +341,27 @@ def to_digraph(G, weight="length"): to_remove.extend((u, v, k) for k in G[u][v] if k != k_min) G.remove_edges_from(to_remove) - utils.log("Converted MultiDiGraph to DiGraph") + msg = "Converted MultiDiGraph to DiGraph" + utils.log(msg, level=lg.INFO) return nx.DiGraph(G) -def to_undirected(G): +def to_undirected(G: nx.MultiDiGraph) -> nx.MultiGraph: """ Convert MultiDiGraph to undirected MultiGraph. - Maintains parallel edges only if their geometries differ. Note: see also + Maintains parallel edges only if their geometries differ. See also `to_digraph` to convert MultiDiGraph to DiGraph. Parameters ---------- - G : networkx.MultiDiGraph - input graph + G + Input graph. Returns ------- - networkx.MultiGraph + Gu """ # make a copy to not mutate original graph object caller passed in G = G.copy() @@ -259,47 +383,48 @@ def to_undirected(G): # convert MultiDiGraph to MultiGraph, retaining edges in both directions # of parallel edges and self-loops for now - H = nx.MultiGraph(**G.graph) - H.add_nodes_from(G.nodes(data=True)) - H.add_edges_from(G.edges(keys=True, data=True)) + Gu = nx.MultiGraph(**G.graph) + Gu.add_nodes_from(G.nodes(data=True)) + Gu.add_edges_from(G.edges(keys=True, data=True)) # the previous operation added all directed edges from G as undirected - # edges in H. we now have duplicate edges for every bidirectional parallel + # edges in Gu. we now have duplicate edges for each bidirectional parallel # edge or self-loop. so, look through the edges and remove any duplicates. duplicate_edges = set() - for u1, v1, key1, data1 in H.edges(keys=True, data=True): + for u1, v1, key1, data1 in Gu.edges(keys=True, data=True): # if we haven't already flagged this edge as a duplicate if (u1, v1, key1) not in duplicate_edges: # look at every other edge between u and v, one at a time - for key2 in H[u1][v1]: + for key2 in Gu[u1][v1]: # don't compare this edge to itself if key1 != key2: # compare the first edge's data to the second's # if they match up, flag the duplicate for removal - data2 = H.edges[u1, v1, key2] + data2 = Gu.edges[u1, v1, key2] if _is_duplicate_edge(data1, data2): duplicate_edges.add((u1, v1, key2)) - H.remove_edges_from(duplicate_edges) - utils.log("Converted MultiDiGraph to undirected MultiGraph") + Gu.remove_edges_from(duplicate_edges) + msg = "Converted MultiDiGraph to undirected MultiGraph" + utils.log(msg, level=lg.INFO) - return H + return Gu -def _is_duplicate_edge(data1, data2): +def _is_duplicate_edge(data1: dict[str, Any], data2: dict[str, Any]) -> bool: """ - Check if two graph edge data dicts have the same osmid and geometry. + Check if two graph edge data dicts have the same `osmid` and `geometry`. Parameters ---------- - data1: dict - the first edge's data - data2 : dict - the second edge's data + data1 + The first edge's attribute data. + data2 + The second edge's attribute data. Returns ------- - is_dupe : bool + is_dupe """ is_dupe = False @@ -326,7 +451,7 @@ def _is_duplicate_edge(data1, data2): return is_dupe -def _is_same_geometry(ls1, ls2): +def _is_same_geometry(ls1: LineString, ls2: LineString) -> bool: """ Determine if two LineString geometries are the same (in either direction). @@ -334,14 +459,14 @@ def _is_same_geometry(ls1, ls2): Parameters ---------- - ls1 : shapely.geometry.LineString - the first LineString geometry - ls2 : shapely.geometry.LineString - the second LineString geometry + ls1 + The first LineString geometry. + ls2 + The second LineString geometry. Returns ------- - bool + is_same """ # extract coordinates from each LineString geometry geom1 = [tuple(coords) for coords in ls1.xy] @@ -351,32 +476,32 @@ def _is_same_geometry(ls1, ls2): geom1_r = [tuple(reversed(coords)) for coords in ls1.xy] # if second geometry matches first in either direction, return True - return geom2 in (geom1, geom1_r) # noqa: PLR6201 + return geom2 in (geom1, geom1_r) -def _update_edge_keys(G): +def _update_edge_keys(G: nx.MultiDiGraph) -> nx.MultiDiGraph: """ Increment key of one edge of parallel edges that differ in geometry. - For example, two streets from u to v that bow away from each other as + For example, two streets from `u` to `v` that bow away from each other as separate streets, rather than opposite direction edges of a single street. - Increment one of these edge's keys so that they do not match across u, v, - k or v, u, k so we can add both to an undirected MultiGraph. + Increment one of these edge's keys so that they do not match across + `(u, v, k)` or `(v, u, k)` so we can add both to an undirected MultiGraph. Parameters ---------- - G : networkx.MultiDiGraph - input graph + G + Input graph. Returns ------- - G : networkx.MultiDiGraph + G """ # identify all the edges that are duplicates based on a sorted combination # of their origin, destination, and key. that is, edge uv will match edge vu # as a duplicate, but only if they have the same key edges = graph_to_gdfs(G, nodes=False, fill_edge_geometry=False) - edges["uvk"] = ["_".join(sorted([str(u), str(v)]) + [str(k)]) for u, v, k in edges.index] + edges["uvk"] = ["_".join([*sorted([str(u), str(v)]), str(k)]) for u, v, k in edges.index] mask = edges["uvk"].duplicated(keep=False) dupes = edges[mask].dropna(subset=["geometry"]) diff --git a/osmnx/distance.py b/osmnx/distance.py index d3e0f30e4..0e0d0d25b 100644 --- a/osmnx/distance.py +++ b/osmnx/distance.py @@ -1,24 +1,27 @@ -"""Calculate distances and find nearest node/edge(s) to point(s).""" +"""Calculate distances and find nearest graph node/edge(s) to point(s).""" -from warnings import warn +from __future__ import annotations + +import logging as lg +from collections.abc import Iterable +from typing import Literal +from typing import overload import networkx as nx import numpy as np -import pandas as pd -from shapely.geometry import Point +import numpy.typing as npt +from shapely import Point from shapely.strtree import STRtree from . import convert from . import projection -from . import routing from . import utils -from . import utils_geo # scipy is optional dependency for projected nearest-neighbor search try: from scipy.spatial import cKDTree except ImportError: # pragma: no cover - cKDTree = None + cKDTree = None # noqa: N816 # scikit-learn is optional dependency for unprojected nearest-neighbor search try: @@ -29,7 +32,50 @@ EARTH_RADIUS_M = 6_371_009 -def great_circle(lat1, lon1, lat2, lon2, earth_radius=EARTH_RADIUS_M): +# if coords are all floats, return float +@overload +def great_circle(lat1: float, lon1: float, lat2: float, lon2: float) -> float: ... + + +# if coords are all floats (and optional arg is provided), return float +@overload +def great_circle( + lat1: float, + lon1: float, + lat2: float, + lon2: float, + earth_radius: float, +) -> float: ... + + +# if coords are all arrays, return array +@overload +def great_circle( + lat1: npt.NDArray[np.float64], + lon1: npt.NDArray[np.float64], + lat2: npt.NDArray[np.float64], + lon2: npt.NDArray[np.float64], +) -> npt.NDArray[np.float64]: ... + + +# if coords are all arrays (and optional arg is provided), return array +@overload +def great_circle( + lat1: npt.NDArray[np.float64], + lon1: npt.NDArray[np.float64], + lat2: npt.NDArray[np.float64], + lon2: npt.NDArray[np.float64], + earth_radius: float, +) -> npt.NDArray[np.float64]: ... + + +def great_circle( + lat1: float | npt.NDArray[np.float64], + lon1: float | npt.NDArray[np.float64], + lat2: float | npt.NDArray[np.float64], + lon2: float | npt.NDArray[np.float64], + earth_radius: float = EARTH_RADIUS_M, +) -> float | npt.NDArray[np.float64]: """ Calculate great-circle distances between pairs of points. @@ -39,23 +85,23 @@ def great_circle(lat1, lon1, lat2, lon2, earth_radius=EARTH_RADIUS_M): Parameters ---------- - lat1 : float or numpy.array of float - first point's latitude coordinate - lon1 : float or numpy.array of float - first point's longitude coordinate - lat2 : float or numpy.array of float - second point's latitude coordinate - lon2 : float or numpy.array of float - second point's longitude coordinate - earth_radius : float - earth's radius in units in which distance will be returned (default is - meters) + lat1 + First point's latitude coordinate(s). + lon1 + First point's longitude coordinate(s). + lat2 + Second point's latitude coordinate(s). + lon2 + Second point's longitude coordinate(s). + earth_radius + Earth's radius in units in which distance will be returned (default + represents meters). Returns ------- - dist : float or numpy.array of float - distance from each (lat1, lon1) to each (lat2, lon2) in units of - earth_radius + dist + Distance from each `(lat1, lon1)` point to each `(lat2, lon2)` point + in units of `earth_radius`. """ y1 = np.deg2rad(lat1) y2 = np.deg2rad(lat2) @@ -70,10 +116,31 @@ def great_circle(lat1, lon1, lat2, lon2, earth_radius=EARTH_RADIUS_M): arc = 2 * np.arcsin(np.sqrt(h)) # return distance in units of earth_radius - return arc * earth_radius + dist: float | npt.NDArray[np.float64] = arc * earth_radius + return dist + + +# if coords are all floats, return float +@overload +def euclidean(y1: float, x1: float, y2: float, x2: float) -> float: ... + +# if coords are all arrays, return array +@overload +def euclidean( + y1: npt.NDArray[np.float64], + x1: npt.NDArray[np.float64], + y2: npt.NDArray[np.float64], + x2: npt.NDArray[np.float64], +) -> npt.NDArray[np.float64]: ... -def euclidean(y1, x1, y2, x2): + +def euclidean( + y1: float | npt.NDArray[np.float64], + x1: float | npt.NDArray[np.float64], + y2: float | npt.NDArray[np.float64], + x2: float | npt.NDArray[np.float64], +) -> float | npt.NDArray[np.float64]: """ Calculate Euclidean distances between pairs of points. @@ -83,101 +150,37 @@ def euclidean(y1, x1, y2, x2): Parameters ---------- - y1 : float or numpy.array of float - first point's y coordinate - x1 : float or numpy.array of float - first point's x coordinate - y2 : float or numpy.array of float - second point's y coordinate - x2 : float or numpy.array of float - second point's x coordinate + y1 + First point's y coordinate(s). + x1 + First point's x coordinate(s). + y2 + Second point's y coordinate(s). + x2 + Second point's x coordinate(s). Returns ------- - dist : float or numpy.array of float - distance from each (x1, y1) to each (x2, y2) in coordinates' units + dist + Distance from each `(x1, y1)` point to each `(x2, y2)` point in same + units as the points' coordinates. """ # pythagorean theorem - return ((x1 - x2) ** 2 + (y1 - y2) ** 2) ** 0.5 - - -def great_circle_vec(lat1, lng1, lat2, lng2, earth_radius=EARTH_RADIUS_M): - """ - Do not use, deprecated. - - The `great_circle_vec` function has been renamed `great_circle`. Calling - `great_circle_vec` will raise an error in the v2.0.0 release. - - Parameters - ---------- - lat1 : float or numpy.array of float - first point's latitude coordinate - lng1 : float or numpy.array of float - first point's longitude coordinate - lat2 : float or numpy.array of float - second point's latitude coordinate - lng2 : float or numpy.array of float - second point's longitude coordinate - earth_radius : float - earth's radius in units in which distance will be returned (default is - meters) - - Returns - ------- - dist : float or numpy.array of float - distance from each (lat1, lng1) to each (lat2, lng2) in units of - earth_radius - """ - warn( - "The `great_circle_vec` function has been renamed `great_circle`. Calling " - "`great_circle_vec` will raise an error starting in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - return great_circle(lat1, lng1, lat2, lng2, earth_radius) + dist: float | npt.NDArray[np.float64] = ((x1 - x2) ** 2 + (y1 - y2) ** 2) ** 0.5 + return dist -def euclidean_dist_vec(y1, x1, y2, x2): +def add_edge_lengths( + G: nx.MultiDiGraph, + *, + edges: Iterable[tuple[int, int, int]] | None = None, +) -> nx.MultiDiGraph: """ - Do not use, deprecated. - - The `euclidean_dist_vec` function has been renamed `euclidean`. Calling - `euclidean_dist_vec` will raise an error in the v2.0.0 release. - - Parameters - ---------- - y1 : float or numpy.array of float - first point's y coordinate - x1 : float or numpy.array of float - first point's x coordinate - y2 : float or numpy.array of float - second point's y coordinate - x2 : float or numpy.array of float - second point's x coordinate - - Returns - ------- - dist : float or numpy.array of float - distance from each (x1, y1) to each (x2, y2) in coordinates' units - """ - warn( - "The `euclidean_dist_vec` function has been renamed `euclidean`. Calling " - "`euclidean_dist_vec` will raise an error starting in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - return euclidean(y1, x1, y2, x2) - - -def add_edge_lengths(G, precision=None, edges=None): - """ - Add `length` attribute (in meters) to each edge. + Calculate and add `length` attribute (in meters) to each edge. Vectorized function to calculate great-circle distance between each edge's - incident nodes. Ensure graph is in unprojected coordinates, and - unsimplified to get accurate distances. + incident nodes. Ensure graph is unprojected and unsimplified to calculate + accurate distances. Note: this function is run by all the `graph.graph_from_x` functions automatically to add `length` attributes to all edges. It calculates edge @@ -185,69 +188,129 @@ def add_edge_lengths(G, precision=None, edges=None): OSMnx automatically runs this function upon graph creation, it does it before simplifying the graph: thus it calculates the straight-line lengths of edge segments that are themselves all straight. Only after - simplification do edges take on a (potentially) curvilinear geometry. If - you wish to calculate edge lengths later, you are calculating + simplification do edges take on (potentially) curvilinear geometry. If you + wish to calculate edge lengths later, note that you will be calculating straight-line distances which necessarily ignore the curvilinear geometry. - You only want to run this function on a graph with all straight edges + Thus you only want to run this function on a graph with all straight edges (such as is the case with an unsimplified graph). Parameters ---------- - G : networkx.MultiDiGraph - unprojected, unsimplified input graph - precision : int - deprecated, do not use - edges : tuple - tuple of (u, v, k) tuples representing subset of edges to add length - attributes to. if None, add lengths to all edges. + G + Unprojected and unsimplified input graph. + edges + The subset of edges to add `length` attributes to, as `(u, v, k)` + tuples. If None, add lengths to all edges. Returns ------- - G : networkx.MultiDiGraph - graph with edge length attributes + G + Graph with `length` attributes on the edges. """ - if precision is None: - precision = 3 - else: - warn( - "The `precision` parameter is deprecated and will be removed in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - - uvk = tuple(G.edges) if edges is None else edges + uvk = G.edges if edges is None else edges # extract edge IDs and corresponding coordinates from their nodes x = G.nodes(data="x") y = G.nodes(data="y") + msg = "Some edges missing nodes, possibly due to input data clipping issue." try: # two-dimensional array of coordinates: y0, x0, y1, x1 c = np.array([(y[u], x[u], y[v], x[v]) for u, v, k in uvk]) - # ensure all coordinates can be converted to float and are non-null - assert not np.isnan(c.astype(float)).any() - except (AssertionError, KeyError) as e: # pragma: no cover - msg = "some edges missing nodes, possibly due to input data clipping issue" + except KeyError as e: # pragma: no cover raise ValueError(msg) from e + else: + # ensure all coordinates can be converted to float and are non-null + if np.isnan(c.astype(float)).any(): + raise ValueError(msg) # calculate great circle distances, round, and fill nulls with zeros - dists = great_circle(c[:, 0], c[:, 1], c[:, 2], c[:, 3]).round(precision) + dists = great_circle(c[:, 0], c[:, 1], c[:, 2], c[:, 3]) dists[np.isnan(dists)] = 0 nx.set_edge_attributes(G, values=dict(zip(uvk, dists)), name="length") - utils.log("Added length attributes to graph edges") + msg = "Added length attributes to graph edges" + utils.log(msg, level=lg.INFO) return G -def nearest_nodes(G, X, Y, return_dist=False): +# if X and Y are floats and return_dist is not provided (defaults False) +@overload +def nearest_nodes(G: nx.MultiDiGraph, X: float, Y: float) -> int: ... + + +# if X and Y are floats and return_dist is provided/False +@overload +def nearest_nodes( + G: nx.MultiDiGraph, + X: float, + Y: float, + *, + return_dist: Literal[False], +) -> int: ... + + +# if X and Y are floats and return_dist is provided/True +@overload +def nearest_nodes( + G: nx.MultiDiGraph, + X: float, + Y: float, + *, + return_dist: Literal[True], +) -> tuple[npt.NDArray[np.int64], npt.NDArray[np.float64]]: ... + + +# if X and Y are iterable and return_dist is not provided (defaults False) +@overload +def nearest_nodes( + G: nx.MultiDiGraph, + X: Iterable[float], + Y: Iterable[float], +) -> npt.NDArray[np.int64]: ... + + +# if X and Y are iterable and return_dist is provided/False +@overload +def nearest_nodes( + G: nx.MultiDiGraph, + X: Iterable[float], + Y: Iterable[float], + *, + return_dist: Literal[False], +) -> npt.NDArray[np.int64]: ... + + +# if X and Y are iterable and return_dist is provided/True +@overload +def nearest_nodes( + G: nx.MultiDiGraph, + X: Iterable[float], + Y: Iterable[float], + *, + return_dist: Literal[True], +) -> tuple[npt.NDArray[np.int64], npt.NDArray[np.float64]]: ... + + +def nearest_nodes( + G: nx.MultiDiGraph, + X: float | Iterable[float], + Y: float | Iterable[float], + *, + return_dist: bool = False, +) -> ( + int + | npt.NDArray[np.int64] + | tuple[int, float] + | tuple[npt.NDArray[np.int64], npt.NDArray[np.float64]] +): """ Find the nearest node to a point or to each of several points. If `X` and `Y` are single coordinate values, this will return the nearest - node to that point. If `X` and `Y` are lists of coordinate values, this - will return the nearest node to each point. + node to that point. If `X` and `Y` are iterables of coordinate values, + this will return the nearest node to each point. - If the graph is projected, this uses a k-d tree for euclidean nearest + If the graph is projected, this uses a k-d tree for Euclidean nearest neighbor search, which requires that scipy is installed as an optional dependency. If it is unprojected, this uses a ball tree for haversine nearest neighbor search, which requires that scikit-learn is installed as @@ -255,245 +318,217 @@ def nearest_nodes(G, X, Y, return_dist=False): Parameters ---------- - G : networkx.MultiDiGraph - graph in which to find nearest nodes - X : float or list - points' x (longitude) coordinates, in same CRS/units as graph and - containing no nulls - Y : float or list - points' y (latitude) coordinates, in same CRS/units as graph and - containing no nulls - return_dist : bool - optionally also return distance between points and nearest nodes + G + Graph in which to find nearest nodes. + X + The points' x (longitude) coordinates, in same CRS/units as graph and + containing no nulls. + Y + The points' y (latitude) coordinates, in same CRS/units as graph and + containing no nulls. + return_dist + If True, optionally also return the distance(s) between point(s) and + nearest node(s). Returns ------- - nn or (nn, dist) : int/list or tuple - nearest node IDs or optionally a tuple where `dist` contains distances - between the points and their nearest nodes + nn or (nn, dist) + Nearest node ID(s) or optionally a tuple of ID(s) and distance(s) + between each point and its nearest node. """ - is_scalar = False - if not (hasattr(X, "__iter__") and hasattr(Y, "__iter__")): - # make coordinates arrays if user passed non-iterable values + # make coordinates arrays whether user passed iterable values or not + if not (isinstance(X, Iterable) and isinstance(Y, Iterable)): is_scalar = True - X = np.array([X]) - Y = np.array([Y]) + X_arr = np.array([X]) + Y_arr = np.array([Y]) + else: + is_scalar = False + X_arr = np.array(X) + Y_arr = np.array(Y) - if np.isnan(X).any() or np.isnan(Y).any(): # pragma: no cover - msg = "`X` and `Y` cannot contain nulls" + if np.isnan(X_arr).any() or np.isnan(Y_arr).any(): # pragma: no cover + msg = "`X` and `Y` cannot contain nulls." raise ValueError(msg) + nodes = convert.graph_to_gdfs(G, edges=False, node_geometry=False)[["x", "y"]] + nn_array: npt.NDArray[np.int64] + dist_array: npt.NDArray[np.float64] if projection.is_projected(G.graph["crs"]): # if projected, use k-d tree for euclidean nearest-neighbor search if cKDTree is None: # pragma: no cover - msg = "scipy must be installed to search a projected graph" + msg = "scipy must be installed as an optional dependency to search a projected graph." raise ImportError(msg) - dist, pos = cKDTree(nodes).query(np.array([X, Y]).T, k=1) - nn = nodes.index[pos] + dist_array, pos = cKDTree(nodes).query(np.array([X_arr, Y_arr]).T, k=1) + nn_array = nodes.index[pos].to_numpy() else: # if unprojected, use ball tree for haversine nearest-neighbor search if BallTree is None: # pragma: no cover - msg = "scikit-learn must be installed to search an unprojected graph" + msg = "scikit-learn must be installed as an optional dependency to search an unprojected graph." raise ImportError(msg) # haversine requires lat, lon coords in radians nodes_rad = np.deg2rad(nodes[["y", "x"]]) - points_rad = np.deg2rad(np.array([Y, X]).T) - dist, pos = BallTree(nodes_rad, metric="haversine").query(points_rad, k=1) - dist = dist[:, 0] * EARTH_RADIUS_M # convert radians -> meters - nn = nodes.index[pos[:, 0]] + points_rad = np.deg2rad(np.array([Y_arr, X_arr]).T) + dist_array, pos = BallTree(nodes_rad, metric="haversine").query(points_rad, k=1) + dist_array = dist_array[:, 0] * EARTH_RADIUS_M # convert radians -> meters + nn_array = nodes.index[pos[:, 0]].to_numpy() # convert results to correct types for return - nn = nn.tolist() - dist = dist.tolist() if is_scalar: - nn = nn[0] - dist = dist[0] + nn = int(nn_array[0]) + dist = float(dist_array[0]) + if return_dist: + return nn, dist + # otherwise + return nn + # otherwise if return_dist: - return nn, dist - + return nn_array, dist_array # otherwise - return nn - - -def nearest_edges(G, X, Y, interpolate=None, return_dist=False): + return nn_array + + +# if X and Y are floats and return_dist is not provided (defaults False) +@overload +def nearest_edges(G: nx.MultiDiGraph, X: float, Y: float) -> tuple[int, int, int]: ... + + +# if X and Y are floats and return_dist is provided/False +@overload +def nearest_edges( + G: nx.MultiDiGraph, + X: float, + Y: float, + *, + return_dist: Literal[False], +) -> tuple[int, int, int]: ... + + +# if X and Y are floats and return_dist is provided/True +@overload +def nearest_edges( + G: nx.MultiDiGraph, + X: float, + Y: float, + *, + return_dist: Literal[True], +) -> tuple[tuple[int, int, int], float]: ... + + +# if X and Y are iterable and return_dist is not provided (defaults False) +@overload +def nearest_edges( + G: nx.MultiDiGraph, + X: Iterable[float], + Y: Iterable[float], +) -> npt.NDArray[np.object_]: ... + + +# if X and Y are iterable and return_dist is provided/False +@overload +def nearest_edges( + G: nx.MultiDiGraph, + X: Iterable[float], + Y: Iterable[float], + *, + return_dist: Literal[False], +) -> npt.NDArray[np.object_]: ... + + +# if X and Y are iterable and return_dist is provided/True +@overload +def nearest_edges( + G: nx.MultiDiGraph, + X: Iterable[float], + Y: Iterable[float], + *, + return_dist: Literal[True], +) -> tuple[npt.NDArray[np.object_], npt.NDArray[np.float64]]: ... + + +def nearest_edges( + G: nx.MultiDiGraph, + X: float | Iterable[float], + Y: float | Iterable[float], + *, + return_dist: bool = False, +) -> ( + tuple[int, int, int] + | npt.NDArray[np.object_] + | tuple[tuple[int, int, int], float] + | tuple[npt.NDArray[np.object_], npt.NDArray[np.float64]] +): """ Find the nearest edge to a point or to each of several points. If `X` and `Y` are single coordinate values, this will return the nearest - edge to that point. If `X` and `Y` are lists of coordinate values, this - will return the nearest edge to each point. This function uses an R-tree - spatial index and minimizes the euclidean distance from each point to the + edge to that point. If `X` and `Y` are iterables of coordinate values, + this will return the nearest edge to each point. This uses an R-tree + spatial index and minimizes the Euclidean distance from each point to the possible matches. For accurate results, use a projected graph and points. Parameters ---------- - G : networkx.MultiDiGraph - graph in which to find nearest edges - X : float or list - points' x (longitude) coordinates, in same CRS/units as graph and - containing no nulls - Y : float or list - points' y (latitude) coordinates, in same CRS/units as graph and - containing no nulls - interpolate : float - deprecated, do not use - return_dist : bool - optionally also return distance between points and nearest edges + G + Graph in which to find nearest edges. + X + The points' x (longitude) coordinates, in same CRS/units as graph and + containing no nulls. + Y + The points' y (latitude) coordinates, in same CRS/units as graph and + containing no nulls. + return_dist + If True, optionally also return the distance(s) between point(s) and + nearest edge(s). Returns ------- - ne or (ne, dist) : tuple or list - nearest edges as (u, v, key) or optionally a tuple where `dist` - contains distances between the points and their nearest edges + ne or (ne, dist) + Nearest edge ID(s) as `(u, v, k)` tuples, or optionally a tuple of + ID(s) and distance(s) between each point and its nearest edge. """ - is_scalar = False - if not (hasattr(X, "__iter__") and hasattr(Y, "__iter__")): - # make coordinates arrays if user passed non-iterable values + # make coordinates arrays whether user passed iterable values or not + if not (isinstance(X, Iterable) and isinstance(Y, Iterable)): is_scalar = True - X = np.array([X]) - Y = np.array([Y]) + X_arr = np.array([X]) + Y_arr = np.array([Y]) + else: + is_scalar = False + X_arr = np.array(X) + Y_arr = np.array(Y) - if np.isnan(X).any() or np.isnan(Y).any(): # pragma: no cover - msg = "`X` and `Y` cannot contain nulls" + if np.isnan(X_arr).any() or np.isnan(Y_arr).any(): # pragma: no cover + msg = "`X` and `Y` cannot contain nulls." raise ValueError(msg) geoms = convert.graph_to_gdfs(G, nodes=False)["geometry"] + ne_array: npt.NDArray[np.object_] # array of tuple[int, int, int] + dist_array: npt.NDArray[np.float64] - # if no interpolation distance was provided - if interpolate is None: - # build an r-tree spatial index by position for subsequent iloc - rtree = STRtree(geoms) + # build an r-tree spatial index by position for subsequent iloc + rtree = STRtree(geoms) - # use the r-tree to find each point's nearest neighbor and distance - points = [Point(xy) for xy in zip(X, Y)] - pos, dist = rtree.query_nearest(points, all_matches=False, return_distance=True) + # use the r-tree to find each point's nearest neighbor and distance + points = [Point(xy) for xy in zip(X_arr, Y_arr)] + pos, dist_array = rtree.query_nearest(points, all_matches=False, return_distance=True) - # if user passed X/Y lists, the 2nd subarray contains geom indices - if len(pos.shape) > 1: - pos = pos[1] - ne = geoms.iloc[pos].index - - # otherwise, if interpolation distance was provided - else: - warn( - "The `interpolate` parameter has been deprecated and will be " - "removed in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - - # interpolate points along edges to index with k-d tree or ball tree - uvk_xy = [] - for uvk, geom in zip(geoms.index, geoms.to_numpy()): - uvk_xy.extend((uvk, xy) for xy in utils_geo.interpolate_points(geom, interpolate)) - labels, xy = zip(*uvk_xy) - vertices = pd.DataFrame(xy, index=labels, columns=["x", "y"]) - - if projection.is_projected(G.graph["crs"]): - # if projected, use k-d tree for euclidean nearest-neighbor search - if cKDTree is None: # pragma: no cover - msg = "scipy must be installed to search a projected graph" - raise ImportError(msg) - dist, pos = cKDTree(vertices).query(np.array([X, Y]).T, k=1) - ne = vertices.index[pos] - - else: - # if unprojected, use ball tree for haversine nearest-neighbor search - if BallTree is None: # pragma: no cover - msg = "scikit-learn must be installed to search an unprojected graph" - raise ImportError(msg) - # haversine requires lat, lon coords in radians - vertices_rad = np.deg2rad(vertices[["y", "x"]]) - points_rad = np.deg2rad(np.array([Y, X]).T) - dist, pos = BallTree(vertices_rad, metric="haversine").query(points_rad, k=1) - dist = dist[:, 0] * EARTH_RADIUS_M # convert radians -> meters - ne = vertices.index[pos[:, 0]] + # if user passed X/Y lists, the 2nd subarray contains geom indices + if len(pos.shape) > 1: + pos = pos[1] + ne_array = geoms.iloc[pos].index.to_numpy() # convert results to correct types for return - ne = list(ne) - dist = list(dist) if is_scalar: - ne = ne[0] - dist = dist[0] + ne: tuple[int, int, int] = ne_array[0] + dist = float(dist_array[0]) + if return_dist: + return ne, dist + # otherwise + return ne + # otherwise if return_dist: - return ne, dist - + return ne_array, dist_array # otherwise - return ne - - -def shortest_path(G, orig, dest, weight="length", cpus=1): - """ - Do not use, deprecated. - - The `shortest_path` function has moved to the `routing` module. Calling - it via the `distance` module will raise an error in the v2.0.0 release. - - Parameters - ---------- - G : networkx.MultiDiGraph - input graph - orig : int or list - origin node ID, or a list of origin node IDs - dest : int or list - destination node ID, or a list of destination node IDs - weight : string - edge attribute to minimize when solving shortest path - cpus : int - how many CPU cores to use; if None, use all available - - Returns - ------- - path : list - list of node IDs constituting the shortest path, or, if orig and dest - are lists, then a list of path lists - """ - warn( - "The `shortest_path` function has moved to the `routing` module. Calling it " - "via the `distance` module will raise an error starting in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - return routing.shortest_path(G, orig, dest, weight, cpus) - - -def k_shortest_paths(G, orig, dest, k, weight="length"): - """ - Do not use, deprecated. - - The `k_shortest_paths` function has moved to the `routing` module. Calling - it via the `distance` module will raise an error in the v2.0.0 release. - - Parameters - ---------- - G : networkx.MultiDiGraph - input graph - orig : int - origin node ID - dest : int - destination node ID - k : int - number of shortest paths to solve - weight : string - edge attribute to minimize when solving shortest paths. default is - edge length in meters. - - Yields - ------ - path : list - a generator of `k` shortest paths ordered by total weight. each path - is a list of node IDs. - """ - warn( - "The `k_shortest_paths` function has moved to the `routing` module. " - "Calling it via the `distance` module will raise an error in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - return routing.k_shortest_paths(G, orig, dest, k, weight) + return ne_array diff --git a/osmnx/elevation.py b/osmnx/elevation.py index 59c9fd088..b0dc6cb96 100644 --- a/osmnx/elevation.py +++ b/osmnx/elevation.py @@ -1,99 +1,103 @@ """Add node elevations from raster files or web APIs, and calculate edge grades.""" +from __future__ import annotations + +import logging as lg import multiprocessing as mp import time from hashlib import sha1 from pathlib import Path -from warnings import warn +from typing import TYPE_CHECKING +from typing import Any import networkx as nx import numpy as np import pandas as pd import requests -from . import _downloader +from . import _http from . import convert from . import settings from . import utils from ._errors import InsufficientResponseError +if TYPE_CHECKING: + from collections.abc import Iterable + # rasterio and gdal are optional dependencies for raster querying try: import rasterio from osgeo import gdal except ImportError: # pragma: no cover - rasterio = gdal = None + rasterio = None + gdal = None -def add_edge_grades(G, add_absolute=True, precision=None): +def add_edge_grades(G: nx.MultiDiGraph, *, add_absolute: bool = True) -> nx.MultiDiGraph: """ - Add `grade` attribute to each graph edge. + Calculate and add `grade` attributes to all graph edges. - Vectorized function to calculate the directed grade (ie, rise over run) + Vectorized function to calculate the directed grade (i.e., rise over run) for each edge in the graph and add it to the edge as an attribute. Nodes - must already have `elevation` attributes to use this function. + must already have `elevation` and `length` attributes before using this + function. See also the `add_node_elevations_raster` and `add_node_elevations_google` functions. Parameters ---------- - G : networkx.MultiDiGraph - input graph with `elevation` node attribute - add_absolute : bool - if True, also add absolute value of grade as `grade_abs` attribute - precision : int - deprecated, do not use + G + Graph with `elevation` node attributes. + add_absolute + If True, also add absolute value of grade as `grade_abs` attribute. Returns ------- - G : networkx.MultiDiGraph - graph with edge `grade` (and optionally `grade_abs`) attributes + G + Graph with `grade` (and optionally `grade_abs`) attributes on the + edges. """ - if precision is None: - precision = 3 - else: - warn( - "The `precision` parameter is deprecated and will be removed in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - elev_lookup = G.nodes(data="elevation") u, v, k, lengths = zip(*G.edges(keys=True, data="length")) uvk = tuple(zip(u, v, k)) # calculate edges' elevation changes from u to v then divide by lengths elevs = np.array([(elev_lookup[u], elev_lookup[v]) for u, v, k in uvk]) - grades = ((elevs[:, 1] - elevs[:, 0]) / np.array(lengths)).round(precision) + grades = (elevs[:, 1] - elevs[:, 0]) / np.array(lengths) nx.set_edge_attributes(G, dict(zip(uvk, grades)), name="grade") # optionally add grade absolute value to the edge attributes if add_absolute: nx.set_edge_attributes(G, dict(zip(uvk, np.abs(grades))), name="grade_abs") - utils.log("Added grade attributes to all edges.") + msg = "Added grade attributes to all edges" + utils.log(msg, level=lg.INFO) return G -def _query_raster(nodes, filepath, band): +def _query_raster( + nodes: pd.DataFrame, + filepath: str | Path, + band: int, +) -> Iterable[tuple[int, Any]]: """ - Query a raster for values at coordinates in a DataFrame's x/y columns. + Query a raster file for values at coordinates in DataFrame x/y columns. Parameters ---------- - nodes : pandas.DataFrame - DataFrame indexed by node ID and with two columns: x and y - filepath : string or pathlib.Path - path to the raster file or VRT to query - band : int - which raster band to query + nodes + DataFrame indexed by node ID and with two columns representing x and y + coordinates. + filepath + Path to the raster file or VRT to query. + band + Which raster band to query. Returns ------- - nodes_values : zip - zipped node IDs and corresponding raster values + nodes_values + Zip of node IDs and corresponding raster values. """ # must open raster file here: cannot pickle it to pass in multiprocessing with rasterio.open(filepath) as raster: @@ -102,46 +106,53 @@ def _query_raster(nodes, filepath, band): return zip(nodes.index, values) -def add_node_elevations_raster(G, filepath, band=1, cpus=None): +def add_node_elevations_raster( + G: nx.MultiDiGraph, + filepath: str | Path | Iterable[str | Path], + *, + band: int = 1, + cpus: int | None = None, +) -> nx.MultiDiGraph: """ - Add `elevation` attribute to each node from local raster file(s). + Add `elevation` attributes to all nodes from local raster file(s). - If `filepath` is a list of paths, this will generate a virtual raster + If `filepath` is an iterable of paths, this will generate a virtual raster composed of the files at those paths as an intermediate step. See also the `add_edge_grades` function. Parameters ---------- - G : networkx.MultiDiGraph - input graph, in same CRS as raster - filepath : string or pathlib.Path or list of strings/Paths - path (or list of paths) to the raster file(s) to query - band : int - which raster band to query - cpus : int - how many CPU cores to use; if None, use all available + G + Graph in same CRS as raster. + filepath + The path(s) to the raster file(s) to query. + band + Which raster band to query. + cpus + How many CPU cores to use. If None, use all available. Returns ------- - G : networkx.MultiDiGraph - graph with node elevation attributes + G + Graph with `elevation` attributes on the nodes. """ if rasterio is None or gdal is None: # pragma: no cover - msg = "gdal and rasterio must be installed to query raster files" + msg = "gdal and rasterio must be installed as optional dependencies to query raster files." raise ImportError(msg) if cpus is None: cpus = mp.cpu_count() cpus = min(cpus, mp.cpu_count()) - utils.log(f"Attaching elevations with {cpus} CPUs...") + msg = f"Attaching elevations with {cpus} CPUs..." + utils.log(msg, level=lg.INFO) - # if a list of filepaths is passed, compose them all as a virtual raster - # use the sha1 hash of the filepaths list as the vrt filename + # if multiple filepaths are passed in, compose them as a virtual raster + # use the sha1 hash of the filepaths object as the vrt filename if not isinstance(filepath, (str, Path)): filepaths = [str(p) for p in filepath] - sha = sha1(str(filepaths).encode("utf-8")).hexdigest() - filepath = f"./.osmnx_{sha}.vrt" + checksum = sha1(str(filepaths).encode("utf-8")).hexdigest() # noqa: S324 + filepath = f"./.osmnx_{checksum}.vrt" gdal.UseExceptions() gdal.BuildVRT(filepath, filepaths).FlushCache() @@ -156,107 +167,66 @@ def add_node_elevations_raster(G, filepath, band=1, cpus=None): results = pool.starmap_async(_query_raster, args).get() elevs = {k: v for kv in results for k, v in kv} - assert len(G) == len(elevs) nx.set_node_attributes(G, elevs, name="elevation") - utils.log("Added elevation data from raster to all nodes.") + msg = "Added elevation data from raster to all nodes" + utils.log(msg, level=lg.INFO) return G def add_node_elevations_google( - G, - api_key=None, - batch_size=350, - pause=0, - max_locations_per_batch=None, - precision=None, - url_template=None, -): + G: nx.MultiDiGraph, + *, + api_key: str | None = None, + batch_size: int = 512, + pause: float = 0, +) -> nx.MultiDiGraph: """ - Add an `elevation` (meters) attribute to each node using a web service. + Add `elevation` (meters) attributes to all nodes using a web service. By default, this uses the Google Maps Elevation API but you can optionally use an equivalent API with the same interface and response format, such as Open Topo Data, via the `settings` module's `elevation_url_template`. The Google Maps Elevation API requires an API key but other providers may not. + You can find more information about the Google Maps Elevation API at: + https://developers.google.com/maps/documentation/elevation For a free local alternative see the `add_node_elevations_raster` function. See also the `add_edge_grades` function. Parameters ---------- - G : networkx.MultiDiGraph - input graph - api_key : string - a valid API key, can be None if the API does not require a key - batch_size : int - max number of coordinate pairs to submit in each API call (if this is - too high, the server will reject the request because its character - limit exceeds the max allowed) - pause : float - time to pause between API calls, which can be increased if you get - rate limited - max_locations_per_batch : int - deprecated, do not use - precision : int - deprecated, do not use - url_template : string - deprecated, do not use + G + Graph to add elevation data to. + api_key + A valid API key. Can be None if the API does not require a key. + batch_size + Max number of coordinate pairs to submit in each request (depends on + provider's limits). Google's limit is 512. + pause + How long to pause in seconds between API calls, which can be increased + if you get rate limited. Returns ------- - G : networkx.MultiDiGraph - graph with node elevation attributes + G + Graph with `elevation` attributes on the nodes. """ - if max_locations_per_batch is None: - max_locations_per_batch = batch_size - else: - warn( - "The `max_locations_per_batch` parameter is deprecated and will be " - "removed the v2.0.0 release, use the `batch_size` parameter instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - - if precision is None: - precision = 3 - else: - warn( - "The `precision` parameter is deprecated and will be removed in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - - if url_template is None: - url_template = settings.elevation_url_template - else: - warn( - "The `url_template` parameter is deprecated and will be removed " - "in the v2.0.0 release. Configure the `settings` module's " - "`elevation_url_template` instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - - # make a pandas series of all the nodes' coordinates as 'lat,lon' - # round coordinates to 5 decimal places (approx 1 meter) to be able to fit - # in more locations per API call - node_points = pd.Series( - {node: f'{data["y"]:.5f},{data["x"]:.5f}' for node, data in G.nodes(data=True)} - ) - n_calls = int(np.ceil(len(node_points) / max_locations_per_batch)) - domain = _downloader._hostname_from_url(url_template) - utils.log(f"Requesting node elevations from {domain!r} in {n_calls} request(s)") + # make a pandas series of all the nodes' coordinates as "lat,lon" and + # round coordinates to 6 decimal places (approx 5 to 10 cm resolution) + node_points = pd.Series({n: f"{d['y']:.6f},{d['x']:.6f}" for n, d in G.nodes(data=True)}) + n_calls = int(np.ceil(len(node_points) / batch_size)) + domain = _http._hostname_from_url(settings.elevation_url_template) + + msg = f"Requesting node elevations from {domain!r} in {n_calls} request(s)" + utils.log(msg, level=lg.INFO) - # break the series of coordinates into chunks of max_locations_per_batch + # break the series of coordinates into chunks of batch_size # API format is locations=lat,lon|lat,lon|lat,lon|lat,lon... results = [] - for i in range(0, len(node_points), max_locations_per_batch): - chunk = node_points.iloc[i : i + max_locations_per_batch] + for i in range(0, len(node_points), batch_size): + chunk = node_points.iloc[i : i + batch_size] locations = "|".join(chunk) - url = url_template.format(locations=locations, key=api_key) + url = settings.elevation_url_template.format(locations=locations, key=api_key) # download and append these elevation results to list of all results response_json = _elevation_request(url, pause) @@ -267,7 +237,7 @@ def add_node_elevations_google( # sanity check that all our vectors have the same number of elements msg = f"Graph has {len(G):,} nodes and we received {len(results):,} results from {domain!r}" - utils.log(msg) + utils.log(msg, level=lg.INFO) if not (len(results) == len(G) == len(node_points)): # pragma: no cover err_msg = f"{msg}\n{response_json}" raise InsufficientResponseError(err_msg) @@ -275,58 +245,52 @@ def add_node_elevations_google( # add elevation as an attribute to the nodes df_elev = pd.DataFrame(node_points, columns=["node_points"]) df_elev["elevation"] = [result["elevation"] for result in results] - df_elev["elevation"] = df_elev["elevation"].round(precision) nx.set_node_attributes(G, name="elevation", values=df_elev["elevation"].to_dict()) - utils.log(f"Added elevation data from {domain!r} to all nodes.") + msg = f"Added elevation data from {domain!r} to all nodes." + utils.log(msg, level=lg.INFO) return G -def _elevation_request(url, pause): +def _elevation_request(url: str, pause: float) -> dict[str, Any]: """ - Send a HTTP GET request to Google Maps-style Elevation API. + Send a HTTP GET request to a Google Maps-style Elevation API. Parameters ---------- - url : string - URL for API endpoint populated with request data - pause : float - how long to pause in seconds before request + url + URL of API endpoint, populated with request data. + pause + How long to pause in seconds before request. Returns ------- - response_json : dict + response_json """ - if settings.timeout is None: - timeout = settings.requests_timeout - else: - timeout = settings.timeout - msg = ( - "`settings.timeout` is deprecated and will be removed in the v2.0.0 " - "release: use `settings.requests_timeout` instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - # check if request already exists in cache - cached_response_json = _downloader._retrieve_from_cache(url) - if cached_response_json is not None: + cached_response_json = _http._retrieve_from_cache(url) + if isinstance(cached_response_json, dict): return cached_response_json # pause then request this URL - domain = _downloader._hostname_from_url(url) - utils.log(f"Pausing {pause} second(s) before making HTTP GET request to {domain!r}") + domain = _http._hostname_from_url(url) + msg = f"Pausing {pause} second(s) before making HTTP GET request to {domain!r}" + utils.log(msg, level=lg.INFO) time.sleep(pause) # transmit the HTTP GET request - utils.log(f"Get {url} with timeout={timeout}") + msg = f"Get {url} with timeout={settings.requests_timeout}" + utils.log(msg, level=lg.INFO) response = requests.get( url, - timeout=timeout, - headers=_downloader._get_http_headers(), + timeout=settings.requests_timeout, + headers=_http._get_http_headers(), **settings.requests_kwargs, ) - response_json = _downloader._parse_response(response) - _downloader._save_to_cache(url, response_json, response.status_code) + response_json = _http._parse_response(response) + if not isinstance(response_json, dict): # pragma: no cover + msg = "Elevation API did not return a dict of results." + raise InsufficientResponseError(msg) + _http._save_to_cache(url, response_json, response.ok) return response_json diff --git a/osmnx/features.py b/osmnx/features.py index 2f783a610..71c6ddb28 100644 --- a/osmnx/features.py +++ b/osmnx/features.py @@ -1,5 +1,5 @@ """ -Download OpenStreetMap geospatial features' geometries and attributes. +Download and create GeoDataFrames from OpenStreetMap geospatial features. Retrieve points of interest, building footprints, transit lines/stops, or any other map features from OSM, including their geometries and attribute data, @@ -13,214 +13,215 @@ Refer to the Getting Started guide for usage limitations. """ +from __future__ import annotations + import logging as lg -import warnings -from warnings import warn +from typing import TYPE_CHECKING +from typing import Any import geopandas as gpd import pandas as pd +from shapely import LineString +from shapely import MultiLineString +from shapely import MultiPolygon +from shapely import Point +from shapely import Polygon from shapely.errors import GEOSException -from shapely.errors import TopologicalError -from shapely.geometry import LineString -from shapely.geometry import MultiPolygon -from shapely.geometry import Point -from shapely.geometry import Polygon from shapely.ops import linemerge from shapely.ops import polygonize +from shapely.ops import unary_union +from . import _osm_xml from . import _overpass from . import geocoder -from . import osm_xml from . import settings from . import utils from . import utils_geo from ._errors import CacheOnlyInterruptError from ._errors import InsufficientResponseError -# dict of tags to determine if closed ways should be polygons, based on JSON -# from https://wiki.openstreetmap.org/wiki/Overpass_turbo/Polygon_Features -_POLYGON_FEATURES = { - "building": {"polygon": "all"}, - "highway": {"polygon": "passlist", "values": ["services", "rest_area", "escape", "elevator"]}, - "natural": { - "polygon": "blocklist", - "values": ["coastline", "cliff", "ridge", "arete", "tree_row"], - }, - "landuse": {"polygon": "all"}, - "waterway": {"polygon": "passlist", "values": ["riverbank", "dock", "boatyard", "dam"]}, +if TYPE_CHECKING: + from collections.abc import Iterable + from pathlib import Path + +# define what types of OSM relations we currently handle +_RELATION_TYPES = {"boundary", "multipolygon"} + +# OSM tags to determine if closed ways should be polygons, based on JSON from +# https://wiki.openstreetmap.org/wiki/Overpass_turbo/Polygon_Features +_POLYGON_FEATURES: dict[str, dict[str, str | set[str]]] = { + "aeroway": {"polygon": "blocklist", "values": {"taxiway"}}, "amenity": {"polygon": "all"}, - "leisure": {"polygon": "all"}, + "area": {"polygon": "all"}, + "area:highway": {"polygon": "all"}, "barrier": { "polygon": "passlist", - "values": ["city_wall", "ditch", "hedge", "retaining_wall", "spikes"], + "values": {"city_wall", "ditch", "hedge", "retaining_wall", "spikes"}, }, - "railway": { - "polygon": "passlist", - "values": ["station", "turntable", "roundhouse", "platform"], - }, - "area": {"polygon": "all"}, "boundary": {"polygon": "all"}, - "man_made": {"polygon": "blocklist", "values": ["cutline", "embankment", "pipeline"]}, - "power": {"polygon": "passlist", "values": ["plant", "substation", "generator", "transformer"]}, - "place": {"polygon": "all"}, - "shop": {"polygon": "all"}, - "aeroway": {"polygon": "blocklist", "values": ["taxiway"]}, - "tourism": {"polygon": "all"}, - "historic": {"polygon": "all"}, - "public_transport": {"polygon": "all"}, - "office": {"polygon": "all"}, + "building": {"polygon": "all"}, "building:part": {"polygon": "all"}, - "military": {"polygon": "all"}, - "ruins": {"polygon": "all"}, - "area:highway": {"polygon": "all"}, "craft": {"polygon": "all"}, "golf": {"polygon": "all"}, + "highway": {"polygon": "passlist", "values": {"elevator", "escape", "rest_area", "services"}}, + "historic": {"polygon": "all"}, "indoor": {"polygon": "all"}, + "landuse": {"polygon": "all"}, + "leisure": {"polygon": "all"}, + "man_made": {"polygon": "blocklist", "values": {"cutline", "embankment", "pipeline"}}, + "military": {"polygon": "all"}, + "natural": { + "polygon": "blocklist", + "values": {"arete", "cliff", "coastline", "ridge", "tree_row"}, + }, + "office": {"polygon": "all"}, + "place": {"polygon": "all"}, + "power": {"polygon": "passlist", "values": {"generator", "plant", "substation", "transformer"}}, + "public_transport": {"polygon": "all"}, + "railway": { + "polygon": "passlist", + "values": {"platform", "roundhouse", "station", "turntable"}, + }, + "ruins": {"polygon": "all"}, + "shop": {"polygon": "all"}, + "tourism": {"polygon": "all"}, + "waterway": {"polygon": "passlist", "values": {"boatyard", "dam", "dock", "riverbank"}}, } -def features_from_bbox(north=None, south=None, east=None, west=None, bbox=None, tags=None): +def features_from_bbox( + bbox: tuple[float, float, float, float], + tags: dict[str, bool | str | list[str]], +) -> gpd.GeoDataFrame: """ - Create a GeoDataFrame of OSM features within a N, S, E, W bounding box. + Download OSM features within a lat-lon bounding box. You can use the `settings` module to retrieve a snapshot of historical OSM data as of a certain date, or to configure the Overpass server timeout, - memory allocation, and other custom settings. - - For more details, see: https://wiki.openstreetmap.org/wiki/Map_features + memory allocation, and other custom settings. This function searches for + features using tags. For more details, see: + https://wiki.openstreetmap.org/wiki/Map_features Parameters ---------- - north : float - deprecated, do not use - south : float - deprecated, do not use - east : float - deprecated, do not use - west : float - deprecated, do not use - bbox : tuple of floats - bounding box as (north, south, east, west) - tags : dict - Dict of tags used for finding elements in the selected area. Results - returned are the union, not intersection of each individual tag. - Each result matches at least one given tag. The dict keys should be - OSM tags, (e.g., `building`, `landuse`, `highway`, etc) and the dict - values should be either `True` to retrieve all items with the given - tag, or a string to get a single tag-value combination, or a list of - strings to get multiple values for the given tag. For example, - `tags = {'building': True}` would return all building footprints in - the area. `tags = {'amenity':True, 'landuse':['retail','commercial'], - 'highway':'bus_stop'}` would return all amenities, landuse=retail, - landuse=commercial, and highway=bus_stop. + bbox + Bounding box as `(north, south, east, west)`. Coordinates should be in + unprojected latitude-longitude degrees (EPSG:4326). + tags + Tags for finding elements in the selected area. Results are the union, + not intersection of the tags and each result matches at least one tag. + The keys are OSM tags (e.g., `building`, `landuse`, `highway`, etc) + and the values can be either `True` to retrieve all elements matching + the tag, or a string to retrieve a single tag:value combination, or a + list of strings to retrieve multiple values for the tag. For example, + `tags = {'building': True}` would return all buildings in the area. + Or, `tags = {'amenity':True, 'landuse':['retail','commercial'], + 'highway':'bus_stop'}` would return all amenities, any landuse=retail, + any landuse=commercial, and any highway=bus_stop. Returns ------- - gdf : geopandas.GeoDataFrame + gdf """ - if not (north is None and south is None and east is None and west is None): - msg = ( - "The `north`, `south`, `east`, and `west` parameters are deprecated and " - "will be removed in the v2.0.0 release. Use the `bbox` parameter instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - bbox = (north, south, east, west) - - # convert bounding box to a polygon - polygon = utils_geo.bbox_to_poly(bbox=bbox) - - # create GeoDataFrame of features within this polygon + # convert bbox to polygon then create GeoDataFrame of features within it + polygon = utils_geo.bbox_to_poly(bbox) return features_from_polygon(polygon, tags) -def features_from_point(center_point, tags, dist=1000): +def features_from_point( + center_point: tuple[float, float], + tags: dict[str, bool | str | list[str]], + dist: float, +) -> gpd.GeoDataFrame: """ - Create GeoDataFrame of OSM features within some distance N, S, E, W of a point. + Download OSM features within some distance of a lat-lon point. You can use the `settings` module to retrieve a snapshot of historical OSM data as of a certain date, or to configure the Overpass server timeout, - memory allocation, and other custom settings. - - For more details, see: https://wiki.openstreetmap.org/wiki/Map_features + memory allocation, and other custom settings. This function searches for + features using tags. For more details, see: + https://wiki.openstreetmap.org/wiki/Map_features Parameters ---------- - center_point : tuple - the (lat, lon) center point around which to get the features - tags : dict - Dict of tags used for finding elements in the selected area. Results - returned are the union, not intersection of each individual tag. - Each result matches at least one given tag. The dict keys should be - OSM tags, (e.g., `building`, `landuse`, `highway`, etc) and the dict - values should be either `True` to retrieve all items with the given - tag, or a string to get a single tag-value combination, or a list of - strings to get multiple values for the given tag. For example, - `tags = {'building': True}` would return all building footprints in - the area. `tags = {'amenity':True, 'landuse':['retail','commercial'], - 'highway':'bus_stop'}` would return all amenities, landuse=retail, - landuse=commercial, and highway=bus_stop. - dist : numeric - distance in meters + center_point + The `(lat, lon)` center point around which to retrieve the features. + Coordinates should be in unprojected latitude-longitude degrees + (EPSG:4326). + tags + Tags for finding elements in the selected area. Results are the union, + not intersection of the tags and each result matches at least one tag. + The keys are OSM tags (e.g., `building`, `landuse`, `highway`, etc) + and the values can be either `True` to retrieve all elements matching + the tag, or a string to retrieve a single tag:value combination, or a + list of strings to retrieve multiple values for the tag. For example, + `tags = {'building': True}` would return all buildings in the area. + Or, `tags = {'amenity':True, 'landuse':['retail','commercial'], + 'highway':'bus_stop'}` would return all amenities, any landuse=retail, + any landuse=commercial, and any highway=bus_stop. + dist + Distance in meters from `center_point` to create a bounding box to + query. Returns ------- - gdf : geopandas.GeoDataFrame + gdf """ - # create bounding box from center point and distance in each direction + # create bbox from point and dist, then create gdf of features within it bbox = utils_geo.bbox_from_point(center_point, dist) + return features_from_bbox(bbox, tags) - # convert the bounding box to a polygon - polygon = utils_geo.bbox_to_poly(bbox=bbox) - # create GeoDataFrame of features within this polygon - return features_from_polygon(polygon, tags) - - -def features_from_address(address, tags, dist=1000): +def features_from_address( + address: str, + tags: dict[str, bool | str | list[str]], + dist: float, +) -> gpd.GeoDataFrame: """ - Create GeoDataFrame of OSM features within some distance N, S, E, W of address. + Download OSM features within some distance of an address. You can use the `settings` module to retrieve a snapshot of historical OSM data as of a certain date, or to configure the Overpass server timeout, - memory allocation, and other custom settings. - - For more details, see: https://wiki.openstreetmap.org/wiki/Map_features + memory allocation, and other custom settings. This function searches for + features using tags. For more details, see: + https://wiki.openstreetmap.org/wiki/Map_features Parameters ---------- - address : string - the address to geocode and use as the central point around which to - get the features - tags : dict - Dict of tags used for finding elements in the selected area. Results - returned are the union, not intersection of each individual tag. - Each result matches at least one given tag. The dict keys should be - OSM tags, (e.g., `building`, `landuse`, `highway`, etc) and the dict - values should be either `True` to retrieve all items with the given - tag, or a string to get a single tag-value combination, or a list of - strings to get multiple values for the given tag. For example, - `tags = {'building': True}` would return all building footprints in - the area. `tags = {'amenity':True, 'landuse':['retail','commercial'], - 'highway':'bus_stop'}` would return all amenities, landuse=retail, - landuse=commercial, and highway=bus_stop. - dist : numeric - distance in meters + address + The address to geocode and use as the center point around which to + retrieve the features. + tags + Tags for finding elements in the selected area. Results are the union, + not intersection of the tags and each result matches at least one tag. + The keys are OSM tags (e.g., `building`, `landuse`, `highway`, etc) + and the values can be either `True` to retrieve all elements matching + the tag, or a string to retrieve a single tag:value combination, or a + list of strings to retrieve multiple values for the tag. For example, + `tags = {'building': True}` would return all buildings in the area. + Or, `tags = {'amenity':True, 'landuse':['retail','commercial'], + 'highway':'bus_stop'}` would return all amenities, any landuse=retail, + any landuse=commercial, and any highway=bus_stop. + dist + Distance in meters from `address` to create a bounding box to query. Returns ------- - gdf : geopandas.GeoDataFrame + gdf """ - # geocode the address string to a (lat, lon) point - center_point = geocoder.geocode(query=address) - - # create GeoDataFrame of features around this point - return features_from_point(center_point, tags, dist=dist) + # geocode the address to a point, then create gdf of features around it + center_point = geocoder.geocode(address) + return features_from_point(center_point, tags, dist) -def features_from_place(query, tags, which_result=None, buffer_dist=None): +def features_from_place( + query: str | dict[str, str] | list[str | dict[str, str]], + tags: dict[str, bool | str | list[str]], + *, + which_result: int | None | list[int | None] = None, +) -> gpd.GeoDataFrame: """ - Create GeoDataFrame of OSM features within boundaries of some place(s). + Download OSM features within the boundaries of some place(s). The query must be geocodable and OSM must have polygon boundaries for the geocode result. If OSM does not have a polygon for this place, you can @@ -237,817 +238,473 @@ def features_from_place(query, tags, which_result=None, buffer_dist=None): You can use the `settings` module to retrieve a snapshot of historical OSM data as of a certain date, or to configure the Overpass server timeout, - memory allocation, and other custom settings. - - For more details, see: https://wiki.openstreetmap.org/wiki/Map_features + memory allocation, and other custom settings. This function searches for + features using tags. For more details, see: + https://wiki.openstreetmap.org/wiki/Map_features Parameters ---------- - query : string or dict or list - the query or queries to geocode to get place boundary polygon(s) - tags : dict - Dict of tags used for finding elements in the selected area. Results - returned are the union, not intersection of each individual tag. - Each result matches at least one given tag. The dict keys should be - OSM tags, (e.g., `building`, `landuse`, `highway`, etc) and the dict - values should be either `True` to retrieve all items with the given - tag, or a string to get a single tag-value combination, or a list of - strings to get multiple values for the given tag. For example, - `tags = {'building': True}` would return all building footprints in - the area. `tags = {'amenity':True, 'landuse':['retail','commercial'], - 'highway':'bus_stop'}` would return all amenities, landuse=retail, - landuse=commercial, and highway=bus_stop. - which_result : int - which geocoding result to use. if None, auto-select the first + query + The query or queries to geocode to retrieve place boundary polygon(s). + tags + Tags for finding elements in the selected area. Results are the union, + not intersection of the tags and each result matches at least one tag. + The keys are OSM tags (e.g., `building`, `landuse`, `highway`, etc) + and the values can be either `True` to retrieve all elements matching + the tag, or a string to retrieve a single tag:value combination, or a + list of strings to retrieve multiple values for the tag. For example, + `tags = {'building': True}` would return all buildings in the area. + Or, `tags = {'amenity':True, 'landuse':['retail','commercial'], + 'highway':'bus_stop'}` would return all amenities, any landuse=retail, + any landuse=commercial, and any highway=bus_stop. + which_result + Which search result to return. If None, auto-select the first (Multi)Polygon or raise an error if OSM doesn't return one. - buffer_dist : float - deprecated, do not use Returns ------- - gdf : geopandas.GeoDataFrame + gdf """ - if buffer_dist is not None: - warn( - "The buffer_dist argument has been deprecated and will be removed " - "in the v2.0.0 release. Buffer your query area directly, if desired. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - - # create a GeoDataFrame with the spatial boundaries of the place(s) - if isinstance(query, (str, dict)): - # if it is a string (place name) or dict (structured place query), - # then it is a single place - gdf_place = geocoder.geocode_to_gdf( - query, which_result=which_result, buffer_dist=buffer_dist - ) - elif isinstance(query, list): - # if it is a list, it contains multiple places to get - gdf_place = geocoder.geocode_to_gdf(query, buffer_dist=buffer_dist) - else: # pragma: no cover - msg = "query must be dict, string, or list of strings" - raise TypeError(msg) - - # extract the geometry from the GeoDataFrame to use in API query + # extract the geometry from the GeoDataFrame to use in query + gdf_place = geocoder.geocode_to_gdf(query, which_result=which_result) polygon = gdf_place["geometry"].unary_union - utils.log("Constructed place geometry polygon(s) to query API") + msg = "Constructed place geometry polygon(s) to query Overpass" + utils.log(msg, level=lg.INFO) # create GeoDataFrame using this polygon(s) geometry return features_from_polygon(polygon, tags) -def features_from_polygon(polygon, tags): +def features_from_polygon( + polygon: Polygon | MultiPolygon, + tags: dict[str, bool | str | list[str]], +) -> gpd.GeoDataFrame: """ - Create GeoDataFrame of OSM features within boundaries of a (multi)polygon. + Download OSM features within the boundaries of a (Multi)Polygon. You can use the `settings` module to retrieve a snapshot of historical OSM data as of a certain date, or to configure the Overpass server timeout, - memory allocation, and other custom settings. - - For more details, see: https://wiki.openstreetmap.org/wiki/Map_features + memory allocation, and other custom settings. This function searches for + features using tags. For more details, see: + https://wiki.openstreetmap.org/wiki/Map_features Parameters ---------- - polygon : shapely.geometry.Polygon or shapely.geometry.MultiPolygon - geographic boundaries to fetch features within - tags : dict - Dict of tags used for finding elements in the selected area. Results - returned are the union, not intersection of each individual tag. - Each result matches at least one given tag. The dict keys should be - OSM tags, (e.g., `building`, `landuse`, `highway`, etc) and the dict - values should be either `True` to retrieve all items with the given - tag, or a string to get a single tag-value combination, or a list of - strings to get multiple values for the given tag. For example, - `tags = {'building': True}` would return all building footprints in - the area. `tags = {'amenity':True, 'landuse':['retail','commercial'], - 'highway':'bus_stop'}` would return all amenities, landuse=retail, - landuse=commercial, and highway=bus_stop. + polygon + The geometry within which to retrieve features. Coordinates should be + in unprojected latitude-longitude degrees (EPSG:4326). + tags + Tags for finding elements in the selected area. Results are the union, + not intersection of the tags and each result matches at least one tag. + The keys are OSM tags (e.g., `building`, `landuse`, `highway`, etc) + and the values can be either `True` to retrieve all elements matching + the tag, or a string to retrieve a single tag:value combination, or a + list of strings to retrieve multiple values for the tag. For example, + `tags = {'building': True}` would return all buildings in the area. + Or, `tags = {'amenity':True, 'landuse':['retail','commercial'], + 'highway':'bus_stop'}` would return all amenities, any landuse=retail, + any landuse=commercial, and any highway=bus_stop. Returns ------- - gdf : geopandas.GeoDataFrame + gdf """ - # verify that the geometry is valid and a Polygon/MultiPolygon + # verify that the geometry is valid and is a Polygon/MultiPolygon if not polygon.is_valid: - msg = "The geometry of `polygon` is invalid" + msg = "The geometry of `polygon` is invalid." raise ValueError(msg) + if not isinstance(polygon, (Polygon, MultiPolygon)): msg = ( - "Boundaries must be a shapely Polygon or MultiPolygon. If you " - "requested features from place name, make sure your query resolves " - "to a Polygon or MultiPolygon, and not some other geometry, like a " - "Point. See OSMnx documentation for details." + "Boundaries must be a Polygon or MultiPolygon. If you requested " + "`features_from_place`, ensure your query geocodes to a Polygon " + "or MultiPolygon. See the documentation for details." ) raise TypeError(msg) - # download the data from OSM + # retrieve the data from Overpass then turn it into a GeoDataFrame response_jsons = _overpass._download_overpass_features(polygon, tags) - - # create GeoDataFrame from the downloaded data return _create_gdf(response_jsons, polygon, tags) -def features_from_xml(filepath, polygon=None, tags=None, encoding="utf-8"): +def features_from_xml( + filepath: str | Path, + *, + polygon: Polygon | MultiPolygon | None = None, + tags: dict[str, bool | str | list[str]] | None = None, + encoding: str = "utf-8", +) -> gpd.GeoDataFrame: """ - Create a GeoDataFrame of OSM features in an OSM-formatted XML file. + Create a GeoDataFrame of OSM features from data in an OSM XML file. - Because this function creates a GeoDataFrame of features from an - OSM-formatted XML file that has already been downloaded (i.e. no query is - made to the Overpass API) the polygon and tags arguments are not required. - If they are not supplied to the function, features_from_xml() will - return features for all of the tagged elements in the file. If they are - supplied they will be used to filter the final GeoDataFrame. - - For more details, see: https://wiki.openstreetmap.org/wiki/Map_features + Because this function creates a GeoDataFrame of features from an OSM XML + file that has already been downloaded (i.e., no query is made to the + Overpass API), the `polygon` and `tags` arguments are optional. If they + are None, filtering will be skipped. Parameters ---------- - filepath : string or pathlib.Path - path to file containing OSM XML data - polygon : shapely.geometry.Polygon - optional geographic boundary to filter elements - tags : dict - optional dict of tags for filtering elements from the XML. Results - returned are the union, not intersection of each individual tag. - Each result matches at least one given tag. The dict keys should be - OSM tags, (e.g., `building`, `landuse`, `highway`, etc) and the dict - values should be either `True` to retrieve all items with the given - tag, or a string to get a single tag-value combination, or a list of - strings to get multiple values for the given tag. For example, - `tags = {'building': True}` would return all building footprints in - the area. `tags = {'amenity':True, 'landuse':['retail','commercial'], - 'highway':'bus_stop'}` would return all amenities, landuse=retail, - landuse=commercial, and highway=bus_stop. - encoding : string - the XML file's character encoding + filepath + Path to file containing OSM XML data. + tags + Query tags to optionally filter the final GeoDataFrame. + polygon + Spatial boundaries to optionally filter the final GeoDataFrame. + encoding + The OSM XML file's character encoding. Returns ------- - gdf : geopandas.GeoDataFrame + gdf """ - # transmogrify file of OSM XML data into JSON - response_jsons = [osm_xml._overpass_json_from_file(filepath, encoding)] + # if tags or polygon is None, create an empty object to skip filtering + if tags is None: + tags = {} + if polygon is None: + polygon = Polygon() - # create GeoDataFrame using this response JSON - return _create_gdf(response_jsons, polygon=polygon, tags=tags) + # transmogrify OSM XML file to JSON then create GeoDataFrame from it + response_jsons = [_osm_xml._overpass_json_from_xml(filepath, encoding)] + gdf = _create_gdf(response_jsons, polygon, tags) + # drop misc element attrs that might have been added from OSM XML file + to_drop = set(gdf.columns) & {"changeset", "timestamp", "uid", "user", "version"} + return gdf.drop(columns=list(to_drop)) -def _create_gdf(response_jsons, polygon, tags): - """ - Parse JSON responses from the Overpass API to a GeoDataFrame. - Note: the `polygon` and `tags` arguments can both be `None` and the - GeoDataFrame will still be created but it won't be filtered at the end - i.e. the final GeoDataFrame will contain all tagged features in the - `response_jsons`. +def _create_gdf( + response_jsons: Iterable[dict[str, Any]], + polygon: Polygon | MultiPolygon, + tags: dict[str, bool | str | list[str]], +) -> gpd.GeoDataFrame: + """ + Convert Overpass API JSON responses to a GeoDataFrame of features. Parameters ---------- - response_jsons : list - list of JSON responses from from the Overpass API - polygon : shapely.geometry.Polygon - geographic boundary used for filtering the final GeoDataFrame - tags : dict - dict of tags used for filtering the final GeoDataFrame + response_jsons + Iterable of Overpass API JSON responses. + polygon + Spatial boundaries to optionally filter the final GeoDataFrame. + tags + Query tags to optionally filter the final GeoDataFrame. Returns ------- - gdf : geopandas.GeoDataFrame - GeoDataFrame of features and their associated tags + gdf + GeoDataFrame of features with tags and geometry columns. """ + # consume response_jsons generator to download data from server + elements = [] response_count = 0 - if settings.cache_only_mode: - # if cache_only_mode, consume response_jsons then interrupt - for _ in response_jsons: - response_count += 1 - utils.log(f"Retrieved all data from API in {response_count} request(s)") - msg = "Interrupted because `settings.cache_only_mode=True`" - raise CacheOnlyInterruptError(msg) - - # Dictionaries to hold nodes and complete geometries - coords = {} - geometries = {} - - # Set to hold the unique IDs of elements that do not have tags - untagged_element_ids = set() - - # identify which relation types to parse to (multi)polygons - relation_types = {"boundary", "multipolygon"} - - # extract geometries from the downloaded osm data for response_json in response_jsons: response_count += 1 + if not settings.cache_only_mode: + elements.extend(response_json["elements"]) - # Parses the JSON of OSM nodes, ways and (multipolygon) relations - # to dictionaries of coordinates, Shapely Points, LineStrings, - # Polygons and MultiPolygons - for element in response_json["elements"]: - # id numbers are only unique within element types - # create unique id from combination of type and id - unique_id = f"{element['type']}/{element['id']}" - - # add elements that are not nodes and that are without tags or - # with empty tags to the untagged_element_ids set (untagged - # nodes are not added to the geometries dict at all) - if (element["type"] != "node") and (("tags" not in element) or (not element["tags"])): - untagged_element_ids.add(unique_id) - - if element["type"] == "node": - # Parse all nodes to coords - coords[element["id"]] = _parse_node_to_coords(element=element) - - # If the node has tags and the tags are not empty parse it - # to a Point. Empty check is necessary for JSONs created - # from XML where nodes without tags are assigned tags={} - if "tags" in element and len(element["tags"]) > 0: - point = _parse_node_to_point(element=element) - geometries[unique_id] = point - - elif element["type"] == "way": - # Parse all ways to linestrings or polygons - linestring_or_polygon = _parse_way_to_linestring_or_polygon( - element=element, coords=coords - ) - geometries[unique_id] = linestring_or_polygon - - elif ( - element["type"] == "relation" and element.get("tags").get("type") in relation_types - ): - # parse relations to (multi)polygons - multipolygon = _parse_relation_to_multipolygon( - element=element, geometries=geometries - ) - geometries[unique_id] = multipolygon - - utils.log(f"Retrieved all data from API in {response_count} request(s)") - - # ensure we got some node/way data back from the server request(s) - if len(geometries) == 0: # pragma: no cover - msg = "No data elements in server response. Check log and query location/tags." - raise InsufficientResponseError(msg) - - # remove untagged elements from the final dict of geometries - utils.log(f"{len(geometries)} geometries created in the dict") - for untagged_element_id in untagged_element_ids: - geometries.pop(untagged_element_id, None) - utils.log(f"{len(untagged_element_ids)} untagged features removed") - - # create GeoDataFrame, ensure it has geometry, then set crs - gdf = gpd.GeoDataFrame.from_dict(geometries, orient="index") - if "geometry" not in gdf.columns: - # if there is no geometry column, create a null column - gdf = gdf.set_geometry([None] * len(gdf)) - gdf = gdf.set_crs(settings.default_crs) - - # Apply .buffer(0) to any invalid geometries - gdf = _buffer_invalid_geometries(gdf) - - # Filter final gdf to requested tags and query polygon - gdf = _filter_gdf_by_polygon_and_tags(gdf, polygon=polygon, tags=tags) - - # bug in geopandas <0.9 raises a TypeError if trying to plot empty - # geometries but missing geometries (gdf['geometry'] = None) cannot be - # projected e.g. gdf.to_crs(). Remove rows with empty (e.g. Point()) - # or missing (e.g. None) geometry, and suppress gpd warning caused by - # calling gdf["geometry"].isna() on GeoDataFrame with empty geometries - if not gdf.empty: - warnings.filterwarnings("ignore", "GeoSeries.isna", UserWarning) - gdf = gdf[~(gdf["geometry"].is_empty | gdf["geometry"].isna())].copy() - warnings.resetwarnings() - - utils.log(f"{len(gdf)} features in the final GeoDataFrame") - return gdf - - -def _parse_node_to_coords(element): - """ - Parse coordinates from a node in the overpass response. - - The coords are only used to create LineStrings and Polygons. - - Parameters - ---------- - element : dict - element type "node" from overpass response JSON + msg = f"Retrieved {len(elements):,} elements from API in {response_count} request(s)" + utils.log(msg, level=lg.INFO) + if settings.cache_only_mode: + msg = "Interrupted because `settings.cache_only_mode=True`." + raise CacheOnlyInterruptError(msg) - Returns - ------- - coords : dict - dict of latitude/longitude coordinates - """ - # return the coordinate of a single node element - return {"lat": element["lat"], "lon": element["lon"]} + # convert the elements into a GeoDataFrame of features + idx = ["element", "id"] + features = _process_features(elements, set(tags.keys())) + gdf = gpd.GeoDataFrame(features, geometry="geometry", crs=settings.default_crs).set_index(idx) + return _filter_features(gdf, polygon, tags) -def _parse_node_to_point(element): +def _process_features( + elements: list[dict[str, Any]], + query_tag_keys: set[str], +) -> list[dict[str, Any]]: """ - Parse point from a tagged node in the overpass response. - - The points are geometries in their own right. + Convert node/way/relation elements into features with geometries. Parameters ---------- - element : dict - element type "node" from overpass response JSON + elements + The node/way/relation elements retrieved from the server. + query_tag_keys + The keys of the tags used to query for matching features. Returns ------- - point : dict - dict of OSM ID, OSM element type, tags and geometry - """ - point = {} - point["osmid"] = element["id"] - point["element_type"] = "node" - - if "tags" in element: - for tag in element["tags"]: - point[tag] = element["tags"][tag] - - point["geometry"] = Point(element["lon"], element["lat"]) - return point - + features + """ + nodes = [] # all nodes, including ones that just compose ways + feature_nodes = [] # nodes that possibly match our query tags + node_coords = {} # hold node lon,lat tuples to create way geoms + ways = [] # all ways, including ones that just compose relations + feature_ways = [] # ways that possibly match our query tags + way_geoms = {} # hold way geoms to create relation geoms + relations = [] # all relations + + # sort elements by node, way, and relation. only retain relations that + # match the relation types we currently handle + for element in elements: + et = element["type"] + if et == "node": + nodes.append(element) + elif et == "way": + ways.append(element) + elif et == "relation" and element.get("tags", {}).get("type") in _RELATION_TYPES: + relations.append(element) + + # extract all nodes' coords, then add to features any nodes with tags that + # match the passed query tags, or with any tags if no query tags passed + for node in nodes: + node_coords[node["id"]] = (node["lon"], node["lat"]) + if (len(query_tag_keys) == 0 and len(node.get("tags", {}).keys()) > 0) or ( + len(query_tag_keys & node.get("tags", {}).keys()) > 0 + ): + node["element"] = node.pop("type") + node["geometry"] = Point(node.pop("lon"), node.pop("lat")) + node.update(node.pop("tags")) + feature_nodes.append(node) + + # build all ways' geometries, then add to features any ways with tags that + # match the passed query tags, or with any tags if no query tags passed + for way in ways: + way["geometry"] = _build_way_geometry( + way["id"], + way.pop("nodes"), + way.get("tags", {}), + node_coords, + ) + way_geoms[way["id"]] = way["geometry"] + if (len(query_tag_keys) == 0 and len(way.get("tags", {}).keys()) > 0) or ( + len(query_tag_keys & way.get("tags", {}).keys()) > 0 + ): + way["element"] = way.pop("type") + way.update(way.pop("tags")) + feature_ways.append(way) + + # process relations and build their geometries + for relation in relations: + relation["element"] = "relation" + relation.update(relation.pop("tags")) + relation["geometry"] = _build_relation_geometry(relation.pop("members"), way_geoms) + + features = [*feature_nodes, *feature_ways, *relations] + if len(features) == 0: + msg = "No matching features. Check query location, tags, and log." + raise InsufficientResponseError(msg) -def _parse_way_to_linestring_or_polygon(element, coords): - """ - Parse open LineString, closed LineString or Polygon from OSM 'way'. + return features - Please see https://wiki.openstreetmap.org/wiki/Overpass_turbo/Polygon_Features - for more information on which tags should be parsed to polygons - Parameters - ---------- - element : dict - element type "way" from overpass response JSON - coords : dict - dict of node IDs and their latitude/longitude coordinates - - Returns - ------- - linestring_or_polygon : dict - dict of OSM ID, OSM element type, nodes, tags and geometry - """ - nodes = element["nodes"] - - linestring_or_polygon = {} - linestring_or_polygon["osmid"] = element["id"] - linestring_or_polygon["element_type"] = "way" - linestring_or_polygon["nodes"] = nodes - - # un-nest individual tags - if "tags" in element: - for tag in element["tags"]: - linestring_or_polygon[tag] = element["tags"][tag] - - # if the OSM element is an open way (i.e. first and last nodes are not the - # same) the geometry should be a Shapely LineString - if element["nodes"][0] != element["nodes"][-1]: - try: - geometry = LineString([(coords[node]["lon"], coords[node]["lat"]) for node in nodes]) - except KeyError as e: # pragma: no cover - # XMLs may include geometries that are incomplete, in which case - # return an empty geometry - utils.log( - f"node/{e} was not found in `coords`.\n" - f"https://www.openstreetmap.org/{element['type']}/{element['id']} was not created." - ) - geometry = LineString() - - # if the OSM element is a closed way (i.e. first and last nodes are the - # same) depending upon the tags the geometry could be a Shapely LineString - # or Polygon - elif element["nodes"][0] == element["nodes"][-1]: - # determine if closed way represents LineString or Polygon - if _is_closed_way_a_polygon(element): - # if it is a Polygon - try: - geometry = Polygon([(coords[node]["lon"], coords[node]["lat"]) for node in nodes]) - except (GEOSException, ValueError) as e: - # XMLs may include geometries that are incomplete, in which - # case return an empty geometry - utils.log( - f"{e} . The geometry for " - f"https://www.openstreetmap.org/{element['type']}/{element['id']} was not created." - ) - geometry = Polygon() - else: - # if it is a LineString - try: - geometry = LineString( - [(coords[node]["lon"], coords[node]["lat"]) for node in nodes] - ) - except (GEOSException, ValueError) as e: - # XMLs may include geometries that are incomplete, in which - # case return an empty geometry - utils.log( - f"{e} . The geometry for " - f"https://www.openstreetmap.org/{element['type']}/{element['id']} was not created." - ) - geometry = LineString() - - linestring_or_polygon["geometry"] = geometry - return linestring_or_polygon - - -def _is_closed_way_a_polygon(element, polygon_features=_POLYGON_FEATURES): +def _build_way_geometry( + way_id: int, + way_nodes: list[int], + way_tags: dict[str, Any], + node_coords: dict[int, tuple[float, float]], +) -> LineString | Polygon: """ - Determine whether a closed OSM way represents a Polygon, not a LineString. + Build a way's geometry from its constituent nodes' coordinates. - Closed OSM ways may represent LineStrings (e.g. a roundabout or hedge - round a field) or Polygons (e.g. a building footprint or land use area) - depending on the tags applied to them. - - The starting assumption is that it is not a polygon, however any polygon - type tagging will return a polygon unless explicitly tagged with area:no. - - It is possible for a single closed OSM way to have both LineString and - Polygon type tags (e.g. both barrier=fence and landuse=agricultural). - OSMnx will return a single Polygon for elements tagged in this way. - For more information see: - https://wiki.openstreetmap.org/wiki/One_feature,_one_OSM_element) + A way can be a LineString (open or closed way) or a Polygon (closed way) + but multi-geometries and polygons with holes are represented as relations. + See documentation: https://wiki.openstreetmap.org/wiki/Way#Types_of_way Parameters ---------- - element : dict - closed element type "way" from overpass response JSON - polygon_features : dict - dict of tag keys with associated values and blocklist/passlist + way_id + The way's OSM ID. + way_nodes + The way's constituent nodes. + way_tags + The way's tags. + node_coords + Keyed by OSM node ID with values of `(lat, lon)` coordinate tuples. Returns ------- - is_polygon : bool - True if the tags are for a polygon type geometry - """ - # polygon_features dict is for determining which ways should become Polygons - # therefore the starting assumption is that the geometry is a LineString - is_polygon = False - - # get the element's tags - element_tags = element.get("tags") - - # if the element doesn't have any tags leave it as a Linestring - if element_tags is not None: - # if the element is specifically tagged 'area':'no' -> LineString - if element_tags.get("area") == "no": - pass - - # if the element has tags and is not tagged 'area':'no' - # compare its tags with the polygon_features dict - else: - # identify common keys in element's tags and polygon_features dict - intersecting_keys = element_tags.keys() & polygon_features.keys() - - # for each key in the intersecting keys (if any found) - for key in intersecting_keys: - # Get the key's value from the element's tags - key_value = element_tags.get(key) - - # Determine if the key is for a blocklist or passlist in - # polygon_features dict - blocklist_or_passlist = polygon_features.get(key).get("polygon") - - # Get values for the key from the polygon_features dict - polygon_features_values = polygon_features.get(key).get("values") - - # if all features with that key should be polygons -> Polygon - if blocklist_or_passlist == "all": - is_polygon = True - - # if the key is for a blocklist i.e. tags that should not - # become Polygons - elif blocklist_or_passlist == "blocklist": - # if the value for that key in the element is not in - # the blocklist -> Polygon - if key_value not in polygon_features_values: - is_polygon = True - - # if the key is for a passlist i.e. specific tags should - # become Polygons, and if the value for that key in the - # element is in the passlist -> Polygon - elif (blocklist_or_passlist == "passlist") and ( - key_value in polygon_features_values - ): - is_polygon = True - - return is_polygon - - -def _parse_relation_to_multipolygon(element, geometries): - """ - Parse multipolygon from OSM relation (type:MultiPolygon). - - See more information about relations from OSM documentation: - https://wiki.openstreetmap.org/wiki/Relation - - Parameters - ---------- - element : dict - element type "relation" from overpass response JSON - geometries : dict - dict containing all linestrings and polygons generated from OSM ways + geometry + """ + # a way is a LineString by default, but if it's a closed way and it's not + # tagged area=no, check if any of its tags denote it as a polygon instead + geom_type = LineString + if way_nodes[0] == way_nodes[-1] and way_tags.get("area") != "no": + for tag in way_tags.keys() & _POLYGON_FEATURES.keys(): + rule = _POLYGON_FEATURES[tag]["polygon"] + values = _POLYGON_FEATURES[tag].get("values", set()) + if ( + rule == "all" + or (rule == "passlist" and way_tags[tag] in values) + or (rule == "blocklist" and way_tags[tag] not in values) + ): + geom_type = Polygon + break - Returns - ------- - multipolygon : dict - dict of tags and geometry for a single multipolygon - """ - multipolygon = {} - multipolygon["osmid"] = element["id"] - multipolygon["element_type"] = "relation" - - # Parse member 'way' ids - member_way_refs = [member["ref"] for member in element["members"] if member["type"] == "way"] - multipolygon["ways"] = member_way_refs - - # Add the tags - if "tags" in element: - for tag in element["tags"]: - multipolygon[tag] = element["tags"][tag] - - # Extract the ways from the geometries dict using their unique id. - # XMLs exported from the openstreetmap.org homepage with a bounding box - # may include the relation but not the ways outside the bounding box. + # create the way geometry from its constituent nodes' coordinates try: - member_ways = [geometries[f"way/{member_way_ref}"] for member_way_ref in member_way_refs] - except KeyError as e: # pragma: no cover - utils.log( - f"{e} was not found in `geometries`.\nThe geometry for " - f"https://www.openstreetmap.org/{element['type']}/{element['id']} was not created." - ) - multipolygon["geometry"] = MultiPolygon() - return multipolygon - - # Extract the nodes of those ways - member_nodes = [[member_way["nodes"] for member_way in member_ways]] - multipolygon["nodes"] = member_nodes + return geom_type(node_coords[node] for node in way_nodes) + except (GEOSException, KeyError, ValueError) as e: + msg = f"Could not build geometry of way {way_id}: {e!r}" + utils.log(msg, level=lg.WARNING) + return geom_type() - # Assemble MultiPolygon component polygons from component LineStrings and - # Polygons - outer_polygons, inner_polygons = _assemble_multipolygon_component_polygons(element, geometries) - # Subtract inner polygons from outer polygons - geometry = _subtract_inner_polygons_from_outer_polygons(element, outer_polygons, inner_polygons) - - multipolygon["geometry"] = geometry - return multipolygon - - -def _assemble_multipolygon_component_polygons(element, geometries): +def _build_relation_geometry( + members: list[dict[str, Any]], + way_geoms: dict[int, LineString | Polygon], +) -> Polygon | MultiPolygon: """ - Assemble a MultiPolygon from its component LineStrings and Polygons. + Build a relation's geometry from its constituent member ways' geometries. - The OSM wiki suggests an algorithm for assembling multipolygon geometries - https://wiki.openstreetmap.org/wiki/Relation:multipolygon/Algorithm. - This method takes a simpler approach relying on the accurate tagging - of component ways with 'inner' and 'outer' roles as required on this page - https://wiki.openstreetmap.org/wiki/Relation:multipolygon. + OSM represents simple polygons as closed ways (see `_build_way_geometry`), + but it uses relations to represent multipolygons (with or without holes) + and polygons with holes. For the former, the relation contains multiple + members with role "outer". For the latter, the relation contains at least + one member with role "outer" representing the shell(s), and at least one + member with role "inner" representing the hole(s). For documentation, see + https://wiki.openstreetmap.org/wiki/Relation:multipolygon Parameters ---------- - element : dict - element type "relation" from overpass response JSON - geometries : dict - dict containing all linestrings and polygons generated from OSM ways + members + The members constituting the relation. + way_geoms + Keyed by OSM way ID with values of their geometries. Returns ------- - geometry : shapely.geometry.MultiPolygon - a single MultiPolygon object + geometry """ - outer_polygons = [] - inner_polygons = [] - outer_linestrings = [] inner_linestrings = [] + outer_linestrings = [] + inner_polygons = [] + outer_polygons = [] - # get the linestrings and polygons that make up the multipolygon - for member in element["members"]: - if member.get("type") == "way": - # get the member's geometry from linestrings_and_polygons - linestring_or_polygon = geometries.get(f"way/{member['ref']}") - # sort it into one of the lists according to its role and geometry - if (member.get("role") == "outer") and ( - linestring_or_polygon["geometry"].geom_type == "Polygon" - ): - outer_polygons.append(linestring_or_polygon["geometry"]) - elif (member.get("role") == "inner") and ( - linestring_or_polygon["geometry"].geom_type == "Polygon" - ): - inner_polygons.append(linestring_or_polygon["geometry"]) - elif (member.get("role") == "outer") and ( - linestring_or_polygon["geometry"].geom_type == "LineString" - ): - outer_linestrings.append(linestring_or_polygon["geometry"]) - elif (member.get("role") == "inner") and ( - linestring_or_polygon["geometry"].geom_type == "LineString" - ): - inner_linestrings.append(linestring_or_polygon["geometry"]) - - # Merge outer linestring fragments. - # Returns a single LineString or MultiLineString collection + # sort member geometries by member role and geometry type + for member in members: + if member["type"] == "way": + geom = way_geoms[member["ref"]] + role = member["role"] + if role == "outer" and geom.geom_type == "LineString": + outer_linestrings.append(geom) + elif role == "outer" and geom.geom_type == "Polygon": + outer_polygons.append(geom) + elif role == "inner" and geom.geom_type == "LineString": + inner_linestrings.append(geom) + elif role == "inner" and geom.geom_type == "Polygon": + inner_polygons.append(geom) + + # merge/polygonize outer linestring fragments then add to outer polygons merged_outer_linestrings = linemerge(outer_linestrings) - - # polygonize each linestring separately and append to list of outer polygons if merged_outer_linestrings.geom_type == "LineString": - outer_polygons += polygonize(merged_outer_linestrings) - elif merged_outer_linestrings.geom_type == "MultiLineString": - for merged_outer_linestring in list(merged_outer_linestrings.geoms): - outer_polygons += polygonize(merged_outer_linestring) + merged_outer_linestrings = MultiLineString([merged_outer_linestrings]) + for merged_outer_linestring in merged_outer_linestrings.geoms: + outer_polygons += polygonize(merged_outer_linestring) - # Merge inner linestring fragments. - # Returns a single LineString or MultiLineString collection + # merge/polygonize inner linestring fragments then add to inner polygons merged_inner_linestrings = linemerge(inner_linestrings) - - # polygonize each linestring separately and append to list of inner polygons if merged_inner_linestrings.geom_type == "LineString": - inner_polygons += polygonize(merged_inner_linestrings) - elif merged_inner_linestrings.geom_type == "MultiLineString": - for merged_inner_linestring in merged_inner_linestrings.geoms: - inner_polygons += polygonize(merged_inner_linestring) - - if not outer_polygons: - utils.log( - "No outer polygons were created for" - f" https://www.openstreetmap.org/{element['type']}/{element['id']}" - ) + merged_inner_linestrings = MultiLineString([merged_inner_linestrings]) + for merged_inner_linestring in merged_inner_linestrings.geoms: + inner_polygons += polygonize(merged_inner_linestring) - return outer_polygons, inner_polygons + # remove holes from polygons, if any, then retun + return _remove_polygon_holes(outer_polygons, inner_polygons) -def _subtract_inner_polygons_from_outer_polygons(element, outer_polygons, inner_polygons): +def _remove_polygon_holes( + outer_polygons: list[Polygon], + inner_polygons: list[Polygon], +) -> Polygon | MultiPolygon: """ - Subtract inner polygons from outer polygons. + Subtract inner holes from outer polygons. - Creates a Polygon or MultiPolygon with holes. + This allows possible island polygons within a larger polygon's holes. Parameters ---------- - element : dict - element type "relation" from overpass response JSON - outer_polygons : list - list of outer polygons that are part of a multipolygon - inner_polygons : list - list of inner polygons that are part of a multipolygon + outer_polygons + Polygons, including possible islands within a larger polygon's holes. + inner_polygons + Inner holes to subtract from the outer polygons that contain them. Returns ------- - geometry : shapely.geometry.Polygon or shapely.geometry.MultiPolygon - a single Polygon or MultiPolygon + geometry """ - # create a new list to hold the outer polygons with the inner polygons - # subtracted - outer_polygons_with_holes = [] - - # loop through the outer polygons subtracting the inner polygons and - # appending to the list - for outer_polygon in outer_polygons: - outer_polygon_diff = outer_polygon - for inner_polygon in inner_polygons: - if inner_polygon.within(outer_polygon): - try: - outer_polygon_diff = outer_polygon_diff.difference(inner_polygon) - except TopologicalError: # pragma: no cover - utils.log( - f"relation https://www.openstreetmap.org/relation/{element['id']} " - "caused a TopologicalError, trying with zero buffer." - ) - outer_polygon_diff = outer_polygon.buffer(0).difference(inner_polygon.buffer(0)) - - # note: .buffer(0) can return either a Polygon or MultiPolygon - # if it returns a MultiPolygon we need to extract the component - # sub Polygons to add to outer_polygons_with_holes - if outer_polygon_diff.geom_type == "Polygon": - outer_polygons_with_holes.append(outer_polygon_diff) - elif outer_polygon_diff.geom_type == "MultiPolygon": - outer_polygons_with_holes.extend(list(outer_polygon_diff.geoms)) - - # if only one polygon with holes was created, return that single polygon - if len(outer_polygons_with_holes) == 1: - geometry = outer_polygons_with_holes[0] - # otherwise create a multipolygon from list of outer polygons with holes + if len(inner_polygons) == 0: + # if there are no holes to remove, geom is the union of outer polygons + geometry = unary_union(outer_polygons) else: - geometry = MultiPolygon(outer_polygons_with_holes) + # otherwise, remove from each outer poly each inner poly it contains + polygons_with_holes = [] + for outer in outer_polygons: + for inner in inner_polygons: + if outer.contains(inner): + outer = outer.difference(inner) # noqa: PLW2901 + polygons_with_holes.append(outer) + geometry = unary_union(polygons_with_holes) - return geometry + # ensure returned geometry is a Polygon or MultiPolygon + if isinstance(geometry, (Polygon, MultiPolygon)): + return geometry + return Polygon() -def _buffer_invalid_geometries(gdf): +def _filter_features( + gdf: gpd.GeoDataFrame, + polygon: Polygon | MultiPolygon, + tags: dict[str, bool | str | list[str]], +) -> gpd.GeoDataFrame: """ - Buffer any invalid geometries remaining in the GeoDataFrame. - - Invalid geometries in the GeoDataFrame (which may accurately reproduce - invalid geometries in OpenStreetMap) can cause the filtering to the query - polygon and other subsequent geometric operations to fail. This function - logs the ids of the invalid geometries and applies a buffer of zero to try - to make them valid. + Filter features GeoDataFrame by spatial boundaries and query tags. - Note: the resulting geometries may differ from the originals - please - check them against OpenStreetMap + If the `polygon` and `tags` arguments are empty objects, the final + GeoDataFrame will not be filtered accordingly. Parameters ---------- - gdf : geopandas.GeoDataFrame - a GeoDataFrame with possibly invalid geometries + gdf + Original GeoDataFrame of features. + polygon + If not empty, the spatial boundaries to filter the GeoDataFrame. + tags + If not empty, the query tags to filter the GeoDataFrame. Returns ------- - gdf : geopandas.GeoDataFrame - the GeoDataFrame with .buffer(0) applied to invalid geometries - """ - # only apply the filters if the GeoDataFrame is not empty - if not gdf.empty: - # create a filter for rows with invalid geometries - invalid_geometry_filter = ~gdf["geometry"].is_valid - - # if there are invalid geometries - if invalid_geometry_filter.any(): - # get their unique_ids from the index - invalid_geometry_ids = gdf.loc[invalid_geometry_filter].index.to_list() - - # create a list of their urls and log them - osm_url = "https://www.openstreetmap.org/" - invalid_geom_urls = [osm_url + unique_id for unique_id in invalid_geometry_ids] - utils.log( - f"{len(invalid_geometry_ids)} invalid geometries" - f".buffer(0) applied to {invalid_geom_urls}", - level=lg.WARNING, - ) - - gdf.loc[invalid_geometry_filter, "geometry"] = gdf.loc[ - invalid_geometry_filter, "geometry" - ].buffer(0) - - return gdf - - -def _filter_gdf_by_polygon_and_tags(gdf, polygon, tags): + gdf + Filtered GeoDataFrame of features. """ - Filter the GeoDataFrame to the requested bounding polygon and tags. + # remove any null or empty geometries then fix any invalid geometries + gdf = gdf[~(gdf["geometry"].isna() | gdf["geometry"].is_empty)] + gdf.loc[:, "geometry"] = gdf["geometry"].make_valid() - Filters GeoDataFrame to query polygon and tags. Removes columns of all - NaNs (that held values only in rows removed by the filters). Resets the - index of GeoDataFrame, writing it into a new column called 'unique_id'. + # retain rows with geometries that intersect the polygon + if polygon.is_empty: + geom_filter = pd.Series(data=True, index=gdf.index) + else: + idx = utils_geo._intersect_index_quadrats(gdf["geometry"], polygon) + geom_filter = gdf.index.isin(idx) - Parameters - ---------- - gdf : geopandas.GeoDataFrame - the GeoDataFrame to filter - polygon : shapely.geometry.Polygon - polygon defining the boundary of the requested area - tags : dict - the tags requested + # retain rows that have any of their tag filters satisfied + if len(tags) == 0: + tags_filter = pd.Series(data=True, index=gdf.index) + else: + tags_filter = pd.Series(data=False, index=gdf.index) + for col in set(gdf.columns) & tags.keys(): + value = tags[col] + if value is True: + tags_filter |= gdf[col].notna() + elif isinstance(value, str): + tags_filter |= gdf[col] == value + elif isinstance(value, list): + tags_filter |= gdf[col].isin(set(value)) + + # filter gdf then drop any columns with only nulls left after filtering + gdf = gdf[geom_filter & tags_filter].dropna(axis="columns", how="all") + if len(gdf) == 0: # pragma: no cover + msg = "No matching features. Check query location, tags, and log." + raise InsufficientResponseError(msg) - Returns - ------- - gdf : geopandas.GeoDataFrame - final filtered GeoDataFrame - """ - # only apply the filters if the GeoDataFrame is not empty - if not gdf.empty: - # create two filters, initially all True - polygon_filter = pd.Series(True, index=gdf.index) - combined_tag_filter = pd.Series(True, index=gdf.index) - - # if a polygon was supplied, create a filter that is True for - # features that intersect with the polygon - if polygon: - # get set of index labels of features that intersect polygon - gdf_indices_in_polygon = utils_geo._intersect_index_quadrats(gdf, polygon) - # create boolean series, True for features whose index is in set - polygon_filter = gdf.index.isin(gdf_indices_in_polygon) - - utils.log(f"{sum(~polygon_filter)} features removed by the polygon filter") - - # if tags were supplied, create filter that is True for features - # that have at least one of the requested tags - if tags: - # Reset all values in the combined_tag_filter to False - combined_tag_filter[:] = False - - # reduce the tags to those that are actually present in the - # GeoDataFrame columns - tags_in_columns = {key: tags[key] for key in tags if key in gdf.columns} - - for key, value in tags_in_columns.items(): - if value is True: - tag_filter = gdf[key].notna() - elif isinstance(value, str): - tag_filter = gdf[key] == value - elif isinstance(value, list): - tag_filter = gdf[key].isin(value) - - combined_tag_filter = combined_tag_filter | tag_filter - - utils.log(f"{sum(~combined_tag_filter)} features removed by the tag filter") - - # apply the filters - gdf = gdf[polygon_filter & combined_tag_filter].copy() - - # remove columns of all nulls (created by discarded component features) - gdf = gdf.dropna(axis="columns", how="all") - - # multi-index gdf by element_type and osmid then return - idx_cols = ["element_type", "osmid"] - if all(c in gdf.columns for c in idx_cols): - gdf = gdf.set_index(idx_cols) + msg = f"{len(gdf):,} features in the final GeoDataFrame" + utils.log(msg, level=lg.INFO) return gdf diff --git a/osmnx/folium.py b/osmnx/folium.py deleted file mode 100644 index dcb76c35b..000000000 --- a/osmnx/folium.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Create interactive Leaflet web maps of graphs and routes via folium. - -This module is deprecated. Do not use. It will be removed in the v2.0.0 release. -You can generate and explore interactive web maps of graph nodes, edges, -and/or routes automatically using GeoPandas.GeoDataFrame.explore instead, for -example like: `ox.graph_to_gdfs(G, nodes=False).explore()`. See the OSMnx -examples gallery for complete details and demonstrations. -""" - -import json -from warnings import warn - -from . import convert - -# folium is an optional dependency for the folium plotting functions -try: - import folium -except ImportError: # pragma: no cover - folium = None - - -def plot_graph_folium( - G, - graph_map=None, - popup_attribute=None, - tiles="cartodbpositron", - zoom=1, - fit_bounds=True, - **kwargs, -): - """ - Do not use: deprecated. - - You can generate and explore interactive web maps of graph nodes, edges, - and/or routes automatically using GeoPandas.GeoDataFrame.explore instead, - for example like: `ox.graph_to_gdfs(G, nodes=False).explore()`. See the - OSMnx examples gallery for complete details and demonstrations. - - Parameters - ---------- - G : networkx.MultiDiGraph - deprecated - graph_map : folium.folium.Map - deprecated - popup_attribute : string - deprecated - tiles : string - deprecated - zoom : int - deprecated - fit_bounds : bool - deprecated - kwargs - deprecated - - Returns - ------- - folium.folium.Map - """ - warn( - "The `folium` module has been deprecated and will be removed in the v2.0.0 release. " - "You can generate and explore interactive web maps of graph nodes, edges, " - "and/or routes automatically using GeoPandas.GeoDataFrame.explore instead, " - "for example like: `ox.graph_to_gdfs(G, nodes=False).explore()`. See the " - "OSMnx examples gallery for complete details and demonstrations.", - FutureWarning, - stacklevel=2, - ) - # create gdf of all graph edges - gdf_edges = convert.graph_to_gdfs(G, nodes=False) - return _plot_folium(gdf_edges, graph_map, popup_attribute, tiles, zoom, fit_bounds, **kwargs) - - -def plot_route_folium( - G, - route, - route_map=None, - popup_attribute=None, - tiles="cartodbpositron", - zoom=1, - fit_bounds=True, - **kwargs, -): - """ - Do not use: deprecated. - - You can generate and explore interactive web maps of graph nodes, edges, - and/or routes automatically using GeoPandas.GeoDataFrame.explore instead, - for example like: `ox.graph_to_gdfs(G, nodes=False).explore()`. See the - OSMnx examples gallery for complete details and demonstrations. - - Parameters - ---------- - G : networkx.MultiDiGraph - deprecated - route : list - deprecated - route_map : folium.folium.Map - deprecated - popup_attribute : string - deprecated - tiles : string - deprecated - zoom : int - deprecated - fit_bounds : bool - deprecated - kwargs - deprecated - - Returns - ------- - folium.folium.Map - """ - warn( - "The `folium` module has been deprecated and will be removed in the v2.0.0 release. " - "You can generate and explore interactive web maps of graph nodes, edges, " - "and/or routes automatically using GeoPandas.GeoDataFrame.explore instead, " - "for example like: `ox.graph_to_gdfs(G, nodes=False).explore()`. See the " - "OSMnx examples gallery for complete details and demonstrations.", - FutureWarning, - stacklevel=2, - ) - # create gdf of the route edges in order - node_pairs = zip(route[:-1], route[1:]) - uvk = ((u, v, min(G[u][v].items(), key=lambda k: k[1]["length"])[0]) for u, v in node_pairs) - gdf_edges = convert.graph_to_gdfs(G.subgraph(route), nodes=False).loc[uvk] - return _plot_folium(gdf_edges, route_map, popup_attribute, tiles, zoom, fit_bounds, **kwargs) - - -def _plot_folium(gdf, m, popup_attribute, tiles, zoom, fit_bounds, **kwargs): - """ - Plot a GeoDataFrame of LineStrings on a folium map object. - - Parameters - ---------- - gdf : geopandas.GeoDataFrame - a GeoDataFrame of LineString geometries and attributes - m : folium.folium.Map or folium.FeatureGroup - if not None, plot on this preexisting folium map object - popup_attribute : string - attribute to display in pop-up on-click, if None, no popup - tiles : string - name of a folium tileset - zoom : int - initial zoom level for the map - fit_bounds : bool - if True, fit the map to gdf's boundaries - kwargs - keyword arguments to pass to folium.PolyLine() - - Returns - ------- - m : folium.folium.Map - """ - # check if we were able to import folium successfully - if folium is None: # pragma: no cover - msg = "folium must be installed to use this optional feature" - raise ImportError(msg) - - # get centroid - x, y = gdf.unary_union.centroid.xy - centroid = (y[0], x[0]) - - # create the folium web map if one wasn't passed-in - if m is None: - m = folium.Map(location=centroid, zoom_start=zoom, tiles=tiles) - - # identify the geometry and popup columns - attrs = ["geometry"] if popup_attribute is None else ["geometry", popup_attribute] - - # add each edge to the map - for vals in gdf[attrs].to_numpy(): - params = dict(zip(["geom", "popup_val"], vals)) - pl = _make_folium_polyline(**params, **kwargs) - pl.add_to(m) - - # if fit_bounds is True, fit the map to the bounds of the route by passing - # list of lat-lon points as [southwest, northeast] - if fit_bounds and isinstance(m, folium.Map): - tb = gdf.total_bounds - m.fit_bounds([(tb[1], tb[0]), (tb[3], tb[2])]) - - return m - - -def _make_folium_polyline(geom, popup_val=None, **kwargs): - """ - Turn LineString geometry into a folium PolyLine with attributes. - - Parameters - ---------- - geom : shapely LineString - geometry of the line - popup_val : string - text to display in pop-up when a line is clicked, if None, no popup - kwargs - keyword arguments to pass to folium.PolyLine() - - Returns - ------- - pl : folium.PolyLine - """ - # locations is a list of points for the polyline folium takes coords in - # lat,lon but geopandas provides them in lon,lat so we must reverse them - locations = [(lat, lon) for lon, lat in geom.coords] - - # create popup if popup_val is not None - # folium doesn't interpret html, so can't do newlines without iframe - popup = None if popup_val is None else folium.Popup(html=json.dumps(popup_val)) - - # create a folium polyline with attributes - return folium.PolyLine(locations=locations, popup=popup, **kwargs) diff --git a/osmnx/geocoder.py b/osmnx/geocoder.py index 85f972aa5..955d5338a 100644 --- a/osmnx/geocoder.py +++ b/osmnx/geocoder.py @@ -6,38 +6,39 @@ https://nominatim.org/. """ +from __future__ import annotations + import logging as lg from collections import OrderedDict -from warnings import warn +from typing import Any import geopandas as gpd import pandas as pd from . import _nominatim -from . import projection from . import settings from . import utils from ._errors import InsufficientResponseError -def geocode(query): +def geocode(query: str) -> tuple[float, float]: """ - Geocode place names or addresses to (lat, lon) with the Nominatim API. + Geocode place names or addresses to `(lat, lon)` with the Nominatim API. This geocodes the query via the Nominatim "search" endpoint. Parameters ---------- - query : string - the query string to geocode + query + The query string to geocode. Returns ------- - point : tuple - the (lat, lon) coordinates returned by the geocoder + point + The `(lat, lon)` coordinates returned by the geocoder. """ # define the parameters - params = OrderedDict() + params: OrderedDict[str, int | str] = OrderedDict() params["format"] = "json" params["limit"] = 1 params["dedupe"] = 0 # prevent deduping to get precise number of results @@ -49,15 +50,22 @@ def geocode(query): lat = float(response_json[0]["lat"]) lon = float(response_json[0]["lon"]) point = (lat, lon) - utils.log(f"Geocoded {query!r} to {point}") + + msg = f"Geocoded {query!r} to {point}" + utils.log(msg, level=lg.INFO) return point # otherwise we got no results back - msg = f"Nominatim could not geocode query {query!r}" + msg = f"Nominatim could not geocode query {query!r}." raise InsufficientResponseError(msg) -def geocode_to_gdf(query, which_result=None, by_osmid=False, buffer_dist=None): +def geocode_to_gdf( + query: str | dict[str, str] | list[str | dict[str, str]], + *, + which_result: int | None | list[int | None] = None, + by_osmid: bool = False, +) -> gpd.GeoDataFrame: """ Retrieve OSM elements by place name or OSM ID with the Nominatim API. @@ -74,115 +82,85 @@ def geocode_to_gdf(query, which_result=None, by_osmid=False, buffer_dist=None): or relation (R) in accordance with the Nominatim API format. For example, `query=["R2192363", "N240109189", "W427818536"]`. - If `query` is a list, then `which_result` must be either a single value or - a list with the same length as `query`. The queries you provide must be + If `query` is a list, then `which_result` must be either an int or a list + with the same length as `query`. The queries you provide must be resolvable to elements in the Nominatim database. The resulting GeoDataFrame's geometry column contains place boundaries if they exist. Parameters ---------- - query : string or dict or list of strings/dicts - query string(s) or structured dict(s) to geocode - which_result : int - which search result to return. if None, auto-select the first - (Multi)Polygon or raise an error if OSM doesn't return one. to get - the top match regardless of geometry type, set which_result=1. - ignored if by_osmid=True. - by_osmid : bool - if True, treat query as an OSM ID lookup rather than text search - buffer_dist : float - deprecated, do not use + query + The query string(s) or structured dict(s) to geocode. + which_result + Which search result to return. If None, auto-select the first + (Multi)Polygon or raise an error if OSM doesn't return one. To get + the top match regardless of geometry type, set `which_result=1`. + Ignored if `by_osmid=True`. + by_osmid + If True, treat query as an OSM ID lookup rather than text search. Returns ------- - gdf : geopandas.GeoDataFrame - a GeoDataFrame with one row for each query + gdf + GeoDataFrame with one row for each query result. """ - if buffer_dist is not None: - warn( - "The buffer_dist argument has been deprecated and will be removed " - "in the v2.0.0 release. Buffer your results directly, if desired. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - - if not isinstance(query, (str, dict, list)): # pragma: no cover - msg = "query must be a string or dict or list" - raise TypeError(msg) - - # if caller passed a list of queries but a scalar which_result value, then - # turn which_result into a list with same length as query list - if isinstance(query, list) and (isinstance(which_result, int) or which_result is None): - which_result = [which_result] * len(query) - - # turn query and which_result into lists if they're not already - if not isinstance(query, list): - query = [query] - if not isinstance(which_result, list): - which_result = [which_result] + if isinstance(query, list): + # if query is a list of queries but which_result is int/None, then + # turn which_result into a list with same length as query list + q_list = query + wr_list = which_result if isinstance(which_result, list) else [which_result] * len(query) + else: + # if query is not already a list, turn it into one + # if which_result was a list, take 0th element, otherwise make it list + q_list = [query] + wr_list = [which_result[0]] if isinstance(which_result, list) else [which_result] # ensure same length - if len(query) != len(which_result): # pragma: no cover - msg = "which_result length must equal query length" + if len(q_list) != len(wr_list): # pragma: no cover + msg = "`which_result` length must equal `query` length." raise ValueError(msg) - # ensure query type of each item - for q in query: - if not isinstance(q, (str, dict)): # pragma: no cover - msg = "each query must be a dict or a string" - raise TypeError(msg) - - # geocode each query and add to GeoDataFrame as a new row - gdf = gpd.GeoDataFrame() - for q, wr in zip(query, which_result): - gdf = pd.concat([gdf, _geocode_query_to_gdf(q, wr, by_osmid)]) - - # reset GeoDataFrame index and set its CRS - gdf = gdf.reset_index(drop=True) - gdf = gdf.set_crs(settings.default_crs) - - # if buffer_dist was passed in, project the geometry to UTM, buffer it in - # meters, then project it back to lat-lon - if buffer_dist is not None and len(gdf) > 0: - gdf_utm = projection.project_gdf(gdf) - gdf_utm["geometry"] = gdf_utm["geometry"].buffer(buffer_dist) - gdf = projection.project_gdf(gdf_utm, to_latlong=True) - utils.log(f"Buffered GeoDataFrame to {buffer_dist} meters") - - utils.log(f"Created GeoDataFrame with {len(gdf)} rows from {len(query)} queries") + # geocode each query, concat as GeoDataFrame rows, then set the CRS + results = (_geocode_query_to_gdf(q, wr, by_osmid) for q, wr in zip(q_list, wr_list)) + gdf = pd.concat(results, ignore_index=True).set_crs(settings.default_crs) + + msg = f"Created GeoDataFrame with {len(gdf)} rows from {len(q_list)} queries" + utils.log(msg, level=lg.INFO) return gdf -def _geocode_query_to_gdf(query, which_result, by_osmid): +def _geocode_query_to_gdf( + query: str | dict[str, str], + which_result: int | None, + by_osmid: bool, # noqa: FBT001 +) -> gpd.GeoDataFrame: """ Geocode a single place query to a GeoDataFrame. Parameters ---------- - query : string or dict - query string or structured dict to geocode - which_result : int - which geocoding result to use. if None, auto-select the first - (Multi)Polygon or raise an error if OSM doesn't return one. to get - the top match regardless of geometry type, set which_result=1. - ignored if by_osmid=True. - by_osmid : bool - if True, handle query as an OSM ID for lookup rather than text search + query + Query string or structured dict to geocode. + which_result + Which search result to return. If None, auto-select the first + (Multi)Polygon or raise an error if OSM doesn't return one. To get + the top match regardless of geometry type, set `which_result=1`. + Ignored if `by_osmid=True`. + by_osmid + If True, treat query as an OSM ID lookup rather than text search. Returns ------- - gdf : geopandas.GeoDataFrame - a GeoDataFrame with one row containing the result of geocoding + gdf + GeoDataFrame with one row containing the geocoding result. """ limit = 50 if which_result is None else which_result - results = _nominatim._download_nominatim_element(query, by_osmid=by_osmid, limit=limit) # choose the right result from the JSON response - if not results: + if len(results) == 0: # if no results were returned, raise error - msg = f"Nominatim geocoder returned 0 results for query {query!r}" + msg = f"Nominatim geocoder returned 0 results for query {query!r}." raise InsufficientResponseError(msg) if by_osmid: @@ -191,7 +169,11 @@ def _geocode_query_to_gdf(query, which_result, by_osmid): elif which_result is None: # else, if which_result=None, auto-select the first (Multi)Polygon - result = _get_first_polygon(results, query) + try: + result = _get_first_polygon(results) + except TypeError as e: + msg = f"Nominatim did not geocode query {query!r} to a geometry of type (Multi)Polygon." + raise TypeError(msg) from e elif len(results) >= which_result: # else, if we got at least which_result results, choose that one @@ -199,7 +181,7 @@ def _geocode_query_to_gdf(query, which_result, by_osmid): else: # pragma: no cover # else, we got fewer results than which_result, raise error - msg = f"Nominatim returned {len(results)} result(s) but which_result={which_result}" + msg = f"Nominatim returned {len(results)} result(s) but `which_result={which_result}`." raise InsufficientResponseError(msg) # if we got a non (Multi)Polygon geometry type (like a point), log warning @@ -233,27 +215,25 @@ def _geocode_query_to_gdf(query, which_result, by_osmid): return gdf -def _get_first_polygon(results, query): +def _get_first_polygon(results: list[dict[str, Any]]) -> dict[str, Any]: """ Choose first result of geometry type (Multi)Polygon from list of results. Parameters ---------- - results : list - list of results from _downloader._osm_place_download - query : str - the query string or structured dict that was geocoded + results + Results from the Nominatim API. Returns ------- - result : dict - the chosen result + result + The chosen result. """ polygon_types = {"Polygon", "MultiPolygon"} + for result in results: if "geojson" in result and result["geojson"]["type"] in polygon_types: return result - # if we never found a polygon, throw an error - msg = f"Nominatim could not geocode query {query!r} to a geometry of type (Multi)Polygon" - raise TypeError(msg) + # if we never found a polygon, raise an error + raise TypeError diff --git a/osmnx/geometries.py b/osmnx/geometries.py deleted file mode 100644 index 8b1473622..000000000 --- a/osmnx/geometries.py +++ /dev/null @@ -1,178 +0,0 @@ -""" -Do not use: deprecated. - -The `geometries` module has been renamed the `features` module. The -`geometries` module is deprecated and will be removed in the v2.0.0 release. -""" - -from warnings import warn - -from . import features - -DEP_MSG = ( - "The `geometries` module and `geometries_from_X` functions have been " - "renamed the `features` module and `features_from_X` functions. Use these " - "instead. The `geometries` module and function names are deprecated and " - "will be removed in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" -) - - -def geometries_from_bbox(north, south, east, west, tags): - """ - Do not use: deprecated. - - The `geometries` module and `geometries_from_X` functions have been - renamed the `features` module and `features_from_X` functions. Use these - instead. The `geometries` module and functions are deprecated and will be - removed in the v2.0.0 release. - - Parameters - ---------- - north : float - Do not use: deprecated. - south : float - Do not use: deprecated. - east : float - Do not use: deprecated. - west : float - Do not use: deprecated. - tags : dict - Do not use: deprecated. - - Returns - ------- - gdf : geopandas.GeoDataFrame - """ - warn(DEP_MSG, FutureWarning, stacklevel=2) - return features.features_from_bbox(north, south, east, west, tags=tags) - - -def geometries_from_point(center_point, tags, dist=1000): - """ - Do not use: deprecated. - - The `geometries` module and `geometries_from_X` functions have been - renamed the `features` module and `features_from_X` functions. Use these - instead. The `geometries` module and functions are deprecated and will be - removed in the v2.0.0 release. - - Parameters - ---------- - center_point : tuple - Do not use: deprecated. - tags : dict - Do not use: deprecated. - dist : numeric - Do not use: deprecated. - - Returns - ------- - gdf : geopandas.GeoDataFrame - """ - warn(DEP_MSG, FutureWarning, stacklevel=2) - return features.features_from_point(center_point, tags, dist) - - -def geometries_from_address(address, tags, dist=1000): - """ - Do not use: deprecated. - - The `geometries` module and `geometries_from_X` functions have been - renamed the `features` module and `features_from_X` functions. Use these - instead. The `geometries` module and functions are deprecated and will be - removed in the v2.0.0 release. - - Parameters - ---------- - address : string - Do not use: deprecated. - tags : dict - Do not use: deprecated. - dist : numeric - Do not use: deprecated. - - Returns - ------- - gdf : geopandas.GeoDataFrame - """ - warn(DEP_MSG, FutureWarning, stacklevel=2) - return features.features_from_address(address, tags, dist) - - -def geometries_from_place(query, tags, which_result=None, buffer_dist=None): - """ - Do not use: deprecated. - - The `geometries` module and `geometries_from_X` functions have been - renamed the `features` module and `features_from_X` functions. Use these - instead. The `geometries` module and functions are deprecated and will be - removed in the v2.0.0 release. - - Parameters - ---------- - query : string or dict or list - Do not use: deprecated. - tags : dict - Do not use: deprecated. - which_result : int - Do not use: deprecated. - buffer_dist : float - Do not use: deprecated. - - Returns - ------- - gdf : geopandas.GeoDataFrame - """ - warn(DEP_MSG, FutureWarning, stacklevel=2) - return features.features_from_place(query, tags, which_result, buffer_dist) - - -def geometries_from_polygon(polygon, tags): - """ - Do not use: deprecated. - - The `geometries` module and `geometries_from_X` functions have been - renamed the `features` module and `features_from_X` functions. Use these - instead. The `geometries` module and functions are deprecated and will be - removed in the v2.0.0 release. - - Parameters - ---------- - polygon : shapely.geometry.Polygon or shapely.geometry.MultiPolygon - Do not use: deprecated. - tags : dict - Do not use: deprecated. - - Returns - ------- - gdf : geopandas.GeoDataFrame - """ - warn(DEP_MSG, FutureWarning, stacklevel=2) - return features.features_from_polygon(polygon, tags) - - -def geometries_from_xml(filepath, polygon=None, tags=None): - """ - Do not use: deprecated. - - The `geometries` module and `geometries_from_X` functions have been - renamed the `features` module and `features_from_X` functions. Use these - instead. The `geometries` module and functions are deprecated and will be - removed in the v2.0.0 release. - - Parameters - ---------- - filepath : string or pathlib.Path - Do not use: deprecated. - polygon : shapely.geometry.Polygon - Do not use: deprecated. - tags : dict - Do not use: deprecated. - - Returns - ------- - gdf : geopandas.GeoDataFrame - """ - warn(DEP_MSG, FutureWarning, stacklevel=2) - return features.features_from_xml(filepath, polygon, tags) diff --git a/osmnx/graph.py b/osmnx/graph.py index 60587ffd5..779d672a1 100644 --- a/osmnx/graph.py +++ b/osmnx/graph.py @@ -1,23 +1,26 @@ """ Download and create graphs from OpenStreetMap data. -This module uses filters to query the Overpass API: you can either specify a -built-in network type or provide your own custom filter with Overpass QL. - Refer to the Getting Started guide for usage limitations. """ -import itertools -from warnings import warn +from __future__ import annotations + +import logging as lg +from collections.abc import Iterable +from itertools import groupby +from pathlib import Path +from typing import TYPE_CHECKING +from typing import Any import networkx as nx -from shapely.geometry import MultiPolygon -from shapely.geometry import Polygon +from shapely import MultiPolygon +from shapely import Polygon +from . import _osm_xml from . import _overpass from . import distance from . import geocoder -from . import osm_xml from . import projection from . import settings from . import simplification @@ -29,60 +32,58 @@ from ._errors import InsufficientResponseError from ._version import __version__ +if TYPE_CHECKING: + from collections.abc import Iterable + from pathlib import Path + def graph_from_bbox( - north=None, - south=None, - east=None, - west=None, - bbox=None, - network_type="all", - simplify=True, - retain_all=False, - truncate_by_edge=False, - clean_periphery=None, - custom_filter=None, -): + bbox: tuple[float, float, float, float], + *, + network_type: str = "all", + simplify: bool = True, + retain_all: bool = False, + truncate_by_edge: bool = False, + custom_filter: str | None = None, +) -> nx.MultiDiGraph: """ - Download and create a graph within some bounding box. + Download and create a graph within a lat-lon bounding box. - You can use the `settings` module to retrieve a snapshot of historical OSM - data as of a certain date, or to configure the Overpass server timeout, - memory allocation, and other custom settings. + This function uses filters to query the Overpass API: you can either + specify a pre-defined `network_type` or provide your own `custom_filter` + with Overpass QL. + + Use the `settings` module's `useful_tags_node` and `useful_tags_way` + settings to configure which OSM node/way tags are added as graph node/edge + attributes. You can also use the `settings` module to retrieve a snapshot + of historical OSM data as of a certain date, or to configure the Overpass + server timeout, memory allocation, and other custom settings. Parameters ---------- - north : float - deprecated, do not use - south : float - deprecated, do not use - east : float - deprecated, do not use - west : float - deprecated, do not use - bbox : tuple of floats - bounding box as (north, south, east, west) - network_type : string {"all", "all_public", "bike", "drive", "drive_service", "walk"} - what type of street network to get if custom_filter is None - simplify : bool - if True, simplify graph topology with the `simplify_graph` function - retain_all : bool - if True, return the entire graph even if it is not connected. - otherwise, retain only the largest weakly connected component. - truncate_by_edge : bool - if True, retain nodes outside bounding box if at least one of node's - neighbors is within the bounding box - clean_periphery : bool - deprecated, do not use - custom_filter : string - a custom ways filter to be used instead of the network_type presets - e.g., '["power"~"line"]' or '["highway"~"motorway|trunk"]'. Also pass - in a network_type that is in settings.bidirectional_network_types if - you want graph to be fully bi-directional. + bbox + Bounding box as `(north, south, east, west)`. Coordinates should be in + unprojected latitude-longitude degrees (EPSG:4326). + network_type + {"all", "all_public", "bike", "drive", "drive_service", "walk"} + What type of street network to retrieve if `custom_filter` is None. + simplify + If True, simplify graph topology via the `simplify_graph` function. + retain_all + If True, return the entire graph even if it is not connected. If + False, retain only the largest weakly connected component. + truncate_by_edge + If True, retain nodes outside bounding box if at least one of node's + neighbors is within the bounding box. + custom_filter + A custom ways filter to be used instead of the `network_type` presets, + e.g. `'["power"~"line"]' or '["highway"~"motorway|trunk"]'`. Also pass + in a `network_type` that is in `settings.bidirectional_network_types` + if you want the graph to be fully bidirectional. Returns ------- - G : networkx.MultiDiGraph + G Notes ----- @@ -90,17 +91,8 @@ def graph_from_bbox( function to automatically make multiple requests: see that function's documentation for caveats. """ - if not (north is None and south is None and east is None and west is None): - msg = ( - "The `north`, `south`, `east`, and `west` parameters are deprecated and " - "will be removed in the v2.0.0 release. Use the `bbox` parameter instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - bbox = (north, south, east, west) - # convert bounding box to a polygon - polygon = utils_geo.bbox_to_poly(bbox=bbox) + polygon = utils_geo.bbox_to_poly(bbox) # create graph using this polygon geometry G = graph_from_polygon( @@ -109,64 +101,72 @@ def graph_from_bbox( simplify=simplify, retain_all=retain_all, truncate_by_edge=truncate_by_edge, - clean_periphery=clean_periphery, custom_filter=custom_filter, ) - utils.log(f"graph_from_bbox returned graph with {len(G):,} nodes and {len(G.edges):,} edges") + msg = f"graph_from_bbox returned graph with {len(G):,} nodes and {len(G.edges):,} edges" + utils.log(msg, level=lg.INFO) return G def graph_from_point( - center_point, - dist=1000, - dist_type="bbox", - network_type="all", - simplify=True, - retain_all=False, - truncate_by_edge=False, - clean_periphery=None, - custom_filter=None, -): + center_point: tuple[float, float], + dist: float, + *, + dist_type: str = "bbox", + network_type: str = "all", + simplify: bool = True, + retain_all: bool = False, + truncate_by_edge: bool = False, + custom_filter: str | None = None, +) -> nx.MultiDiGraph: """ - Download and create a graph within some distance of a (lat, lon) point. + Download and create a graph within some distance of a lat-lon point. + + This function uses filters to query the Overpass API: you can either + specify a pre-defined `network_type` or provide your own `custom_filter` + with Overpass QL. - You can use the `settings` module to retrieve a snapshot of historical OSM - data as of a certain date, or to configure the Overpass server timeout, - memory allocation, and other custom settings. + Use the `settings` module's `useful_tags_node` and `useful_tags_way` + settings to configure which OSM node/way tags are added as graph node/edge + attributes. You can also use the `settings` module to retrieve a snapshot + of historical OSM data as of a certain date, or to configure the Overpass + server timeout, memory allocation, and other custom settings. Parameters ---------- - center_point : tuple - the (lat, lon) center point around which to construct the graph - dist : int - retain only those nodes within this many meters of the center of the - graph, with distance determined according to dist_type argument - dist_type : string {"network", "bbox"} - if "bbox", retain only those nodes within a bounding box of the - distance parameter. if "network", retain only those nodes within some - network distance from the center-most node. - network_type : string, {"all", "all_public", "bike", "drive", "drive_service", "walk"} - what type of street network to get if custom_filter is None - simplify : bool - if True, simplify graph topology with the `simplify_graph` function - retain_all : bool - if True, return the entire graph even if it is not connected. - otherwise, retain only the largest weakly connected component. - truncate_by_edge : bool - if True, retain nodes outside bounding box if at least one of node's - neighbors is within the bounding box - clean_periphery : bool, - deprecated, do not use - custom_filter : string - a custom ways filter to be used instead of the network_type presets - e.g., '["power"~"line"]' or '["highway"~"motorway|trunk"]'. Also pass - in a network_type that is in settings.bidirectional_network_types if - you want graph to be fully bi-directional. + center_point + The `(lat, lon)` center point around which to construct the graph. + Coordinates should be in unprojected latitude-longitude degrees + (EPSG:4326). + dist + Retain only those nodes within this many meters of `center_point`, + measuring distance according to `dist_type`. + dist_type + {"bbox", "network"} + If "bbox", retain only those nodes within a bounding box of `dist` + length/width. If "network", retain only those nodes within `dist` + network distance of the nearest node to `center_point`. + network_type + {"all", "all_public", "bike", "drive", "drive_service", "walk"} + What type of street network to retrieve if `custom_filter` is None. + simplify + If True, simplify graph topology with the `simplify_graph` function. + retain_all + If True, return the entire graph even if it is not connected. If + False, retain only the largest weakly connected component. + truncate_by_edge + If True, retain nodes outside bounding box if at least one of node's + neighbors is within the bounding box. + custom_filter + A custom ways filter to be used instead of the `network_type` presets, + e.g. `'["power"~"line"]' or '["highway"~"motorway|trunk"]'`. Also pass + in a `network_type` that is in `settings.bidirectional_network_types` + if you want the graph to be fully bidirectional. Returns ------- - G : networkx.MultiDiGraph + G Notes ----- @@ -175,7 +175,7 @@ def graph_from_point( documentation for caveats. """ if dist_type not in {"bbox", "network"}: # pragma: no cover - msg = 'dist_type must be "bbox" or "network"' + msg = "`dist_type` must be 'bbox' or 'network'." raise ValueError(msg) # create bounding box from center point and distance in each direction @@ -183,79 +183,81 @@ def graph_from_point( # create a graph from the bounding box G = graph_from_bbox( - bbox=bbox, + bbox, network_type=network_type, simplify=simplify, retain_all=retain_all, truncate_by_edge=truncate_by_edge, - clean_periphery=clean_periphery, custom_filter=custom_filter, ) if dist_type == "network": - # if dist_type is network, find node in graph nearest to center point - # then truncate graph by network dist from it - node = distance.nearest_nodes(G, X=[center_point[1]], Y=[center_point[0]])[0] - G = truncate.truncate_graph_dist(G, node, max_dist=dist) + # find node nearest to center then truncate graph by dist from it + node = distance.nearest_nodes(G, X=center_point[1], Y=center_point[0]) + G = truncate.truncate_graph_dist(G, node, dist) - utils.log(f"graph_from_point returned graph with {len(G):,} nodes and {len(G.edges):,} edges") + msg = f"graph_from_point returned graph with {len(G):,} nodes and {len(G.edges):,} edges" + utils.log(msg, level=lg.INFO) return G def graph_from_address( - address, - dist=1000, - dist_type="bbox", - network_type="all", - simplify=True, - retain_all=False, - truncate_by_edge=False, - return_coords=None, - clean_periphery=None, - custom_filter=None, -): + address: str, + dist: float, + *, + dist_type: str = "bbox", + network_type: str = "all", + simplify: bool = True, + retain_all: bool = False, + truncate_by_edge: bool = False, + custom_filter: str | None = None, +) -> nx.MultiDiGraph | tuple[nx.MultiDiGraph, tuple[float, float]]: """ Download and create a graph within some distance of an address. - You can use the `settings` module to retrieve a snapshot of historical OSM - data as of a certain date, or to configure the Overpass server timeout, - memory allocation, and other custom settings. + This function uses filters to query the Overpass API: you can either + specify a pre-defined `network_type` or provide your own `custom_filter` + with Overpass QL. + + Use the `settings` module's `useful_tags_node` and `useful_tags_way` + settings to configure which OSM node/way tags are added as graph node/edge + attributes. You can also use the `settings` module to retrieve a snapshot + of historical OSM data as of a certain date, or to configure the Overpass + server timeout, memory allocation, and other custom settings. Parameters ---------- - address : string - the address to geocode and use as the central point around which to - construct the graph - dist : int - retain only those nodes within this many meters of the center of the - graph - dist_type : string {"network", "bbox"} - if "bbox", retain only those nodes within a bounding box of the - distance parameter. if "network", retain only those nodes within some - network distance from the center-most node. - network_type : string {"all", "all_public", "bike", "drive", "drive_service", "walk"} - what type of street network to get if custom_filter is None - simplify : bool - if True, simplify graph topology with the `simplify_graph` function - retain_all : bool - if True, return the entire graph even if it is not connected. - otherwise, retain only the largest weakly connected component. - truncate_by_edge : bool - if True, retain nodes outside bounding box if at least one of node's - neighbors is within the bounding box - return_coords : bool - deprecated, do not use - clean_periphery : bool - deprecated, do not use - custom_filter : string - a custom ways filter to be used instead of the network_type presets - e.g., '["power"~"line"]' or '["highway"~"motorway|trunk"]'. Also pass - in a network_type that is in settings.bidirectional_network_types if - you want graph to be fully bi-directional. + address + The address to geocode and use as the central point around which to + construct the graph. + dist + Retain only those nodes within this many meters of `center_point`, + measuring distance according to `dist_type`. + dist_type + {"network", "bbox"} + If "bbox", retain only those nodes within a bounding box of `dist`. If + "network", retain only those nodes within `dist` network distance from + the centermost node. + network_type + {"all", "all_public", "bike", "drive", "drive_service", "walk"} + What type of street network to retrieve if `custom_filter` is None. + simplify + If True, simplify graph topology with the `simplify_graph` function. + retain_all + If True, return the entire graph even if it is not connected. If + False, retain only the largest weakly connected component. + truncate_by_edge + If True, retain nodes outside bounding box if at least one of node's + neighbors is within the bounding box. + custom_filter + A custom ways filter to be used instead of the `network_type` presets, + e.g. `'["power"~"line"]' or '["highway"~"motorway|trunk"]'`. Also pass + in a `network_type` that is in `settings.bidirectional_network_types` + if you want the graph to be fully bidirectional. Returns ------- - networkx.MultiDiGraph or optionally (networkx.MultiDiGraph, (lat, lon)) + G or (G, (lat, lon)) Notes ----- @@ -263,101 +265,89 @@ def graph_from_address( function to automatically make multiple requests: see that function's documentation for caveats. """ - if return_coords is None: - return_coords = False - else: - warn( - "The `return_coords` argument has been deprecated and will be removed in " - "the v2.0.0 release. Future behavior will be as though `return_coords=False`. " - "If you want the address's geocoded coordinates, use the `geocode` function. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) # geocode the address string to a (lat, lon) point - point = geocoder.geocode(query=address) + point = geocoder.geocode(address) # then create a graph from this point G = graph_from_point( point, dist, - dist_type, + dist_type=dist_type, network_type=network_type, simplify=simplify, retain_all=retain_all, truncate_by_edge=truncate_by_edge, - clean_periphery=clean_periphery, custom_filter=custom_filter, ) - utils.log(f"graph_from_address returned graph with {len(G):,} nodes and {len(G.edges):,} edges") - if return_coords: - return G, point - - # otherwise + msg = f"graph_from_address returned graph with {len(G):,} nodes and {len(G.edges):,} edges" + utils.log(msg, level=lg.INFO) return G def graph_from_place( - query, - network_type="all", - simplify=True, - retain_all=False, - truncate_by_edge=False, - which_result=None, - buffer_dist=None, - clean_periphery=None, - custom_filter=None, -): + query: str | dict[str, str] | list[str | dict[str, str]], + *, + network_type: str = "all", + simplify: bool = True, + retain_all: bool = False, + truncate_by_edge: bool = False, + which_result: int | None | list[int | None] = None, + custom_filter: str | None = None, +) -> nx.MultiDiGraph: """ Download and create a graph within the boundaries of some place(s). The query must be geocodable and OSM must have polygon boundaries for the geocode result. If OSM does not have a polygon for this place, you can - instead get its street network using the graph_from_address function, + instead get its street network using the `graph_from_address` function, which geocodes the place name to a point and gets the network within some distance of that point. If OSM does have polygon boundaries for this place but you're not finding it, try to vary the query string, pass in a structured query dict, or vary - the which_result argument to use a different geocode result. If you know + the `which_result` argument to use a different geocode result. If you know the OSM ID of the place, you can retrieve its boundary polygon using the - geocode_to_gdf function, then pass it to the graph_from_polygon function. + `geocode_to_gdf` function, then pass it to the `features_from_polygon` + function. + + This function uses filters to query the Overpass API: you can either + specify a pre-defined `network_type` or provide your own `custom_filter` + with Overpass QL. - You can use the `settings` module to retrieve a snapshot of historical OSM - data as of a certain date, or to configure the Overpass server timeout, - memory allocation, and other custom settings. + Use the `settings` module's `useful_tags_node` and `useful_tags_way` + settings to configure which OSM node/way tags are added as graph node/edge + attributes. You can also use the `settings` module to retrieve a snapshot + of historical OSM data as of a certain date, or to configure the Overpass + server timeout, memory allocation, and other custom settings. Parameters ---------- - query : string or dict or list - the query or queries to geocode to get place boundary polygon(s) - network_type : string {"all", "all_public", "bike", "drive", "drive_service", "walk"} - what type of street network to get if custom_filter is None - simplify : bool - if True, simplify graph topology with the `simplify_graph` function - retain_all : bool - if True, return the entire graph even if it is not connected. - otherwise, retain only the largest weakly connected component. - truncate_by_edge : bool - if True, retain nodes outside boundary polygon if at least one of - node's neighbors is within the polygon - which_result : int + query + The query or queries to geocode to retrieve place boundary polygon(s). + network_type + {"all", "all_public", "bike", "drive", "drive_service", "walk"} + What type of street network to retrieve if `custom_filter` is None. + simplify + If True, simplify graph topology with the `simplify_graph` function. + retain_all + If True, return the entire graph even if it is not connected. If + False, retain only the largest weakly connected component. + truncate_by_edge + If True, retain nodes outside bounding box if at least one of node's + neighbors is within the bounding box. + which_result which geocoding result to use. if None, auto-select the first (Multi)Polygon or raise an error if OSM doesn't return one. - buffer_dist : float - deprecated, do not use - clean_periphery : bool - deprecated, do not use - custom_filter : string - a custom ways filter to be used instead of the network_type presets - e.g., '["power"~"line"]' or '["highway"~"motorway|trunk"]'. Also pass - in a network_type that is in settings.bidirectional_network_types if - you want graph to be fully bi-directional. + custom_filter + A custom ways filter to be used instead of the `network_type` presets, + e.g. `'["power"~"line"]' or '["highway"~"motorway|trunk"]'`. Also pass + in a `network_type` that is in `settings.bidirectional_network_types` + if you want the graph to be fully bidirectional. Returns ------- - G : networkx.MultiDiGraph + G Notes ----- @@ -365,32 +355,12 @@ def graph_from_place( function to automatically make multiple requests: see that function's documentation for caveats. """ - if buffer_dist is not None: - warn( - "The buffer_dist argument has been deprecated and will be removed " - "in the v2.0.0 release. Buffer your query area directly, if desired. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - - # create a GeoDataFrame with the spatial boundaries of the place(s) - if isinstance(query, (str, dict)): - # if it is a string (place name) or dict (structured place query), - # then it is a single place - gdf_place = geocoder.geocode_to_gdf( - query, which_result=which_result, buffer_dist=buffer_dist - ) - elif isinstance(query, list): - # if it is a list, it contains multiple places to get - gdf_place = geocoder.geocode_to_gdf(query, buffer_dist=buffer_dist) - else: # pragma: no cover - msg = "query must be dict, string, or list of strings" - raise TypeError(msg) - - # extract the geometry from the GeoDataFrame to use in API query + # extract the geometry from the GeoDataFrame to use in query + gdf_place = geocoder.geocode_to_gdf(query, which_result=which_result) polygon = gdf_place["geometry"].unary_union - utils.log("Constructed place geometry polygon(s) to query API") + + msg = "Constructed place geometry polygon(s) to query Overpass" + utils.log(msg, level=lg.INFO) # create graph using this polygon(s) geometry G = graph_from_polygon( @@ -399,56 +369,61 @@ def graph_from_place( simplify=simplify, retain_all=retain_all, truncate_by_edge=truncate_by_edge, - clean_periphery=clean_periphery, custom_filter=custom_filter, ) - utils.log(f"graph_from_place returned graph with {len(G):,} nodes and {len(G.edges):,} edges") + msg = f"graph_from_place returned graph with {len(G):,} nodes and {len(G.edges):,} edges" + utils.log(msg, level=lg.INFO) return G def graph_from_polygon( - polygon, - network_type="all", - simplify=True, - retain_all=False, - truncate_by_edge=False, - clean_periphery=None, - custom_filter=None, -): + polygon: Polygon | MultiPolygon, + *, + network_type: str = "all", + simplify: bool = True, + retain_all: bool = False, + truncate_by_edge: bool = False, + custom_filter: str | None = None, +) -> nx.MultiDiGraph: """ - Download and create a graph within the boundaries of a (multi)polygon. + Download and create a graph within the boundaries of a (Multi)Polygon. - You can use the `settings` module to retrieve a snapshot of historical OSM - data as of a certain date, or to configure the Overpass server timeout, - memory allocation, and other custom settings. + This function uses filters to query the Overpass API: you can either + specify a pre-defined `network_type` or provide your own `custom_filter` + with Overpass QL. + + Use the `settings` module's `useful_tags_node` and `useful_tags_way` + settings to configure which OSM node/way tags are added as graph node/edge + attributes. You can also use the `settings` module to retrieve a snapshot + of historical OSM data as of a certain date, or to configure the Overpass + server timeout, memory allocation, and other custom settings. Parameters ---------- - polygon : shapely.geometry.Polygon or shapely.geometry.MultiPolygon - the shape to get network data within. coordinates should be in - unprojected latitude-longitude degrees (EPSG:4326). - network_type : string {"all", "all_public", "bike", "drive", "drive_service", "walk"} - what type of street network to get if custom_filter is None - simplify : bool - if True, simplify graph topology with the `simplify_graph` function - retain_all : bool - if True, return the entire graph even if it is not connected. - otherwise, retain only the largest weakly connected component. - truncate_by_edge : bool - if True, retain nodes outside boundary polygon if at least one of - node's neighbors is within the polygon - clean_periphery : bool - deprecated, do not use - custom_filter : string - a custom ways filter to be used instead of the network_type presets - e.g., '["power"~"line"]' or '["highway"~"motorway|trunk"]'. Also pass - in a network_type that is in settings.bidirectional_network_types if - you want graph to be fully bi-directional. + polygon + The geometry within which to construct the graph. Coordinates should + be in unprojected latitude-longitude degrees (EPSG:4326). + network_type + {"all", "all_public", "bike", "drive", "drive_service", "walk"} + What type of street network to retrieve if `custom_filter` is None. + simplify + If True, simplify graph topology with the `simplify_graph` function. + retain_all + If True, return the entire graph even if it is not connected. If + False, retain only the largest weakly connected component. + truncate_by_edge + If True, retain nodes outside bounding box if at least one of node's + neighbors is within the bounding box. + custom_filter + A custom ways filter to be used instead of the `network_type` presets, + e.g. `'["power"~"line"]' or '["highway"~"motorway|trunk"]'`. Also pass + in a `network_type` that is in `settings.bidirectional_network_types` + if you want the graph to be fully bidirectional. Returns ------- - G : networkx.MultiDiGraph + G Notes ----- @@ -456,21 +431,10 @@ def graph_from_polygon( function to automatically make multiple requests: see that function's documentation for caveats. """ - if clean_periphery is None: - clean_periphery = True - else: - warn( - "The clean_periphery argument has been deprecated and will be removed in " - "the v2.0.0 release. Future behavior will be as though clean_periphery=True. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - # verify that the geometry is valid and is a shapely Polygon/MultiPolygon # before proceeding if not polygon.is_valid: # pragma: no cover - msg = "The geometry to query within is invalid" + msg = "The geometry of `polygon` is invalid." raise ValueError(msg) if not isinstance(polygon, (Polygon, MultiPolygon)): # pragma: no cover msg = ( @@ -481,124 +445,119 @@ def graph_from_polygon( ) raise TypeError(msg) - if clean_periphery: - # create a new buffered polygon 0.5km around the desired one - buffer_dist = 500 - poly_proj, crs_utm = projection.project_geometry(polygon) - poly_proj_buff = poly_proj.buffer(buffer_dist) - poly_buff, _ = projection.project_geometry(poly_proj_buff, crs=crs_utm, to_latlong=True) + # create a new buffered polygon 0.5km around the desired one + poly_proj, crs_utm = projection.project_geometry(polygon) + poly_proj_buff = poly_proj.buffer(500) + poly_buff, _ = projection.project_geometry(poly_proj_buff, crs=crs_utm, to_latlong=True) - # download the network data from OSM within buffered polygon - response_jsons = _overpass._download_overpass_network( - poly_buff, network_type, custom_filter - ) + # download the network data from OSM within buffered polygon + response_jsons = _overpass._download_overpass_network(poly_buff, network_type, custom_filter) - # create buffered graph from the downloaded data - bidirectional = network_type in settings.bidirectional_network_types - G_buff = _create_graph(response_jsons, retain_all=True, bidirectional=bidirectional) - - # truncate buffered graph to the buffered polygon and retain_all for - # now. needed because overpass returns entire ways that also include - # nodes outside the poly if the way (that is, a way with a single OSM - # ID) has a node inside the poly at some point. - G_buff = truncate.truncate_graph_polygon(G_buff, poly_buff, True, truncate_by_edge) - - # simplify the graph topology - if simplify: - G_buff = simplification.simplify_graph(G_buff) - - # truncate graph by original polygon to return graph within polygon - # caller wants. don't simplify again: this allows us to retain - # intersections along the street that may now only connect 2 street - # segments in the network, but in reality also connect to an - # intersection just outside the polygon - G = truncate.truncate_graph_polygon(G_buff, polygon, retain_all, truncate_by_edge) - - # count how many physical streets in buffered graph connect to each - # intersection in un-buffered graph, to retain true counts for each - # intersection, even if some of its neighbors are outside the polygon - spn = stats.count_streets_per_node(G_buff, nodes=G.nodes) - nx.set_node_attributes(G, values=spn, name="street_count") - - # if clean_periphery=False, just use the polygon as provided - else: - # download the network data from OSM - response_jsons = _overpass._download_overpass_network(polygon, network_type, custom_filter) - - # create graph from the downloaded data - bidirectional = network_type in settings.bidirectional_network_types - G = _create_graph(response_jsons, retain_all=True, bidirectional=bidirectional) - - # truncate the graph to the extent of the polygon - G = truncate.truncate_graph_polygon(G, polygon, retain_all, truncate_by_edge) - - # simplify the graph topology after truncation. don't truncate after - # simplifying or you may have simplified out to an endpoint beyond the - # truncation distance, which would strip out the entire edge - if simplify: - G = simplification.simplify_graph(G) - - # count how many physical streets connect to each intersection/deadend - # note this will be somewhat inaccurate due to periphery effects, so - # it's best to parameterize function with clean_periphery=True - spn = stats.count_streets_per_node(G) - nx.set_node_attributes(G, values=spn, name="street_count") - warn( - "the graph-level street_count attribute will likely be inaccurate " - "when you set clean_periphery=False", - stacklevel=2, - ) + # create buffered graph from the downloaded data + bidirectional = network_type in settings.bidirectional_network_types + G_buff = _create_graph(response_jsons, bidirectional) + + # truncate buffered graph to the buffered polygon and retain_all for + # now. needed because overpass returns entire ways that also include + # nodes outside the poly if the way (that is, a way with a single OSM + # ID) has a node inside the poly at some point. + G_buff = truncate.truncate_graph_polygon(G_buff, poly_buff, truncate_by_edge=truncate_by_edge) + + # keep only the largest weakly connected component if retain_all is False + if not retain_all: + G_buff = truncate.largest_component(G_buff, strongly=False) + + # simplify the graph topology + if simplify: + G_buff = simplification.simplify_graph(G_buff) + + # truncate graph by original polygon to return graph within polygon + # caller wants. don't simplify again: this allows us to retain + # intersections along the street that may now only connect 2 street + # segments in the network, but in reality also connect to an + # intersection just outside the polygon + G = truncate.truncate_graph_polygon(G_buff, polygon, truncate_by_edge=truncate_by_edge) + + # keep only the largest weakly connected component if retain_all is False + # we're doing this again in case the last truncate disconnected anything + # on the periphery + if not retain_all: + G = truncate.largest_component(G, strongly=False) - utils.log(f"graph_from_polygon returned graph with {len(G):,} nodes and {len(G.edges):,} edges") + # count how many physical streets in buffered graph connect to each + # intersection in un-buffered graph, to retain true counts for each + # intersection, even if some of its neighbors are outside the polygon + spn = stats.count_streets_per_node(G_buff, nodes=G.nodes) + nx.set_node_attributes(G, values=spn, name="street_count") + + msg = f"graph_from_polygon returned graph with {len(G):,} nodes and {len(G.edges):,} edges" + utils.log(msg, level=lg.INFO) return G def graph_from_xml( - filepath, bidirectional=False, simplify=True, retain_all=False, encoding="utf-8" -): + filepath: str | Path, + *, + bidirectional: bool = False, + simplify: bool = True, + retain_all: bool = False, + encoding: str = "utf-8", +) -> nx.MultiDiGraph: """ - Create a graph from data in a .osm formatted XML file. + Create a graph from data in an OSM XML file. + + Do not load an XML file previously generated by OSMnx: this use case is + not supported and may not behave as expected. To save/load graphs to/from + disk for later use in OSMnx, use the `io.save_graphml` and + `io.load_graphml` functions instead. - Do not load an XML file generated by OSMnx: this use case is not supported - and may not behave as expected. To save/load graphs to/from disk for later - use in OSMnx, use the `io.save_graphml` and `io.load_graphml` functions - instead. + Use the `settings` module's `useful_tags_node` and `useful_tags_way` + settings to configure which OSM node/way tags are added as graph node/edge + attributes. Parameters ---------- - filepath : string or pathlib.Path - path to file containing OSM XML data - bidirectional : bool - if True, create bi-directional edges for one-way streets - simplify : bool - if True, simplify graph topology with the `simplify_graph` function - retain_all : bool - if True, return the entire graph even if it is not connected. - otherwise, retain only the largest weakly connected component. - encoding : string - the XML file's character encoding + filepath + Path to file containing OSM XML data. + bidirectional + If True, create bidirectional edges for one-way streets. + simplify + If True, simplify graph topology with the `simplify_graph` function. + retain_all + If True, return the entire graph even if it is not connected. If + False, retain only the largest weakly connected component. + encoding + The OSM XML file's character encoding. Returns ------- - G : networkx.MultiDiGraph + G """ # transmogrify file of OSM XML data into JSON - response_jsons = [osm_xml._overpass_json_from_file(filepath, encoding)] + response_jsons = [_osm_xml._overpass_json_from_xml(filepath, encoding)] # create graph using this response JSON - G = _create_graph(response_jsons, bidirectional=bidirectional, retain_all=retain_all) + G = _create_graph(response_jsons, bidirectional) + + # keep only the largest weakly connected component if retain_all is False + if not retain_all: + G = truncate.largest_component(G, strongly=False) # simplify the graph topology as the last step if simplify: G = simplification.simplify_graph(G) - utils.log(f"graph_from_xml returned graph with {len(G):,} nodes and {len(G.edges):,} edges") + msg = f"graph_from_xml returned graph with {len(G):,} nodes and {len(G.edges):,} edges" + utils.log(msg, level=lg.INFO) return G -def _create_graph(response_jsons, retain_all=False, bidirectional=False): +def _create_graph( + response_jsons: Iterable[dict[str, Any]], + bidirectional: bool, # noqa: FBT001 +) -> nx.MultiDiGraph: """ - Create a networkx MultiDiGraph from Overpass API responses. + Create a NetworkX MultiDiGraph from Overpass API responses. Adds length attributes in meters (great-circle distance between endpoints) to all of the graph's (pre-simplified, straight-line) edges via the @@ -606,23 +565,24 @@ def _create_graph(response_jsons, retain_all=False, bidirectional=False): Parameters ---------- - response_jsons : iterable - iterable of dicts of JSON responses from from the Overpass API - retain_all : bool - if True, return the entire graph even if it is not connected. - otherwise, retain only the largest weakly connected component. - bidirectional : bool - if True, create bi-directional edges for one-way streets + response_jsons + Iterable of JSON responses from the Overpass API. + retain_all + If True, return the entire graph even if it is not connected. + Otherwise, retain only the largest weakly connected component. + bidirectional + If True, create bidirectional edges for one-way streets. Returns ------- - G : networkx.MultiDiGraph + G """ - response_count = 0 - nodes = {} - paths = {} + # each dict's keys are OSM IDs and values are dicts of attributes + nodes: dict[int, dict[str, Any]] = {} + paths: dict[int, dict[str, Any]] = {} # consume response_jsons generator to download data from server + response_count = 0 for response_json in response_jsons: response_count += 1 @@ -635,10 +595,11 @@ def _create_graph(response_jsons, retain_all=False, bidirectional=False): nodes.update(nodes_temp) paths.update(paths_temp) - utils.log(f"Retrieved all data from API in {response_count} request(s)") + msg = f"Retrieved all data from API in {response_count} request(s)" + utils.log(msg, level=lg.INFO) if settings.cache_only_mode: # pragma: no cover # after consuming all response_jsons in loop, raise exception to catch - msg = "Interrupted because `settings.cache_only_mode=True`" + msg = "Interrupted because `settings.cache_only_mode=True`." raise CacheOnlyInterruptError(msg) # ensure we got some node/way data back from the server request(s) @@ -655,15 +616,13 @@ def _create_graph(response_jsons, retain_all=False, bidirectional=False): G = nx.MultiDiGraph(**metadata) # add each OSM node and way (a path of edges) to the graph - utils.log(f"Creating graph from {len(nodes):,} OSM nodes and {len(paths):,} OSM ways...") + msg = f"Creating graph from {len(nodes):,} OSM nodes and {len(paths):,} OSM ways..." + utils.log(msg, level=lg.INFO) G.add_nodes_from(nodes.items()) _add_paths(G, paths.values(), bidirectional) - # retain only the largest connected component if retain_all=False - if not retain_all: - G = truncate.largest_component(G) - - utils.log(f"Created graph with {len(G):,} nodes and {len(G.edges):,} edges") + msg = f"Created graph with {len(G):,} nodes and {len(G.edges):,} edges" + utils.log(msg, level=lg.INFO) # add length (great-circle distance between nodes) attribute to each edge if len(G.edges) > 0: @@ -672,18 +631,18 @@ def _create_graph(response_jsons, retain_all=False, bidirectional=False): return G -def _convert_node(element): +def _convert_node(element: dict[str, Any]) -> dict[str, Any]: """ - Convert an OSM node element into the format for a networkx node. + Convert an OSM node element into the format for a NetworkX node. Parameters ---------- - element : dict - an OSM node element + element + OSM element of type "node". Returns ------- - node : dict + node """ node = {"y": element["lat"], "x": element["lon"]} if "tags" in element: @@ -693,23 +652,23 @@ def _convert_node(element): return node -def _convert_path(element): +def _convert_path(element: dict[str, Any]) -> dict[str, Any]: """ - Convert an OSM way element into the format for a networkx path. + Convert an OSM way element into the format for a NetworkX path. Parameters ---------- - element : dict - an OSM way element + element + OSM element of type "way". Returns ------- - path : dict + path """ path = {"osmid": element["id"]} # remove any consecutive duplicate elements in the list of nodes - path["nodes"] = [group[0] for group in itertools.groupby(element["nodes"])] + path["nodes"] = [group[0] for group in groupby(element["nodes"])] if "tags" in element: for useful_tag in settings.useful_tags_way: @@ -718,19 +677,21 @@ def _convert_path(element): return path -def _parse_nodes_paths(response_json): +def _parse_nodes_paths( + response_json: dict[str, Any], +) -> tuple[dict[int, dict[str, Any]], dict[int, dict[str, Any]]]: """ Construct dicts of nodes and paths from an Overpass response. Parameters ---------- - response_json : dict - JSON response from the Overpass API + response_json + JSON response from the Overpass API. Returns ------- - nodes, paths : tuple of dicts - dicts' keys = osmid and values = dict of attributes + nodes, paths + Each dict's keys are OSM IDs and values are dicts of attributes. """ nodes = {} paths = {} @@ -743,22 +704,22 @@ def _parse_nodes_paths(response_json): return nodes, paths -def _is_path_one_way(path, bidirectional, oneway_values): +def _is_path_one_way(attrs: dict[str, Any], bidirectional: bool, oneway_values: set[str]) -> bool: # noqa: FBT001 """ Determine if a path of nodes allows travel in only one direction. Parameters ---------- - path : dict - a path's `tag:value` attribute data - bidirectional : bool - whether this is a bi-directional network type - oneway_values : set - the values OSM uses in its 'oneway' tag to denote True + attrs + A path's `tag:value` attribute data. + bidirectional + Whether this is a bidirectional network type. + oneway_values + The values OSM uses in its "oneway" tag to denote True. Returns ------- - bool + is_one_way """ # rule 1 if settings.all_oneway: @@ -767,22 +728,22 @@ def _is_path_one_way(path, bidirectional, oneway_values): # rule 2 if bidirectional: - # if this is a bi-directional network type, then nothing in it is + # if this is a bidirectional network type, then nothing in it is # considered one-way. eg, if this is a walking network, this may very # well be a one-way street (as cars/bikes go), but in a walking-only - # network it is a bi-directional edge (you can walk both directions on + # network it is a bidirectional edge (you can walk both directions on # a one-way street). so we will add this path (in both directions) to # the graph and set its oneway attribute to False. return False # rule 3 - if "oneway" in path and path["oneway"] in oneway_values: - # if this path is tagged as one-way and if it is not a bi-directional + if "oneway" in attrs and attrs["oneway"] in oneway_values: + # if this path is tagged as one-way and if it is not a bidirectional # network type then we'll add the path in one direction only return True # rule 4 - if "junction" in path and path["junction"] == "roundabout": + if "junction" in attrs and attrs["junction"] == "roundabout": # roundabouts are also one-way but are not explicitly tagged as such return True @@ -790,37 +751,41 @@ def _is_path_one_way(path, bidirectional, oneway_values): return False -def _is_path_reversed(path, reversed_values): +def _is_path_reversed(attrs: dict[str, Any], reversed_values: set[str]) -> bool: """ Determine if the order of nodes in a path should be reversed. Parameters ---------- - path : dict - a path's `tag:value` attribute data - reversed_values : set - the values OSM uses in its 'oneway' tag to denote travel can only - occur in the opposite direction of the node order + attrs + A path's `tag:value` attribute data. + reversed_values + The values OSM uses in its 'oneway' tag to denote travel can only + occur in the opposite direction of the node order. Returns ------- - bool + is_reversed """ - return "oneway" in path and path["oneway"] in reversed_values + return "oneway" in attrs and attrs["oneway"] in reversed_values -def _add_paths(G, paths, bidirectional=False): +def _add_paths( + G: nx.MultiDiGraph, + paths: Iterable[dict[str, Any]], + bidirectional: bool, # noqa: FBT001 +) -> None: """ - Add a list of paths to the graph as edges. + Add OSM paths to the graph as edges. Parameters ---------- - G : networkx.MultiDiGraph - graph to add paths to - paths : list - list of paths' `tag:value` attribute data dicts - bidirectional : bool - if True, create bi-directional edges for one-way streets + G + The graph to add paths to. + paths + Iterable of paths' `tag:value` attribute data dicts. + bidirectional + If True, create bidirectional edges for one-way streets. Returns ------- diff --git a/osmnx/io.py b/osmnx/io.py index 12ca82fbe..4e37b0f06 100644 --- a/osmnx/io.py +++ b/osmnx/io.py @@ -1,37 +1,50 @@ -"""Serialize graphs to/from files on disk.""" +"""File I/O functions to save/load graphs to/from files on disk.""" + +from __future__ import annotations import ast import contextlib +import logging as lg from pathlib import Path -from warnings import warn +from typing import TYPE_CHECKING +from typing import Any import networkx as nx import pandas as pd from shapely import wkt +from . import _osm_xml from . import convert -from . import osm_xml from . import settings from . import utils +if TYPE_CHECKING: + import geopandas as gpd + -def save_graph_geopackage(G, filepath=None, encoding="utf-8", directed=False): +def save_graph_geopackage( + G: nx.MultiDiGraph, + filepath: str | Path | None = None, + *, + directed: bool = False, + encoding: str = "utf-8", +) -> None: """ Save graph nodes and edges to disk as layers in a GeoPackage file. Parameters ---------- - G : networkx.MultiDiGraph - input graph - filepath : string or pathlib.Path - path to the GeoPackage file including extension. if None, use default - data folder + graph.gpkg - encoding : string - the character encoding for the saved file - directed : bool - if False, save one edge for each undirected edge in the graph but - retain original oneway and to/from information as edge attributes; if - True, save one edge for each directed edge in the graph + G + The graph to save. + filepath + Path to the GeoPackage file including extension. If None, use default + `settings.data_folder/graph.gpkg`. + directed + If False, save one edge for each undirected edge in the graph but + retain original oneway and to/from information as edge attributes. If + True, save one edge for each directed edge in the graph. + encoding + The character encoding of the saved GeoPackage file. Returns ------- @@ -54,85 +67,33 @@ def save_graph_geopackage(G, filepath=None, encoding="utf-8", directed=False): # save the nodes and edges as GeoPackage layers gdf_nodes.to_file(filepath, layer="nodes", driver="GPKG", index=True, encoding=encoding) gdf_edges.to_file(filepath, layer="edges", driver="GPKG", index=True, encoding=encoding) - utils.log(f"Saved graph as GeoPackage at {filepath!r}") - - -def save_graph_shapefile(G, filepath=None, encoding="utf-8", directed=False): - """ - Do not use: deprecated. Use the save_graph_geopackage function instead. - - The Shapefile format is proprietary and outdated. Instead, use the - superior GeoPackage file format via the save_graph_geopackage function. - See http://switchfromshapefile.org/ for more information. - - Parameters - ---------- - G : networkx.MultiDiGraph - input graph - filepath : string or pathlib.Path - path to the shapefiles folder (no file extension). if None, use - default data folder + graph_shapefile - encoding : string - the character encoding for the saved files - directed : bool - if False, save one edge for each undirected edge in the graph but - retain original oneway and to/from information as edge attributes; if - True, save one edge for each directed edge in the graph - - Returns - ------- - None - """ - warn( - "The `save_graph_shapefile` function is deprecated and will be removed " - "in the v2.0.0 release. Instead, use the `save_graph_geopackage` function " - "to save graphs as GeoPackage files for subsequent GIS analysis. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - - # default filepath if none was provided - filepath = ( - Path(settings.data_folder) / "graph_shapefile" if filepath is None else Path(filepath) - ) - - # if save folder does not already exist, create it (shapefiles - # get saved as set of files) - filepath.mkdir(parents=True, exist_ok=True) - filepath_nodes = filepath / "nodes.shp" - filepath_edges = filepath / "edges.shp" - # convert graph to gdfs and stringify non-numeric columns - if directed: - gdf_nodes, gdf_edges = convert.graph_to_gdfs(G) - else: - gdf_nodes, gdf_edges = convert.graph_to_gdfs(convert.to_undirected(G)) - gdf_nodes = _stringify_nonnumeric_cols(gdf_nodes) - gdf_edges = _stringify_nonnumeric_cols(gdf_edges) + msg = f"Saved graph as GeoPackage at {filepath!r}" + utils.log(msg, level=lg.INFO) - # save the nodes and edges as separate ESRI shapefiles - gdf_nodes.to_file(filepath_nodes, driver="ESRI Shapefile", index=True, encoding=encoding) - gdf_edges.to_file(filepath_edges, driver="ESRI Shapefile", index=True, encoding=encoding) - utils.log(f"Saved graph as shapefiles at {filepath!r}") - -def save_graphml(G, filepath=None, gephi=False, encoding="utf-8"): +def save_graphml( + G: nx.MultiDiGraph, + filepath: str | Path | None = None, + *, + gephi: bool = False, + encoding: str = "utf-8", +) -> None: """ Save graph to disk as GraphML file. Parameters ---------- - G : networkx.MultiDiGraph - input graph - filepath : string or pathlib.Path - path to the GraphML file including extension. if None, use default - data folder + graph.graphml - gephi : bool - if True, give each edge a unique key/id to work around Gephi's - interpretation of the GraphML specification - encoding : string - the character encoding for the saved file + G + The graph to save as. + filepath + Path to the GraphML file including extension. If None, use default + `settings.data_folder/graph.graphml`. + gephi + If True, give each edge a unique key/id for compatibility with Gephi's + interpretation of the GraphML specification. + encoding + The character encoding of the saved GraphML file. Returns ------- @@ -168,12 +129,18 @@ def save_graphml(G, filepath=None, gephi=False, encoding="utf-8"): data[attr] = str(value) nx.write_graphml(G, path=filepath, encoding=encoding) - utils.log(f"Saved graph as GraphML file at {filepath!r}") + msg = f"Saved graph as GraphML file at {filepath!r}" + utils.log(msg, level=lg.INFO) def load_graphml( - filepath=None, graphml_str=None, node_dtypes=None, edge_dtypes=None, graph_dtypes=None -): + filepath: str | Path | None = None, + *, + graphml_str: str | None = None, + node_dtypes: dict[str, Any] | None = None, + edge_dtypes: dict[str, Any] | None = None, + graph_dtypes: dict[str, Any] | None = None, +) -> nx.MultiDiGraph: """ Load an OSMnx-saved GraphML file from disk or GraphML string. @@ -194,24 +161,23 @@ def load_graphml( Parameters ---------- - filepath : string or pathlib.Path - path to the GraphML file - graphml_str : string - a valid and decoded string representation of a GraphML file's contents - node_dtypes : dict - dict of node attribute names:types to convert values' data types. the - type can be a python type or a custom string converter function. - edge_dtypes : dict - dict of edge attribute names:types to convert values' data types. the - type can be a python type or a custom string converter function. - graph_dtypes : dict - dict of graph-level attribute names:types to convert values' data - types. the type can be a python type or a custom string converter - function. + filepath + Path to the GraphML file. + graphml_str + Valid and decoded string representation of a GraphML file's contents. + node_dtypes + Dict of node attribute names:types to convert values' data types. The + type can be a type or a custom string converter function. + edge_dtypes + Dict of edge attribute names:types to convert values' data types. The + type can be a type or a custom string converter function. + graph_dtypes + Dict of graph-level attribute names:types to convert values' data + types. The type can be a type or a custom string converter function. Returns ------- - G : networkx.MultiDiGraph + G """ if (filepath is None and graphml_str is None) or ( filepath is not None and graphml_str is not None @@ -224,8 +190,6 @@ def load_graphml( default_node_dtypes = { "elevation": float, "elevation_res": float, - "lat": float, - "lon": float, "osmid": int, "street_count": int, "x": float, @@ -255,116 +219,96 @@ def load_graphml( # read the graphml file from disk source = filepath G = nx.read_graphml( - Path(filepath), node_type=default_node_dtypes["osmid"], force_multigraph=True + Path(filepath), + node_type=default_node_dtypes["osmid"], + force_multigraph=True, ) else: # parse the graphml string source = "string" G = nx.parse_graphml( - graphml_str, node_type=default_node_dtypes["osmid"], force_multigraph=True + graphml_str, + node_type=default_node_dtypes["osmid"], + force_multigraph=True, ) # convert graph/node/edge attribute data types - utils.log("Converting node, edge, and graph-level attribute data types") + msg = "Converting node, edge, and graph-level attribute data types" + utils.log(msg, level=lg.INFO) G = _convert_graph_attr_types(G, default_graph_dtypes) G = _convert_node_attr_types(G, default_node_dtypes) G = _convert_edge_attr_types(G, default_edge_dtypes) - utils.log(f"Loaded graph with {len(G)} nodes and {len(G.edges)} edges from {source!r}") + msg = f"Loaded graph with {len(G)} nodes and {len(G.edges)} edges from {source!r}" + utils.log(msg, level=lg.INFO) return G def save_graph_xml( - data, - filepath=None, - node_tags=None, - node_attrs=None, - edge_tags=None, - edge_attrs=None, - oneway=None, - merge_edges=None, - edge_tag_aggs=None, - api_version=None, - precision=None, - way_tag_aggs=None, -): + G: nx.MultiDiGraph, + filepath: str | Path | None = None, + *, + way_tag_aggs: dict[str, Any] | None = None, + encoding: str = "utf-8", +) -> None: """ - Save graph to disk as an OSM-formatted XML .osm file. + Save graph to disk as an OSM XML file. - This function exists only to allow serialization to the .osm file format + This function exists only to allow serialization to the OSM XML format for applications that require it, and has constraints to conform to that. - As such, this function has a limited use case which does not include - saving/loading graphs for subsequent OSMnx analysis. To save/load graphs - to/from disk for later use in OSMnx, use the `io.save_graphml` and - `io.load_graphml` functions instead. To load a graph from a .osm file that - you have downloaded or generated elsewhere, use the `graph.graph_from_xml` + As such, it has a limited use case which does not include saving/loading + graphs for subsequent OSMnx analysis. To save/load graphs to/from disk for + later use in OSMnx, use the `io.save_graphml` and `io.load_graphml` + functions instead. To load a graph from an OSM XML file that you have + downloaded or generated elsewhere, use the `graph.graph_from_xml` function. + Use the `settings` module's `useful_tags_node` and `useful_tags_way` + settings to configure which tags your graph is created and saved with. + This function merges graph edges such that each OSM way has one entry in + the XML output, with the way's nodes topologically sorted. `G` must be + unsimplified to save as OSM XML: otherwise, one edge could comprise + multiple OSM ways, making it impossible to group and sort edges in way. + `G` should also have been created with `ox.settings.all_oneway=True` for + this function to behave properly. + Parameters ---------- - data : networkx.MultiDiGraph - the input graph - filepath : string or pathlib.Path - do not use, deprecated - node_tags : list - do not use, deprecated - node_attrs: list - do not use, deprecated - edge_tags : list - do not use, deprecated - edge_attrs : list - do not use, deprecated - oneway : bool - do not use, deprecated - merge_edges : bool - do not use, deprecated - edge_tag_aggs : tuple - do not use, deprecated - api_version : float - do not use, deprecated - precision : int - do not use, deprecated - way_tag_aggs : dict + G + Unsimplified, unprojected graph to save as an OSM XML file. + filepath + Path to the saved file including extension. If None, use default + `settings.data_folder/graph.osm`. + way_tag_aggs Keys are OSM way tag keys and values are aggregation functions (anything accepted as an argument by pandas.agg). Allows user to aggregate graph edge attribute values into single OSM way values. If None, or if some tag's key does not exist in the dict, the way attribute will be assigned the value of the first edge of the way. + encoding + The character encoding of the saved OSM XML file. Returns ------- None """ - osm_xml._save_graph_xml( - data, - filepath, - node_tags, - node_attrs, - edge_tags, - edge_attrs, - oneway, - merge_edges, - edge_tag_aggs, - api_version, - precision, - way_tag_aggs, - ) - - -def _convert_graph_attr_types(G, dtypes=None): + _osm_xml._save_graph_xml(G, filepath, way_tag_aggs, encoding) + + +def _convert_graph_attr_types(G: nx.MultiDiGraph, dtypes: dict[str, Any]) -> nx.MultiDiGraph: """ Convert graph-level attributes using a dict of data types. Parameters ---------- - G : networkx.MultiDiGraph - input graph - dtypes : dict - dict of graph-level attribute names:types + G + Graph to convert the graph-level attributes of. + dtypes + Dict of graph-level attribute names:types. Returns ------- - G : networkx.MultiDiGraph + G """ # remove node_default and edge_default metadata keys if they exist G.graph.pop("node_default", None) @@ -376,20 +320,20 @@ def _convert_graph_attr_types(G, dtypes=None): return G -def _convert_node_attr_types(G, dtypes=None): +def _convert_node_attr_types(G: nx.MultiDiGraph, dtypes: dict[str, Any]) -> nx.MultiDiGraph: """ Convert graph nodes' attributes using a dict of data types. Parameters ---------- - G : networkx.MultiDiGraph - input graph - dtypes : dict - dict of node attribute names:types + G + Graph to convert the node attributes of. + dtypes + Dict of node attribute names:types. Returns ------- - G : networkx.MultiDiGraph + G """ for _, data in G.nodes(data=True): # first, eval stringified lists, dicts, or sets to convert them to objects @@ -406,20 +350,20 @@ def _convert_node_attr_types(G, dtypes=None): return G -def _convert_edge_attr_types(G, dtypes=None): +def _convert_edge_attr_types(G: nx.MultiDiGraph, dtypes: dict[str, Any]) -> nx.MultiDiGraph: """ Convert graph edges' attributes using a dict of data types. Parameters ---------- - G : networkx.MultiDiGraph - input graph - dtypes : dict - dict of edge attribute names:types + G + Graph to convert the edge attributes of. + dtypes + Dict of edge attribute names:types. Returns ------- - G : networkx.MultiDiGraph + G """ # for each edge in the graph, eval attribute value lists and convert types for _, _, data in G.edges(data=True, keys=False): @@ -452,7 +396,7 @@ def _convert_edge_attr_types(G, dtypes=None): return G -def _convert_bool_string(value): +def _convert_bool_string(value: bool | str) -> bool: """ Convert a "True" or "False" string literal to corresponding boolean type. @@ -465,25 +409,25 @@ def _convert_bool_string(value): Parameters ---------- - value : string {"True", "False"} - the value to convert + value + The string value to convert to bool. Returns ------- - bool + bool_value """ - if value in {"True", "False"}: - return value == "True" - if isinstance(value, bool): return value + if value in {"True", "False"}: + return value == "True" + # otherwise the value is not a valid boolean - msg = f"invalid literal for boolean: {value!r}" + msg = f"Invalid literal for boolean: {value!r}." raise ValueError(msg) -def _stringify_nonnumeric_cols(gdf): +def _stringify_nonnumeric_cols(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame: """ Make every non-numeric GeoDataFrame column (besides geometry) a string. @@ -492,13 +436,13 @@ def _stringify_nonnumeric_cols(gdf): Parameters ---------- - gdf : geopandas.GeoDataFrame - gdf to stringify non-numeric columns of + gdf + GeoDataFrame to stringify non-numeric columns of. Returns ------- - gdf : geopandas.GeoDataFrame - gdf with non-numeric columns stringified + gdf + GeoDataFrame with non-numeric columns stringified. """ # stringify every non-numeric column other than geometry column for col in (c for c in gdf.columns if c != "geometry"): diff --git a/osmnx/osm_xml.py b/osmnx/osm_xml.py deleted file mode 100644 index a54ac7205..000000000 --- a/osmnx/osm_xml.py +++ /dev/null @@ -1,686 +0,0 @@ -"""Read/write .osm formatted XML files.""" - -import bz2 -import xml.sax -from pathlib import Path -from warnings import warn -from xml.etree import ElementTree as ET - -import networkx as nx -import numpy as np -import pandas as pd - -from . import convert -from . import settings -from . import truncate -from . import utils -from ._version import __version__ - - -class _OSMContentHandler(xml.sax.handler.ContentHandler): - """ - SAX content handler for OSM XML. - - Used to build an Overpass-like response JSON object in self.object. For - format notes, see https://wiki.openstreetmap.org/wiki/OSM_XML and - https://overpass-api.de - """ - - def __init__(self): - self._element = None - self.object = {"elements": []} - - def startElement(self, name, attrs): - if name == "osm": - self.object.update({k: v for k, v in attrs.items() if k in {"version", "generator"}}) - - elif name in {"node", "way"}: - self._element = dict(type=name, tags={}, nodes=[], **attrs) - self._element.update({k: float(v) for k, v in attrs.items() if k in {"lat", "lon"}}) - self._element.update( - {k: int(v) for k, v in attrs.items() if k in {"id", "uid", "version", "changeset"}} - ) - - elif name == "relation": - self._element = dict(type=name, tags={}, members=[], **attrs) - self._element.update( - {k: int(v) for k, v in attrs.items() if k in {"id", "uid", "version", "changeset"}} - ) - - elif name == "tag": - self._element["tags"].update({attrs["k"]: attrs["v"]}) - - elif name == "nd": - self._element["nodes"].append(int(attrs["ref"])) - - elif name == "member": - self._element["members"].append( - {k: (int(v) if k == "ref" else v) for k, v in attrs.items()} - ) - - def endElement(self, name): - if name in {"node", "way", "relation"}: - self.object["elements"].append(self._element) - - -def _overpass_json_from_file(filepath, encoding): - """ - Read OSM XML from file and return Overpass-like JSON. - - Parameters - ---------- - filepath : string or pathlib.Path - path to file containing OSM XML data - encoding : string - the XML file's character encoding - - Returns - ------- - OSMContentHandler object - """ - - # open the XML file, handling bz2 or regular XML - def _opener(filepath, encoding): - if filepath.suffix == ".bz2": - return bz2.open(filepath, mode="rt", encoding=encoding) - - # otherwise just open it if it's not bz2 - return filepath.open(encoding=encoding) - - # warn if this XML file was generated by OSMnx itself - with _opener(Path(filepath), encoding) as f: - root_attrs = ET.parse(f).getroot().attrib - if "generator" in root_attrs and "OSMnx" in root_attrs["generator"]: - warn( - "The XML file you are loading appears to have been generated " - "by OSMnx: this use case is not supported and may not behave " - "as expected. To save/load graphs to/from disk for later use " - "in OSMnx, use the `io.save_graphml` and `io.load_graphml` " - "functions instead. Refer to the documentation for details.", - stacklevel=2, - ) - - # parse the XML to Overpass-like JSON - with _opener(Path(filepath), encoding) as f: - handler = _OSMContentHandler() - xml.sax.parse(f, handler) - return handler.object - - -def save_graph_xml( - data, - filepath=None, - node_tags=None, - node_attrs=None, - edge_tags=None, - edge_attrs=None, - oneway=None, - merge_edges=None, - edge_tag_aggs=None, - api_version=None, - precision=None, - way_tag_aggs=None, -): - """ - Do not use: deprecated. - - The `save_graph_xml` has moved from the `osm_xml` module to the `io` - module. `osm_xml.save_graph_xml` has been deprecated and will be removed - in the v2.0.0 release. Access the function via the `io` module instead. - - Parameters - ---------- - data : networkx.MultiDiGraph - do not use, deprecated - filepath : string or pathlib.Path - do not use, deprecated - node_tags : list - do not use, deprecated - node_attrs: list - do not use, deprecated - edge_tags : list - do not use, deprecated - edge_attrs : list - do not use, deprecated - oneway : bool - do not use, deprecated - merge_edges : bool - do not use, deprecated - edge_tag_aggs : tuple - do not use, deprecated - api_version : float - do not use, deprecated - precision : int - do not use, deprecated - way_tag_aggs : dict - do not use, deprecated - - Returns - ------- - None - """ - warn( - "The save_graph_xml function has moved from the osm_xml module to the io module. " - "osm_xml.save_graph_xml has been deprecated and will be removed in the v2.0.0 " - "release. Access the function via the io module instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - _save_graph_xml( - data, - filepath, - node_tags, - node_attrs, - edge_tags, - edge_attrs, - oneway, - merge_edges, - edge_tag_aggs, - api_version, - precision, - way_tag_aggs, - ) - - -def _save_graph_xml( # noqa: C901 - data, - filepath, - node_tags, - node_attrs, - edge_tags, - edge_attrs, - oneway, - merge_edges, - edge_tag_aggs, - api_version, - precision, - way_tag_aggs, -): - """ - Save graph to disk as an OSM-formatted UTF-8 encoded XML .osm file. - - Parameters - ---------- - data : networkx.MultiDiGraph - the input graph - filepath : string or pathlib.Path - do not use, deprecated - node_tags : list - do not use, deprecated - node_attrs: list - do not use, deprecated - edge_tags : list - do not use, deprecated - edge_attrs : list - do not use, deprecated - oneway : bool - do not use, deprecated - merge_edges : bool - do not use, deprecated - edge_tag_aggs : tuple - do not use, deprecated - api_version : float - do not use, deprecated - precision : int - do not use, deprecated - way_tag_aggs : dict - Keys are OSM way tag keys and values are aggregation functions - (anything accepted as an argument by pandas.agg). Allows user to - aggregate graph edge attribute values into single OSM way values. If - None, or if some tag's key does not exist in the dict, the way - attribute will be assigned the value of the first edge of the way. - - Returns - ------- - None - """ - if settings.osm_xml_node_attrs is None: - osm_xml_node_attrs = [ - "id", - "timestamp", - "uid", - "user", - "version", - "changeset", - "lat", - "lon", - ] - else: - osm_xml_node_attrs = settings.osm_xml_node_attrs - msg = ( - "`settings.osm_xml_node_attrs` is deprecated and will be removed " - "in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - - if settings.osm_xml_node_tags is None: - osm_xml_node_tags = ["highway"] - else: - osm_xml_node_tags = settings.osm_xml_node_tags - msg = ( - "`settings.osm_xml_node_tags` is deprecated and will be removed " - "in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - - if settings.osm_xml_way_attrs is None: - osm_xml_way_attrs = ["id", "timestamp", "uid", "user", "version", "changeset"] - else: - osm_xml_way_attrs = settings.osm_xml_way_attrs - msg = ( - "`settings.osm_xml_way_attrs` is deprecated and will be removed " - "in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - - if settings.osm_xml_way_tags is None: - osm_xml_way_tags = ["highway", "lanes", "maxspeed", "name", "oneway"] - else: - osm_xml_way_tags = settings.osm_xml_way_tags - msg = ( - "`settings.osm_xml_way_tags` is deprecated and will be removed in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - - if node_tags is None: - node_tags = osm_xml_node_tags - else: - msg = ( - "the `node_tags` parameter is deprecated and will be removed in the v2.0.0 release: " - "use `settings.useful_tags_node` instead starting in v2.0.0. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - - if node_attrs is None: - node_attrs = osm_xml_node_attrs - else: - msg = ( - "the `node_attrs` parameter is deprecated and will be removed in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - - if edge_tags is None: - edge_tags = osm_xml_way_tags - else: - msg = ( - "the `edge_tags` parameter is deprecated and will be removed in the v2.0.0 release: " - "use `settings.useful_tags_way` instead starting in v2.0.0. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - - if edge_attrs is None: - edge_attrs = osm_xml_way_attrs - else: - msg = ( - "the `edge_attrs` parameter is deprecated and will be removed in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - - if oneway is None: - oneway = False - else: - msg = ( - "the `oneway` parameter is deprecated and will be removed in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - - if merge_edges is None: - merge_edges = True - else: - msg = ( - "the `merge_edges` parameter is deprecated and will be removed in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - - if edge_tag_aggs is None: - if way_tag_aggs is not None: - edge_tag_aggs = way_tag_aggs.items() - else: - msg = ( - "the `edge_tag_aggs` parameter is deprecated and will be removed in the v2.0.0 release: " - "use `way_tag_aggs` instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - - if api_version is None: - api_version = 0.6 - else: - msg = ( - "the `api_version` parameter is deprecated and will be removed in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - - if precision is None: - precision = 6 - else: - msg = ( - "the `precision` parameter is deprecated and will be removed in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - - if not isinstance(data, nx.MultiDiGraph): - msg = ( - "the graph to save as XML must be of type MultiDiGraph, starting in v2.0.0. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - elif data.graph.get("simplified", False): - msg = ( - "starting in v2.0.0, graph must be unsimplified to save as OSM XML. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - - # default filepath if none was provided - filepath = Path(settings.data_folder) / "graph.osm" if filepath is None else Path(filepath) - - # if save folder does not already exist, create it - filepath.parent.mkdir(parents=True, exist_ok=True) - - if not settings.all_oneway: # pragma: no cover - warn( - "For the `save_graph_xml` function to behave properly, the graph " - "must have been created with `ox.settings.all_oneway=True`.", - stacklevel=2, - ) - - try: - gdf_nodes, gdf_edges = data - except ValueError: - gdf_nodes, gdf_edges = convert.graph_to_gdfs( - data, node_geometry=False, fill_edge_geometry=False - ) - - # rename columns per osm specification - gdf_nodes = gdf_nodes.rename(columns={"x": "lon", "y": "lat"}) - gdf_nodes["lon"] = gdf_nodes["lon"].round(precision) - gdf_nodes["lat"] = gdf_nodes["lat"].round(precision) - gdf_nodes = gdf_nodes.reset_index().rename(columns={"osmid": "id"}) - if "id" in gdf_edges.columns: - gdf_edges = gdf_edges[[col for col in gdf_edges if col != "id"]] - if "uniqueid" in gdf_edges.columns: - gdf_edges = gdf_edges.rename(columns={"uniqueid": "id"}) - else: - gdf_edges = gdf_edges.reset_index().reset_index().rename(columns={"index": "id"}) - - # add default values for required attributes - for table in (gdf_nodes, gdf_edges): - table["uid"] = "1" - table["user"] = "OSMnx" - table["version"] = "1" - table["changeset"] = "1" - table["timestamp"] = utils.ts(template="{:%Y-%m-%dT%H:%M:%SZ}") - - # misc. string replacements to meet OSM XML spec - if "oneway" in gdf_edges.columns: - # fill blank oneway tags with default (False) - gdf_edges.loc[pd.isna(gdf_edges["oneway"]), "oneway"] = oneway - gdf_edges.loc[:, "oneway"] = gdf_edges["oneway"].astype(str) - gdf_edges.loc[:, "oneway"] = ( - gdf_edges["oneway"].str.replace("False", "no").replace("True", "yes") - ) - - # initialize XML tree with an OSM root element then append nodes/edges - root = ET.Element( - "osm", attrib={"version": str(api_version), "generator": f"OSMnx {__version__}"} - ) - root = _append_nodes_xml_tree(root, gdf_nodes, node_attrs, node_tags) - root = _append_edges_xml_tree( - root, gdf_edges, edge_attrs, edge_tags, edge_tag_aggs, merge_edges - ) - - # write to disk - ET.ElementTree(root).write(filepath, encoding="utf-8", xml_declaration=True) - utils.log(f"Saved graph as .osm file at {filepath!r}") - - -def _append_nodes_xml_tree(root, gdf_nodes, node_attrs, node_tags): - """ - Append nodes to an XML tree. - - Parameters - ---------- - root : ElementTree.Element - xml tree - gdf_nodes : geopandas.GeoDataFrame - GeoDataFrame of graph nodes - node_attrs : list - osm way attributes to include in output OSM XML - node_tags : list - osm way tags to include in output OSM XML - - Returns - ------- - root : ElementTree.Element - xml tree with nodes appended - """ - for _, row in gdf_nodes.iterrows(): - row_str = row.dropna().astype(str) - node = ET.SubElement(root, "node", attrib=row_str[node_attrs].to_dict()) - - for tag in node_tags: - if tag in row_str: - ET.SubElement(node, "tag", attrib={"k": tag, "v": row_str[tag]}) - return root - - -def _create_way_for_each_edge(root, gdf_edges, edge_attrs, edge_tags): - """ - Append a new way to an empty XML tree graph for each edge in way. - - This will generate separate OSM ways for each network edge, even if the - edges are all part of the same original OSM way. As such, each way will be - composed of two nodes, and there will be many ways with the same OSM ID. - This does not conform to the OSM XML schema standard, but the data will - still comprise a valid network and will be readable by most OSM tools. - - Parameters - ---------- - root : ElementTree.Element - an empty XML tree - gdf_edges : geopandas.GeoDataFrame - GeoDataFrame of graph edges - edge_attrs : list - osm way attributes to include in output OSM XML - edge_tags : list - osm way tags to include in output OSM XML - """ - for _, row in gdf_edges.iterrows(): - row_str = row.dropna().astype(str) - edge = ET.SubElement(root, "way", attrib=row_str[edge_attrs].to_dict()) - ET.SubElement(edge, "nd", attrib={"ref": row_str["u"]}) - ET.SubElement(edge, "nd", attrib={"ref": row_str["v"]}) - for tag in edge_tags: - if tag in row_str: - ET.SubElement(edge, "tag", attrib={"k": tag, "v": row_str[tag]}) - - -def _append_merged_edge_attrs(xml_edge, sample_edge, all_edges_df, edge_tags, edge_tag_aggs): - """ - Extract edge attributes and append to XML edge. - - Parameters - ---------- - xml_edge : ElementTree.SubElement - XML representation of an output graph edge - sample_edge: pandas.Series - sample row from the the dataframe of way edges - all_edges_df: pandas.DataFrame - a dataframe with one row for each edge in an OSM way - edge_tags : list - osm way tags to include in output OSM XML - edge_tag_aggs : list of length-2 string tuples - useful only if merge_edges is True, this argument allows the user to - specify edge attributes to aggregate such that the merged OSM way - entry tags accurately represent the sum total of their component edge - attributes. For example if the user wants the OSM way to have a length - attribute, the user must specify `edge_tag_aggs=[('length', 'sum')]` - to tell this method to aggregate the lengths of the individual - component edges. Otherwise, the length attribute will simply reflect - the length of the first edge associated with the way. - - """ - if edge_tag_aggs is None: - for tag in edge_tags: - if tag in sample_edge: - ET.SubElement(xml_edge, "tag", attrib={"k": tag, "v": sample_edge[tag]}) - else: - for tag in edge_tags: - if (tag in sample_edge) and (tag not in (t for t, agg in edge_tag_aggs)): - ET.SubElement(xml_edge, "tag", attrib={"k": tag, "v": sample_edge[tag]}) - - for tag, agg in edge_tag_aggs: - if tag in all_edges_df.columns: - ET.SubElement( - xml_edge, - "tag", - attrib={ - "k": tag, - "v": str(all_edges_df[tag].aggregate(agg)), - }, - ) - - -def _append_nodes_as_edge_attrs(xml_edge, sample_edge, all_edges_df): - """ - Extract list of ordered nodes and append as attributes of XML edge. - - Parameters - ---------- - xml_edge : ElementTree.SubElement - XML representation of an output graph edge - sample_edge: pandas.Series - sample row from the the dataframe of way edges - all_edges_df: pandas.DataFrame - a dataframe with one row for each edge in an OSM way - """ - if len(all_edges_df) == 1: - ET.SubElement(xml_edge, "nd", attrib={"ref": sample_edge["u"]}) - ET.SubElement(xml_edge, "nd", attrib={"ref": sample_edge["v"]}) - else: - # topological sort - all_edges_df = all_edges_df.reset_index() - try: - ordered_nodes = _get_unique_nodes_ordered_from_way(all_edges_df) - except nx.NetworkXUnfeasible: - first_node = all_edges_df.iloc[0]["u"] - ordered_nodes = _get_unique_nodes_ordered_from_way(all_edges_df.iloc[1:]) - ordered_nodes = [first_node] + ordered_nodes - for node in ordered_nodes: - ET.SubElement(xml_edge, "nd", attrib={"ref": str(node)}) - - -def _append_edges_xml_tree(root, gdf_edges, edge_attrs, edge_tags, edge_tag_aggs, merge_edges): - """ - Append edges to an XML tree. - - Parameters - ---------- - root : ElementTree.Element - xml tree - gdf_edges : geopandas.GeoDataFrame - GeoDataFrame of graph edges - edge_attrs : list - osm way attributes to include in output OSM XML - edge_tags : list - osm way tags to include in output OSM XML - edge_tag_aggs : list of length-2 string tuples - useful only if merge_edges is True, this argument allows the user - to specify edge attributes to aggregate such that the merged - OSM way entry tags accurately represent the sum total of - their component edge attributes. For example, if the user - wants the OSM way to have a "length" attribute, the user must - specify `edge_tag_aggs=[('length', 'sum')]` in order to tell - this method to aggregate the lengths of the individual - component edges. Otherwise, the length attribute will simply - reflect the length of the first edge associated with the way. - merge_edges : bool - if True merges graph edges such that each OSM way has one entry - and one entry only in the OSM XML. Otherwise, every OSM way - will have a separate entry for each node pair it contains. - - Returns - ------- - root : ElementTree.Element - XML tree with edges appended - """ - gdf_edges = gdf_edges.reset_index() - if merge_edges: - for _, all_way_edges in gdf_edges.groupby("id"): - first = all_way_edges.iloc[0].dropna().astype(str) - edge = ET.SubElement(root, "way", attrib=first[edge_attrs].dropna().to_dict()) - _append_nodes_as_edge_attrs( - xml_edge=edge, sample_edge=first, all_edges_df=all_way_edges - ) - _append_merged_edge_attrs( - xml_edge=edge, - sample_edge=first, - edge_tags=edge_tags, - edge_tag_aggs=edge_tag_aggs, - all_edges_df=all_way_edges, - ) - - else: - _create_way_for_each_edge( - root=root, - gdf_edges=gdf_edges, - edge_attrs=edge_attrs, - edge_tags=edge_tags, - ) - - return root - - -def _get_unique_nodes_ordered_from_way(df_way_edges): - """ - Recover original node order from edges associated with a single OSM way. - - Parameters - ---------- - df_way_edges : pandas.DataFrame - Dataframe containing columns 'u' and 'v' corresponding to - origin/destination nodes. - - Returns - ------- - unique_ordered_nodes : list - An ordered list of unique node IDs. If the edges do not all connect - (e.g. [(1, 2), (2,3), (10, 11), (11, 12), (12, 13)]), then this method - will return only those nodes associated with the largest component of - connected edges, even if subsequent connected chunks are contain more - total nodes. This ensures a proper topological representation of nodes - in the XML way records because if there are unconnected components, - the sorting algorithm cannot recover their original order. We would - not likely ever encounter this kind of disconnected structure of nodes - within a given way, but it is not explicitly forbidden in the OSM XML - design schema. - """ - G = nx.MultiDiGraph() - all_nodes = list(df_way_edges["u"].to_numpy()) + list(df_way_edges["v"].to_numpy()) - - G.add_nodes_from(all_nodes) - G.add_edges_from(df_way_edges[["u", "v"]].to_numpy()) - - # copy nodes into new graph - H = truncate.largest_component(G, strongly=False) - unique_ordered_nodes = list(nx.topological_sort(H)) - num_unique_nodes = len(np.unique(all_nodes)) - - if len(unique_ordered_nodes) < num_unique_nodes: - utils.log(f"Recovered order for {len(unique_ordered_nodes)} of {num_unique_nodes} nodes") - - return unique_ordered_nodes diff --git a/osmnx/plot.py b/osmnx/plot.py index 282cce044..772783189 100644 --- a/osmnx/plot.py +++ b/osmnx/plot.py @@ -1,7 +1,15 @@ """Visualize street networks, routes, orientations, and geospatial features.""" +from __future__ import annotations + +import logging as lg +from collections.abc import Iterable +from collections.abc import Sequence from pathlib import Path -from warnings import warn +from typing import TYPE_CHECKING +from typing import Any +from typing import Literal +from typing import overload import networkx as nx import numpy as np @@ -9,233 +17,238 @@ from . import bearing from . import convert -from . import graph from . import projection from . import settings -from . import simplification from . import utils from . import utils_geo +if TYPE_CHECKING: + import geopandas as gpd + # matplotlib is an optional dependency needed for visualization try: import matplotlib.pyplot as plt from matplotlib import cm from matplotlib import colormaps from matplotlib import colors + from matplotlib.axes._axes import Axes # noqa: TCH002 + from matplotlib.figure import Figure # noqa: TCH002 + from matplotlib.projections.polar import PolarAxes # noqa: TCH002 + + mpl_available = True + except ImportError: # pragma: no cover - cm = colors = plt = colormaps = None # type: ignore[assignment] + mpl_available = False -def get_colors(n, cmap="viridis", start=0.0, stop=1.0, alpha=1.0, return_hex=None): +def get_colors( + n: int, + *, + cmap: str = "viridis", + start: float = 0, + stop: float = 1, + alpha: float | None = None, +) -> list[str]: """ - Get `n` evenly-spaced colors from a matplotlib colormap. + Return `n` evenly-spaced colors from a matplotlib colormap. Parameters ---------- - n : int - number of colors - cmap : string - name of a matplotlib colormap - start : float - where to start in the colorspace - stop : float - where to end in the colorspace - alpha : float + n + How many colors to generate. + cmap + Name of the matplotlib colormap from which to choose the colors. + start + Where to start in the colorspace (from 0 to 1). + stop + Where to end in the colorspace (from 0 to 1). + alpha If `None`, return colors as HTML-like hex triplet "#rrggbb" RGB strings. If `float`, return as "#rrggbbaa" RGBa strings. - return_hex : bool - deprecated, do not use Returns ------- - color_list : list + color_list """ - if return_hex is None: - return_hex = False - else: - warn( - "The `return_hex` parameter has been deprecated and will be removed " - "in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - _verify_mpl() - - color_list = [colormaps[cmap](x) for x in np.linspace(start, stop, n)] + color_gen = (colormaps[cmap](x) for x in np.linspace(start, stop, n)) keep_alpha = alpha is not None if keep_alpha: - color_list = [(r, g, b, alpha) for r, g, b, _ in color_list] - else: - color_list = [(r, g, b) for r, g, b, _ in color_list] - - if return_hex: - return [colors.to_hex(c, keep_alpha=keep_alpha) for c in color_list] - - return color_list + color_gen = ((r, g, b, alpha) for r, g, b, _ in color_gen) + return [colors.to_hex(c, keep_alpha=keep_alpha) for c in color_gen] def get_node_colors_by_attr( - G, attr, num_bins=None, cmap="viridis", start=0, stop=1, na_color="none", equal_size=False -): + G: nx.MultiDiGraph, + attr: str, + *, + num_bins: int | None = None, + cmap: str = "viridis", + start: float = 0, + stop: float = 1, + na_color: str = "none", + equal_size: bool = False, +) -> pd.Series: # type: ignore[type-arg] """ - Get colors based on node attribute values. + Return colors based on nodes' numerical attribute values. Parameters ---------- - G : networkx.MultiDiGraph - input graph - attr : string - name of a numerical node attribute - num_bins : int - if None, linearly map a color to each value. otherwise, assign values + G + Input graph. + attr + Name of a node attribute with numerical values. + num_bins + If None, linearly map a color to each value. Otherwise, assign values to this many bins then assign a color to each bin. - cmap : string - name of a matplotlib colormap - start : float - where to start in the colorspace - stop : float - where to end in the colorspace - na_color : string - what color to assign nodes with missing attr values - equal_size : bool - ignored if num_bins is None. if True, bin into equal-sized quantiles - (requires unique bin edges). if False, bin into equal-spaced bins. + cmap + Name of the matplotlib colormap from which to choose the colors. + start + Where to start in the colorspace (from 0 to 1). + stop + Where to end in the colorspace (from 0 to 1). + na_color + The color to assign to nodes with missing `attr` values. + equal_size + Ignored if `num_bins` is None. If True, bin into equal-sized quantiles + (requires unique bin edges). If False, bin into equal-spaced bins. Returns ------- - node_colors : pandas.Series - series labels are node IDs and values are colors + node_colors + Labels are node IDs, values are colors as hex strings. """ - _verify_mpl() vals = pd.Series(nx.get_node_attributes(G, attr)) return _get_colors_by_value(vals, num_bins, cmap, start, stop, na_color, equal_size) def get_edge_colors_by_attr( - G, attr, num_bins=None, cmap="viridis", start=0, stop=1, na_color="none", equal_size=False -): + G: nx.MultiDiGraph, + attr: str, + *, + num_bins: int | None = None, + cmap: str = "viridis", + start: float = 0, + stop: float = 1, + na_color: str = "none", + equal_size: bool = False, +) -> pd.Series: # type: ignore[type-arg] """ - Get colors based on edge attribute values. + Return colors based on edges' numerical attribute values. Parameters ---------- - G : networkx.MultiDiGraph - input graph - attr : string - name of a numerical edge attribute - num_bins : int - if None, linearly map a color to each value. otherwise, assign values + G + Input graph. + attr + Name of a node attribute with numerical values. + num_bins + If None, linearly map a color to each value. Otherwise, assign values to this many bins then assign a color to each bin. - cmap : string - name of a matplotlib colormap - start : float - where to start in the colorspace - stop : float - where to end in the colorspace - na_color : string - what color to assign edges with missing attr values - equal_size : bool - ignored if num_bins is None. if True, bin into equal-sized quantiles - (requires unique bin edges). if False, bin into equal-spaced bins. + cmap + Name of the matplotlib colormap from which to choose the colors. + start + Where to start in the colorspace (from 0 to 1). + stop + Where to end in the colorspace (from 0 to 1). + na_color + The color to assign to nodes with missing `attr` values. + equal_size + Ignored if `num_bins` is None. If True, bin into equal-sized quantiles + (requires unique bin edges). If False, bin into equal-spaced bins. Returns ------- - edge_colors : pandas.Series - series labels are edge IDs (u, v, key) and values are colors + edge_colors + Labels are `(u, v, k)` edge IDs, values are colors as hex strings. """ - _verify_mpl() vals = pd.Series(nx.get_edge_attributes(G, attr)) return _get_colors_by_value(vals, num_bins, cmap, start, stop, na_color, equal_size) -def plot_graph( - G, - ax=None, - figsize=(8, 8), - bgcolor="#111111", - node_color="w", - node_size=15, - node_alpha=None, - node_edgecolor="none", - node_zorder=1, - edge_color="#999999", - edge_linewidth=1, - edge_alpha=None, - show=True, - close=False, - save=False, - filepath=None, - dpi=300, - bbox=None, -): +def plot_graph( # noqa: PLR0913 + G: nx.MultiGraph | nx.MultiDiGraph, + *, + ax: Axes | None = None, + figsize: tuple[float, float] = (8, 8), + bgcolor: str = "#111111", + node_color: str | Sequence[str] = "w", + node_size: float | Sequence[float] = 15, + node_alpha: float | None = None, + node_edgecolor: str | Iterable[str] = "none", + node_zorder: int = 1, + edge_color: str | Iterable[str] = "#999999", + edge_linewidth: float | Sequence[float] = 1, + edge_alpha: float | None = None, + bbox: tuple[float, float, float, float] | None = None, + show: bool = True, + close: bool = False, + save: bool = False, + filepath: str | Path | None = None, + dpi: int = 300, +) -> tuple[Figure, Axes]: """ Visualize a graph. Parameters ---------- - G : networkx.MultiDiGraph - input graph - ax : matplotlib axis - if not None, plot on this preexisting axis - figsize : tuple - if ax is None, create new figure with size (width, height) - bgcolor : string - background color of plot - node_color : string or list - color(s) of the nodes - node_size : int - size of the nodes: if 0, then skip plotting the nodes - node_alpha : float - opacity of the nodes, note: if you passed RGBA values to node_color, - set node_alpha=None to use the alpha channel in node_color - node_edgecolor : string - color of the nodes' markers' borders - node_zorder : int - zorder to plot nodes: edges are always 1, so set node_zorder=0 to plot - nodes below edges - edge_color : string or list - color(s) of the edges' lines - edge_linewidth : float - width of the edges' lines: if 0, then skip plotting the edges - edge_alpha : float - opacity of the edges, note: if you passed RGBA values to edge_color, - set edge_alpha=None to use the alpha channel in edge_color - show : bool - if True, call pyplot.show() to show the figure - close : bool - if True, call pyplot.close() to close the figure - save : bool - if True, save the figure to disk at filepath - filepath : string - if save is True, the path to the file. file format determined from - extension. if None, use settings.imgs_folder/image.png - dpi : int - if save is True, the resolution of saved file - bbox : tuple - bounding box as (north, south, east, west). if None, will calculate + G + Input graph. + ax + If not None, plot on this pre-existing axes instance. + figsize + If `ax` is None, create new figure with size `(width, height)`. + bgcolor + Background color of the figure. + node_color + Color(s) of the nodes. + node_size + Size(s) of the nodes. If 0, then skip plotting the nodes. + node_alpha + Opacity of the nodes. If you passed RGBa values to `node_color`, set + `node_alpha=None` to use the alpha channel in `node_color`. + node_edgecolor + Color(s) of the nodes' markers' borders. + node_zorder + The zorder to plot nodes. Edges are always 1, so set `node_zorder=0` + to plot nodes beneath edges. + edge_color + Color(s) of the edges' lines. + edge_linewidth + Width(s) of the edges' lines. If 0, then skip plotting the edges. + edge_alpha + Opacity of the edges. If you passed RGBa values to `edge_color`, set + `edge_alpha=None` to use the alpha channel in `edge_color`. + bbox + Bounding box as `(north, south, east, west)`. If None, calculate it from spatial extents of plotted geometries. + show + If True, call `pyplot.show()` to show the figure. + close + If True, call `pyplot.close()` to close the figure. + save + If True, save the figure to disk at `filepath`. + filepath + The path to the file if `save` is True. File format is determined from + the extension. If None, save at `settings.imgs_folder/image.png`. + dpi + The resolution of saved file if `save` is True. Returns ------- - fig, ax : tuple - matplotlib figure, axis + fig, ax """ _verify_mpl() - max_node_size = max(node_size) if hasattr(node_size, "__iter__") else node_size - max_edge_lw = max(edge_linewidth) if hasattr(edge_linewidth, "__iter__") else edge_linewidth + max_node_size = max(node_size) if isinstance(node_size, Sequence) else node_size + max_edge_lw = max(edge_linewidth) if isinstance(edge_linewidth, Sequence) else edge_linewidth if max_node_size <= 0 and max_edge_lw <= 0: # pragma: no cover - msg = "Either node_size or edge_linewidth must be > 0 to plot something." + msg = "Either `node_size` or `edge_linewidth` must be > 0 to plot something." raise ValueError(msg) # create fig, ax as needed - utils.log("Begin plotting the graph...") - if ax is None: - fig, ax = plt.subplots(figsize=figsize, facecolor=bgcolor, frameon=False) - ax.set_facecolor(bgcolor) - else: - fig = ax.figure + msg = "Begin plotting the graph..." + utils.log(msg, level=lg.INFO) + fig, ax = _get_fig_ax(ax=ax, figsize=figsize, bgcolor=bgcolor, polar=False) if max_edge_lw > 0: # plot the edges' geometries @@ -245,7 +258,7 @@ def plot_graph( if max_node_size > 0: # scatter plot the nodes' x/y coordinates gdf_nodes = convert.graph_to_gdfs(G, edges=False, node_geometry=False)[["x", "y"]] - ax.scatter( + ax.scatter( # type: ignore[union-attr] x=gdf_nodes["x"], y=gdf_nodes["y"], s=node_size, @@ -256,7 +269,7 @@ def plot_graph( ) # get spatial extents from bbox parameter or the edges' geometries - padding = 0 + padding = 0.0 if bbox is None: try: west, south, east, north = gdf_edges.total_bounds @@ -266,50 +279,58 @@ def plot_graph( bbox = north, south, east, west padding = 0.02 # pad 2% to not cut off peripheral nodes' circles - # configure axis appearance, save/show figure as specified, and return - ax = _config_ax(ax, G.graph["crs"], bbox, padding) - fig, ax = _save_and_show(fig, ax, save, show, close, filepath, dpi) - utils.log("Finished plotting the graph") + # configure axes appearance, save/show figure as specified, and return + ax = _config_ax(ax, G.graph["crs"], bbox, padding) # type: ignore[arg-type] + fig, ax = _save_and_show( + fig=fig, + ax=ax, + show=show, + close=close, + save=save, + filepath=filepath, + dpi=dpi, + ) + msg = "Finished plotting the graph" + utils.log(msg, level=lg.INFO) return fig, ax def plot_graph_route( - G, - route, - route_color="r", - route_linewidth=4, - route_alpha=0.5, - orig_dest_size=100, - ax=None, - **pg_kwargs, -): + G: nx.MultiDiGraph, + route: list[int], + *, + route_color: str = "r", + route_linewidth: float = 4, + route_alpha: float = 0.5, + orig_dest_size: float = 100, + ax: Axes | None = None, + **pg_kwargs: Any, # noqa: ANN401 +) -> tuple[Figure, Axes]: """ - Visualize a route along a graph. + Visualize a path along a graph. Parameters ---------- - G : networkx.MultiDiGraph - input graph - route : list - route as a list of node IDs - route_color : string - color of the route - route_linewidth : int - width of the route line - route_alpha : float - opacity of the route line - orig_dest_size : int - size of the origin and destination nodes - ax : matplotlib axis - if not None, plot route on this preexisting axis instead of creating a - new fig, ax and drawing the underlying graph + G + Input graph. + route + A path of node IDs. + route_color + The color of the route. + route_linewidth + Width of the route's line. + route_alpha + Opacity of the route's line. + orig_dest_size + Size of the origin and destination nodes. + ax + If not None, plot on this pre-existing axes instance. pg_kwargs - keyword arguments to pass to plot_graph + Keyword arguments to pass to `plot_graph`. Returns ------- - fig, ax : tuple - matplotlib figure, axis + fig, ax """ _verify_mpl() if ax is None: @@ -319,12 +340,12 @@ def plot_graph_route( kwargs = {k: v for k, v in pg_kwargs.items() if k not in overrides} fig, ax = plot_graph(G, show=False, save=False, close=False, **kwargs) else: - fig = ax.figure + fig = ax.figure # type: ignore[assignment] # scatterplot origin and destination points (first/last nodes in route) - x = (G.nodes[route[0]]["x"], G.nodes[route[-1]]["x"]) - y = (G.nodes[route[0]]["y"], G.nodes[route[-1]]["y"]) - ax.scatter(x, y, s=orig_dest_size, c=route_color, alpha=route_alpha, edgecolor="none") + od_x = (G.nodes[route[0]]["x"], G.nodes[route[-1]]["x"]) + od_y = (G.nodes[route[0]]["y"], G.nodes[route[-1]]["y"]) + ax.scatter(od_x, od_y, s=orig_dest_size, c=route_color, alpha=route_alpha, edgecolor="none") # assemble the route edge geometries' x and y coords then plot the line x = [] @@ -344,52 +365,62 @@ def plot_graph_route( ax.plot(x, y, c=route_color, lw=route_linewidth, alpha=route_alpha) # save and show the figure as specified, passing relevant kwargs - sas_kwargs = {"save", "show", "close", "filepath", "file_format", "dpi"} + sas_kwargs = {"show", "close", "save", "filepath", "dpi"} kwargs = {k: v for k, v in pg_kwargs.items() if k in sas_kwargs} - fig, ax = _save_and_show(fig, ax, **kwargs) + fig, ax = _save_and_show(fig=fig, ax=ax, **kwargs) return fig, ax -def plot_graph_routes(G, routes, route_colors="r", route_linewidths=4, **pgr_kwargs): +def plot_graph_routes( + G: nx.MultiDiGraph, + routes: Iterable[list[int]], + *, + route_colors: str | Iterable[str] = "r", + route_linewidths: float | Iterable[float] = 4, + **pgr_kwargs: Any, # noqa: ANN401 +) -> tuple[Figure, Axes]: """ - Visualize several routes along a graph. + Visualize multiple paths along a graph. Parameters ---------- - G : networkx.MultiDiGraph - input graph - routes : list - routes as a list of lists of node IDs - route_colors : string or list - if string, 1 color for all routes. if list, the colors for each route. - route_linewidths : int or list - if int, 1 linewidth for all routes. if list, the linewidth for each route. + G + Input graph. + routes + Paths of node IDs. + route_colors + If string, the one color for all routes. Otherwise, the color for each + route. + route_linewidths + If float, the one linewidth for all routes. Otherwise, the linewidth + for each route. pgr_kwargs - keyword arguments to pass to plot_graph_route + Keyword arguments to pass to `plot_graph_route`. Returns ------- - fig, ax : tuple - matplotlib figure, axis + fig, ax """ - _verify_mpl() + # make iterables lists (so we're guaranteed to be able to get their sizes) + routes = list(routes) + route_colors = ( + [route_colors] * len(routes) if isinstance(route_colors, str) else list(route_colors) + ) + route_linewidths = ( + [route_linewidths] * len(routes) + if not isinstance(route_linewidths, Iterable) + else list(route_linewidths) + ) # check for valid arguments if not all(isinstance(r, list) for r in routes): # pragma: no cover - msg = "routes must be a list of route lists" - raise ValueError(msg) - if len(routes) <= 1: # pragma: no cover - msg = "You must pass more than 1 route" + msg = "`routes` must be an iterable of route lists." + raise TypeError(msg) + if len(routes) == 0: # pragma: no cover + msg = "You must pass at least 1 route." raise ValueError(msg) - if isinstance(route_colors, str): - route_colors = [route_colors] * len(routes) - if len(routes) != len(route_colors): # pragma: no cover - msg = "route_colors list must have same length as routes" - raise ValueError(msg) - if isinstance(route_linewidths, int): - route_linewidths = [route_linewidths] * len(routes) - if len(routes) != len(route_linewidths): # pragma: no cover - msg = "route_linewidths list must have same length as routes" + if not (len(routes) == len(route_colors) == len(route_linewidths)): # pragma: no cover + msg = "`route_colors` and `route_linewidths` must have same lengths as `routes`." raise ValueError(msg) # plot the graph and the first route @@ -424,81 +455,49 @@ def plot_graph_routes(G, routes, route_colors="r", route_linewidths=4, **pgr_kwa ) # save and show the figure as specified, passing relevant kwargs - sas_kwargs = {"save", "show", "close", "filepath", "file_format", "dpi"} + sas_kwargs = {"show", "close", "save", "filepath", "dpi"} kwargs = {k: v for k, v in pgr_kwargs.items() if k in sas_kwargs} - fig, ax = _save_and_show(fig, ax, **kwargs) + fig, ax = _save_and_show(fig=fig, ax=ax, **kwargs) return fig, ax def plot_figure_ground( - G=None, - address=None, - point=None, - dist=805, - network_type="drive_service", - street_widths=None, - default_width=4, - color="w", - edge_color=None, - smooth_joints=None, - **pg_kwargs, -): + G: nx.MultiDiGraph, + *, + dist: float = 805, + street_widths: dict[str, float] | None = None, + default_width: float = 4, + color: str = "w", + **pg_kwargs: Any, # noqa: ANN401 +) -> tuple[Figure, Axes]: """ Plot a figure-ground diagram of a street network. Parameters ---------- - G : networkx.MultiDiGraph - input graph, must be unprojected - address : string - deprecated, do not use - point : tuple - deprecated, do not use - dist : numeric - how many meters to extend north, south, east, west from center point - network_type : string - deprecated, do not use - street_widths : dict - dict keys are street types and values are widths to plot in pixels - default_width : numeric - fallback width in pixels for any street type not in street_widths - color : string - color of the streets - edge_color : string - deprecated, do not use - smooth_joints : bool - deprecated, do not use + G + An unprojected graph. + dist + How many meters to extend plot's bounding box north, south, east, and + west from the graph's center point. Default corresponds to a square + mile bounding box. + street_widths + Dict keys are street types (ie, OSM "highway" tags) and values are the + widths to plot them, in pixels. + default_width + Fallback width, in pixels, for any street type not in `street_widths`. + color + The color of the streets. pg_kwargs - keyword arguments to pass to plot_graph + Keyword arguments to pass to `plot_graph`. Returns ------- - fig, ax : tuple - matplotlib figure, axis + fig, ax """ _verify_mpl() - if edge_color is None: - edge_color = color - else: - msg = ( - "The `edge_color` parameter is deprecated and will be removed in the " - "v2.0.0 release. Use `color` instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - - if smooth_joints is None: - smooth_joints = True - else: - msg = ( - "The `smooth_joints` parameter is deprecated and will be removed in the " - "v2.0.0 release. In the future this function will behave as though True. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - - # if user did not pass in custom street widths, create a dict of defaults + # if user did not pass in custom street widths, define default values if street_widths is None: street_widths = { "footway": 1.5, @@ -510,49 +509,6 @@ def plot_figure_ground( "motorway": 6, } - multiplier = 1.2 - dep_msg = ( - "The `address`, `point`, and `network_type` parameters are deprecated " - "and will be removed in the v2.0.0 release. Pass `G` instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - - # if G was passed in, plot it centered on its node centroid - if G is not None: - gdf_nodes = convert.graph_to_gdfs(G, edges=False, node_geometry=True) - lonlat_point = gdf_nodes.unary_union.centroid.coords[0] - point = tuple(reversed(lonlat_point)) - - # otherwise get network by address or point (whichever was passed) using a - # dist multiplier to ensure we get more than enough network. simplify in - # non-strict mode to not combine multiple street types into single edge - elif address is not None: - warn(dep_msg, FutureWarning, stacklevel=2) - G, point = graph.graph_from_address( - address, - dist=dist * multiplier, - dist_type="bbox", - network_type=network_type, - simplify=False, - truncate_by_edge=True, - return_coords=True, - ) - G = simplification.simplify_graph(G, edge_attrs_differ=["osmid"]) - elif point is not None: - warn(dep_msg, FutureWarning, stacklevel=2) - G = graph.graph_from_point( - point, - dist=dist * multiplier, - dist_type="bbox", - network_type=network_type, - simplify=False, - truncate_by_edge=True, - ) - G = simplification.simplify_graph(G, edge_attrs_differ=["osmid"]) - else: # pragma: no cover - msg = "You must pass an address or lat-lon point or graph." - raise ValueError(msg) - # we need an undirected graph to find every edge incident on a node Gu = convert.to_undirected(G) @@ -565,43 +521,42 @@ def plot_figure_ground( else: edge_linewidths.append(default_width) - if smooth_joints: - # for each node, get a nodesize according to the narrowest incident edge - node_widths = {} - for node in Gu.nodes: - # first, identify all the highway types of this node's incident edges - ie_data = [Gu.get_edge_data(node, nbr) for nbr in Gu.neighbors(node)] - edge_types = [d[min(d)]["highway"] for d in ie_data] - if not edge_types: - # if node has no incident edges, make size zero - node_widths[node] = 0 - else: - # flatten the list of edge types - et_flat = [] - for et in edge_types: - if isinstance(et, list): - et_flat.extend(et) - else: - et_flat.append(et) - - # lookup corresponding width for each edge type in flat list - edge_widths = [street_widths.get(et, default_width) for et in et_flat] - - # node diameter should equal largest edge width to make joints - # perfectly smooth. alternatively use min(?) to prevent - # anything larger from extending past smallest street's line. - # circle marker sizes are in area, so use diameter squared. - circle_diameter = max(edge_widths) - circle_area = circle_diameter**2 - node_widths[node] = circle_area - - # assign the node size to each node in the graph - node_sizes = [node_widths[node] for node in Gu.nodes] - else: - node_sizes = 0 + # smooth the street segment joints + # for each node, get a node size according to the narrowest incident edge + node_widths: dict[int, float] = {} + for node in Gu.nodes: + # first, identify all the highway types of this node's incident edges + ie_data = (Gu.get_edge_data(node, nbr) for nbr in Gu.neighbors(node)) + edge_types = [d[min(d)]["highway"] for d in ie_data] + if len(edge_types) == 0: + # if node has no incident edges, make size zero + node_widths[node] = 0 + else: + # flatten the list of edge types + et_flat = [] + for et in edge_types: + if isinstance(et, list): + et_flat.extend(et) + else: + et_flat.append(et) + + # look up corresponding width for each edge type in flat list + edge_widths = [street_widths.get(et, default_width) for et in et_flat] + + # node diameter should = largest edge width to make joints smooth + # mpl circle marker sizes are in area, so use diameter squared + circle_diameter = max(edge_widths) + circle_area = circle_diameter**2 + node_widths[node] = circle_area + + # assign the node size to each node in the graph + node_sizes: list[float] | float = [node_widths[node] for node in Gu.nodes] # define the view extents of the plotting figure - bbox = utils_geo.bbox_from_point(point, dist, project_utm=False) + node_geoms = convert.graph_to_gdfs(Gu, edges=False, node_geometry=True).unary_union + lonlat_point = node_geoms.centroid.coords[0] + latlon_point = tuple(reversed(lonlat_point)) + bbox = utils_geo.bbox_from_point(latlon_point, dist=dist, project_utm=False) # plot the figure overrides = {"bbox", "node_size", "node_color", "edge_linewidth"} @@ -610,83 +565,82 @@ def plot_figure_ground( G=Gu, bbox=bbox, node_size=node_sizes, - node_color=edge_color, - edge_color=edge_color, + node_color=color, + edge_color=color, edge_linewidth=edge_linewidths, **kwargs, ) return fig, ax -def plot_footprints( - gdf, - ax=None, - figsize=(8, 8), - color="orange", - edge_color="none", - edge_linewidth=0, - alpha=None, - bgcolor="#111111", - bbox=None, - save=False, - show=True, - close=False, - filepath=None, - dpi=600, -): +def plot_footprints( # noqa: PLR0913 + gdf: gpd.GeoDataFrame, + *, + ax: Axes | None = None, + figsize: tuple[float, float] = (8, 8), + color: str = "orange", + edge_color: str = "none", + edge_linewidth: float = 0, + alpha: float | None = None, + bgcolor: str = "#111111", + bbox: tuple[float, float, float, float] | None = None, + show: bool = True, + close: bool = False, + save: bool = False, + filepath: str | Path | None = None, + dpi: int = 600, +) -> tuple[Figure, Axes]: """ Visualize a GeoDataFrame of geospatial features' footprints. Parameters ---------- - gdf : geopandas.GeoDataFrame - GeoDataFrame of footprints (shapely Polygons and MultiPolygons) - ax : axis - if not None, plot on this preexisting axis - figsize : tuple - if ax is None, create new figure with size (width, height) - color : string - color of the footprints - edge_color : string - color of the edge of the footprints - edge_linewidth : float - width of the edge of the footprints - alpha : float - opacity of the footprints - bgcolor : string - background color of the plot - bbox : tuple - bounding box as (north, south, east, west). if None, will calculate - from the spatial extents of the geometries in gdf - save : bool - if True, save the figure to disk at filepath - show : bool - if True, call pyplot.show() to show the figure - close : bool - if True, call pyplot.close() to close the figure - filepath : string - if save is True, the path to the file. file format determined from - extension. if None, use settings.imgs_folder/image.png - dpi : int - if save is True, the resolution of saved file + gdf + GeoDataFrame of footprints (i.e., Polygons and/or MultiPolygons). + ax + If not None, plot on this pre-existing axes instance. + figsize + If `ax` is None, create new figure with size `(width, height)`. + color + Color of the footprints. + edge_color + Color of the footprints' edges. + edge_linewidth + Width of the footprints' edges. + alpha + Opacity of the footprints' edges. + bgcolor + Background color of the figure. + bbox + Bounding box as `(north, south, east, west)`. If None, calculate it + from the spatial extents of the geometries in `gdf`. + show + If True, call `pyplot.show()` to show the figure. + close + If True, call `pyplot.close()` to close the figure. + save + If True, save the figure to disk at `filepath`. + filepath + The path to the file if `save` is True. File format is determined from + the extension. If None, save at `settings.imgs_folder/image.png`. + dpi + The resolution of saved file if `save` is True. Returns ------- - fig, ax : tuple - matplotlib figure, axis + fig, ax """ _verify_mpl() - - if ax is None: - fig, ax = plt.subplots(figsize=figsize, facecolor=bgcolor, frameon=False) - ax.set_facecolor(bgcolor) - else: - fig = ax.figure + fig, ax = _get_fig_ax(ax=ax, figsize=figsize, bgcolor=bgcolor, polar=False) # retain only Polygons and MultiPolygons, then plot gdf = gdf[gdf["geometry"].type.isin({"Polygon", "MultiPolygon"})] ax = gdf.plot( - ax=ax, facecolor=color, edgecolor=edge_color, linewidth=edge_linewidth, alpha=alpha + ax=ax, + facecolor=color, + edgecolor=edge_color, + linewidth=edge_linewidth, + alpha=alpha, ) # determine figure extents @@ -695,33 +649,46 @@ def plot_footprints( else: north, south, east, west = bbox - # configure axis appearance, save/show figure as specified, and return - ax = _config_ax(ax, gdf.crs, (north, south, east, west), 0) - fig, ax = _save_and_show(fig, ax, save, show, close, filepath, dpi) + # configure axes appearance, save/show figure as specified, and return + ax = _config_ax(ax, gdf.crs, (north, south, east, west), 0) # type: ignore[arg-type] + fig, ax = _save_and_show( + fig=fig, + ax=ax, + show=show, + close=close, + save=save, + filepath=filepath, + dpi=dpi, + ) return fig, ax -def plot_orientation( - Gu, - num_bins=36, - min_length=0, - weight=None, - ax=None, - figsize=(5, 5), - area=True, - color="#003366", - edgecolor="k", - linewidth=0.5, - alpha=0.7, - title=None, - title_y=1.05, - title_font=None, - xtick_font=None, -): +def plot_orientation( # noqa: PLR0913 + G: nx.MultiGraph | nx.MultiDiGraph, + *, + num_bins: int = 36, + min_length: float = 0, + weight: str | None = None, + ax: PolarAxes | None = None, + figsize: tuple[float, float] = (5, 5), + area: bool = True, + color: str = "#003366", + edgecolor: str = "k", + linewidth: float = 0.5, + alpha: float = 0.7, + title: str | None = None, + title_y: float = 1.05, + title_font: dict[str, Any] | None = None, + xtick_font: dict[str, Any] | None = None, +) -> tuple[Figure, PolarAxes]: """ - Plot a polar histogram of a spatial network's bidirectional edge bearings. + Plot a polar histogram of a spatial network's edge bearings. - Ignores self-loop edges as their bearings are undefined. + Ignores self-loop edges as their bearings are undefined. If `G` is a + MultiGraph, all edge bearings will be bidirectional (ie, two reciprocal + bearings per undirected edge). If `G` is a MultiDiGraph, all edge bearings + will be directional (ie, one bearing per directed edge). See also the + `bearings` module. For more info see: Boeing, G. 2019. "Urban Spatial Order: Street Network Orientation, Configuration, and Entropy." Applied Network Science, 4 (1), @@ -729,43 +696,44 @@ def plot_orientation( Parameters ---------- - Gu : networkx.MultiGraph - undirected, unprojected graph with `bearing` attributes on each edge - num_bins : int - number of bins; for example, if `num_bins=36` is provided, then each - bin will represent 10 degrees around the compass - min_length : float - ignore edges with `length` attributes less than `min_length` - weight : string - if not None, weight edges' bearings by this (non-null) edge attribute - ax : matplotlib.axes.PolarAxesSubplot - if not None, plot on this preexisting axis; must have projection=polar - figsize : tuple - if ax is None, create new figure with size (width, height) - area : bool - if True, set bar length so area is proportional to frequency, - otherwise set bar length so height is proportional to frequency - color : string - color of histogram bars - edgecolor : string - color of histogram bar edges - linewidth : float - width of histogram bar edges - alpha : float - opacity of histogram bars - title : string - title for plot - title_y : float - y position to place title - title_font : dict - the title's fontdict to pass to matplotlib - xtick_font : dict - the xtick labels' fontdict to pass to matplotlib + G + Unprojected graph with `bearing` attributes on each edge. + num_bins + Number of bins. For example, if `num_bins=36` is provided, then each + bin will represent 10 degrees around the compass. + min_length + Ignore edges with "length" attribute values less than `min_length`. + weight + If not None, weight the edges' bearings by this (non-null) edge + attribute. + ax + If not None, plot on this pre-existing axes instance (must have + projection=polar). + figsize + If `ax` is None, create new figure with size `(width, height)`. + area + If True, set bar length so area is proportional to frequency. + Otherwise, set bar length so height is proportional to frequency. + color + Color of the histogram bars. + edgecolor + Color of the histogram bar edges. + linewidth + Width of the histogram bar edges. + alpha + Opacity of the histogram bars. + title + The figure's title. + title_y + The y position to place `title`. + title_font + The title's `fontdict` to pass to matplotlib. + xtick_font + The xtick labels' `fontdict` to pass to matplotlib. Returns ------- - fig, ax : tuple - matplotlib figure, axis + fig, ax """ _verify_mpl() @@ -780,12 +748,16 @@ def plot_orientation( "zorder": 3, } - # get the bearings' distribution's bin counts and edges - bin_counts, bin_edges = bearing._bearings_distribution(Gu, num_bins, min_length, weight) + # get the bearing distribution's bin counts and center values in degrees + bin_counts, bin_centers = bearing._bearings_distribution( + G, + num_bins, + min_length=min_length, + weight=weight, + ) - # positions: where to center each bar. ignore the last bin edge, because - # it's the same as the first (i.e., 0 degrees = 360 degrees) - positions = np.radians(bin_edges[:-1]) + # positions: where to center each bar + positions = np.radians(bin_centers) # width: make bars fill the circumference without gaps or overlaps width = 2 * np.pi / num_bins @@ -795,11 +767,8 @@ def plot_orientation( bin_frequency = bin_counts / bin_counts.sum() radius = np.sqrt(bin_frequency) if area else bin_frequency - # create ax (if necessary) then set N at top and go clockwise - if ax is None: - fig, ax = plt.subplots(figsize=figsize, subplot_kw={"projection": "polar"}) - else: - fig = ax.figure + # create PolarAxes (if not passed-in) then set N at top and go clockwise + fig, ax = _get_fig_ax(ax=ax, figsize=figsize, bgcolor=None, polar=True) ax.set_theta_zero_location("N") ax.set_theta_direction("clockwise") ax.set_ylim(top=radius.max()) @@ -834,34 +803,44 @@ def plot_orientation( return fig, ax -def _get_colors_by_value(vals, num_bins, cmap, start, stop, na_color, equal_size): +def _get_colors_by_value( + vals: pd.Series, # type: ignore[type-arg] + num_bins: int | None, + cmap: str, + start: float, + stop: float, + na_color: str, + equal_size: bool, # noqa: FBT001 +) -> pd.Series: # type: ignore[type-arg] """ - Map colors to the values in a series. + Map colors to the values in a Series of node/edge attribute values. Parameters ---------- - vals : pandas.Series - series labels are node/edge IDs and values are attribute values - num_bins : int - if None, linearly map a color to each value. otherwise, assign values + vals + Series labels are node/edge IDs and values are attribute values. + num_bins + If None, linearly map a color to each value. Otherwise, assign values to this many bins then assign a color to each bin. - cmap : string - name of a matplotlib colormap - start : float - where to start in the colorspace - stop : float - where to end in the colorspace - na_color : string - what color to assign to missing values - equal_size : bool - ignored if num_bins is None. if True, bin into equal-sized quantiles - (requires unique bin edges). if False, bin into equal-spaced bins. + cmap + Name of the matplotlib colormap from which to choose the colors. + start + Where to start in the colorspace (from 0 to 1). + stop + Where to end in the colorspace (from 0 to 1). + na_color + The color to assign to nodes with missing `attr` values. + equal_size + Ignored if `num_bins` is None. If True, bin into equal-sized quantiles + (requires unique bin edges). If False, bin into equal-spaced bins. Returns ------- - color_series : pandas.Series - series labels are node/edge IDs and values are colors + color_series + Labels are node/edge IDs, values are colors as hex strings. """ + _verify_mpl() + if len(vals) == 0: msg = "There are no attribute values." raise ValueError(msg) @@ -877,79 +856,89 @@ def _get_colors_by_value(vals, num_bins, cmap, start, stop, na_color, equal_size # linearly map a color to each attribute value normalizer = colors.Normalize(full_min, full_max) scalar_mapper = cm.ScalarMappable(normalizer, colormaps[cmap]) - color_series = vals.map(scalar_mapper.to_rgba) + color_series = vals.map(scalar_mapper.to_rgba).map(colors.to_hex) color_series.loc[pd.isna(vals)] = na_color else: # otherwise, bin values then assign colors to bins - cut_func = pd.qcut if equal_size else pd.cut - bins = cut_func(vals, num_bins, labels=range(num_bins)) - bin_colors = get_colors(num_bins, cmap, start, stop) + if equal_size: + bins = pd.qcut(vals, num_bins, labels=range(num_bins)) + else: + bins = pd.cut(vals, num_bins, labels=range(num_bins)) + bin_colors = get_colors(num_bins, cmap=cmap, start=start, stop=stop) color_list = [bin_colors[b] if pd.notna(b) else na_color for b in bins] color_series = pd.Series(color_list, index=bins.index) return color_series -def _save_and_show(fig, ax, save=False, show=True, close=True, filepath=None, dpi=300): +def _save_and_show( + fig: Figure, + ax: Axes, + *, + show: bool = True, + close: bool = True, + save: bool = False, + filepath: str | Path | None = None, + dpi: int = 300, +) -> tuple[Figure, Axes]: """ - Save a figure to disk and/or show it, as specified by args. + Save a figure to disk and/or show it, as specified by arguments. Parameters ---------- - fig : figure - matplotlib figure - ax : axis - matplotlib axis - save : bool - if True, save the figure to disk at filepath - show : bool - if True, call pyplot.show() to show the figure - close : bool - if True, call pyplot.close() to close the figure - filepath : string - if save is True, the path to the file. file format determined from - extension. if None, use settings.imgs_folder/image.png - dpi : int - if save is True, the resolution of saved file + fig + The figure. + ax + The axes instance. + show + If True, call `pyplot.show()` to show the figure. + close + If True, call `pyplot.close()` to close the figure. + save + If True, save the figure to disk at `filepath`. + filepath + The path to the file if `save` is True. File format is determined from + the extension. If None, save at `settings.imgs_folder/image.png`. + dpi + The resolution of saved file if `save` is True. Returns ------- - fig, ax : tuple - matplotlib figure, axis + fig, ax """ fig.canvas.draw() fig.canvas.flush_events() if save: # default filepath, if none provided - filepath = Path(settings.imgs_folder) / "image.png" if filepath is None else Path(filepath) + fp = Path(settings.imgs_folder) / "image.png" if filepath is None else Path(filepath) # if save folder does not already exist, create it - filepath.parent.mkdir(parents=True, exist_ok=True) + fp.parent.mkdir(parents=True, exist_ok=True) # get the file extension and figure facecolor - ext = filepath.suffix.strip(".") + ext = fp.suffix.strip(".") fc = fig.get_facecolor() if ext == "svg": # if the file format is svg, prep the fig/ax for saving ax.axis("off") - ax.set_position([0, 0, 1, 1]) - ax.patch.set_alpha(0.0) - fig.patch.set_alpha(0.0) - fig.savefig(filepath, bbox_inches=0, format=ext, facecolor=fc, transparent=True) + ax.set_position((0, 0, 1, 1)) + ax.patch.set_alpha(0) + fig.patch.set_alpha(0) + fig.savefig(fp, bbox_inches=0, format=ext, facecolor=fc, transparent=True) else: - # constrain saved figure's extent to interior of the axis + # constrain saved figure's extent to interior of the axes extent = ax.bbox.transformed(fig.dpi_scale_trans.inverted()) # temporarily turn figure frame on to save with facecolor fig.set_frameon(True) - fig.savefig( - filepath, dpi=dpi, bbox_inches=extent, format=ext, facecolor=fc, transparent=True - ) + fig.savefig(fp, dpi=dpi, bbox_inches=extent, format=ext, facecolor=fc, transparent=True) fig.set_frameon(False) # and turn it back off again - utils.log(f"Saved figure to disk at {filepath}") + + msg = f"Saved figure to disk at {fp!r}" + utils.log(msg, level=lg.INFO) if show: plt.show() @@ -960,27 +949,26 @@ def _save_and_show(fig, ax, save=False, show=True, close=True, filepath=None, dp return fig, ax -def _config_ax(ax, crs, bbox, padding): +def _config_ax(ax: Axes, crs: Any, bbox: tuple[float, float, float, float], padding: float) -> Axes: # noqa: ANN401 """ - Configure axis for display. + Configure a matplotlib axes instance for display. Parameters ---------- - ax : matplotlib axis - the axis containing the plot - crs : dict or string or pyproj.CRS - the CRS of the plotted geometries - bbox : tuple - bounding box as (north, south, east, west) - padding : float - relative padding to add around the plot's bbox + ax + The axes instance. + crs + The coordinate reference system of the plotted geometries. + bbox + Bounding box as `(north, south, east, west)`. + padding + Relative padding to add around `bbox`. Returns ------- - ax : matplotlib axis - the configured/styled axis + ax """ - # set the axis view limits to bbox + relative padding + # set the axes view limits to bbox + relative padding north, south, east, west = bbox padding_ns = (north - south) * padding padding_ew = (east - west) * padding @@ -991,7 +979,7 @@ def _config_ax(ax, crs, bbox, padding): # so there is no space around the plot ax.margins(0) ax.tick_params(which="both", direction="in") - _ = [s.set_visible(False) for s in ax.spines.values()] + _ = [s.set_visible(False) for s in ax.spines.values()] # type: ignore[func-returns-value] ax.get_xaxis().set_visible(False) ax.get_yaxis().set_visible(False) @@ -1007,14 +995,72 @@ def _config_ax(ax, crs, bbox, padding): return ax -def _verify_mpl(): +# if polar = False, return Axes +@overload +def _get_fig_ax( + ax: Axes | None, + figsize: tuple[float, float], + bgcolor: str | None, + polar: Literal[False], +) -> tuple[Figure, Axes]: ... + + +# if polar = True, return PolarAxes +@overload +def _get_fig_ax( + ax: Axes | None, + figsize: tuple[float, float], + bgcolor: str | None, + polar: Literal[True], +) -> tuple[Figure, PolarAxes]: ... + + +def _get_fig_ax( + ax: Axes | None, + figsize: tuple[float, float], + bgcolor: str | None, + polar: bool, # noqa: FBT001 +) -> tuple[Figure, Axes | PolarAxes]: + """ + Generate a matplotlib Figure and (Polar)Axes or return existing ones. + + Parameters + ---------- + ax + If not None, plot on this pre-existing axes instance. + figsize + If `ax` is None, create new figure with size `(width, height)`. + bgcolor + Background color of figure. + polar + If True, generate a `PolarAxes` instead of an `Axes` instance. + + Returns + ------- + fig, ax + """ + if ax is None: + if polar: + # make PolarAxes + fig, ax = plt.subplots(figsize=figsize, subplot_kw={"projection": "polar"}) + else: + # make regular Axes + fig, ax = plt.subplots(figsize=figsize, facecolor=bgcolor, frameon=False) + ax.set_facecolor(bgcolor) + else: + fig = ax.figure # type: ignore[assignment] + + return fig, ax + + +def _verify_mpl() -> None: """ - Verify that matplotlib is installed and successfully imported. + Verify that matplotlib is installed and imported. Returns ------- None """ - if cm is None or colors is None or plt is None or colormaps is None: # pragma: no cover - msg = "matplotlib must be installed as an optional dependency for visualization" + if not mpl_available: # pragma: no cover + msg = "matplotlib must be installed as an optional dependency for visualization." raise ImportError(msg) diff --git a/osmnx/projection.py b/osmnx/projection.py index 31caab7f3..a5bf34f6f 100644 --- a/osmnx/projection.py +++ b/osmnx/projection.py @@ -1,57 +1,73 @@ """Project a graph, GeoDataFrame, or geometry to a different CRS.""" +from __future__ import annotations + +import logging as lg +from typing import TYPE_CHECKING +from typing import Any + import geopandas as gpd from . import convert from . import settings from . import utils +if TYPE_CHECKING: + import networkx as nx + from shapely import Geometry + -def is_projected(crs): +def is_projected(crs: Any) -> bool: # noqa: ANN401 """ Determine if a coordinate reference system is projected or not. Parameters ---------- - crs : string or pyproj.CRS - the identifier of the coordinate reference system, which can be - anything accepted by `pyproj.CRS.from_user_input()` such as an - authority string or a WKT string + crs + The identifier of the coordinate reference system. This can be + anything accepted by `pyproj.CRS.from_user_input()`, such as an + authority string or a WKT string. Returns ------- - projected : bool - True if crs is projected, otherwise False + projected + True if `crs` is projected, otherwise False """ - return gpd.GeoSeries(crs=crs).crs.is_projected + return bool(gpd.GeoSeries(crs=crs).crs.is_projected) -def project_geometry(geometry, crs=None, to_crs=None, to_latlong=False): +def project_geometry( + geometry: Geometry, + *, + crs: Any | None = None, # noqa: ANN401 + to_crs: Any | None = None, # noqa: ANN401 + to_latlong: bool = False, +) -> tuple[Geometry, Any]: """ Project a Shapely geometry from its current CRS to another. - If `to_latlong` is `True`, this projects the GeoDataFrame to the CRS - defined by `settings.default_crs`, otherwise it projects it to the CRS - defined by `to_crs`. If `to_crs` is `None`, it projects it to the CRS of - an appropriate UTM zone given `geometry`'s bounds. + If `to_latlong` is True, this projects the geometry to the coordinate + reference system defined by `settings.default_crs`. Otherwise it projects + it to the CRS defined by `to_crs`. If `to_crs` is `None`, it projects it + to the CRS of an appropriate UTM zone given `geometry`'s bounds. Parameters ---------- - geometry : shapely geometry - the geometry to be projected - crs : string or pyproj.CRS - the initial CRS of `geometry`. if None, it will be set to - `settings.default_crs` - to_crs : string or pyproj.CRS - if None, project to an appropriate UTM zone, otherwise project to - this CRS - to_latlong : bool - if True, project to `settings.default_crs` and ignore `to_crs` + geometry + The geometry to be projected. + crs + The initial CRS of `geometry`. If None, it will be set to + `settings.default_crs`. + to_crs + If None, project to an appropriate UTM zone. Otherwise project to this + CRS. + to_latlong + If True, project to `settings.default_crs` and ignore `to_crs`. Returns ------- - geometry_proj, crs : tuple - the projected geometry and its new CRS + geometry_proj, crs + The projected geometry and its new CRS. """ if crs is None: crs = settings.default_crs @@ -62,32 +78,37 @@ def project_geometry(geometry, crs=None, to_crs=None, to_latlong=False): return geometry_proj, gdf_proj.crs -def project_gdf(gdf, to_crs=None, to_latlong=False): +def project_gdf( + gdf: gpd.GeoDataFrame, + *, + to_crs: Any | None = None, # noqa: ANN401 + to_latlong: bool = False, +) -> gpd.GeoDataFrame: """ Project a GeoDataFrame from its current CRS to another. - If `to_latlong` is `True`, this projects the GeoDataFrame to the CRS - defined by `settings.default_crs`, otherwise it projects it to the CRS - defined by `to_crs`. If `to_crs` is `None`, it projects it to the CRS of - an appropriate UTM zone given `gdf`'s bounds. + If `to_latlong` is True, this projects the GeoDataFrame to the coordinate + reference system defined by `settings.default_crs`. Otherwise it projects + it to the CRS defined by `to_crs`. If `to_crs` is `None`, it projects it + to the CRS of an appropriate UTM zone given `geometry`'s bounds. Parameters ---------- - gdf : geopandas.GeoDataFrame - the GeoDataFrame to be projected - to_crs : string or pyproj.CRS - if None, project to an appropriate UTM zone, otherwise project to - this CRS - to_latlong : bool - if True, project to `settings.default_crs` and ignore `to_crs` + gdf + The GeoDataFrame to be projected. + to_crs + If None, project to an appropriate UTM zone. Otherwise project to + this CRS. + to_latlong + If True, project to `settings.default_crs` and ignore `to_crs`. Returns ------- - gdf_proj : geopandas.GeoDataFrame - the projected GeoDataFrame + gdf_proj + The projected GeoDataFrame. """ - if gdf.crs is None or len(gdf) < 1: # pragma: no cover - msg = "GeoDataFrame must have a valid CRS and cannot be empty" + if gdf.crs is None or len(gdf) == 0: # pragma: no cover + msg = "`gdf` must have a valid CRS and cannot be empty." raise ValueError(msg) # if to_latlong is True, project the gdf to the default_crs @@ -101,33 +122,40 @@ def project_gdf(gdf, to_crs=None, to_latlong=False): # project the gdf gdf_proj = gdf.to_crs(to_crs) crs_desc = f"{gdf_proj.crs.to_string()} / {gdf_proj.crs.name}" - utils.log(f"Projected GeoDataFrame to {crs_desc!r}") + + msg = f"Projected GeoDataFrame to {crs_desc!r}" + utils.log(msg, level=lg.INFO) return gdf_proj -def project_graph(G, to_crs=None, to_latlong=False): +def project_graph( + G: nx.MultiDiGraph, + *, + to_crs: Any | None = None, # noqa: ANN401 + to_latlong: bool = False, +) -> nx.MultiDiGraph: """ Project a graph from its current CRS to another. - If `to_latlong` is `True`, this projects the GeoDataFrame to the CRS - defined by `settings.default_crs`, otherwise it projects it to the CRS - defined by `to_crs`. If `to_crs` is `None`, it projects it to the CRS of - an appropriate UTM zone given `G`'s bounds. + If `to_latlong` is True, this projects the graph to the coordinate + reference system defined by `settings.default_crs`. Otherwise it projects + it to the CRS defined by `to_crs`. If `to_crs` is `None`, it projects it + to the CRS of an appropriate UTM zone given `geometry`'s bounds. Parameters ---------- - G : networkx.MultiDiGraph - the graph to be projected - to_crs : string or pyproj.CRS - if None, project to an appropriate UTM zone, otherwise project to - this CRS - to_latlong : bool - if True, project to `settings.default_crs` and ignore `to_crs` + G + The graph to be projected. + to_crs + If None, project to an appropriate UTM zone. Otherwise project to + this CRS. + to_latlong + If True, project to `settings.default_crs` and ignore `to_crs`. Returns ------- - G_proj : networkx.MultiDiGraph - the projected graph + G_proj + The projected graph. """ if to_latlong: to_crs = settings.default_crs @@ -135,21 +163,14 @@ def project_graph(G, to_crs=None, to_latlong=False): # STEP 1: PROJECT THE NODES gdf_nodes = convert.graph_to_gdfs(G, edges=False) - # create new lat/lon columns to preserve lat/lon for later reference if - # cols do not already exist (ie, don't overwrite in later re-projections) - if "lon" not in gdf_nodes.columns or "lat" not in gdf_nodes.columns: - gdf_nodes["lon"] = gdf_nodes["x"] - gdf_nodes["lat"] = gdf_nodes["y"] - # project the nodes GeoDataFrame and extract the projected x/y values gdf_nodes_proj = project_gdf(gdf_nodes, to_crs=to_crs) gdf_nodes_proj["x"] = gdf_nodes_proj["geometry"].x gdf_nodes_proj["y"] = gdf_nodes_proj["geometry"].y to_crs = gdf_nodes_proj.crs - gdf_nodes_proj = gdf_nodes_proj.drop(columns=["geometry"]) # STEP 2: PROJECT THE EDGES - if "simplified" in G.graph and G.graph["simplified"]: + if G.graph.get("simplified"): # if graph has previously been simplified, project the edge geometries gdf_edges = convert.graph_to_gdfs(G, nodes=False, fill_edge_geometry=False) gdf_edges_proj = project_gdf(gdf_edges, to_crs=to_crs) @@ -157,14 +178,13 @@ def project_graph(G, to_crs=None, to_latlong=False): # if not, you don't have to project these edges because the nodes # contain all the spatial data in the graph (unsimplified edges have # no geometry attributes) - gdf_edges_proj = convert.graph_to_gdfs(G, nodes=False, fill_edge_geometry=False).drop( - columns=["geometry"] - ) + gdf_edges_proj = convert.graph_to_gdfs(G, nodes=False, fill_edge_geometry=False) # STEP 3: REBUILD GRAPH # turn projected node/edge gdfs into a graph and update its CRS attribute - G_proj = convert.graph_from_gdfs(gdf_nodes_proj, gdf_edges_proj, G.graph) + G_proj = convert.graph_from_gdfs(gdf_nodes_proj, gdf_edges_proj, graph_attrs=G.graph) G_proj.graph["crs"] = to_crs - utils.log(f"Projected graph with {len(G)} nodes and {len(G.edges)} edges") + msg = f"Projected graph with {len(G)} nodes and {len(G.edges)} edges" + utils.log(msg, level=lg.INFO) return G_proj diff --git a/osmnx/routing.py b/osmnx/routing.py index a9a907358..2437dc4db 100644 --- a/osmnx/routing.py +++ b/osmnx/routing.py @@ -1,8 +1,17 @@ -"""Calculate weighted shortest paths between graph nodes.""" +"""Calculate edge speeds, travel times, and weighted shortest paths.""" + +from __future__ import annotations import itertools +import logging as lg import multiprocessing as mp import re +from collections.abc import Iterable +from collections.abc import Iterator +from typing import TYPE_CHECKING +from typing import Any +from typing import Callable +from typing import overload from warnings import warn import networkx as nx @@ -12,31 +21,283 @@ from . import convert from . import utils - -def route_to_gdf(G, route, weight="length"): +if TYPE_CHECKING: + import geopandas as gpd + +# Dict that is used by `add_edge_speeds` to convert implicit values +# to numbers, based on https://wiki.openstreetmap.org/wiki/Key:maxspeed +_IMPLICIT_MAXSPEEDS: dict[str, float] = { + "AR:rural": 110.0, + "AR:urban": 40.0, + "AR:urban:primary": 60.0, + "AR:urban:secondary": 60.0, + "AT:bicycle_road": 30.0, + "AT:motorway": 130.0, + "AT:rural": 100.0, + "AT:trunk": 100.0, + "AT:urban": 50.0, + "BE-BRU:rural": 70.0, + "BE-BRU:urban": 30.0, + "BE-VLG:rural": 70.0, + "BE-VLG:urban": 50.0, + "BE-WAL:rural": 90.0, + "BE-WAL:urban": 50.0, + "BE:cyclestreet": 30.0, + "BE:living_street": 20.0, + "BE:motorway": 120.0, + "BE:trunk": 120.0, + "BE:zone30": 30.0, + "BG:living_street": 20.0, + "BG:motorway": 140.0, + "BG:rural": 90.0, + "BG:trunk": 120.0, + "BG:urban": 50.0, + "BY:living_street": 20.0, + "BY:motorway": 110.0, + "BY:rural": 90.0, + "BY:urban": 60.0, + "CA-AB:rural": 90.0, + "CA-AB:urban": 65.0, + "CA-BC:rural": 80.0, + "CA-BC:urban": 50.0, + "CA-MB:rural": 90.0, + "CA-MB:urban": 50.0, + "CA-ON:rural": 80.0, + "CA-ON:urban": 50.0, + "CA-QC:motorway": 100.0, + "CA-QC:rural": 75.0, + "CA-QC:urban": 50.0, + "CA-SK:nsl": 80.0, + "CH:motorway": 120.0, + "CH:rural": 80.0, + "CH:trunk": 100.0, + "CH:urban": 50.0, + "CZ:living_street": 20.0, + "CZ:motorway": 130.0, + "CZ:pedestrian_zone": 20.0, + "CZ:rural": 90.0, + "CZ:trunk": 110.0, + "CZ:urban": 50.0, + "CZ:urban_motorway": 80.0, + "CZ:urban_trunk": 80.0, + "DE:bicycle_road": 30.0, + "DE:living_street": 15.0, + "DE:motorway": 120.0, + "DE:rural": 80.0, + "DE:urban": 50.0, + "DK:motorway": 130.0, + "DK:rural": 80.0, + "DK:urban": 50.0, + "EE:rural": 90.0, + "EE:urban": 50.0, + "ES:living_street": 20.0, + "ES:motorway": 120.0, + "ES:rural": 90.0, + "ES:trunk": 90.0, + "ES:urban": 50.0, + "ES:zone30": 30.0, + "FI:motorway": 120.0, + "FI:rural": 80.0, + "FI:trunk": 100.0, + "FI:urban": 50.0, + "FR:motorway": 120.0, + "FR:rural": 80.0, + "FR:urban": 50.0, + "FR:zone30": 30.0, + "GB:nsl_restricted": 48.28, + "GR:motorway": 130.0, + "GR:rural": 90.0, + "GR:trunk": 110.0, + "GR:urban": 50.0, + "HU:living_street": 20.0, + "HU:motorway": 130.0, + "HU:rural": 90.0, + "HU:trunk": 110.0, + "HU:urban": 50.0, + "IT:motorway": 130.0, + "IT:rural": 90.0, + "IT:trunk": 110.0, + "IT:urban": 50.0, + "JP:express": 100.0, + "JP:nsl": 60.0, + "LT:rural": 90.0, + "LT:urban": 50.0, + "NO:rural": 80.0, + "NO:urban": 50.0, + "PH:express": 100.0, + "PH:rural": 80.0, + "PH:urban": 30.0, + "PT:motorway": 120.0, + "PT:rural": 90.0, + "PT:trunk": 100.0, + "PT:urban": 50.0, + "RO:motorway": 130.0, + "RO:rural": 90.0, + "RO:trunk": 100.0, + "RO:urban": 50.0, + "RS:living_street": 10.0, + "RS:motorway": 130.0, + "RS:rural": 80.0, + "RS:trunk": 100.0, + "RS:urban": 50.0, + "RU:living_street": 20.0, + "RU:motorway": 110.0, + "RU:rural": 90.0, + "RU:urban": 60.0, + "SE:rural": 70.0, + "SE:urban": 50.0, + "SI:motorway": 130.0, + "SI:rural": 90.0, + "SI:trunk": 110.0, + "SI:urban": 50.0, + "SK:living_street": 20.0, + "SK:motorway": 130.0, + "SK:motorway_urban": 90.0, + "SK:rural": 90.0, + "SK:trunk": 90.0, + "SK:urban": 50.0, + "TR:living_street": 20.0, + "TR:motorway": 130.0, + "TR:rural": 90.0, + "TR:trunk": 110.0, + "TR:urban": 50.0, + "TR:zone30": 30.0, + "UA:living_street": 20.0, + "UA:motorway": 130.0, + "UA:rural": 90.0, + "UA:trunk": 110.0, + "UA:urban": 50.0, + "UK:motorway": 112.65, + "UK:nsl_dual": 112.65, + "UK:nsl_single": 96.56, + "UZ:living_street": 30.0, + "UZ:motorway": 110.0, + "UZ:rural": 100.0, + "UZ:urban": 70.0, +} + + +def route_to_gdf( + G: nx.MultiDiGraph, + route: list[int], + *, + weight: str = "length", +) -> gpd.GeoDataFrame: """ Return a GeoDataFrame of the edges in a path, in order. Parameters ---------- - G : networkx.MultiDiGraph - input graph - route : list - list of node IDs constituting the path - weight : string - if there are parallel edges between two nodes, choose lowest weight + G + Input graph. + route + Node IDs constituting the path. + weight + Attribute value to minimize when choosing between parallel edges. Returns ------- - gdf_edges : geopandas.GeoDataFrame - GeoDataFrame of the edges + gdf_edges """ pairs = zip(route[:-1], route[1:]) uvk = ((u, v, min(G[u][v].items(), key=lambda i: i[1][weight])[0]) for u, v in pairs) return convert.graph_to_gdfs(G.subgraph(route), nodes=False).loc[uvk] -def shortest_path(G, orig, dest, weight="length", cpus=1): +# orig/dest int, weight present, cpus present +@overload +def shortest_path( + G: nx.MultiDiGraph, + orig: int, + dest: int, + *, + weight: str, + cpus: int | None, +) -> list[int] | None: ... + + +# orig/dest int, weight missing, cpus present +@overload +def shortest_path( + G: nx.MultiDiGraph, + orig: int, + dest: int, + *, + cpus: int | None, +) -> list[int] | None: ... + + +# orig/dest int, weight present, cpus missing +@overload +def shortest_path( + G: nx.MultiDiGraph, + orig: int, + dest: int, + *, + weight: str, +) -> list[int] | None: ... + + +# orig/dest int, weight missing, cpus missing +@overload +def shortest_path( + G: nx.MultiDiGraph, + orig: int, + dest: int, +) -> list[int] | None: ... + + +# orig/dest Iterable, weight present, cpus present +@overload +def shortest_path( + G: nx.MultiDiGraph, + orig: Iterable[int], + dest: Iterable[int], + *, + weight: str, + cpus: int | None, +) -> list[list[int] | None]: ... + + +# orig/dest Iterable, weight missing, cpus present +@overload +def shortest_path( + G: nx.MultiDiGraph, + orig: Iterable[int], + dest: Iterable[int], + *, + cpus: int | None, +) -> list[list[int] | None]: ... + + +# orig/dest Iterable, weight present, cpus missing +@overload +def shortest_path( + G: nx.MultiDiGraph, + orig: Iterable[int], + dest: Iterable[int], + *, + weight: str, +) -> list[list[int] | None]: ... + + +# orig/dest Iterable, weight missing, cpus missing +@overload +def shortest_path( + G: nx.MultiDiGraph, + orig: Iterable[int], + dest: Iterable[int], +) -> list[list[int] | None]: ... + + +def shortest_path( + G: nx.MultiDiGraph, + orig: int | Iterable[int], + dest: int | Iterable[int], + *, + weight: str = "length", + cpus: int | None = 1, +) -> list[int] | None | list[list[int] | None]: """ Solve shortest path from origin node(s) to destination node(s). @@ -54,44 +315,49 @@ def shortest_path(G, orig, dest, weight="length", cpus=1): Parameters ---------- - G : networkx.MultiDiGraph - input graph - orig : int or list - origin node ID, or a list of origin node IDs - dest : int or list - destination node ID, or a list of destination node IDs - weight : string - edge attribute to minimize when solving shortest path - cpus : int - how many CPU cores to use; if None, use all available + G + Input graph, + orig + Origin node ID(s). + dest + Destination node ID(s). + weight + Edge attribute to minimize when solving shortest path. + cpus + How many CPU cores to use. If None, use all available. Returns ------- - path : list - list of node IDs constituting the shortest path, or, if orig and dest - are lists, then a list of path lists + path + The node IDs constituting the shortest path, or, if `orig` and `dest` + are both iterable, then a list of such paths. """ _verify_edge_attribute(G, weight) # if neither orig nor dest is iterable, just return the shortest path - if not (hasattr(orig, "__iter__") or hasattr(dest, "__iter__")): + if not (isinstance(orig, Iterable) or isinstance(dest, Iterable)): return _single_shortest_path(G, orig, dest, weight) # if only 1 of orig or dest is iterable and the other is not, raise error - if not (hasattr(orig, "__iter__") and hasattr(dest, "__iter__")): - msg = "orig and dest must either both be iterable or neither must be iterable" - raise ValueError(msg) - - # if both orig and dest are iterable, ensure they have same lengths + if not (isinstance(orig, Iterable) and isinstance(dest, Iterable)): + msg = "`orig` and `dest` must either both be iterable or neither must be iterable." + raise TypeError(msg) + + # if both orig and dest are iterable, make them lists (so we're guaranteed + # to be able to get their sizes) then ensure they have same lengths + orig = list(orig) + dest = list(dest) if len(orig) != len(dest): # pragma: no cover - msg = "orig and dest must be of equal length" + msg = "`orig` and `dest` must be of equal length." raise ValueError(msg) # determine how many cpu cores to use if cpus is None: cpus = mp.cpu_count() cpus = min(cpus, mp.cpu_count()) - utils.log(f"Solving {len(orig)} paths with {cpus} CPUs...") + + msg = f"Solving {len(orig)} paths with {cpus} CPUs..." + utils.log(msg, level=lg.INFO) # if single-threading, calculate each shortest path one at a time if cpus == 1: @@ -106,7 +372,14 @@ def shortest_path(G, orig, dest, weight="length", cpus=1): return paths -def k_shortest_paths(G, orig, dest, k, weight="length"): +def k_shortest_paths( + G: nx.MultiDiGraph, + orig: int, + dest: int, + k: int, + *, + weight: str = "length", +) -> Iterator[list[int]]: """ Solve `k` shortest paths from an origin node to a destination node. @@ -115,88 +388,104 @@ def k_shortest_paths(G, orig, dest, k, weight="length"): Parameters ---------- - G : networkx.MultiDiGraph - input graph - orig : int - origin node ID - dest : int - destination node ID - k : int - number of shortest paths to solve - weight : string - edge attribute to minimize when solving shortest paths. default is - edge length in meters. + G + Input graph. + orig + Origin node ID. + dest + Destination node ID. + k + Number of shortest paths to solve. + weight + Edge attribute to minimize when solving shortest paths. Yields ------ - path : list - a generator of `k` shortest paths ordered by total weight. each path - is a list of node IDs. + path + The node IDs constituting the next-shortest path. """ _verify_edge_attribute(G, weight) - paths_gen = nx.shortest_simple_paths(convert.to_digraph(G, weight), orig, dest, weight) + paths_gen = nx.shortest_simple_paths( + G=convert.to_digraph(G, weight=weight), + source=orig, + target=dest, + weight=weight, + ) yield from itertools.islice(paths_gen, 0, k) -def _single_shortest_path(G, orig, dest, weight): +def _single_shortest_path( + G: nx.MultiDiGraph, + orig: int, + dest: int, + weight: str, +) -> list[int] | None: """ Solve the shortest path from an origin node to a destination node. - This function is a convenience wrapper around networkx.shortest_path, with - exception handling for unsolvable paths. It uses Dijkstra's algorithm. + This function uses Dijkstra's algorithm. It is a convenience wrapper + around `networkx.shortest_path`, with exception handling for unsolvable + paths. If the path is unsolvable, it returns None. Parameters ---------- - G : networkx.MultiDiGraph - input graph - orig : int - origin node ID - dest : int - destination node ID - weight : string - edge attribute to minimize when solving shortest path + G + Input graph. + orig + Origin node ID. + dest + Destination node ID. + weight + Edge attribute to minimize when solving shortest path. Returns ------- - path : list - list of node IDs constituting the shortest path + path + The node IDs constituting the shortest path. """ try: - return nx.shortest_path(G, orig, dest, weight=weight, method="dijkstra") + return list(nx.shortest_path(G, orig, dest, weight=weight, method="dijkstra")) except nx.exception.NetworkXNoPath: # pragma: no cover - utils.log(f"Cannot solve path from {orig} to {dest}") + msg = f"Cannot solve path from {orig} to {dest}" + utils.log(msg, level=lg.WARNING) return None -def _verify_edge_attribute(G, attr): +def _verify_edge_attribute(G: nx.MultiDiGraph, attr: str) -> None: """ Verify attribute values are numeric and non-null across graph edges. - Raises a `ValueError` if attribute contains non-numeric values and raises - a warning if attribute is missing or null on any edges. + Raises a ValueError if this attribute contains non-numeric values, and + issues a UserWarning if this attribute is missing or null on any edges. Parameters ---------- - G : networkx.MultiDiGraph - input graph - attr : string - edge attribute to verify + G + Input graph. + attr + Name of the edge attribute to verify. Returns ------- None """ try: - values = np.array(tuple(G.edges(data=attr)))[:, 2] - values_float = values.astype(float) + values_float = (np.array(tuple(G.edges(data=attr)))[:, 2]).astype(float) if np.isnan(values_float).any(): - warn(f"The attribute {attr!r} is missing or null on some edges.", stacklevel=2) + msg = f"The attribute {attr!r} is missing or null on some edges." + warn(msg, category=UserWarning, stacklevel=2) except ValueError as e: msg = f"The edge attribute {attr!r} contains non-numeric values." raise ValueError(msg) from e -def add_edge_speeds(G, hwy_speeds=None, fallback=None, precision=None, agg=np.mean): +def add_edge_speeds( + G: nx.MultiDiGraph, + *, + hwy_speeds: dict[str, float] | None = None, + fallback: float | None = None, + agg: Callable[[Any], Any] = np.mean, +) -> nx.MultiDiGraph: """ Add edge speeds (km per hour) to graph as new `speed_kph` edge attributes. @@ -220,40 +509,28 @@ def add_edge_speeds(G, hwy_speeds=None, fallback=None, precision=None, agg=np.me Parameters ---------- - G : networkx.MultiDiGraph - input graph - hwy_speeds : dict - dict keys = OSM highway types and values = typical speeds (km per + G + Input graph. + hwy_speeds + Dict keys are OSM highway types and values are typical speeds (km per hour) to assign to edges of that highway type for any edges missing speed data. Any edges with highway type not in `hwy_speeds` will be - assigned the mean preexisting speed value of all edges of that highway - type. - fallback : numeric - default speed value (km per hour) to assign to edges whose highway - type did not appear in `hwy_speeds` and had no preexisting speed - values on any edge - precision : int - deprecated, do not use - agg : function - aggregation function to impute missing values from observed values. - the default is numpy.mean, but you might also consider for example - numpy.median, numpy.nanmedian, or your own custom function + assigned the mean pre-existing speed value of all edges of that + highway type. + fallback + Default speed value (km per hour) to assign to edges whose highway + type did not appear in `hwy_speeds` and had no pre-existing speed + attribute values on any edge. + agg + Aggregation function to impute missing values from observed values. + The default is `numpy.mean`, but you might also consider for example + `numpy.median`, `numpy.nanmedian`, or your own custom function. Returns ------- - G : networkx.MultiDiGraph - graph with speed_kph attributes on all edges + G + Graph with `speed_kph` attributes on all edges. """ - if precision is None: - precision = 1 - else: - warn( - "The `precision` parameter is deprecated and will be removed in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - if fallback is None: fallback = np.nan @@ -301,19 +578,19 @@ def add_edge_speeds(G, hwy_speeds=None, fallback=None, precision=None, agg=np.me # caller did not pass in hwy_speeds or fallback arguments if pd.isna(speed_kph).all(): msg = ( - "this graph's edges have no preexisting `maxspeed` attribute " + "This graph's edges have no preexisting 'maxspeed' attribute " "values so you must pass `hwy_speeds` or `fallback` arguments." ) raise ValueError(msg) # add speed kph attribute to graph edges - edges["speed_kph"] = speed_kph.round(precision).to_numpy() + edges["speed_kph"] = speed_kph.to_numpy() nx.set_edge_attributes(G, values=edges["speed_kph"], name="speed_kph") return G -def add_edge_travel_times(G, precision=None): +def add_edge_travel_times(G: nx.MultiDiGraph) -> nx.MultiDiGraph: """ Add edge travel time (seconds) to graph as new `travel_time` edge attributes. @@ -324,36 +601,24 @@ def add_edge_travel_times(G, precision=None): Parameters ---------- - G : networkx.MultiDiGraph - input graph - precision : int - deprecated, do not use + G + Input graph. Returns ------- - G : networkx.MultiDiGraph - graph with travel_time attributes on all edges + G + Graph with `travel_time` attributes on all edges. """ - if precision is None: - precision = 1 - else: - warn( - "The `precision` parameter is deprecated and will be removed in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - edges = convert.graph_to_gdfs(G, nodes=False) # verify edge length and speed_kph attributes exist if not ("length" in edges.columns and "speed_kph" in edges.columns): # pragma: no cover - msg = "all edges must have `length` and `speed_kph` attributes." + msg = "All edges must have 'length' and 'speed_kph' attributes." raise KeyError(msg) # verify edge length and speed_kph attributes contain no nulls if pd.isna(edges["length"]).any() or pd.isna(edges["speed_kph"]).any(): # pragma: no cover - msg = "edge `length` and `speed_kph` values must be non-null." + msg = "Edge 'length' and 'speed_kph' values must be non-null." raise ValueError(msg) # convert distance meters to km, and speed km per hour to km per second @@ -364,36 +629,48 @@ def add_edge_travel_times(G, precision=None): travel_time = distance_km / speed_km_sec # add travel time attribute to graph edges - edges["travel_time"] = travel_time.round(precision).to_numpy() + edges["travel_time"] = travel_time.to_numpy() nx.set_edge_attributes(G, values=edges["travel_time"], name="travel_time") return G -def _clean_maxspeed(maxspeed, agg=np.mean, convert_mph=True): +def _clean_maxspeed( + maxspeed: str | float, + *, + agg: Callable[[Any], Any] = np.mean, + convert_mph: bool = True, +) -> float | None: """ Clean a maxspeed string and convert mph to kph if necessary. If present, splits maxspeed on "|" (which denotes that the value contains - different speeds per lane) then aggregates the resulting values. Invalid - inputs return None. See https://wiki.openstreetmap.org/wiki/Key:maxspeed - for details on values and formats. + different speeds per lane) then aggregates the resulting values. If given + string is not a valid numeric string, tries to look up its value in + implicit maxspeed values mapping. Invalid inputs return None. See + https://wiki.openstreetmap.org/wiki/Key:maxspeed for details on values and + formats. Parameters ---------- - maxspeed : string - a valid OpenStreetMap way maxspeed value - agg : function - aggregation function if maxspeed contains multiple values (default - is numpy.mean) - convert_mph : bool - if True, convert miles per hour to km per hour + maxspeed + An OSM way "maxspeed" attribute value. Null values are expected to be + of type float (`numpy.nan`), and non-null values are strings. + agg + Aggregation function if `maxspeed` contains multiple values (default + is `numpy.mean`). + convert_mph + If True, convert miles per hour to kilometers per hour. Returns ------- - clean_value : string + clean_value + Clean value resulting from `agg` function. """ MILES_TO_KM = 1.60934 + if not isinstance(maxspeed, str): + return None + # regex adapted from OSM wiki pattern = "^([0-9][\\.,0-9]+?)(?:[ ]?(?:km/h|kmh|kph|mph|knots))?$" values = re.split(r"\|", maxspeed) # creates a list even if it's a single value @@ -401,33 +678,40 @@ def _clean_maxspeed(maxspeed, agg=np.mean, convert_mph=True): clean_values = [] for value in values: match = re.match(pattern, value) - clean_value = float(match.group(1).replace(",", ".")) + clean_value = float(match.group(1).replace(",", ".")) # type: ignore[union-attr] if convert_mph and "mph" in maxspeed.lower(): clean_value = clean_value * MILES_TO_KM clean_values.append(clean_value) - return agg(clean_values) + return float(agg(clean_values)) except (ValueError, AttributeError): - # if invalid input, return None - return None + # if not valid numeric string, try looking it up as implicit value + return _IMPLICIT_MAXSPEEDS.get(maxspeed) -def _collapse_multiple_maxspeed_values(value, agg): +def _collapse_multiple_maxspeed_values( + value: str | float | list[str | float], + agg: Callable[[Any], Any], +) -> float | str | None: """ Collapse a list of maxspeed values to a single value. + Returns None if a ValueError is encountered. + Parameters ---------- - value : list or string - an OSM way maxspeed value, or a list of them - agg : function - the aggregation function to reduce the list to a single value + value + An OSM way "maxspeed" attribute value. Null values are expected to be + of type float (`numpy.nan`), and non-null values are strings. + agg + The aggregation function to reduce the list to a single value. Returns ------- - agg_value : int - an integer representation of the aggregated value in the list, - converted to kph if original value was in mph. + collapsed + If `value` was a string or null, it is just returned directly. + Otherwise, the return is a float representation of the aggregated + value in the list (converted to kph if original value was in mph). """ # if this isn't a list, just return it right back to the caller if not isinstance(value, list): @@ -438,6 +722,10 @@ def _collapse_multiple_maxspeed_values(value, agg): # clean each value in list and convert to kph if it is mph then # return a single aggregated value values = [_clean_maxspeed(x) for x in value] - return int(agg(pd.Series(values).dropna())) + collapsed = float(agg(pd.Series(values).dropna())) + if pd.isna(collapsed): + return None + # otherwise + return collapsed # noqa: TRY300 except ValueError: return None diff --git a/osmnx/settings.py b/osmnx/settings.py index aca725cca..248ed6d07 100644 --- a/osmnx/settings.py +++ b/osmnx/settings.py @@ -2,47 +2,41 @@ Global settings that can be configured by the user. all_oneway : bool - Only use if specifically saving to .osm XML file with the `save_graph_xml` - function. If True, forces all ways to be loaded as oneway ways, preserving - the original order of nodes stored in the OSM way XML. This also retains - original OSM string values for oneway attribute values, rather than - converting them to a True/False bool. Default is `False`. -bidirectional_network_types : list + Only use if subsequently saving graph to an OSM XML file via the + `save_graph_xml` function. If True, forces all ways to be added as one-way + ways, preserving the original order of the nodes in the OSM way. This also + retains the original OSM way's oneway tag's string value as edge attribute + values, rather than converting them to True/False bool values. Default is + `False`. +bidirectional_network_types : list[str] Network types for which a fully bidirectional graph will be created. Default is `["walk"]`. -cache_folder : str or pathlib.Path - Path to folder in which to save/load HTTP response cache, if the - `use_cache` setting equals `True`. Default is `"./cache"`. +cache_folder : str | Path + Path to folder to save/load HTTP response cache files, if the `use_cache` + setting is True. Default is `"./cache"`. cache_only_mode : bool If True, download network data from Overpass then raise a `CacheOnlyModeInterrupt` error for user to catch. This prevents graph - building from taking place and instead just saves OSM response data to + building from taking place and instead just saves Overpass response to cache. Useful for sequentially caching lots of raw data (as you can only query Overpass one request at a time) then using the local cache to quickly build many graphs simultaneously with multiprocessing. Default is `False`. -data_folder : str or pathlib.Path - Path to folder in which to save/load graph files by default. Default is - `"./data"`. -default_accept_language : str - Do not use, deprecated. Use `http_accept_language` instead. +data_folder : str | Path + Path to folder to save/load graph files by default. Default is `"./data"`. default_access : str - Default filter for OSM "access" key. Default is `'["access"!~"private"]'`. + Filter for the OSM "access" tag. Default is `'["access"!~"private"]'`. Note that also filtering out "access=no" ways prevents including transit-only bridges (e.g., Tilikum Crossing) from appearing in drivable road network (e.g., `'["access"!~"private|no"]'`). However, some drivable - tollroads have "access=no" plus a "access:conditional" key to clarify when + tollroads have "access=no" plus a "access:conditional" tag to clarify when it is accessible, so we can't filter out all "access=no" ways by default. Best to be permissive here then remove complicated combinations of tags programatically after the full graph is downloaded and constructed. default_crs : str Default coordinate reference system to set when creating graphs. Default is `"epsg:4326"`. -default_referer : str - Do not use, deprecated. Use `http_referer` instead. -default_user_agent : str - Do not use, deprecated. Use `http_user_agent` instead. -doh_url_template : str +doh_url_template : str | None Endpoint to resolve DNS-over-HTTPS if local DNS resolution fails. Set to None to disable DoH, but see `downloader._config_dns` documentation for caveats. Default is: `"https://8.8.8.8/resolve?name={hostname}"` @@ -54,7 +48,7 @@ `"https://api.opentopodata.org/v1/aster30m?locations={locations}&key={key}"` http_accept_language : str HTTP header accept-language. Default is `"en"`. Note that Nominatim's - default language is "en" and it can sort result importance scores + default language is "en" and it may sort its results' importance scores differently if a different language is specified. http_referer : str HTTP header referer. Default is @@ -62,46 +56,32 @@ http_user_agent : str HTTP header user-agent. Default is `"OSMnx Python package (https://github.com/gboeing/osmnx)"`. -imgs_folder : str or pathlib.Path +imgs_folder : str | Path Path to folder in which to save plotted images by default. Default is `"./images"`. log_file : bool - If True, save log output to a file in logs_folder. Default is `False`. + If True, save log output to a file in `logs_folder`. Default is `False`. log_filename : str Name of the log file, without file extension. Default is `"osmnx"`. log_console : bool If True, print log output to the console (terminal window). Default is `False`. log_level : int - One of Python's logger.level constants. Default is `logging.INFO`. + One of Python's `logger.level` constants. Default is `logging.INFO`. log_name : str Name of the logger. Default is `"OSMnx"`. -logs_folder : str or pathlib.Path +logs_folder : str | Path Path to folder in which to save log files. Default is `"./logs"`. -max_query_area_size : int +max_query_area_size : float Maximum area for any part of the geometry in meters: any polygon bigger than this will get divided up for multiple queries to the API. Default is `2500000000`. -memory : int - Do not use, deprecated. Use `overpass_memory` instead. -nominatim_endpoint : str - Do not use, deprecated. Use `nominatim_url` instead. -nominatim_key : str +nominatim_key : str | None Your Nominatim API key, if you are using an API instance that requires one. Default is `None`. nominatim_url : str The base API url to use for Nominatim queries. Default is `"https://nominatim.openstreetmap.org/"`. -osm_xml_node_attrs : list - Do not use, deprecated. -osm_xml_node_tags : list - Do not use, deprecated. -osm_xml_way_attrs : list - Do not use, deprecated. -osm_xml_way_tags : list - Do not use, deprecated. -overpass_endpoint : str - Do not use, deprecated. Use `overpass_url` instead. overpass_memory : int | None Overpass server memory allocation size for the query, in bytes. If None, server will choose its default allocation size. Use with caution. @@ -109,7 +89,7 @@ overpass_rate_limit : bool If True, check the Overpass server status endpoint for how long to pause before making request. Necessary if server uses slot management, - but can be set to False if you are running your own overpass instance + but can be set to False if you are running your own Overpass instance without rate limiting. Default is `True`. overpass_settings : str Settings string for Overpass queries. Default is @@ -120,7 +100,7 @@ overpass_url : str The base API url to use for Overpass queries. Default is `"https://overpass-api.de/api"`. -requests_kwargs : dict +requests_kwargs : dict[str, Any] Optional keyword args to pass to the requests package when connecting to APIs, for example to configure authentication or provide a path to a local certificate file. More info on options such as auth, cert, @@ -129,80 +109,74 @@ requests_timeout : int The timeout interval in seconds for HTTP requests, and (when applicable) for Overpass server to use for executing the query. Default is `180`. -timeout : int - Do not use, deprecated. Use `requests_timeout` instead. use_cache : bool - If True, cache HTTP responses locally instead of calling API repeatedly - for the same request. Default is `True`. -useful_tags_node : list + If True, cache HTTP responses locally in `cache_folder` instead of calling + API repeatedly for the same request. Default is `True`. +useful_tags_node : list[str] OSM "node" tags to add as graph node attributes, when present in the data - retrieved from OSM. Default is `["ref", "highway"]`. -useful_tags_way : list + retrieved from OSM. Default is `["highway", "junction", "railway", "ref"]`. +useful_tags_way : list[str] OSM "way" tags to add as graph edge attributes, when present in the data - retrieved from OSM. Default is `["bridge", "tunnel", "oneway", "lanes", - "ref", "name", "highway", "maxspeed", "service", "access", "area", - "landuse", "width", "est_width", "junction"]`. + retrieved from OSM. Default is `["access", "area", "bridge", "est_width", + "highway", "junction", "landuse", "lanes", "maxspeed", "name", "oneway", + "ref", "service", "tunnel", "width"]`. """ +from __future__ import annotations + import logging as lg +from typing import TYPE_CHECKING +from typing import Any + +if TYPE_CHECKING: + from pathlib import Path -all_oneway = False -bidirectional_network_types = ["walk"] -cache_folder = "./cache" -cache_only_mode = False -data_folder = "./data" -default_accept_language = None -default_access = '["access"!~"private"]' -default_crs = "epsg:4326" -default_referer = None -default_user_agent = None -doh_url_template = "https://8.8.8.8/resolve?name={hostname}" -elevation_url_template = ( +all_oneway: bool = False +bidirectional_network_types: list[str] = ["walk"] +cache_folder: str | Path = "./cache" +cache_only_mode: bool = False +data_folder: str | Path = "./data" +default_access: str = '["access"!~"private"]' +default_crs: str = "epsg:4326" +doh_url_template: str | None = "https://8.8.8.8/resolve?name={hostname}" +elevation_url_template: str = ( "https://maps.googleapis.com/maps/api/elevation/json?locations={locations}&key={key}" ) -http_accept_language = "en" -http_referer = "OSMnx Python package (https://github.com/gboeing/osmnx)" -http_user_agent = "OSMnx Python package (https://github.com/gboeing/osmnx)" -imgs_folder = "./images" -log_console = False -log_file = False -log_filename = "osmnx" -log_level = lg.INFO -log_name = "OSMnx" -logs_folder = "./logs" -max_query_area_size = 50 * 1000 * 50 * 1000 -memory = None -nominatim_endpoint = None -nominatim_key = None -nominatim_url = "https://nominatim.openstreetmap.org/" -osm_xml_node_attrs = None -osm_xml_node_tags = None -osm_xml_way_attrs = None -osm_xml_way_tags = None -overpass_endpoint = None -overpass_memory = None -overpass_rate_limit = True -overpass_settings = "[out:json][timeout:{timeout}]{maxsize}" -overpass_url = "https://overpass-api.de/api" -requests_kwargs: dict = {} -requests_timeout = 180 -timeout = None -use_cache = True -useful_tags_node = ["ref", "highway"] -useful_tags_way = [ +http_accept_language: str = "en" +http_referer: str = "OSMnx Python package (https://github.com/gboeing/osmnx)" +http_user_agent: str = "OSMnx Python package (https://github.com/gboeing/osmnx)" +imgs_folder: str | Path = "./images" +log_console: bool = False +log_file: bool = False +log_filename: str = "osmnx" +log_level: int = lg.INFO +log_name: str = "OSMnx" +logs_folder: str | Path = "./logs" +max_query_area_size: float = 50 * 1000 * 50 * 1000 +nominatim_key: str | None = None +nominatim_url: str = "https://nominatim.openstreetmap.org/" +overpass_memory: int | None = None +overpass_rate_limit: bool = True +overpass_settings: str = "[out:json][timeout:{timeout}]{maxsize}" +overpass_url: str = "https://overpass-api.de/api" +requests_kwargs: dict[str, Any] = {} +requests_timeout: float = 180 +use_cache: bool = True +useful_tags_node: list[str] = ["highway", "junction", "railway", "ref"] +useful_tags_way: list[str] = [ + "access", + "area", "bridge", - "tunnel", - "oneway", - "lanes", - "ref", - "name", + "est_width", "highway", + "junction", + "landuse", + "lanes", "maxspeed", + "name", + "oneway", + "ref", "service", - "access", - "area", - "landuse", + "tunnel", "width", - "est_width", - "junction", ] diff --git a/osmnx/simplification.py b/osmnx/simplification.py index 8d2675fc0..18e068166 100644 --- a/osmnx/simplification.py +++ b/osmnx/simplification.py @@ -1,29 +1,41 @@ -"""Simplify, correct, and consolidate network topology.""" +"""Simplify, correct, and consolidate spatial graph nodes and edges.""" + +from __future__ import annotations import logging as lg -from warnings import warn +from typing import TYPE_CHECKING +from typing import Any import geopandas as gpd import networkx as nx -from shapely.geometry import LineString -from shapely.geometry import MultiPolygon -from shapely.geometry import Point -from shapely.geometry import Polygon +import numpy as np +import pandas as pd +from shapely import LineString +from shapely import Point from . import convert from . import stats from . import utils from ._errors import GraphSimplificationError +if TYPE_CHECKING: + from collections.abc import Iterable + from collections.abc import Iterator + -def _is_endpoint(G, node, endpoint_attrs): +def _is_endpoint( + G: nx.MultiDiGraph, + node: int, + node_attrs_include: Iterable[str] | None, + edge_attrs_differ: Iterable[str] | None, +) -> bool: """ Determine if a node is a true endpoint of an edge. Return True if the node is a "true" endpoint of an edge in the network, otherwise False. OpenStreetMap data includes many nodes that exist only as - geometric vertices to allow ways to curve. A true edge endpoint is a node - that satisfies at least 1 of the following 4 rules: + geometric vertices to allow ways to curve. `node` is a true edge endpoint + if it satisfies at least 1 of the following 5 rules: 1) It is its own neighbor (ie, it self-loops). @@ -32,25 +44,32 @@ def _is_endpoint(G, node, endpoint_attrs): 3) Or, it does not have exactly two neighbors and degree of 2 or 4. - 4) Or, if `endpoint_attrs` is not None, and its incident edges have + 4) Or, if `node_attrs_include` is not None and it has one or more of the + attributes in `node_attrs_include`. + + 5) Or, if `edge_attrs_differ` is not None and its incident edges have different values than each other for any of the edge attributes in - `endpoint_attrs`. + `edge_attrs_differ`. Parameters ---------- - G : networkx.MultiDiGraph - input graph - node : int - the node to examine - endpoint_attrs : iterable - An iterable of edge attribute names for relaxing the strictness of - endpoint determination. If not None, a node is an endpoint if its - incident edges have different values then each other for any of the - edge attributes in `endpoint_attrs`. + G + Input graph. + node + The ID of the node. + node_attrs_include + Node attribute names for relaxing the strictness of endpoint + determination. A node is always an endpoint if it possesses one or + more of the attributes in `node_attrs_include`. + edge_attrs_differ + Edge attribute names for relaxing the strictness of endpoint + determination. A node is always an endpoint if its incident edges have + different values than each other for any attribute in + `edge_attrs_differ`. Returns ------- - bool + endpoint """ neighbors = set(list(G.predecessors(node)) + list(G.successors(node))) n = len(neighbors) @@ -77,12 +96,17 @@ def _is_endpoint(G, node, endpoint_attrs): return True # RULE 4 + # non-strict mode: does it contain an attr denoting that it is an endpoint + if node_attrs_include is not None and len(set(node_attrs_include) & G.nodes[node].keys()) > 0: + return True + + # RULE 5 # non-strict mode: do its incident edges have different attr values? for # each attribute to check, collect the attribute's values in all inbound - # and outbound edges. if there is more than 1 unique value then then this - # node is an endpoint - if endpoint_attrs is not None: - for attr in endpoint_attrs: + # and outbound edges. if there is more than 1 unique value then this node + # is an endpoint + if edge_attrs_differ is not None: + for attr in edge_attrs_differ: in_values = {v for _, _, v in G.in_edges(node, data=attr, keys=False)} out_values = {v for _, _, v in G.out_edges(node, data=attr, keys=False)} if len(in_values | out_values) > 1: @@ -92,28 +116,33 @@ def _is_endpoint(G, node, endpoint_attrs): return False -def _build_path(G, endpoint, endpoint_successor, endpoints): +def _build_path( + G: nx.MultiDiGraph, + endpoint: int, + endpoint_successor: int, + endpoints: set[int], +) -> list[int]: """ Build a path of nodes from one endpoint node to next endpoint node. Parameters ---------- - G : networkx.MultiDiGraph - input graph - endpoint : int - the endpoint node from which to start the path - endpoint_successor : int - the successor of endpoint through which the path to the next endpoint - will be built - endpoints : set - the set of all nodes in the graph that are endpoints + G + Input graph. + endpoint + Ehe endpoint node from which to start the path. + endpoint_successor + The successor of endpoint through which the path to the next endpoint + will be built. + endpoints + The set of all nodes in the graph that are endpoints. Returns ------- - path : list - the first and last items in the resulting path list are endpoint + path + The first and last items in the resulting path list are endpoint nodes, and all other items are interstitial nodes that can be removed - subsequently + subsequently. """ # start building path from endpoint node through its successor path = [endpoint, endpoint_successor] @@ -139,7 +168,7 @@ def _build_path(G, endpoint, endpoint_successor, endpoints): if endpoint in G.successors(successor): # we have come to the end of a self-looping edge, so # add first node to end of path to close it and return - return path + [endpoint] + return [*path, endpoint] # otherwise, this can happen due to OSM digitization error # where a one-way street turns into a two-way here, but @@ -151,7 +180,7 @@ def _build_path(G, endpoint, endpoint_successor, endpoints): # if successor has >1 successors, then successor must have # been an endpoint because you can go in 2 new directions. # this should never occur in practice - msg = f"Impossible simplify pattern failed near {successor}" + msg = f"Impossible simplify pattern failed near {successor}." raise GraphSimplificationError(msg) # if this successor is an endpoint, we've completed the path @@ -162,7 +191,11 @@ def _build_path(G, endpoint, endpoint_successor, endpoints): return path -def _get_paths_to_simplify(G, endpoint_attrs): +def _get_paths_to_simplify( + G: nx.MultiDiGraph, + node_attrs_include: Iterable[str] | None, + edge_attrs_differ: Iterable[str] | None, +) -> Iterator[list[int]]: """ Generate all the paths to be simplified between endpoint nodes. @@ -171,22 +204,26 @@ def _get_paths_to_simplify(G, endpoint_attrs): Parameters ---------- - G : networkx.MultiDiGraph - input graph - endpoint_attrs : iterable - An iterable of edge attribute names for relaxing the strictness of - endpoint determination. If not None, a node is an endpoint if its - incident edges have different values then each other for any of the - edge attributes in `endpoint_attrs`. + G + Input graph. + node_attrs_include + Node attribute names for relaxing the strictness of endpoint + determination. A node is always an endpoint if it possesses one or + more of the attributes in `node_attrs_include`. + edge_attrs_differ + Edge attribute names for relaxing the strictness of endpoint + determination. A node is always an endpoint if its incident edges have + different values than each other for any attribute in + `edge_attrs_differ`. Yields ------ - path_to_simplify : list - a generator of paths to simplify + path_to_simplify """ # first identify all the nodes that are endpoints - endpoints = {n for n in G.nodes if _is_endpoint(G, n, endpoint_attrs)} - utils.log(f"Identified {len(endpoints):,} edge endpoints") + endpoints = {n for n in G.nodes if _is_endpoint(G, n, node_attrs_include, edge_attrs_differ)} + msg = f"Identified {len(endpoints):,} edge endpoints" + utils.log(msg, level=lg.INFO) # for each endpoint node, look at each of its successor nodes for endpoint in endpoints: @@ -198,120 +235,120 @@ def _get_paths_to_simplify(G, endpoint_attrs): yield _build_path(G, endpoint, successor, endpoints) -def _remove_rings(G, endpoint_attrs): +def _remove_rings( + G: nx.MultiDiGraph, + node_attrs_include: Iterable[str] | None, + edge_attrs_differ: Iterable[str] | None, +) -> nx.MultiDiGraph: """ - Remove all self-contained rings from a graph. + Remove all graph components that consist only of a single chordless cycle. - This identifies any connected components that form a self-contained ring - without any endpoints, and removes them from the graph. + This identifies all connected components in the graph that consist only of + a single isolated self-contained ring, and removes them from the graph. Parameters ---------- - G : networkx.MultiDiGraph - input graph - endpoint_attrs : iterable - An iterable of edge attribute names for relaxing the strictness of - endpoint determination. If not None, a node is an endpoint if its - incident edges have different values then each other for any of the - edge attributes in `endpoint_attrs`. + G + Input graph. + node_attrs_include + Node attribute names for relaxing the strictness of endpoint + determination. A node is always an endpoint if it possesses one or + more of the attributes in `node_attrs_include`. + edge_attrs_differ + Edge attribute names for relaxing the strictness of endpoint + determination. A node is always an endpoint if its incident edges have + different values than each other for any attribute in + `edge_attrs_differ`. Returns ------- - G : networkx.MultiDiGraph - graph with self-contained rings removed + G + Graph with all chordless cycle components removed. """ - nodes_in_rings = set() + to_remove = set() for wcc in nx.weakly_connected_components(G): - if not any(_is_endpoint(G, n, endpoint_attrs) for n in wcc): - nodes_in_rings.update(wcc) - G.remove_nodes_from(nodes_in_rings) + if not any(_is_endpoint(G, n, node_attrs_include, edge_attrs_differ) for n in wcc): + to_remove.update(wcc) + G.remove_nodes_from(to_remove) return G -def simplify_graph( # noqa: C901 - G, - strict=None, - edge_attrs_differ=None, - endpoint_attrs=None, - remove_rings=True, - track_merged=False, -): +def simplify_graph( # noqa: C901, PLR0912 + G: nx.MultiDiGraph, + *, + node_attrs_include: Iterable[str] | None = None, + edge_attrs_differ: Iterable[str] | None = None, + remove_rings: bool = True, + track_merged: bool = False, + edge_attr_aggs: dict[str, Any] | None = None, +) -> nx.MultiDiGraph: """ Simplify a graph's topology by removing interstitial nodes. - This simplifies graph topology by removing all nodes that are not + This simplifies the graph's topology by removing all nodes that are not intersections or dead-ends, by creating an edge directly between the end points that encapsulate them while retaining the full geometry of the original edges, saved as a new `geometry` attribute on the new edge. Note that only simplified edges receive a `geometry` attribute. Some of the resulting consolidated edges may comprise multiple OSM ways, and if - so, their multiple attribute values are stored as a list. Optionally, the + so, their unique attribute values are stored as a list. Optionally, the simplified edges can receive a `merged_edges` attribute that contains a - list of all the (u, v) node pairs that were merged together. - - Use the `edge_attrs_differ` parameter to relax simplification strictness. For - example, `edge_attrs_differ=['osmid']` will retain every node whose incident - edges have different OSM IDs. This lets you keep nodes at elbow two-way - intersections (but be aware that sometimes individual blocks have multiple - OSM IDs within them too). You could also use this parameter to retain - nodes where sidewalks or bike lanes begin/end in the middle of a block. + list of all the `(u, v)` node pairs that were merged together. + + Use the `node_attrs_include` or `edge_attrs_differ` parameters to relax + simplification strictness. For example, `edge_attrs_differ=["osmid"]` will + retain every node whose incident edges have different OSM IDs. This lets + you keep nodes at elbow two-way intersections (but be aware that sometimes + individual blocks have multiple OSM IDs within them too). You could also + use this parameter to retain nodes where sidewalks or bike lanes begin/end + in the middle of a block. Or for example, `node_attrs_include=["highway"]` + will retain every node with a "highway" attribute (regardless of its + value), even if it does not represent a street junction. Parameters ---------- - G : networkx.MultiDiGraph - input graph - strict : bool - deprecated, do not use - edge_attrs_differ : iterable - An iterable of edge attribute names for relaxing the strictness of - endpoint determination. If not None, a node is an endpoint if its - incident edges have different values then each other for any of the - edge attributes in `edge_attrs_differ`. - endpoint_attrs : iterable - deprecated, do not use - remove_rings : bool - if True, remove isolated self-contained rings that have no endpoints - track_merged : bool - if True, add `merged_edges` attribute on simplified edges, containing - a list of all the (u, v) node pairs that were merged together + G + Input graph. + node_attrs_include + Node attribute names for relaxing the strictness of endpoint + determination. A node is always an endpoint if it possesses one or + more of the attributes in `node_attrs_include`. + edge_attrs_differ + Edge attribute names for relaxing the strictness of endpoint + determination. A node is always an endpoint if its incident edges have + different values than each other for any attribute in + `edge_attrs_differ`. + remove_rings + If True, remove any graph components that consist only of a single + chordless cycle (i.e., an isolated self-contained ring). + track_merged + If True, add `merged_edges` attribute on simplified edges, containing + a list of all the `(u, v)` node pairs that were merged together. + edge_attr_aggs + Allows user to aggregate edge segment attributes when simplifying an + edge. Keys are edge attribute names and values are aggregation + functions to apply to these attributes when they exist for a set of + edges being merged. Edge attributes not in `edge_attr_aggs` will + contain the unique values across the merged edge segments. If None, + defaults to `{"length": sum, "travel_time": sum}`. Returns ------- - G : networkx.MultiDiGraph - topologically simplified graph, with a new `geometry` attribute on - each simplified edge + G + Topologically simplified graph, with a new `geometry` attribute on + each simplified edge. """ - if endpoint_attrs is not None: - msg = ( - "The `endpoint_attrs` parameter has been deprecated and will be removed " - "in the v2.0.0 release. Use the `edge_attrs_differ` parameter instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - edge_attrs_differ = endpoint_attrs - - if strict is not None: - msg = ( - "The `strict` parameter has been deprecated and will be removed in " - "the v2.0.0 release. Use the `edge_attrs_differ` parameter instead to " - "relax simplification strictness. For example, `edge_attrs_differ=None` " - "reproduces the old `strict=True` behvavior and `edge_attrs_differ=['osmid']` " - "reproduces the old `strict=False` behavior. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - # maintain old behavior if strict is passed during deprecation - edge_attrs_differ = None if strict else ["osmid"] - - if "simplified" in G.graph and G.graph["simplified"]: # pragma: no cover + if G.graph.get("simplified"): # pragma: no cover msg = "This graph has already been simplified, cannot simplify it again." raise GraphSimplificationError(msg) - utils.log("Begin topologically simplifying the graph...") + msg = "Begin topologically simplifying the graph..." + utils.log(msg, level=lg.INFO) - # define edge segment attributes to sum upon edge simplification - attrs_to_sum = {"length", "travel_time"} + # default edge segment attributes to aggregate upon simplification + if edge_attr_aggs is None: + edge_attr_aggs = {"length": sum, "travel_time": sum} # make a copy to not mutate original graph object caller passed in G = G.copy() @@ -321,11 +358,11 @@ def simplify_graph( # noqa: C901 all_edges_to_add = [] # generate each path that needs to be simplified - for path in _get_paths_to_simplify(G, edge_attrs_differ): + for path in _get_paths_to_simplify(G, node_attrs_include, edge_attrs_differ): # add the interstitial edges we're removing to a list so we can retain # their spatial geometry merged_edges = [] - path_attributes = {} + path_attributes: dict[str, Any] = {} for u, v in zip(path[:-1], path[1:]): if track_merged: # keep track of the edges that were merged @@ -336,14 +373,15 @@ def simplify_graph( # noqa: C901 # street... we will keep only one of the edges (see below) edge_count = G.number_of_edges(u, v) if edge_count != 1: - utils.log(f"Found {edge_count} edges between {u} and {v} when simplifying") + msg = f"Found {edge_count} edges between {u} and {v} when simplifying" + utils.log(msg, level=lg.WARNING) # get edge between these nodes: if multiple edges exist between # them (see above), we retain only one in the simplified graph # We can't assume that there exists an edge from u to v # with key=0, so we get a list of all edges from u to v # and just take the first one. - edge_data = list(G.get_edge_data(u, v).values())[0] + edge_data = next(iter(G.get_edge_data(u, v).values())) for attr in edge_data: if attr in path_attributes: # if this key already exists in the dict, append it to the @@ -356,20 +394,20 @@ def simplify_graph( # noqa: C901 # consolidate the path's edge segments' attribute values for attr in path_attributes: - if attr in attrs_to_sum: - # if this attribute must be summed, sum it now - path_attributes[attr] = sum(path_attributes[attr]) + if attr in edge_attr_aggs: + # if this attribute's values must be aggregated, do so now + agg_func = edge_attr_aggs[attr] + path_attributes[attr] = agg_func(path_attributes[attr]) elif len(set(path_attributes[attr])) == 1: - # if there's only 1 unique value in this attribute list, - # consolidate it to the single value (the zero-th): + # if there's only 1 unique value, keep that single value path_attributes[attr] = path_attributes[attr][0] else: - # otherwise, if there are multiple values, keep one of each + # otherwise, if there are multiple uniques, keep one of each path_attributes[attr] = list(set(path_attributes[attr])) # construct the new consolidated edge's geometry for this path path_attributes["geometry"] = LineString( - [Point((G.nodes[node]["x"], G.nodes[node]["y"])) for node in path] + [Point((G.nodes[node]["x"], G.nodes[node]["y"])) for node in path], ) if track_merged: @@ -379,7 +417,7 @@ def simplify_graph( # noqa: C901 # add the nodes and edge to their lists for processing at the end all_nodes_to_remove.extend(path[1:-1]) all_edges_to_add.append( - {"origin": path[0], "destination": path[-1], "attr_dict": path_attributes} + {"origin": path[0], "destination": path[-1], "attr_dict": path_attributes}, ) # for each edge to add in the list we assembled, create a new edge between @@ -391,7 +429,7 @@ def simplify_graph( # noqa: C901 G.remove_nodes_from(set(all_nodes_to_remove)) if remove_rings: - G = _remove_rings(G, edge_attrs_differ) + G = _remove_rings(G, node_attrs_include, edge_attrs_differ) # mark the graph as having been simplified G.graph["simplified"] = True @@ -400,33 +438,40 @@ def simplify_graph( # noqa: C901 f"Simplified graph: {initial_node_count:,} to {len(G):,} nodes, " f"{initial_edge_count:,} to {len(G.edges):,} edges" ) - utils.log(msg) + utils.log(msg, level=lg.INFO) return G def consolidate_intersections( - G, tolerance=10, rebuild_graph=True, dead_ends=False, reconnect_edges=True -): + G: nx.MultiDiGraph, + *, + tolerance: float | dict[int, float] = 10, + rebuild_graph: bool = True, + dead_ends: bool = False, + reconnect_edges: bool = True, + node_attr_aggs: dict[str, Any] | None = None, +) -> nx.MultiDiGraph | gpd.GeoSeries: """ Consolidate intersections comprising clusters of nearby nodes. Merges nearby nodes and returns either their centroids or a rebuilt graph with consolidated intersections and reconnected edge geometries. The - tolerance argument should be adjusted to approximately match street design - standards in the specific street network, and you should always use a - projected graph to work in meaningful and consistent units like meters. - Note the tolerance represents a per-node buffering radius: for example, to - consolidate nodes within 10 meters of each other, use tolerance=5. + `tolerance` argument can be a single value applied to all nodes or + individual per-node values. It should be adjusted to approximately match + street design standards in the specific street network, and you should use + a projected graph to work in meaningful and consistent units like meters. + Note: `tolerance` represents a per-node buffering radius. For example, to + consolidate nodes within 10 meters of each other, use `tolerance=5`. - When rebuild_graph=False, it uses a purely geometrical (and relatively + When `rebuild_graph` is False, it uses a purely geometric (and relatively fast) algorithm to identify "geometrically close" nodes, merge them, and - return just the merged intersections' centroids. When rebuild_graph=True, + return the merged intersections' centroids. When `rebuild_graph` is True, it uses a topological (and slower but more accurate) algorithm to identify "topologically close" nodes, merge them, then rebuild/return the graph. - Returned graph's node IDs represent clusters rather than osmids. Refer to - nodes' osmid_original attributes for original osmids. If multiple nodes - were merged together, the osmid_original attribute is a list of merged - nodes' osmids. + Returned graph's node IDs represent clusters rather than "osmid" values. + Refer to nodes' "osmid_original" attributes for original "osmid" values. + If multiple nodes were merged together, the "osmid_original" attribute is + a list of merged nodes' "osmid" values. Divided roads are often represented by separate centerline edges. The intersection of two divided roads thus creates 4 nodes, representing where @@ -438,33 +483,41 @@ def consolidate_intersections( Parameters ---------- - G : networkx.MultiDiGraph - a projected graph - tolerance : float - nodes are buffered to this distance (in graph's geometry's units) and - subsequent overlaps are dissolved into a single node - rebuild_graph : bool - if True, consolidate the nodes topologically, rebuild the graph, and - return as networkx.MultiDiGraph. if False, consolidate the nodes - geometrically and return the consolidated node points as - geopandas.GeoSeries - dead_ends : bool - if False, discard dead-end nodes to return only street-intersection - points - reconnect_edges : bool - ignored if rebuild_graph is not True. if True, reconnect edges and - their geometries in rebuilt graph to the consolidated nodes and update - edge length attributes; if False, returned graph has no edges (which - is faster if you just need topologically consolidated intersection - counts). + G + A projected graph. + tolerance + Nodes are buffered to this distance (in graph's geometry's units) and + subsequent overlaps are dissolved into a single node. If scalar, then + that single value will be used for all nodes. If dict (mapping node + IDs to individual values), then those values will be used per node and + any missing node IDs will not be buffered. + rebuild_graph + If True, consolidate the nodes topologically, rebuild the graph, and + return as MultiDiGraph. Otherwise, consolidate the nodes geometrically + and return the consolidated node points as GeoSeries. + dead_ends + If False, discard dead-end nodes to return only street-intersection + points. + reconnect_edges + If True, reconnect edges (and their geometries) to the consolidated + nodes in rebuilt graph, and update the edge length attributes. If + False, the returned graph has no edges (which is faster if you just + need topologically consolidated intersection counts). Ignored if + `rebuild_graph` is not True. + node_attr_aggs + Allows user to aggregate node attributes values when merging nodes. + Keys are node attribute names and values are aggregation functions + (anything accepted as an argument by `pandas.agg`). Node attributes + not in `node_attr_aggs` will contain the unique values across the + merged nodes. If None, defaults to `{"elevation": numpy.mean}`. Returns ------- - networkx.MultiDiGraph or geopandas.GeoSeries - if rebuild_graph=True, returns MultiDiGraph with consolidated - intersections and reconnected edge geometries. if rebuild_graph=False, - returns GeoSeries of shapely Points representing the centroids of - street intersections + G or gs + If `rebuild_graph=True`, returns MultiDiGraph with consolidated + intersections and (optionally) reconnected edge geometries. If + `rebuild_graph=False`, returns GeoSeries of Points representing the + centroids of street intersections. """ # if dead_ends is False, discard dead-ends to retain only intersections if not dead_ends: @@ -476,15 +529,20 @@ def consolidate_intersections( G.remove_nodes_from(dead_end_nodes) if rebuild_graph: - if not G or not G.edges: + if len(G.nodes) == 0 or len(G.edges) == 0: # cannot rebuild a graph with no nodes or no edges, just return it return G # otherwise - return _consolidate_intersections_rebuild_graph(G, tolerance, reconnect_edges) + return _consolidate_intersections_rebuild_graph( + G, + tolerance, + reconnect_edges, + node_attr_aggs, + ) # otherwise, if we're not rebuilding the graph - if not G: + if len(G) == 0: # if graph has no nodes, just return empty GeoSeries return gpd.GeoSeries(crs=G.graph["crs"]) @@ -492,67 +550,90 @@ def consolidate_intersections( return _merge_nodes_geometric(G, tolerance).centroid -def _merge_nodes_geometric(G, tolerance): +def _merge_nodes_geometric( + G: nx.MultiDiGraph, + tolerance: float | dict[int, float], +) -> gpd.GeoSeries: """ Geometrically merge nodes within some distance of each other. Parameters ---------- - G : networkx.MultiDiGraph - a projected graph - tolerance : float - buffer nodes to this distance (in graph's geometry's units) then merge - overlapping polygons into a single polygon via a unary union operation + G + A projected graph. + tolerance + Nodes are buffered to this distance (in graph's geometry's units) and + subsequent overlaps are dissolved into a single node. If scalar, then + that single value will be used for all nodes. If dict (mapping node + IDs to individual values), then those values will be used per node and + any missing node IDs will not be buffered. Returns ------- - merged : GeoSeries - the merged overlapping polygons of the buffered nodes + merged + The merged overlapping polygons of the buffered nodes. """ - # buffer nodes GeoSeries then get unary union to merge overlaps - merged = convert.graph_to_gdfs(G, edges=False)["geometry"].buffer(tolerance).unary_union - - # if only a single node results, make it iterable to convert to GeoSeries - merged = MultiPolygon([merged]) if isinstance(merged, Polygon) else merged - return gpd.GeoSeries(merged.geoms, crs=G.graph["crs"]) - - -def _consolidate_intersections_rebuild_graph(G, tolerance=10, reconnect_edges=True): + gdf_nodes = convert.graph_to_gdfs(G, edges=False) + + if isinstance(tolerance, dict): + # create series of tolerances reindexed like nodes, then buffer, then + # fill nulls (resulting from missing tolerances) with original points, + # then merge overlapping geometries + tols = pd.Series(tolerance).reindex(gdf_nodes.index) + merged = gdf_nodes.buffer(tols).fillna(gdf_nodes["geometry"]).unary_union + else: + # buffer nodes then merge overlapping geometries + merged = gdf_nodes.buffer(tolerance).unary_union + + # extract the member geometries if it's a multi-geometry + merged = merged.geoms if hasattr(merged, "geoms") else merged + return gpd.GeoSeries(merged, crs=G.graph["crs"]) + + +def _consolidate_intersections_rebuild_graph( # noqa: C901,PLR0912,PLR0915 + G: nx.MultiDiGraph, + tolerance: float | dict[int, float], + reconnect_edges: bool, # noqa: FBT001 + node_attr_aggs: dict[str, Any] | None, +) -> nx.MultiDiGraph: """ Consolidate intersections comprising clusters of nearby nodes. Merge nodes and return a rebuilt graph with consolidated intersections and reconnected edge geometries. - The tolerance argument should be adjusted to approximately match street - design standards in the specific street network, and you should always use - a projected graph to work in meaningful and consistent units like meters. - - Returned graph's node IDs represent clusters rather than osmids. Refer to - nodes' osmid_original attributes for original osmids. If multiple nodes - were merged together, the osmid_original attribute is a list of merged - nodes' osmids. - Parameters ---------- - G : networkx.MultiDiGraph - a projected graph - tolerance : float - nodes are buffered to this distance (in graph's geometry's units) and - subsequent overlaps are dissolved into a single node - reconnect_edges : bool - ignored if rebuild_graph is not True. if True, reconnect edges and - their geometries in rebuilt graph to the consolidated nodes and update - edge length attributes; if False, returned graph has no edges (which - is faster if you just need topologically consolidated intersection - counts). + G + A projected graph. + tolerance + Nodes are buffered to this distance (in graph's geometry's units) and + subsequent overlaps are dissolved into a single node. If scalar, then + that single value will be used for all nodes. If dict (mapping node + IDs to individual values), then those values will be used per node and + any missing node IDs will not be buffered. + reconnect_edges + If True, reconnect edges (and their geometries) to the consolidated + nodes in rebuilt graph, and update the edge length attributes. If + False, the returned graph has no edges (which is faster if you just + need topologically consolidated intersection counts). + node_attr_aggs + Allows user to aggregate node attributes values when merging nodes. + Keys are node attribute names and values are aggregation functions + (anything accepted as an argument by `pandas.agg`). Node attributes + not in `node_attr_aggs` will contain the unique values across the + merged nodes. If None, defaults to `{"elevation": numpy.mean}`. Returns ------- - H : networkx.MultiDiGraph - a rebuilt graph with consolidated intersections and reconnected - edge geometries + Gc + A rebuilt graph with consolidated intersections and (optionally) + reconnected edge geometries. """ + # default node attributes to aggregate upon consolidation + if node_attr_aggs is None: + node_attr_aggs = {"elevation": np.mean} + # STEP 1 # buffer nodes to passed-in distance and merge overlaps. turn merged nodes # into gdf and get centroids of each cluster as x, y @@ -565,7 +646,7 @@ def _consolidate_intersections_rebuild_graph(G, tolerance=10, reconnect_edges=Tr # attach each node to its cluster of merged nodes. first get the original # graph's node points then spatial join to give each node the label of # cluster it's within. make cluster labels type string. - node_points = convert.graph_to_gdfs(G, edges=False)[["geometry"]] + node_points = convert.graph_to_gdfs(G, edges=False).drop(columns=["x", "y"]) gdf = gpd.sjoin(node_points, node_clusters, how="left", predicate="within") gdf = gdf.drop(columns="geometry").rename(columns={"index_right": "cluster"}) gdf["cluster"] = gdf["cluster"].astype(str) @@ -575,8 +656,7 @@ def _consolidate_intersections_rebuild_graph(G, tolerance=10, reconnect_edges=Tr # move each component to its own cluster (otherwise you will connect # nodes together that are not truly connected, e.g., nearby deadends or # surface streets with bridge). - groups = gdf.groupby("cluster") - for cluster_label, nodes_subset in groups: + for cluster_label, nodes_subset in gdf.groupby("cluster"): if len(nodes_subset) > 1: # identify all the (weakly connected) component in cluster wccs = list(nx.weakly_connected_components(G.subgraph(nodes_subset.index))) @@ -596,8 +676,8 @@ def _consolidate_intersections_rebuild_graph(G, tolerance=10, reconnect_edges=Tr # STEP 4 # create new empty graph and copy over misc graph data - H = nx.MultiDiGraph() - H.graph = G.graph + Gc = nx.MultiDiGraph() + Gc.graph = G.graph # STEP 5 # create a new node for each cluster of merged nodes @@ -608,26 +688,37 @@ def _consolidate_intersections_rebuild_graph(G, tolerance=10, reconnect_edges=Tr if len(osmids) == 1: # if cluster is a single node, add that node to new graph osmid = osmids[0] - H.add_node(cluster_label, osmid_original=osmid, **G.nodes[osmid]) + Gc.add_node(cluster_label, osmid_original=osmid, **G.nodes[osmid]) else: - # if cluster is multiple merged nodes, create one new node to - # represent them - H.add_node( - cluster_label, - osmid_original=str(osmids), - x=nodes_subset["x"].iloc[0], - y=nodes_subset["y"].iloc[0], - ) - - # calculate street_count attribute for all nodes lacking it - null_nodes = [n for n, sc in H.nodes(data="street_count") if sc is None] - street_count = stats.count_streets_per_node(H, nodes=null_nodes) - nx.set_node_attributes(H, street_count, name="street_count") - - if not G.edges or not reconnect_edges: + # if cluster is multiple merged nodes, create one new node with + # attributes to represent the merged nodes' non-null values + node_attrs = { + "osmid_original": osmids, + "x": nodes_subset["x"].iloc[0], + "y": nodes_subset["y"].iloc[0], + } + for col in set(nodes_subset.columns): + # get the unique non-null values (we won't add null attrs) + unique_vals = list(set(nodes_subset[col].dropna())) + if len(unique_vals) > 0 and col in node_attr_aggs: + # if this attribute's values must be aggregated, do so now + node_attrs[col] = nodes_subset[col].agg(node_attr_aggs[col]) + elif col == "street_count": + # if user doesn't specifically handle street_count with an + # agg function, just skip it here then calculate it later + continue + elif len(unique_vals) == 1: + # if there's 1 unique value for this attribute, keep it + node_attrs[col] = unique_vals[0] + elif len(unique_vals) > 1: + # if there are multiple unique values, keep one of each + node_attrs[col] = unique_vals + Gc.add_node(cluster_label, **node_attrs) + + if len(G.edges) == 0 or not reconnect_edges: # if reconnect_edges is False or there are no edges in original graph # (after dead-end removed), then skip edges and return new graph as-is - return H + return Gc # STEP 6 # create new edge from cluster to cluster for each edge in original graph @@ -643,7 +734,7 @@ def _consolidate_intersections_rebuild_graph(G, tolerance=10, reconnect_edges=Tr data["v_original"] = v if "geometry" not in data: data["geometry"] = gdf_edges.loc[(u, v, k), "geometry"] - H.add_edge(u2, v2, **data) + Gc.add_edge(u2, v2, **data) # STEP 7 # for every group of merged nodes with more than 1 node in it, extend the @@ -654,21 +745,26 @@ def _consolidate_intersections_rebuild_graph(G, tolerance=10, reconnect_edges=Tr if len(nodes_subset) > 1: # get coords of merged nodes point centroid to prepend or # append to the old edge geom's coords - x = H.nodes[cluster_label]["x"] - y = H.nodes[cluster_label]["y"] + x = Gc.nodes[cluster_label]["x"] + y = Gc.nodes[cluster_label]["y"] xy = [(x, y)] # for each edge incident on this new merged node, update its # geometry to extend to/from the new node's point coords - in_edges = set(H.in_edges(cluster_label, keys=True)) - out_edges = set(H.out_edges(cluster_label, keys=True)) + in_edges = set(Gc.in_edges(cluster_label, keys=True)) + out_edges = set(Gc.out_edges(cluster_label, keys=True)) for u, v, k in in_edges | out_edges: - old_coords = list(H.edges[u, v, k]["geometry"].coords) + old_coords = list(Gc.edges[u, v, k]["geometry"].coords) new_coords = xy + old_coords if cluster_label == u else old_coords + xy new_geom = LineString(new_coords) - H.edges[u, v, k]["geometry"] = new_geom + Gc.edges[u, v, k]["geometry"] = new_geom # update the edge length attribute, given the new geometry - H.edges[u, v, k]["length"] = new_geom.length + Gc.edges[u, v, k]["length"] = new_geom.length + + # calculate street_count attribute for all nodes lacking it + null_nodes = [n for n, sc in Gc.nodes(data="street_count") if sc is None] + street_counts = stats.count_streets_per_node(Gc, nodes=null_nodes) + nx.set_node_attributes(Gc, street_counts, name="street_count") - return H + return Gc diff --git a/osmnx/speed.py b/osmnx/speed.py deleted file mode 100644 index ec555512d..000000000 --- a/osmnx/speed.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Calculate graph edge speeds and travel times.""" - -from warnings import warn - -import numpy as np - -from . import routing - - -def add_edge_speeds(G, hwy_speeds=None, fallback=None, precision=None, agg=np.mean): - """ - Do not use: deprecated. - - Use the `routing.add_edge_speeds` function instead. - - Parameters - ---------- - G : networkx.MultiDiGraph - deprecated, do not use - hwy_speeds : dict - deprecated, do not use - fallback : numeric - deprecated, do not use - precision : int - deprecated, do not use - agg : function - deprecated, do not use - - Returns - ------- - G : networkx.MultiDiGraph - """ - msg = ( - "The `add_edge_speeds` function has moved to the `routing` module. Calling " - "`speed.add_edge_speeds` is deprecated and will be removed in the " - "v2.0.0 release. Call it via `routing.add_edge_speeds` instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - return routing.add_edge_speeds(G, hwy_speeds, fallback, precision, agg) - - -def add_edge_travel_times(G, precision=None): - """ - Do not use: deprecated. - - Use the `routing.add_edge_travel_times` function instead. - - Parameters - ---------- - G : networkx.MultiDiGraph - deprecated, do not use - precision : int - deprecated, do not use - - Returns - ------- - G : networkx.MultiDiGraph - """ - msg = ( - "The `add_edge_travel_times` function has moved to the `routing` module. Calling " - "`speed.add_edge_travel_times` is deprecated and will be removed in the " - "v2.0.0 release. Call it via `routing.add_edge_travel_times` instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - return routing.add_edge_travel_times(G, precision) diff --git a/osmnx/stats.py b/osmnx/stats.py index 2f7314866..62cd42cab 100644 --- a/osmnx/stats.py +++ b/osmnx/stats.py @@ -4,17 +4,20 @@ This module defines streets as the edges in an undirected representation of the graph. Using undirected graph edges prevents double-counting bidirectional edges of a two-way street, but may double-count a divided road's separate -centerlines with different end point nodes. If `clean_periphery=True` when the -graph was created (which is the default parameterization), then you will get -accurate node degrees (and in turn streets-per-node counts) even at the -periphery of the graph. +centerlines with different end point nodes. Due to OSMnx's periphery cleaning +when the graph was created, you will get accurate node degrees (and in turn +streets-per-node counts) even at the periphery of the graph. You can use NetworkX directly for additional topological network measures. """ -import itertools +from __future__ import annotations + import logging as lg from collections import Counter +from itertools import chain +from typing import TYPE_CHECKING +from typing import Any import networkx as nx import numpy as np @@ -25,85 +28,93 @@ from . import simplification from . import utils +if TYPE_CHECKING: + from collections.abc import Iterable + -def streets_per_node(G): +def streets_per_node(G: nx.MultiDiGraph) -> dict[int, int]: """ - Count streets (undirected edges) incident on each node. + Retrieve nodes' `street_count` attribute values. + + See also the `count_streets_per_node` function for the calculation. Parameters ---------- - G : networkx.MultiDiGraph - input graph + G + Input graph. Returns ------- - spn : dict - dictionary with node ID keys and street count values + spn + Dictionary with node ID keys and street count values. """ - spn = dict(nx.get_node_attributes(G, "street_count")) + # ensure each count value has type int (otherwise could be type np.int64) + # if user has projected the graph bc GeoDataFrames use np.int64 for ints + spn = {k: int(v) for k, v in nx.get_node_attributes(G, "street_count").items()} if set(spn) != set(G.nodes): - utils.log("Graph nodes changed since `street_count`s were calculated", level=lg.WARNING) + msg = "Graph nodes changed since `street_count`s were calculated" + utils.log(msg, level=lg.WARNING) return spn -def streets_per_node_avg(G): +def streets_per_node_avg(G: nx.MultiDiGraph) -> float: """ Calculate graph's average count of streets per node. Parameters ---------- - G : networkx.MultiDiGraph - input graph + G + Input graph. Returns ------- - spna : float - average count of streets per node + spna + Average count of streets per node. """ spn_vals = streets_per_node(G).values() - return sum(spn_vals) / len(G.nodes) + return float(sum(spn_vals) / len(G.nodes)) -def streets_per_node_counts(G): +def streets_per_node_counts(G: nx.MultiDiGraph) -> dict[int, int]: """ Calculate streets-per-node counts. Parameters ---------- - G : networkx.MultiDiGraph - input graph + G + Input graph. Returns ------- - spnc : dict - dictionary keyed by count of streets incident on each node, and with - values of how many nodes in the graph have this count + spnc + Dictionary keyed by count of streets incident on each node, and with + values of how many nodes in the graph have this count. """ spn_vals = list(streets_per_node(G).values()) return {i: spn_vals.count(i) for i in range(int(max(spn_vals)) + 1)} -def streets_per_node_proportions(G): +def streets_per_node_proportions(G: nx.MultiDiGraph) -> dict[int, float]: """ Calculate streets-per-node proportions. Parameters ---------- - G : networkx.MultiDiGraph - input graph + G + Input graph. Returns ------- - spnp : dict - dictionary keyed by count of streets incident on each node, and with - values of what proportion of nodes in the graph have this count + spnp + Dictionary keyed by count of streets incident on each node, and with + values of what proportion of nodes in the graph have this count. """ n = len(G.nodes) spnc = streets_per_node_counts(G) return {i: count / n for i, count in spnc.items()} -def intersection_count(G=None, min_streets=2): +def intersection_count(G: nx.MultiDiGraph, *, min_streets: int = 2) -> int: """ Count the intersections in a graph. @@ -112,80 +123,84 @@ def intersection_count(G=None, min_streets=2): Parameters ---------- - G : networkx.MultiDiGraph - input graph - min_streets : int - a node must have at least `min_streets` incident on them to count as - an intersection + G + Input graph. + min_streets + A node must have at least `min_streets` incident on them to count as + an intersection. Returns ------- - count : int - count of intersections in graph + count + Count of intersections in graph. """ spn = streets_per_node(G) node_ids = set(G.nodes) - return sum(count >= min_streets and node in node_ids for node, count in spn.items()) + count = sum(c >= min_streets and n in node_ids for n, c in spn.items()) + + # ensure count value has type int (otherwise could be type np.int64) if + # user has projected the graph bc GeoDataFrames use np.int64 for ints + return int(count) -def street_segment_count(Gu): +def street_segment_count(Gu: nx.MultiGraph) -> int: """ Count the street segments in a graph. Parameters ---------- - Gu : networkx.MultiGraph - undirected input graph + Gu + Undirected input graph. Returns ------- - count : int - count of street segments in graph + count + Count of street segments in graph. """ if nx.is_directed(Gu): # pragma: no cover - msg = "`Gu` must be undirected" + msg = "`Gu` must be undirected." raise ValueError(msg) return len(Gu.edges) -def street_length_total(Gu): +def street_length_total(Gu: nx.MultiGraph) -> float: """ Calculate graph's total street segment length. Parameters ---------- - Gu : networkx.MultiGraph - undirected input graph + Gu + Undirected input graph. Returns ------- - length : float - total length (meters) of streets in graph + length + Total length (meters) of streets in graph. """ if nx.is_directed(Gu): # pragma: no cover - msg = "`Gu` must be undirected" + msg = "`Gu` must be undirected." raise ValueError(msg) - return sum(d["length"] for u, v, d in Gu.edges(data=True)) + return float(sum(d["length"] for u, v, d in Gu.edges(data=True))) -def edge_length_total(G): +def edge_length_total(G: nx.MultiGraph) -> float: """ Calculate graph's total edge length. Parameters ---------- - G : networkx.MultiDiGraph - input graph + G + Input graph. Returns ------- - length : float - total length (meters) of edges in graph + length + Total length (meters) of edges in graph. """ - return sum(d["length"] for u, v, d in G.edges(data=True)) + return float(sum(d["length"] for u, v, d in G.edges(data=True))) -def self_loop_proportion(Gu): +def self_loop_proportion(Gu: nx.MultiGraph) -> float: """ Calculate percent of edges that are self-loops in a graph. @@ -193,49 +208,46 @@ def self_loop_proportion(Gu): Parameters ---------- - Gu : networkx.MultiGraph - undirected input graph + Gu + Undirected input graph. Returns ------- - proportion : float - proportion of graph edges that are self-loops + proportion + Proportion of graph edges that are self-loops. """ if nx.is_directed(Gu): # pragma: no cover - msg = "`Gu` must be undirected" + msg = "`Gu` must be undirected." raise ValueError(msg) - return sum(u == v for u, v, k in Gu.edges) / len(Gu.edges) + return float(sum(u == v for u, v, k in Gu.edges) / len(Gu.edges)) -def circuity_avg(Gu): +def circuity_avg(Gu: nx.MultiGraph) -> float | None: """ Calculate average street circuity using edges of undirected graph. Circuity is the sum of edge lengths divided by the sum of straight-line distances between edge endpoints. Calculates straight-line distance as euclidean distance if projected or great-circle distance if unprojected. + Returns None if the edge lengths sum to zero. Parameters ---------- - Gu : networkx.MultiGraph - undirected input graph + Gu + Undirected input graph. Returns ------- - circuity_avg : float - the graph's average undirected edge circuity + circuity_avg + The graph's average undirected edge circuity. """ if nx.is_directed(Gu): # pragma: no cover - msg = "`Gu` must be undirected" + msg = "`Gu` must be undirected." raise ValueError(msg) # extract the edges' endpoint nodes' coordinates - coords = np.array( - [ - (Gu.nodes[u]["y"], Gu.nodes[u]["x"], Gu.nodes[v]["y"], Gu.nodes[v]["x"]) - for u, v, _ in Gu.edges - ] - ) + n = Gu.nodes + coords = np.array([(n[u]["y"], n[u]["x"], n[v]["y"], n[v]["x"]) for u, v, _ in Gu.edges]) y1 = coords[:, 0] x1 = coords[:, 1] y2 = coords[:, 2] @@ -251,12 +263,16 @@ def circuity_avg(Gu): # return the ratio, handling possible division by zero sl_dists_total = sl_dists[~np.isnan(sl_dists)].sum() try: - return edge_length_total(Gu) / sl_dists_total + return float(edge_length_total(Gu) / sl_dists_total) except ZeroDivisionError: return None -def count_streets_per_node(G, nodes=None): +def count_streets_per_node( + G: nx.MultiDiGraph, + *, + nodes: Iterable[int] | None = None, +) -> dict[int, int]: """ Count how many physical street segments connect to each node in a graph. @@ -270,17 +286,17 @@ def count_streets_per_node(G, nodes=None): Parameters ---------- - G : networkx.MultiDiGraph - input graph - nodes : list - which node IDs to get counts for. if None, use all graph nodes, - otherwise calculate counts only for these node IDs + G + Input graph. + nodes + Which node IDs to get counts for. If None, use all graph nodes. + Otherwise calculate counts only for these node IDs. Returns ------- - streets_per_node : dict - counts of how many physical streets connect to each node, with keys = - node ids and values = counts + streets_per_node + Counts of how many physical streets connect to each node, with keys = + node ids and values = counts. """ if nodes is None: nodes = G.nodes @@ -289,7 +305,7 @@ def count_streets_per_node(G, nodes=None): # appear twice in the undirected graph (u,v,0 and u,v,1 where u=v), but # one-way self-loops will appear only once Gu = G.to_undirected(reciprocal=False, as_view=True) - self_loop_edges = set(nx.selfloop_edges(Gu)) + self_loop_edges = set(nx.selfloop_edges(Gu, keys=False)) # get all non-self-loop undirected edges, including parallel edges non_self_loop_edges = [e for e in Gu.edges(keys=False) if e not in self_loop_edges] @@ -299,15 +315,21 @@ def count_streets_per_node(G, nodes=None): all_unique_edges = non_self_loop_edges + list(self_loop_edges) # flatten list of (u, v) edge tuples to count how often each node appears - edges_flat = itertools.chain.from_iterable(all_unique_edges) + edges_flat = chain.from_iterable(all_unique_edges) counts = Counter(edges_flat) streets_per_node = {node: counts[node] for node in nodes} - utils.log("Counted undirected street segments incident on each node") + msg = "Counted undirected street segments incident on each node" + utils.log(msg, level=lg.INFO) return streets_per_node -def basic_stats(G, area=None, clean_int_tol=None): +def basic_stats( + G: nx.MultiDiGraph, + *, + area: float | None = None, + clean_int_tol: float | None = None, +) -> dict[str, Any]: """ Calculate basic descriptive geometric and topological measures of a graph. @@ -316,21 +338,21 @@ def basic_stats(G, area=None, clean_int_tol=None): Parameters ---------- - G : networkx.MultiDiGraph - input graph - area : float - if not None, calculate density measures and use this value (in square - meters) as the denominator - clean_int_tol : float - if not None, calculate consolidated intersections count (and density, - if `area` is also provided) and use this tolerance value; refer to the + G + Input graph. + area + If not None, calculate density measures and use `area` (in square + meters) as the denominator. + clean_int_tol + If not None, calculate consolidated intersections count (and density, + if `area` is also provided) and use this tolerance value. Refer to the `simplification.consolidate_intersections` function documentation for - details + details. Returns ------- - stats : dict - dictionary containing the following keys + stats + Dictionary containing the following keys: - `circuity_avg` - see `circuity_avg` function documentation - `clean_intersection_count` - see `clean_intersection_count` function documentation - `clean_intersection_density_km` - `clean_intersection_count` per sq km @@ -353,7 +375,7 @@ def basic_stats(G, area=None, clean_int_tol=None): - `streets_per_node_proportions` - see `streets_per_node_proportions` function documentation """ Gu = convert.to_undirected(G) - stats = {} + stats: dict[str, Any] = {} stats["n"] = len(G.nodes) stats["m"] = len(G.edges) @@ -374,8 +396,11 @@ def basic_stats(G, area=None, clean_int_tol=None): if clean_int_tol: stats["clean_intersection_count"] = len( simplification.consolidate_intersections( - G, tolerance=clean_int_tol, rebuild_graph=False, dead_ends=False - ) + G, + tolerance=clean_int_tol, + rebuild_graph=False, + dead_ends=False, + ), ) # can only calculate density measures if area was provided diff --git a/osmnx/truncate.py b/osmnx/truncate.py index 434a75e23..8ca89cdd0 100644 --- a/osmnx/truncate.py +++ b/osmnx/truncate.py @@ -1,178 +1,135 @@ """Truncate graph by distance, bounding box, or polygon.""" -from warnings import warn +from __future__ import annotations + +import logging as lg +from typing import TYPE_CHECKING import networkx as nx from . import convert from . import utils from . import utils_geo -from . import utils_graph + +if TYPE_CHECKING: + from shapely import MultiPolygon + from shapely import Polygon -def truncate_graph_dist(G, source_node, max_dist=1000, weight="length", retain_all=False): +def truncate_graph_dist( + G: nx.MultiDiGraph, + source_node: int, + dist: float, + *, + weight: str = "length", +) -> nx.MultiDiGraph: """ - Remove every node farther than some network distance from source_node. + Remove from a graph every node beyond some network distance from a node. - This function can be slow for large graphs, as it must calculate shortest - path distances between source_node and every other graph node. + This function must calculate shortest path distances between `source_node` + and every other graph node, which can be slow on large graphs. Parameters ---------- - G : networkx.MultiDiGraph - input graph - source_node : int - node in graph from which to measure network distances to other nodes - max_dist : float - remove every node in the graph that is greater than this distance (in - same units as `weight` attribute) along the network from `source_node` - weight : string - graph edge attribute to use to measure distance - retain_all : bool - if True, return the entire graph even if it is not connected. - otherwise, retain only the largest weakly connected component. + G + Input graph. + source_node + Node from which to measure network distances to all other nodes. + dist + Remove every node in the graph that is greater than `dist` distance + (in same units as `weight` attribute) along the network from + `source_node`. + weight + Graph edge attribute to use to measure distance. Returns ------- - G : networkx.MultiDiGraph - the truncated graph + G + The truncated graph. """ # get the shortest distance between the node and every other node distances = nx.shortest_path_length(G, source=source_node, weight=weight) - # then identify every node further than max_dist away - distant_nodes = {k for k, v in distances.items() if v > max_dist} + # then identify every node further than dist away + distant_nodes = {k for k, v in distances.items() if v > dist} unreachable_nodes = G.nodes - distances.keys() # make a copy to not mutate original graph object caller passed in G = G.copy() G.remove_nodes_from(distant_nodes | unreachable_nodes) - # remove any isolated nodes and retain only the largest component (if - # retain_all is True) - if not retain_all: - G = utils_graph.remove_isolated_nodes(G, warn=False) - G = largest_component(G) - - utils.log(f"Truncated graph by {weight}-weighted network distance") + msg = f"Truncated graph by {weight}-weighted network distance" + utils.log(msg, level=lg.INFO) return G def truncate_graph_bbox( - G, - north=None, - south=None, - east=None, - west=None, - bbox=None, - truncate_by_edge=False, - retain_all=False, - quadrat_width=None, - min_num=None, -): + G: nx.MultiDiGraph, + bbox: tuple[float, float, float, float], + *, + truncate_by_edge: bool = False, +) -> nx.MultiDiGraph: """ - Remove every node in graph that falls outside a bounding box. + Remove from a graph every node that falls outside a bounding box. Parameters ---------- - G : networkx.MultiDiGraph - input graph - north : float - deprecated, do not use - south : float - deprecated, do not use - east : float - deprecated, do not use - west : float - deprecated, do not use - bbox : tuple of floats - bounding box as (north, south, east, west) - truncate_by_edge : bool - if True, retain nodes outside bounding box if at least one of node's - neighbors is within the bounding box - retain_all : bool - if True, return the entire graph even if it is not connected. - otherwise, retain only the largest weakly connected component. - quadrat_width : float - deprecated, do not use - min_num : int - deprecated, do not use + G + Input graph. + bbox + Bounding box as `(north, south, east, west)`. + truncate_by_edge + If True, retain nodes outside bounding box if at least one of node's + neighbors is within the bounding box. Returns ------- - G : networkx.MultiDiGraph - the truncated graph + G + The truncated graph. """ - if not (north is None and south is None and east is None and west is None): - msg = ( - "The `north`, `south`, `east`, and `west` parameters are deprecated and " - "will be removed in the v2.0.0 release. Use the `bbox` parameter instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - bbox = (north, south, east, west) - # convert bounding box to a polygon, then truncate polygon = utils_geo.bbox_to_poly(bbox=bbox) - G = truncate_graph_polygon( - G, - polygon, - retain_all=retain_all, - truncate_by_edge=truncate_by_edge, - quadrat_width=quadrat_width, - min_num=min_num, - ) - - utils.log("Truncated graph by bounding box") + G = truncate_graph_polygon(G, polygon, truncate_by_edge=truncate_by_edge) + + msg = "Truncated graph by bounding box" + utils.log(msg, level=lg.INFO) return G def truncate_graph_polygon( - G, polygon, retain_all=False, truncate_by_edge=False, quadrat_width=None, min_num=None -): + G: nx.MultiDiGraph, + polygon: Polygon | MultiPolygon, + *, + truncate_by_edge: bool = False, +) -> nx.MultiDiGraph: """ - Remove every node in graph that falls outside a (Multi)Polygon. + Remove from a graph every node that falls outside a (Multi)Polygon. Parameters ---------- - G : networkx.MultiDiGraph - input graph - polygon : shapely.geometry.Polygon or shapely.geometry.MultiPolygon - only retain nodes in graph that lie within this geometry - retain_all : bool - if True, return the entire graph even if it is not connected. - otherwise, retain only the largest weakly connected component. - truncate_by_edge : bool - if True, retain nodes outside boundary polygon if at least one of - node's neighbors is within the polygon - quadrat_width : float - deprecated, do not use - min_num : int - deprecated, do not use + G + Input graph. + polygon + Only retain nodes in graph that lie within this geometry. + truncate_by_edge + If True, retain nodes outside boundary polygon if at least one of + node's neighbors is within the polygon. Returns ------- - G : networkx.MultiDiGraph - the truncated graph + G + The truncated graph. """ - if quadrat_width is not None or min_num is not None: - warn( - "The `quadrat_width` and `min_num` parameters are deprecated and " - "will be removed in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - - utils.log("Identifying all nodes that lie outside the polygon...") + msg = "Identifying all nodes that lie outside the polygon..." + utils.log(msg, level=lg.INFO) # first identify all nodes whose point geometries lie within the polygon - gs_nodes = convert.graph_to_gdfs(G, edges=False)[["geometry"]] + gs_nodes = convert.graph_to_gdfs(G, edges=False)["geometry"] to_keep = utils_geo._intersect_index_quadrats(gs_nodes, polygon) - if not to_keep: + if len(to_keep) == 0: # no graph nodes within the polygon: can't create a graph from that - msg = "Found no graph nodes within the requested polygon" + msg = "Found no graph nodes within the requested polygon." raise ValueError(msg) # now identify all nodes whose point geometries lie outside the polygon @@ -196,33 +153,30 @@ def truncate_graph_polygon( # make a copy to not mutate original graph object caller passed in G = G.copy() G.remove_nodes_from(nodes_to_remove) - utils.log(f"Removed {len(nodes_to_remove):,} nodes outside polygon") + msg = f"Removed {len(nodes_to_remove):,} nodes outside polygon" + utils.log(msg, level=lg.INFO) - if not retain_all: - # remove any isolated nodes and retain only the largest component - G = utils_graph.remove_isolated_nodes(G, warn=False) - G = largest_component(G) - - utils.log("Truncated graph by polygon") + msg = "Truncated graph by polygon" + utils.log(msg, level=lg.INFO) return G -def largest_component(G, strongly=False): +def largest_component(G: nx.MultiDiGraph, *, strongly: bool = False) -> nx.MultiDiGraph: """ - Get subgraph of G's largest weakly/strongly connected component. + Return `G`'s largest weakly or strongly connected component as a graph. Parameters ---------- - G : networkx.MultiDiGraph - input graph - strongly : bool - if True, return the largest strongly instead of weakly connected - component + G + Input graph. + strongly + If True, return the largest strongly connected component. Otherwise + return the largest weakly connected component. Returns ------- - G : networkx.MultiDiGraph - the largest connected component subgraph of the original graph + G + The largest connected component subgraph of the original graph. """ if strongly: kind = "strongly" @@ -240,6 +194,8 @@ def largest_component(G, strongly=False): # induce (frozen) subgraph then unfreeze it by making new MultiDiGraph G = nx.MultiDiGraph(G.subgraph(largest_cc)) - utils.log(f"Got largest {kind} connected component ({len(G):,} of {n:,} total nodes)") + + msg = f"Got largest {kind} connected component ({len(G):,} of {n:,} total nodes)" + utils.log(msg, level=lg.INFO) return G diff --git a/osmnx/utils.py b/osmnx/utils.py index 6ab04cb9b..b982866a2 100644 --- a/osmnx/utils.py +++ b/osmnx/utils.py @@ -1,5 +1,7 @@ """General utility functions.""" +from __future__ import annotations + import datetime as dt import logging as lg import os @@ -7,12 +9,11 @@ import unicodedata as ud from contextlib import redirect_stdout from pathlib import Path -from warnings import warn from . import settings -def citation(style="bibtex"): +def citation(style: str = "bibtex") -> None: """ Print the OSMnx package's citation information. @@ -21,8 +22,9 @@ def citation(style="bibtex"): Parameters ---------- - style : string {"apa", "bibtex", "ieee"} - citation format, either APA or BibTeX or IEEE + style + {"apa", "bibtex", "ieee"} + The citation format, either APA or BibTeX or IEEE. Returns ------- @@ -49,217 +51,70 @@ def citation(style="bibtex"): "Working paper, https://geoffboeing.com/publications/osmnx-paper/" ) else: # pragma: no cover - err_msg = f"unrecognized citation style {style!r}" + err_msg = f"Invalid citation style {style!r}." raise ValueError(err_msg) print(msg) # noqa: T201 -def ts(style="datetime", template=None): +def ts(style: str = "datetime", template: str | None = None) -> str: """ Return current local timestamp as a string. Parameters ---------- - style : string {"datetime", "date", "time"} - format the timestamp with this built-in style - template : string - if not None, format the timestamp with this format string instead of - one of the built-in styles + style + {"datetime", "iso8601", "date", "time"} + Format the timestamp with this built-in style. + template + If not None, format the timestamp with this format string instead of + one of the built-in styles. Returns ------- - ts : string - local timestamp string + timestamp """ if template is None: if style == "datetime": template = "{:%Y-%m-%d %H:%M:%S}" + elif style == "iso8601": + template = "{:%Y-%m-%dT%H:%M:%SZ}" elif style == "date": template = "{:%Y-%m-%d}" elif style == "time": template = "{:%H:%M:%S}" else: # pragma: no cover - msg = f"unrecognized timestamp style {style!r}" + msg = f"Invalid timestamp style {style!r}." raise ValueError(msg) return template.format(dt.datetime.now().astimezone()) -def config( - all_oneway=settings.all_oneway, - bidirectional_network_types=settings.bidirectional_network_types, - cache_folder=settings.cache_folder, - cache_only_mode=settings.cache_only_mode, - data_folder=settings.data_folder, - default_accept_language=settings.default_accept_language, - default_access=settings.default_access, - default_crs=settings.default_crs, - default_referer=settings.default_referer, - default_user_agent=settings.default_user_agent, - imgs_folder=settings.imgs_folder, - log_console=settings.log_console, - log_file=settings.log_file, - log_filename=settings.log_filename, - log_level=settings.log_level, - log_name=settings.log_name, - logs_folder=settings.logs_folder, - max_query_area_size=settings.max_query_area_size, - memory=settings.memory, - nominatim_endpoint=settings.nominatim_endpoint, - nominatim_key=settings.nominatim_key, - osm_xml_node_attrs=settings.osm_xml_node_attrs, - osm_xml_node_tags=settings.osm_xml_node_tags, - osm_xml_way_attrs=settings.osm_xml_way_attrs, - osm_xml_way_tags=settings.osm_xml_way_tags, - overpass_endpoint=settings.overpass_endpoint, - overpass_rate_limit=settings.overpass_rate_limit, - overpass_settings=settings.overpass_settings, - requests_kwargs=settings.requests_kwargs, - timeout=settings.timeout, - use_cache=settings.use_cache, - useful_tags_node=settings.useful_tags_node, - useful_tags_way=settings.useful_tags_way, -): - """ - Do not use: deprecated. Use the settings module directly. - - Parameters - ---------- - all_oneway : bool - deprecated - bidirectional_network_types : list - deprecated - cache_folder : string or pathlib.Path - deprecated - data_folder : string or pathlib.Path - deprecated - cache_only_mode : bool - deprecated - default_accept_language : string - deprecated - default_access : string - deprecated - default_crs : string - deprecated - default_referer : string - deprecated - default_user_agent : string - deprecated - imgs_folder : string or pathlib.Path - deprecated - log_file : bool - deprecated - log_filename : string - deprecated - log_console : bool - deprecated - log_level : int - deprecated - log_name : string - deprecated - logs_folder : string or pathlib.Path - deprecated - max_query_area_size : int - deprecated - memory : int - deprecated - nominatim_endpoint : string - deprecated - nominatim_key : string - deprecated - osm_xml_node_attrs : list - deprecated - osm_xml_node_tags : list - deprecated - osm_xml_way_attrs : list - deprecated - osm_xml_way_tags : list - deprecated - overpass_endpoint : string - deprecated - overpass_rate_limit : bool - deprecated - overpass_settings : string - deprecated - requests_kwargs : dict - deprecated - timeout : int - deprecated - use_cache : bool - deprecated - useful_tags_node : list - deprecated - useful_tags_way : list - deprecated - - Returns - ------- - None - """ - warn( - "The `utils.config` function is deprecated and will be removed in " - "the v2.0.0 release. Instead, use the `settings` module directly to " - "configure a global setting's value. For example, " - "`ox.settings.log_console=True`. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - - # set each global setting to the argument value - settings.all_oneway = all_oneway - settings.bidirectional_network_types = bidirectional_network_types - settings.cache_folder = cache_folder - settings.cache_only_mode = cache_only_mode - settings.data_folder = data_folder - settings.default_accept_language = default_accept_language - settings.default_access = default_access - settings.default_crs = default_crs - settings.default_referer = default_referer - settings.default_user_agent = default_user_agent - settings.imgs_folder = imgs_folder - settings.log_console = log_console - settings.log_file = log_file - settings.log_filename = log_filename - settings.log_level = log_level - settings.log_name = log_name - settings.logs_folder = logs_folder - settings.max_query_area_size = max_query_area_size - settings.memory = memory - settings.nominatim_endpoint = nominatim_endpoint - settings.nominatim_key = nominatim_key - settings.osm_xml_node_attrs = osm_xml_node_attrs - settings.osm_xml_node_tags = osm_xml_node_tags - settings.osm_xml_way_attrs = osm_xml_way_attrs - settings.osm_xml_way_tags = osm_xml_way_tags - settings.overpass_endpoint = overpass_endpoint - settings.overpass_rate_limit = overpass_rate_limit - settings.overpass_settings = overpass_settings - settings.timeout = timeout - settings.use_cache = use_cache - settings.useful_tags_node = useful_tags_node - settings.useful_tags_way = useful_tags_way - settings.requests_kwargs = requests_kwargs - - -def log(message, level=None, name=None, filename=None): +def log( + message: str, + level: int | None = None, + name: str | None = None, + filename: str | None = None, +) -> None: """ Write a message to the logger. This logs to file and/or prints to the console (terminal), depending on - the current configuration of settings.log_file and settings.log_console. + the current configuration of `settings.log_file` and + `settings.log_console`. Parameters ---------- - message : string - the message to log - level : int - one of Python's logger.level constants - name : string - name of the logger - filename : string - name of the log file, without file extension + message + The message to log. + level + One of the Python `logger.level` constants. If None, set to + `settings.log_level` value. + name + The name of the logger. If None, set to `settings.log_name` value. + filename + The name of the log file, without file extension. If None, set to + `settings.log_filename` value. Returns ------- @@ -276,7 +131,7 @@ def log(message, level=None, name=None, filename=None): if settings.log_file: # get the current logger (or create a new one, if none), then log # message at requested level - logger = _get_logger(level=level, name=name, filename=filename) + logger = _get_logger(name=name, filename=filename) if level == lg.DEBUG: logger.debug(message) elif level == lg.INFO: @@ -296,8 +151,8 @@ def log(message, level=None, name=None, filename=None): # print explicitly to terminal in case Jupyter has captured stdout if getattr(sys.stdout, "_original_stdstream_copy", None) is not None: # redirect the Jupyter-captured pipe back to original - os.dup2(sys.stdout._original_stdstream_copy, sys.__stdout__.fileno()) - sys.stdout._original_stdstream_copy = None + os.dup2(sys.stdout._original_stdstream_copy, sys.__stdout__.fileno()) # type: ignore[attr-defined] + sys.stdout._original_stdstream_copy = None # type: ignore[attr-defined] with redirect_stdout(sys.__stdout__): print(message, file=sys.__stdout__, flush=True) except OSError: @@ -305,39 +160,34 @@ def log(message, level=None, name=None, filename=None): print(message, flush=True) # noqa: T201 -def _get_logger(level, name, filename): +def _get_logger(name: str, filename: str) -> lg.Logger: """ Create a logger or return the current one if already instantiated. Parameters ---------- - level : int - one of Python's logger.level constants - name : string - name of the logger - filename : string - name of the log file, without file extension + name + Name of the logger. + filename + Name of the log file, without file extension. Returns ------- - logger : logging.logger + logger """ logger = lg.getLogger(name) - # if a logger with this name is not already set up - if not getattr(logger, "handler_set", None): - # get today's date and construct a log filename - log_filename = Path(settings.logs_folder) / f'{filename}_{ts(style="date")}.log' - - # if the logs folder does not already exist, create it - log_filename.parent.mkdir(parents=True, exist_ok=True) + # if a logger with this name is not already set up with a handler + if len(logger.handlers) == 0: + # make log filepath and create parent folder if it doesn't exist + filepath = Path(settings.logs_folder) / f"{filename}_{ts(style='date')}.log" + filepath.parent.mkdir(parents=True, exist_ok=True) # create file handler and log formatter and set them up - handler = lg.FileHandler(log_filename, encoding="utf-8") - formatter = lg.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s") - handler.setFormatter(formatter) + handler = lg.FileHandler(filepath, encoding="utf-8") + handler.setLevel(lg.DEBUG) + handler.setFormatter(lg.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")) logger.addHandler(handler) - logger.setLevel(level) - logger.handler_set = True + logger.setLevel(lg.DEBUG) return logger diff --git a/osmnx/utils_geo.py b/osmnx/utils_geo.py index 1a4e26d51..b6c41fc48 100644 --- a/osmnx/utils_geo.py +++ b/osmnx/utils_geo.py @@ -1,15 +1,19 @@ """Geospatial utility functions.""" +from __future__ import annotations + +import logging as lg +from typing import TYPE_CHECKING +from typing import Any +from typing import Literal +from typing import overload from warnings import warn import networkx as nx import numpy as np -from shapely.geometry import LineString -from shapely.geometry import MultiLineString -from shapely.geometry import MultiPoint -from shapely.geometry import MultiPolygon -from shapely.geometry import Point -from shapely.geometry import Polygon +from shapely import LineString +from shapely import MultiPolygon +from shapely import Polygon from shapely.ops import split from . import convert @@ -17,8 +21,13 @@ from . import settings from . import utils +if TYPE_CHECKING: + from collections.abc import Iterator + + import geopandas as gpd + -def sample_points(G, n): +def sample_points(G: nx.MultiGraph, n: int) -> gpd.GeoSeries: """ Randomly sample points constrained to a spatial graph. @@ -29,21 +38,22 @@ def sample_points(G, n): Parameters ---------- - G : networkx.MultiGraph - graph from which to sample points. should be undirected (to avoid + G + Graph from which to sample points. Should be undirected (to avoid oversampling bidirectional edges) and projected (for accurate point - interpolation) - n : int - how many points to sample + interpolation). + n + How many points to sample. Returns ------- - points : geopandas.GeoSeries - the sampled points, multi-indexed by (u, v, key) of the edge from - which each point was drawn + point + The sampled points, multi-indexed by `(u, v, key)` of the edge from + which each point was sampled. """ if nx.is_directed(G): # pragma: no cover - warn("graph should be undirected to avoid oversampling bidirectional edges", stacklevel=2) + msg = "`G` should be undirected to avoid oversampling bidirectional edges." + warn(msg, category=UserWarning, stacklevel=2) gdf_edges = convert.graph_to_gdfs(G, nodes=False)[["geometry", "length"]] weights = gdf_edges["length"] / gdf_edges["length"].sum() idx = np.random.default_rng().choice(gdf_edges.index, size=n, p=weights) @@ -51,7 +61,7 @@ def sample_points(G, n): return lines.interpolate(np.random.default_rng().random(n), normalized=True) -def interpolate_points(geom, dist): +def interpolate_points(geom: LineString, dist: float) -> Iterator[tuple[float, float]]: """ Interpolate evenly spaced points along a LineString. @@ -60,16 +70,16 @@ def interpolate_points(geom, dist): Parameters ---------- - geom : shapely.geometry.LineString - a LineString geometry - dist : float - spacing distance between interpolated points, in same units as `geom`. - smaller values accordingly generate more points. + geom + A LineString geometry. + dist + Spacing distance between interpolated points, in same units as `geom`. + Smaller values accordingly generate more points. Yields ------ - points : generator - tuples of (x, y) floats of the interpolated points' coordinates + point + Interpolated point's `(x, y)` coordinates. """ if isinstance(geom, LineString): num_vert = max(round(geom.length / dist), 1) @@ -77,196 +87,37 @@ def interpolate_points(geom, dist): point = geom.interpolate(n / num_vert, normalized=True) yield point.x, point.y else: # pragma: no cover - msg = f"unhandled geometry type {geom.geom_type}" + msg = "`geom` must be a LineString." raise TypeError(msg) -def _round_polygon_coords(p, precision): - """ - Round the coordinates of a shapely Polygon to some decimal precision. - - Parameters - ---------- - p : shapely.geometry.Polygon - the polygon to round the coordinates of - precision : int - decimal precision to round coordinates to - - Returns - ------- - shapely.geometry.Polygon - """ - # round coords of Polygon exterior - shell = [[round(x, precision) for x in c] for c in p.exterior.coords] - - # round coords of (possibly multiple, possibly none) Polygon interior(s) - holes = [[[round(x, precision) for x in c] for c in i.coords] for i in p.interiors] - - # construct new Polygon with rounded coordinates and buffer by zero to - # clean self-touching or self-crossing polygons - return Polygon(shell=shell, holes=holes).buffer(0) - - -def _round_multipolygon_coords(mp, precision): - """ - Round the coordinates of a shapely MultiPolygon to some decimal precision. - - Parameters - ---------- - mp : shapely.geometry.MultiPolygon - the MultiPolygon to round the coordinates of - precision : int - decimal precision to round coordinates to - - Returns - ------- - shapely.geometry.MultiPolygon - """ - return MultiPolygon([_round_polygon_coords(p, precision) for p in mp.geoms]) - - -def _round_point_coords(pt, precision): - """ - Round the coordinates of a shapely Point to some decimal precision. - - Parameters - ---------- - pt : shapely.geometry.Point - the Point to round the coordinates of - precision : int - decimal precision to round coordinates to - - Returns - ------- - shapely.geometry.Point - """ - return Point([round(x, precision) for x in pt.coords[0]]) - - -def _round_multipoint_coords(mpt, precision): - """ - Round the coordinates of a shapely MultiPoint to some decimal precision. - - Parameters - ---------- - mpt : shapely.geometry.MultiPoint - the MultiPoint to round the coordinates of - precision : int - decimal precision to round coordinates to - - Returns - ------- - shapely.geometry.MultiPoint - """ - return MultiPoint([_round_point_coords(pt, precision) for pt in mpt.geoms]) - - -def _round_linestring_coords(ls, precision): - """ - Round the coordinates of a shapely LineString to some decimal precision. - - Parameters - ---------- - ls : shapely.geometry.LineString - the LineString to round the coordinates of - precision : int - decimal precision to round coordinates to - - Returns - ------- - shapely.geometry.LineString - """ - return LineString([[round(x, precision) for x in c] for c in ls.coords]) - - -def _round_multilinestring_coords(mls, precision): - """ - Round the coordinates of a shapely MultiLineString to some decimal precision. - - Parameters - ---------- - mls : shapely.geometry.MultiLineString - the MultiLineString to round the coordinates of - precision : int - decimal precision to round coordinates to - - Returns - ------- - shapely.geometry.MultiLineString - """ - return MultiLineString([_round_linestring_coords(ls, precision) for ls in mls.geoms]) - - -def round_geometry_coords(geom, precision): +def _consolidate_subdivide_geometry(geometry: Polygon | MultiPolygon) -> MultiPolygon: """ - Do not use: deprecated. - - Parameters - ---------- - geom : shapely.geometry.geometry {Point, MultiPoint, LineString, MultiLineString, Polygon, MultiPolygon} - deprecated, do not use - precision : int - deprecated, do not use - - Returns - ------- - shapely.geometry.geometry - """ - warn( - "The `round_geometry_coords` function is deprecated and will be " - "removed in the v2.0.0 release. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - - if isinstance(geom, Point): - return _round_point_coords(geom, precision) - - if isinstance(geom, MultiPoint): - return _round_multipoint_coords(geom, precision) - - if isinstance(geom, LineString): - return _round_linestring_coords(geom, precision) - - if isinstance(geom, MultiLineString): - return _round_multilinestring_coords(geom, precision) - - if isinstance(geom, Polygon): - return _round_polygon_coords(geom, precision) - - if isinstance(geom, MultiPolygon): - return _round_multipolygon_coords(geom, precision) - - # otherwise - msg = f"cannot round coordinates of unhandled geometry type: {type(geom)}" - raise TypeError(msg) - - -def _consolidate_subdivide_geometry(geometry): - """ - Consolidate and subdivide some geometry. + Consolidate and subdivide some (projected) geometry. Consolidate a geometry into a convex hull, then subdivide it into smaller sub-polygons if its area exceeds max size (in geometry's units). Configure - the max size via max_query_area_size in the settings module. + the max size via the `settings` module's `max_query_area_size`. Geometries + with areas much larger than `max_query_area_size` may take a long time to + process. When the geometry has a very large area relative to its vertex count, the resulting MultiPolygon's boundary may differ somewhat from the input, due to the way long straight lines are projected. You can interpolate - additional vertices along your input geometry's exterior to mitigate this. + additional vertices along your input geometry's exterior to mitigate this + if necessary. Parameters ---------- - geometry : shapely.geometry.Polygon or shapely.geometry.MultiPolygon - the projected (in meter units) geometry to consolidate and subdivide + geometry + The projected (in meter units) geometry to consolidate and subdivide. Returns ------- - geometry : shapely.geometry.MultiPolygon + geometry """ if not isinstance(geometry, (Polygon, MultiPolygon)): # pragma: no cover - msg = "Geometry must be a shapely Polygon or MultiPolygon" + msg = "Geometry must be a shapely Polygon or MultiPolygon." raise TypeError(msg) # if geometry is either 1) a Polygon whose area exceeds the max size, or @@ -286,7 +137,7 @@ def _consolidate_subdivide_geometry(geometry): "area size. It will automatically be divided up into multiple " "sub-queries accordingly. This may take a long time." ) - warn(msg, stacklevel=2) + warn(msg, category=UserWarning, stacklevel=2) # if geometry area exceeds max size, subdivide it into smaller subpolygons # that are no greater than settings.max_query_area_size in size @@ -299,21 +150,21 @@ def _consolidate_subdivide_geometry(geometry): return geometry -def _quadrat_cut_geometry(geometry, quadrat_width): +def _quadrat_cut_geometry(geometry: Polygon | MultiPolygon, quadrat_width: float) -> MultiPolygon: """ Split a Polygon or MultiPolygon up into sub-polygons of a specified size. Parameters ---------- - geometry : shapely.geometry.Polygon or shapely.geometry.MultiPolygon - the geometry to split up into smaller sub-polygons - quadrat_width : float - width (in geometry's units) of quadrat squares with which to split up - the geometry + geometry + The geometry to split up into smaller sub-polygons. + quadrat_width + Width (in geometry's units) of quadrat squares with which to split up + the geometry. Returns ------- - geometry : shapely.geometry.MultiPolygon + geometry """ # min number of dividing lines (3 produces a grid of 4 quadrat squares) min_num = 3 @@ -341,7 +192,10 @@ def _quadrat_cut_geometry(geometry, quadrat_width): return MultiPolygon(geometries) -def _intersect_index_quadrats(geometries, polygon): +def _intersect_index_quadrats( + geometries: gpd.GeoSeries, + polygon: Polygon | MultiPolygon, +) -> set[Any]: """ Identify geometries that intersect a (Multi)Polygon. @@ -351,26 +205,28 @@ def _intersect_index_quadrats(geometries, polygon): Parameters ---------- - geometries : geopandas.GeoSeries - the geometries to intersect with the polygon - polygon : shapely.geometry.Polygon or shapely.geometry.MultiPolygon - the polygon to intersect with the geometries + geometries + The geometries to intersect with the polygon. + polygon + The polygon to intersect with the geometries. Returns ------- - geoms_in_poly : set - set of the index labels of the geometries that intersected the polygon + geoms_in_poly + The index labels of the geometries that intersected the polygon. """ # create an r-tree spatial index for the geometries rtree = geometries.sindex - utils.log(f"Built r-tree spatial index for {len(geometries):,} geometries") + msg = f"Built r-tree spatial index for {len(geometries):,} geometries" + utils.log(msg, level=lg.INFO) # cut polygon into chunks for faster spatial index intersecting. specify a # sensible quadrat_width to balance performance (eg, 0.1 degrees is approx # 8 km at NYC's latitude) with either projected or unprojected coordinates quadrat_width = max(0.1, np.sqrt(polygon.area) / 10) multipoly = _quadrat_cut_geometry(polygon, quadrat_width) - utils.log(f"Accelerating r-tree with {len(multipoly.geoms)} quadrats") + msg = f"Accelerating r-tree with {len(multipoly.geoms)} quadrats" + utils.log(msg, level=lg.INFO) # loop through each chunk of the polygon to find intersecting geometries # first find approximate matches with spatial index, then precise matches @@ -384,11 +240,110 @@ def _intersect_index_quadrats(geometries, polygon): precise_matches = possible_matches[possible_matches.intersects(poly_buff)] geoms_in_poly.update(precise_matches.index) - utils.log(f"Identified {len(geoms_in_poly):,} geometries inside polygon") + msg = f"Identified {len(geoms_in_poly):,} geometries inside polygon" + utils.log(msg, level=lg.INFO) return geoms_in_poly -def bbox_from_point(point, dist=1000, project_utm=False, return_crs=False): +# dist present, project_utm missing/False, return_crs missing/False +@overload +def bbox_from_point( + point: tuple[float, float], + dist: float, +) -> tuple[float, float, float, float]: ... + + +# dist present, project_utm missing/False, return_crs present/True +@overload +def bbox_from_point( + point: tuple[float, float], + dist: float, + *, + return_crs: Literal[True], +) -> tuple[float, float, float, float]: ... + + +# dist present, project_utm missing/False, return_crs present/False +@overload +def bbox_from_point( + point: tuple[float, float], + dist: float, + *, + return_crs: Literal[False], +) -> tuple[float, float, float, float]: ... + + +# dist present, project_utm present/True, return_crs missing/False +@overload +def bbox_from_point( + point: tuple[float, float], + dist: float, + *, + project_utm: Literal[True], +) -> tuple[float, float, float, float]: ... + + +# dist present, project_utm present/True, return_crs present/True +@overload +def bbox_from_point( + point: tuple[float, float], + dist: float, + *, + project_utm: Literal[True], + return_crs: Literal[True], +) -> tuple[tuple[float, float, float, float], Any]: ... + + +# dist present, project_utm present/True, return_crs present/False +@overload +def bbox_from_point( + point: tuple[float, float], + dist: float, + *, + project_utm: Literal[True], + return_crs: Literal[False], +) -> tuple[float, float, float, float]: ... + + +# dist present, project_utm present/False, return_crs missing/False +@overload +def bbox_from_point( + point: tuple[float, float], + dist: float, + *, + project_utm: Literal[False], +) -> tuple[float, float, float, float]: ... + + +# dist present, project_utm present/False, return_crs present/True +@overload +def bbox_from_point( + point: tuple[float, float], + dist: float, + *, + project_utm: Literal[False], + return_crs: Literal[True], +) -> tuple[float, float, float, float]: ... + + +# dist present, project_utm present/False, return_crs present/False +@overload +def bbox_from_point( + point: tuple[float, float], + dist: float, + *, + project_utm: Literal[False], + return_crs: Literal[False], +) -> tuple[float, float, float, float]: ... + + +def bbox_from_point( + point: tuple[float, float], + dist: float, + *, + project_utm: bool = False, + return_crs: bool = False, +) -> tuple[float, float, float, float] | tuple[tuple[float, float, float, float], Any]: """ Create a bounding box around a (lat, lon) point. @@ -397,19 +352,19 @@ def bbox_from_point(point, dist=1000, project_utm=False, return_crs=False): Parameters ---------- - point : tuple - the (lat, lon) center point to create the bounding box around - dist : int - bounding box distance in meters from the center point - project_utm : bool - if True, return bounding box as UTM-projected coordinates - return_crs : bool - if True, and project_utm=True, return the projected CRS too + point + The `(lat, lon)` center point to create the bounding box around. + dist + Bounding box distance in meters from the center point. + project_utm + If True, return bounding box as UTM-projected coordinates. + return_crs + If True, and `project_utm` is True, then return the projected CRS too. Returns ------- - bbox or bbox, crs: tuple or tuple, crs - (north, south, east, west) or ((north, south, east, west), crs) + bbox or bbox, crs + `(north, south, east, west)` or `((north, south, east, west), crs)`. """ EARTH_RADIUS_M = 6_371_009 # meters lat, lon = point @@ -426,7 +381,8 @@ def bbox_from_point(point, dist=1000, project_utm=False, return_crs=False): bbox_proj, crs_proj = projection.project_geometry(bbox_poly) west, south, east, north = bbox_proj.bounds - utils.log(f"Created bbox {dist} m from {point}: {north},{south},{east},{west}") + msg = f"Created bbox {dist} m from {point}: {north},{south},{east},{west}" + utils.log(msg, level=lg.INFO) if project_utm and return_crs: return (north, south, east, west), crs_proj @@ -435,34 +391,18 @@ def bbox_from_point(point, dist=1000, project_utm=False, return_crs=False): return north, south, east, west -def bbox_to_poly(north=None, south=None, east=None, west=None, bbox=None): +def bbox_to_poly(bbox: tuple[float, float, float, float]) -> Polygon: """ - Convert bounding box coordinates to shapely Polygon. + Convert bounding box coordinates to Shapely Polygon. Parameters ---------- - north : float - deprecated, do not use - south : float - deprecated, do not use - east : float - deprecated, do not use - west : float - deprecated, do not use - bbox : tuple of floats - bounding box as (north, south, east, west) + bbox + Bounding box as `(north, south, east, west)`. Returns ------- - shapely.geometry.Polygon + polygon """ - if not (north is None and south is None and east is None and west is None): - msg = ( - "The `north`, `south`, `east`, and `west` parameters are deprecated and " - "will be removed in the v2.0.0 release. Use the `bbox` parameter instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - else: - north, south, east, west = bbox + north, south, east, west = bbox return Polygon([(west, south), (east, south), (east, north), (west, north)]) diff --git a/osmnx/utils_graph.py b/osmnx/utils_graph.py deleted file mode 100644 index f33b410b9..000000000 --- a/osmnx/utils_graph.py +++ /dev/null @@ -1,251 +0,0 @@ -"""Graph utility functions.""" - -from warnings import warn - -from . import convert -from . import routing -from . import truncate -from . import utils - - -def graph_to_gdfs(G, nodes=True, edges=True, node_geometry=True, fill_edge_geometry=True): - """ - Do not use: deprecated. - - Use the `convert.graph_to_gdfs` function instead. - - Parameters - ---------- - G : networkx.MultiDiGraph - deprecated, do not use - nodes : bool - deprecated, do not use - edges : bool - deprecated, do not use - node_geometry : bool - deprecated, do not use - fill_edge_geometry : bool - deprecated, do not use - - Returns - ------- - geopandas.GeoDataFrame or tuple - """ - msg = ( - "The `graph_to_gdfs` function has moved to the `convert` module. Calling " - "`utils_graph.graph_to_gdfs` is deprecated and will be removed in the " - "v2.0.0 release. Call it via `convert.graph_to_gdfs` instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - return convert.graph_to_gdfs(G, nodes, edges, node_geometry, fill_edge_geometry) - - -def graph_from_gdfs(gdf_nodes, gdf_edges, graph_attrs=None): - """ - Do not use: deprecated. - - Use the `convert.graph_from_gdfs` function instead. - - Parameters - ---------- - gdf_nodes : geopandas.GeoDataFrame - deprecated, do not use - gdf_edges : geopandas.GeoDataFrame - deprecated, do not use - graph_attrs : dict - deprecated, do not use - - Returns - ------- - G : networkx.MultiDiGraph - """ - msg = ( - "The `graph_from_gdfs` function has moved to the `convert` module. Calling " - "`utils_graph.graph_from_gdfs` is deprecated and will be removed in the " - "v2.0.0 release. Call it via `convert.graph_from_gdfs` instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - return convert.graph_from_gdfs(gdf_nodes, gdf_edges, graph_attrs) - - -def route_to_gdf(G, route, weight="length"): - """ - Do not use: deprecated. - - Use the `routing.route_to_gdf` function instead. - - Parameters - ---------- - G : networkx.MultiDiGraph - deprecated, do not use - route : list - deprecated, do not use - weight : string - deprecated, do not use - - Returns - ------- - gdf_edges : geopandas.GeoDataFrame - """ - msg = ( - "The `route_to_gdf` function has moved to the `routing` module. Calling " - "`utils_graph.route_to_gdf` is deprecated and will be removed in the " - "v2.0.0 release. Call it via `routing.route_to_gdf` instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - return routing.route_to_gdf(G, route, weight) - - -def get_route_edge_attributes( - G, route, attribute=None, minimize_key="length", retrieve_default=None -): - """ - Do not use: deprecated. - - Use the `routing.route_to_gdf` function instead. - - Parameters - ---------- - G : networkx.MultiDiGraph - deprecated, do not use - route : list - deprecated, do not use - attribute : string - deprecated, do not use - minimize_key : string - deprecated, do not use - retrieve_default : Callable[Tuple[Any, Any], Any] - deprecated, do not use - - Returns - ------- - attribute_values : list - """ - warn( - "The `get_route_edge_attributes` function has been deprecated and will " - "be removed in the v2.0.0 release. Use the `routing.route_to_gdf` function instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123", - FutureWarning, - stacklevel=2, - ) - attribute_values = [] - for u, v in zip(route[:-1], route[1:]): - # if there are parallel edges between two nodes, select the one with the - # lowest value of minimize_key - data = min(G.get_edge_data(u, v).values(), key=lambda x: x[minimize_key]) - if attribute is None: - attribute_value = data - elif retrieve_default is not None: - attribute_value = data.get(attribute, retrieve_default(u, v)) - else: - attribute_value = data[attribute] - attribute_values.append(attribute_value) - return attribute_values - - -def remove_isolated_nodes(G, warn=True): - """ - Do not use: deprecated. - - Parameters - ---------- - G : networkx.MultiDiGraph - deprecated, do not use - warn : bool - deprecated, do not use - - Returns - ------- - G : networkx.MultiDiGraph - """ - if warn: - msg = "The `remove_isolated_nodes` function is deprecated and will be removed in the v2.0.0 release." - warn(msg, FutureWarning, stacklevel=2) - - # make a copy to not mutate original graph object caller passed in - G = G.copy() - - # get the set of all isolated nodes, then remove them - isolated_nodes = {node for node, degree in G.degree() if degree < 1} - G.remove_nodes_from(isolated_nodes) - utils.log(f"Removed {len(isolated_nodes):,} isolated nodes") - return G - - -def get_largest_component(G, strongly=False): - """ - Do not use: deprecated. - - Use the `truncate.largest_component` function instead. - - Parameters - ---------- - G : networkx.MultiDiGraph - deprecated, do not use - strongly : bool - deprecated, do not use - - Returns - ------- - G : networkx.MultiDiGraph - """ - msg = ( - "The `get_largest_component` function is deprecated and will be removed in the " - "v2.0.0 release. Replace it with `truncate.largest_component` instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - return truncate.largest_component(G, strongly) - - -def get_digraph(G, weight="length"): - """ - Do not use: deprecated. - - Use the `convert.to_digraph` function instead. - - Parameters - ---------- - G : networkx.MultiDiGraph - deprecated, do not use - weight : string - deprecated, do not use - - Returns - ------- - networkx.DiGraph - """ - msg = ( - "The `get_digraph` function is deprecated and will be removed in the " - "v2.0.0 release. Replace it with `convert.to_digraph` instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - return convert.to_digraph(G, weight) - - -def get_undirected(G): - """ - Do not use: deprecated. - - Use the `convert.to_undirected` function instead. - - Parameters - ---------- - G : networkx.MultiDiGraph - deprecated, do not use - - Returns - ------- - networkx.MultiGraph - """ - msg = ( - "The `get_undirected` function is deprecated and will be removed in the " - "v2.0.0 release. Replace it with `convert.to_undirected` instead. " - "See the OSMnx v2 migration guide: https://github.com/gboeing/osmnx/issues/1123" - ) - warn(msg, FutureWarning, stacklevel=2) - return convert.to_undirected(G) diff --git a/pyproject.toml b/pyproject.toml index 887d2cb9f..e00876ef1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,43 +3,42 @@ build-backend = "hatchling.build" requires = ["hatchling"] [project] -authors = [{name = "Geoff Boeing", email = "boeing@usc.edu"}] +authors = [{ name = "Geoff Boeing", email = "boeing@usc.edu" }] classifiers = [ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "Intended Audience :: Science/Research", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Topic :: Scientific/Engineering :: GIS", - "Topic :: Scientific/Engineering :: Information Analysis", - "Topic :: Scientific/Engineering :: Mathematics", - "Topic :: Scientific/Engineering :: Physics", - "Topic :: Scientific/Engineering :: Visualization", + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: GIS", + "Topic :: Scientific/Engineering :: Information Analysis", + "Topic :: Scientific/Engineering :: Mathematics", + "Topic :: Scientific/Engineering :: Physics", + "Topic :: Scientific/Engineering :: Visualization", ] dependencies = [ - "geopandas>=0.12", - "networkx>=2.5", - "numpy>=1.20", - "pandas>=1.1", - "requests>=2.27", - "shapely>=2.0", + "geopandas>=0.12", + "networkx>=2.5", + "numpy>=1.21", + "pandas>=1.1", + "requests>=2.27", + "shapely>=2.0", ] description = "Download, model, analyze, and visualize street networks and other geospatial features from OpenStreetMap" dynamic = ["version"] keywords = ["GIS", "Networks", "OpenStreetMap", "Routing"] -license = {text = "MIT License"} -maintainers = [{name = "OSMnx contributors"}] +license = { text = "MIT License" } +maintainers = [{ name = "OSMnx contributors" }] name = "osmnx" readme = "README.md" -requires-python = ">=3.8" # match classifiers above and ruff target-version below +requires-python = ">=3.9" # match classifiers above and ruff/mypy versions below [project.optional-dependencies] entropy = ["scipy>=1.5"] @@ -52,56 +51,31 @@ Documentation = "https://osmnx.readthedocs.io" "Code Repository" = "https://github.com/gboeing/osmnx" "Examples Gallery" = "https://github.com/gboeing/osmnx-examples" +[tool.coverage.report] +exclude_also = ["@overload", "if TYPE_CHECKING:"] + [tool.hatch.build] packages = ["osmnx"] [tool.hatch.version] path = "osmnx/_version.py" +[tool.mypy] +cache_dir = "~/.cache/mypy" +ignore_missing_imports = true +python_version = "3.9" +strict = true +warn_no_return = true +warn_unreachable = true + [tool.ruff] cache-dir = "~/.cache/ruff" exclude = ["build/*"] line-length = 100 -target-version = "py38" [tool.ruff.lint] -extend-ignore = ["PLR091"] # ignore PLR complexity checks (we check mccabe with C9) -extend-select = [ - "A", # check python builtins being used as variables or parameters - "ARG", # check unused function arguments - "B", # check common design problems a la flake8-bugbear - "BLE", # check blind exception catching - "C4", # check proper comprehensions - "C9", # check mccabe complexity - "D", # check docstring conventions a la pydocstyle - "D417", # check missing args in docstrings (disabled by default for numpy convention) - "DTZ", # check unsafe naive datetime use a la flake8-datetimez - "E", # check code style conventions a la pycodestyle errors - "EM", # check raw literals inside exception raising - "ERA", # check commented-out code from python files a la eradicate - "F", # check python source code for errors a la pyflakes - "FA", # check from __future__ import annotations - "FIX", # check temporary developer notes a la flake8-fixme - #"FURB", # check code improvements a la refurb (preview) - "G", # check logging string formatting a la flake8-logging-format - "I", # check isort imports - #"LOG", # check logging module usage a la flake8-logging (preview) - "NPY", # check numpy usage - "PD", # check pandas linting a la pandas-vet - "PERF", # check performance anti-patterns a la perflint - "PGH", # check pygrep hooks - "PIE", # check misc lints a la flake8-pie - "PL", # check code errors and smells a la pylint - "PT", # check common pytest issues - "PTH", # check pathlib usage - "RET", # check return statements a la flake8-return - "RSE", # check exception raise statements via flake8-raise - "SIM", # check code simplicity a la flake8-simplify - "T20", # check for any print statements - "TRY", # check exception handling anti-patterns a la tryceratops - "UP", # check outdated syntax a la pyupgrade - "W", # check code style conventions a la pycodestyle warnings -] +extend-ignore = ["N803", "N806", "SLF001"] +extend-select = ["ALL"] [tool.ruff.lint.isort] force-single-line = true @@ -110,7 +84,10 @@ force-single-line = true max-complexity = 14 [tool.ruff.lint.pycodestyle] -max-line-length = 110 # line length + 10% since it isn't a hard upper bound +max-line-length = 110 # line length + 10% since it isn't a hard upper bound [tool.ruff.lint.pydocstyle] convention = "numpy" + +[tool.ruff.lint.pylint] +max-args = 8 diff --git a/tests/README.md b/tests/README.md index ca1660b86..f18d11bed 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,46 +1,47 @@ # OSMnx tests -First, ensure that you have installed the necessary [dependencies](../tests/environments/env-ci.yml) for the test suite. Then use the repository's [pre-commit hooks](../.pre-commit-config.yaml) and the scripts in this folder to: +First, ensure that you have installed the necessary [dependencies](../environments/tests/env-ci.yml) for the test suite. Then use the repository's [pre-commit hooks](../.pre-commit-config.yaml) and the scripts in this folder to: -- format code/docstrings to the project's style -- lint the code -- lint the docstrings +- format the code and docstrings per the project's style +- lint the code and docstrings +- type check the code - run tests and coverage -You can read more about the project's standards in the [contributing guidelines](../CONTRIBUTING.md). +Read more about the project's standards in the [contributing guidelines](../CONTRIBUTING.md). ## Code format -Format the code and sort imports according to the project's style by changing directories to the repository's root and running: +Format the code and sort imports per the project's style by running (from the repository root): -``` +```shell bash ./tests/format.sh ``` -## Run the test suite +## Run tests -Lint and test the code and docstrings by changing directories to the repository's root and running: +Lint, type check, and test the code/docstrings by running (from the repository root): -``` +```shell +pre-commit install bash ./tests/lint_test.sh ``` ## Continuous integration -All PRs trigger continuous integration tests via GitHub Actions. See the [configuration](../.github/workflows/ci.yml). The following steps are automatically run: +Pull requests trigger continuous integration tests via GitHub Actions. See the [configuration](../.github/workflows/ci.yml). This includes the following steps: - build the docs - check code formatting -- lint the docstrings -- lint the code -- tests and coverage +- lint the code and docstrings +- type check the code +- run tests and coverage ## Releases To package and release a new version, update `CHANGELOG.md` and edit the version number in `osmnx/_version.py`. If necessary, update the dates in `LICENSE.txt` and `docs/source/conf.py` and the dependency versions in `pyproject.toml`. Then change directories to the repository's root and run: -``` -bash ./tests/packaging.sh +```shell +bash -i ./tests/release.sh ``` -This will tag the repository with the new version number, upload the PyPI distribution, and update the conda-forge feedstock. Then, open a pull request at the [feedstock](https://github.com/conda-forge/osmnx-feedstock) to release on conda-forge. Finally, when the new version is available on conda-forge, update the [Docker image](../environments/docker) and the [OSMnx Examples](https://github.com/gboeing/osmnx-examples) gallery to use the new version. +This will tag the repository with the new version number, upload the PyPI distribution, and update the conda-forge feedstock. Then, open a pull request at the [feedstock](https://github.com/conda-forge/osmnx-feedstock) to release on conda-forge. Finally, when the new version is available on conda-forge, update the [Docker image](../environments/docker) and the OSMnx [Examples Gallery](https://github.com/gboeing/osmnx-examples) to use the new version. diff --git a/tests/format.sh b/tests/format.sh old mode 100644 new mode 100755 diff --git a/tests/git_repack.sh b/tests/git_repack.sh old mode 100644 new mode 100755 diff --git a/tests/input_data/osm_schema.xsd b/tests/input_data/osm_schema.xsd new file mode 100644 index 000000000..167fefc5f --- /dev/null +++ b/tests/input_data/osm_schema.xsd @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/lint_test.sh b/tests/lint_test.sh old mode 100644 new mode 100755 index ea529a5dc..b1dd835eb --- a/tests/lint_test.sh +++ b/tests/lint_test.sh @@ -1,7 +1,5 @@ #!/bin/bash - -# exit on error -set -e +set -e # exit on error # delete temp files and folders rm -r -f .pytest_cache .temp ./dist/ ./docs/build osmnx/__pycache__ tests/__pycache__ @@ -11,16 +9,11 @@ find . -type f -name ".coverage*" -delete # lint pre-commit run --all-files -# test building and validating the package -#hatch build --clean -#twine check --strict ./dist/* - # build the docs -make -C ./docs html -#python -m sphinx -b linkcheck ./docs/source ./docs/build/linkcheck +make -C ./docs html SPHINXOPTS="-E -W --keep-going" # run the tests and report the test coverage -pytest --cov=./osmnx --cov-report=term-missing --verbose +pytest --verbose --maxfail=1 --typeguard-packages=osmnx --cov=osmnx --cov-report=term-missing:skip-covered # delete temp files and folders rm -r -f .pytest_cache .temp ./dist/ ./docs/build osmnx/__pycache__ tests/__pycache__ diff --git a/tests/packaging.sh b/tests/release.sh old mode 100644 new mode 100755 similarity index 93% rename from tests/packaging.sh rename to tests/release.sh index 2f60e3414..2b19df042 --- a/tests/packaging.sh +++ b/tests/release.sh @@ -17,8 +17,8 @@ mamba update conda-smithy --yes --no-banner # test the docs build and validate that all their links are live rm -rf ./docs/build -make -C ./docs html SPHINXOPTS="-W --keep-going" -python -m sphinx -b linkcheck ./docs/source ./docs/build/linkcheck +make -C ./docs html SPHINXOPTS="-E -W --keep-going" +python -m sphinx -E -W --keep-going -b linkcheck ./docs/source ./docs/build/linkcheck rm -rf ./docs/build # get the current package version number diff --git a/tests/test_osmnx.py b/tests/test_osmnx.py old mode 100755 new mode 100644 index 10e01c2e0..04e91fde2 --- a/tests/test_osmnx.py +++ b/tests/test_osmnx.py @@ -1,6 +1,8 @@ -# ruff: noqa: E402 F841 PLR2004 +# ruff: noqa: E402,F841,INP001,PLR2004,S101 """Test suite for the package.""" +from __future__ import annotations + # use agg backend so you don't need a display on CI # do this first before pyplot is imported by anything import matplotlib as mpl @@ -13,28 +15,21 @@ import tempfile from collections import OrderedDict from pathlib import Path -from xml.etree import ElementTree as etree -import folium import geopandas as gpd import networkx as nx import numpy as np import pandas as pd import pytest +from lxml import etree from requests.exceptions import ConnectionError +from shapely import Point +from shapely import Polygon from shapely import wkt -from shapely.geometry import GeometryCollection -from shapely.geometry import LineString -from shapely.geometry import MultiLineString -from shapely.geometry import MultiPoint -from shapely.geometry import MultiPolygon -from shapely.geometry import Point -from shapely.geometry import Polygon +from typeguard import suppress_type_checks import osmnx as ox -import osmnx.speed -ox.config(log_console=True) ox.settings.log_console = True ox.settings.log_file = True ox.settings.use_cache = True @@ -55,22 +50,23 @@ polygon = wkt.loads(p) -def test_logging(): +def test_logging() -> None: """Test the logger.""" - ox.log("test a fake default message") - ox.log("test a fake debug", level=lg.DEBUG) - ox.log("test a fake info", level=lg.INFO) - ox.log("test a fake warning", level=lg.WARNING) - ox.log("test a fake error", level=lg.ERROR) + ox.utils.log("test a fake default message") + ox.utils.log("test a fake debug", level=lg.DEBUG) + ox.utils.log("test a fake info", level=lg.INFO) + ox.utils.log("test a fake warning", level=lg.WARNING) + ox.utils.log("test a fake error", level=lg.ERROR) - ox.citation(style="apa") - ox.citation(style="bibtex") - ox.citation(style="ieee") - ox.ts(style="date") - ox.ts(style="time") + ox.utils.citation(style="apa") + ox.utils.citation(style="bibtex") + ox.utils.citation(style="ieee") + ox.utils.ts(style="iso8601") + ox.utils.ts(style="date") + ox.utils.ts(style="time") -def test_exceptions(): +def test_exceptions() -> None: """Test the custom errors.""" message = "testing exception" @@ -87,66 +83,32 @@ def test_exceptions(): raise ox._errors.GraphSimplificationError(message) -def test_coords_rounding(): - """Test the rounding of geometry coordinates.""" - precision = 3 - - shape1 = Point(1.123456, 2.123456) - shape2 = ox.utils_geo.round_geometry_coords(shape1, precision) - - shape1 = MultiPoint([(1.123456, 2.123456), (3.123456, 4.123456)]) - shape2 = ox.utils_geo.round_geometry_coords(shape1, precision) - - shape1 = LineString([(1.123456, 2.123456), (3.123456, 4.123456)]) - shape2 = ox.utils_geo.round_geometry_coords(shape1, precision) - - shape1 = MultiLineString( - [ - [(1.123456, 2.123456), (3.123456, 4.123456)], - [(11.123456, 12.123456), (13.123456, 14.123456)], - ] - ) - - shape2 = ox.utils_geo.round_geometry_coords(shape1, precision) - - shape1 = Polygon([(1.123456, 2.123456), (3.123456, 4.123456), (6.123456, 5.123456)]) - shape2 = ox.utils_geo.round_geometry_coords(shape1, precision) - - shape1 = MultiPolygon( - [ - Polygon([(1.123456, 2.123456), (3.123456, 4.123456), (6.123456, 5.123456)]), - Polygon([(16.123456, 15.123456), (13.123456, 14.123456), (12.123456, 11.123456)]), - ] - ) - shape2 = ox.utils_geo.round_geometry_coords(shape1, precision) - - with pytest.raises(TypeError, match="cannot round coordinates of unhandled geometry type"): - ox.utils_geo.round_geometry_coords(GeometryCollection(), precision) - - -def test_geocoder(): +def test_geocoder() -> None: """Test retrieving elements by place name and OSM ID.""" city = ox.geocode_to_gdf("R2999176", by_osmid=True) - city = ox.geocode_to_gdf(place1, which_result=1, buffer_dist=100) + city = ox.geocode_to_gdf(place1, which_result=1) city = ox.geocode_to_gdf(place2) - city_projected = ox.project_gdf(city, to_crs="epsg:3395") + city_projected = ox.projection.project_gdf(city, to_crs="epsg:3395") # test geocoding a bad query: should raise exception with pytest.raises(ox._errors.InsufficientResponseError): _ = ox.geocode("!@#$%^&*") + with pytest.raises(ox._errors.InsufficientResponseError): + _ = ox.geocode_to_gdf(query="AAAZZZ") + + # fails to geocode to a (Multi)Polygon + with pytest.raises(TypeError): + _ = ox.geocode_to_gdf("Bunker Hill, Los Angeles, CA, USA") -def test_stats(): + +def test_stats() -> None: """Test generating graph stats.""" # create graph, add a new node, add bearings, project it G = ox.graph_from_place(place1, network_type="all") - G.add_node(0, x=location_point[1], y=location_point[0]) - _ = ox.bearing.calculate_bearing(0, 0, 1, 1) - G = ox.add_edge_bearings(G) - G = ox.add_edge_bearings(G, precision=2) + G.add_node(0, x=location_point[1], y=location_point[0], street_count=0) G_proj = ox.project_graph(G) G_proj = ox.distance.add_edge_lengths(G_proj, edges=tuple(G_proj.edges)[0:3]) - G_proj = ox.distance.add_edge_lengths(G_proj, edges=tuple(G_proj.edges)[0:3], precision=2) # calculate stats cspn = ox.stats.count_streets_per_node(G) @@ -154,27 +116,88 @@ def test_stats(): stats = ox.basic_stats(G, area=1000) stats = ox.basic_stats(G_proj, area=1000, clean_int_tol=15) - # calculate entropy - Gu = ox.get_undirected(G) - entropy = ox.bearing.orientation_entropy(Gu, weight="length") - fig, ax = ox.bearing.plot_orientation(Gu, area=True, title="Title") - fig, ax = ox.plot_orientation(Gu, area=True, title="Title") - fig, ax = ox.plot_orientation(Gu, ax=ax, area=False, title="Title") - # test cleaning and rebuilding graph G_clean = ox.consolidate_intersections(G_proj, tolerance=10, rebuild_graph=True, dead_ends=True) G_clean = ox.consolidate_intersections( - G_proj, tolerance=10, rebuild_graph=True, reconnect_edges=False + G_proj, + tolerance=10, + rebuild_graph=True, + reconnect_edges=False, ) G_clean = ox.consolidate_intersections(G_proj, tolerance=10, rebuild_graph=False) + G_clean = ox.consolidate_intersections(G_proj, tolerance=50000, rebuild_graph=True) # try consolidating an empty graph G = nx.MultiDiGraph(crs="epsg:4326") G_clean = ox.consolidate_intersections(G, rebuild_graph=True) G_clean = ox.consolidate_intersections(G, rebuild_graph=False) + # test passing dict of tolerances to consolidate_intersections + tols: dict[int, float] + # every node present + tols = {node: 5 for node in G_proj.nodes} + G_clean = ox.consolidate_intersections(G_proj, tolerance=tols, rebuild_graph=True) + # one node missing + tols.popitem() + G_clean = ox.consolidate_intersections(G_proj, tolerance=tols, rebuild_graph=True) + # one node 0 + tols[next(iter(tols))] = 0 + G_clean = ox.consolidate_intersections(G_proj, tolerance=tols, rebuild_graph=True) + + +def test_bearings() -> None: + """Test bearings and orientation entropy.""" + G = ox.graph_from_place(place1, network_type="all") + G.add_node(0, x=location_point[1], y=location_point[0], street_count=0) + _ = ox.bearing.calculate_bearing(0, 0, 1, 1) + G = ox.add_edge_bearings(G) + G_proj = ox.project_graph(G) + + # calculate entropy + Gu = ox.convert.to_undirected(G) + entropy = ox.bearing.orientation_entropy(Gu, weight="length") + fig, ax = ox.plot.plot_orientation(Gu, area=True, title="Title") + fig, ax = ox.plot.plot_orientation(Gu, ax=ax, area=False, title="Title") + + # test support of edge bearings for directed and undirected graphs + G = nx.MultiDiGraph(crs="epsg:4326") + G.add_node("point_1", x=0.0, y=0.0) + G.add_node("point_2", x=0.0, y=1.0) # latitude increases northward + G.add_edge("point_1", "point_2", weight=2.0) + G = ox.distance.add_edge_lengths(G) + G = ox.add_edge_bearings(G) + with pytest.warns(UserWarning, match="edge bearings will be directional"): + bearings, weights = ox.bearing._extract_edge_bearings(G, min_length=0, weight=None) + assert list(bearings) == [0.0] # north + assert list(weights) == [1.0] + bearings, weights = ox.bearing._extract_edge_bearings( + ox.convert.to_undirected(G), + min_length=0, + weight="weight", + ) + assert list(bearings) == [0.0, 180.0] # north and south + assert list(weights) == [2.0, 2.0] + + # test _bearings_distribution split bin implementation + bin_counts, bin_centers = ox.bearing._bearings_distribution( + G, + num_bins=1, + min_length=0, + weight=None, + ) + assert list(bin_counts) == [1.0] + assert list(bin_centers) == [0.0] + bin_counts, bin_centers = ox.bearing._bearings_distribution( + G, + num_bins=2, + min_length=0, + weight=None, + ) + assert list(bin_counts) == [1.0, 0.0] + assert list(bin_centers) == [0.0, 180.0] + -def test_osm_xml(): +def test_osm_xml() -> None: """Test working with .osm XML data.""" # test loading a graph from a local .osm xml file node_id = 53098262 @@ -197,54 +220,49 @@ def test_osm_xml(): Path.unlink(Path(temp_filename)) - # test .osm xml saving - default_all_oneway = ox.settings.all_oneway - ox.settings.all_oneway = True - G = ox.graph_from_point(location_point, dist=500, network_type="drive") - ox.save_graph_xml(G, merge_edges=False, filepath=Path(ox.settings.data_folder) / "graph.osm") - - # test osm xml output merge edges - ox.io.save_graph_xml(G, merge_edges=True, edge_tag_aggs=[("length", "sum")], precision=5) - - # test osm xml output from gdfs - nodes, edges = ox.graph_to_gdfs(G) - ox.osm_xml.save_graph_xml([nodes, edges]) + # test OSM xml saving + G = ox.graph_from_point(location_point, dist=500, network_type="drive", simplify=False) + fp = Path(ox.settings.data_folder) / "graph.osm" + ox.io.save_graph_xml(G, filepath=fp, way_tag_aggs={"lanes": "sum"}) - # test ordered nodes from way - df_uv = pd.DataFrame({"u": [54, 2, 5, 3, 10, 19, 20], "v": [76, 3, 8, 10, 5, 20, 15]}) - ordered_nodes = ox.osm_xml._get_unique_nodes_ordered_from_way(df_uv) - assert ordered_nodes == [2, 3, 10, 5, 8] + # validate saved XML against XSD schema + xsd_filepath = "./tests/input_data/osm_schema.xsd" + parser = etree.XMLParser(schema=etree.XMLSchema(file=xsd_filepath)) + _ = etree.parse(fp, parser=parser) # noqa: S320 # test roundabout handling + default_all_oneway = ox.settings.all_oneway + ox.settings.all_oneway = True default_overpass_settings = ox.settings.overpass_settings ox.settings.overpass_settings += '[date:"2023-04-01T00:00:00Z"]' point = (39.0290346, -84.4696884) G = ox.graph_from_point(point, dist=500, dist_type="bbox", network_type="drive", simplify=False) - gdf_edges = ox.graph_to_gdfs(G, nodes=False) - gdf_way = gdf_edges[gdf_edges["osmid"] == 570883705] # roundabout - first = gdf_way.iloc[0].dropna().astype(str) - root = etree.Element("osm", attrib={"version": "0.6", "generator": "OSMnx"}) - edge = etree.SubElement(root, "way") - ox.osm_xml._append_nodes_as_edge_attrs(edge, first, gdf_way) + ox.io.save_graph_xml(G) + _ = etree.parse(fp, parser=parser) # noqa: S320 + + # raise error if trying to save a simplified graph + with pytest.raises(ox._errors.GraphSimplificationError): + ox.io.save_graph_xml(ox.simplification.simplify_graph(G)) + + # save a projected/consolidated graph as OSM XML + Gc = ox.simplification.consolidate_intersections(ox.projection.project_graph(G)) + nx.set_node_attributes(Gc, 0, name="uid") + ox.io.save_graph_xml(Gc, fp) # issues UserWarning + Gc = ox.graph.graph_from_xml(fp) # issues UserWarning + _ = etree.parse(fp, parser=parser) # noqa: S320 # restore settings ox.settings.overpass_settings = default_overpass_settings ox.settings.all_oneway = default_all_oneway -def test_elevation(): +def test_elevation() -> None: """Test working with elevation data.""" G = ox.graph_from_address(address=address, dist=500, dist_type="bbox", network_type="bike") # add node elevations from Google (fails without API key) with pytest.raises(ox._errors.InsufficientResponseError): - _ = ox.elevation.add_node_elevations_google( - G, - api_key="", - max_locations_per_batch=350, - precision=2, - url_template=ox.settings.elevation_url_template, - ) + _ = ox.elevation.add_node_elevations_google(G, api_key="", batch_size=350) # add node elevations from Open Topo Data (works without API key) ox.settings.elevation_url_template = ( @@ -252,6 +270,9 @@ def test_elevation(): ) _ = ox.elevation.add_node_elevations_google(G, batch_size=100, pause=0.01) + # same thing again, to hit the cache + _ = ox.elevation.add_node_elevations_google(G, batch_size=100, pause=0.01) + # add node elevations from a single raster file (some nodes will be null) rasters = list(Path("tests/input_data").glob("elevation*.tif")) G = ox.elevation.add_node_elevations_raster(G, rasters[0], cpus=1) @@ -261,20 +282,21 @@ def test_elevation(): G = ox.elevation.add_node_elevations_raster(G, rasters) assert pd.notna(pd.Series(dict(G.nodes(data="elevation")))).all() + # consolidate nodes with elevation (by default will aggregate via mean) + G = ox.simplification.consolidate_intersections(G) + # add edge grades and their absolute values G = ox.add_edge_grades(G, add_absolute=True) - G = ox.add_edge_grades(G, add_absolute=True, precision=2) -def test_routing(): +def test_routing() -> None: """Test working with speed, travel time, and routing.""" G = ox.graph_from_address(address=address, dist=500, dist_type="bbox", network_type="bike") # give each edge speed and travel time attributes - G = ox.speed.add_edge_speeds(G) - G = ox.add_edge_speeds(G, hwy_speeds={"motorway": 100}, precision=2) - G = ox.speed.add_edge_travel_times(G) - G = ox.add_edge_travel_times(G, precision=2) + G = ox.add_edge_speeds(G) + G = ox.add_edge_speeds(G, hwy_speeds={"motorway": 100}) + G = ox.add_edge_travel_times(G) # test value cleaning assert ox.routing._clean_maxspeed("100,2") == 100.2 @@ -285,9 +307,10 @@ def test_routing(): assert ox.routing._clean_maxspeed("60|100 mph") == pytest.approx(128.7472) assert ox.routing._clean_maxspeed("signal") is None assert ox.routing._clean_maxspeed("100;70") is None + assert ox.routing._clean_maxspeed("FR:urban") == 50.0 # test collapsing multiple mph values to single kph value - assert ox.routing._collapse_multiple_maxspeed_values(["25 mph", "30 mph"], np.mean) == 44 + assert ox.routing._collapse_multiple_maxspeed_values(["25 mph", "30 mph"], np.mean) == 44.25685 # test collapsing invalid values: should return None assert ox.routing._collapse_multiple_maxspeed_values(["mph", "kph"], np.mean) is None @@ -296,70 +319,62 @@ def test_routing(): dest_x = np.array([-122.401429]) orig_y = np.array([37.794302]) dest_y = np.array([37.794987]) - orig_node = ox.distance.nearest_nodes(G, orig_x, orig_y)[0] - dest_node = ox.distance.nearest_nodes(G, dest_x, dest_y)[0] + orig_node = int(ox.distance.nearest_nodes(G, orig_x, orig_y)[0]) + dest_node = int(ox.distance.nearest_nodes(G, dest_x, dest_y)[0]) - # test non-numeric weight (should raise ValueError) + # test non-numeric weight, should raise ValueError with pytest.raises(ValueError, match="contains non-numeric values"): - route = ox.shortest_path(G, orig_node, dest_node, weight="highway") + route1 = ox.shortest_path(G, orig_node, dest_node, weight="highway") - # mismatch iterable and non-iterable orig/dest should raise ValueError - msg = "orig and dest must either both be iterable or neither must be iterable" + # mismatch iterable and non-iterable orig/dest, should raise TypeError + msg = "must either both be iterable or neither must be iterable" + with pytest.raises(TypeError, match=msg): + route2 = ox.shortest_path(G, orig_node, [dest_node]) # type: ignore[call-overload] + + # mismatch lengths of orig/dest, should raise ValueError + msg = "must be of equal length" with pytest.raises(ValueError, match=msg): - route = ox.shortest_path(G, orig_node, [dest_node]) + route2 = ox.shortest_path(G, [orig_node] * 2, [dest_node] * 3) # test missing weight (should raise warning) - route = ox.shortest_path(G, orig_node, dest_node, weight="time") + route3 = ox.shortest_path(G, orig_node, dest_node, weight="time") # test good weight - route = ox.shortest_path(G, orig_node, dest_node, weight="travel_time") - route = ox.distance.shortest_path(G, orig_node, dest_node, weight="travel_time") + route4 = ox.routing.shortest_path(G, orig_node, dest_node, weight="travel_time") + route5 = ox.shortest_path(G, orig_node, dest_node, weight="travel_time") + assert route5 is not None - route_edges = ox.utils_graph.route_to_gdf(G, route, "travel_time") - attributes = ox.utils_graph.get_route_edge_attributes(G, route) - attributes = ox.utils_graph.get_route_edge_attributes(G, route, "travel_time") + route_edges = ox.routing.route_to_gdf(G, route5, weight="travel_time") - fig, ax = ox.plot_graph_route(G, route, save=True) + fig, ax = ox.plot_graph_route(G, route5, save=True) # test multiple origins-destinations n = 5 nodes = np.array(G.nodes) - origs = np.random.default_rng().choice(nodes, size=n, replace=True) - dests = np.random.default_rng().choice(nodes, size=n, replace=True) + origs = [int(x) for x in np.random.default_rng().choice(nodes, size=n, replace=True)] + dests = [int(x) for x in np.random.default_rng().choice(nodes, size=n, replace=True)] paths1 = ox.shortest_path(G, origs, dests, weight="length", cpus=1) paths2 = ox.shortest_path(G, origs, dests, weight="length", cpus=2) paths3 = ox.shortest_path(G, origs, dests, weight="length", cpus=None) assert paths1 == paths2 == paths3 # test k shortest paths - routes = ox.k_shortest_paths(G, orig_node, dest_node, k=2, weight="travel_time") - routes = ox.distance.k_shortest_paths(G, orig_node, dest_node, k=2, weight="travel_time") + routes = ox.routing.k_shortest_paths(G, orig_node, dest_node, k=2, weight="travel_time") fig, ax = ox.plot_graph_routes(G, list(routes)) # test great circle and euclidean distance calculators - assert ox.distance.great_circle_vec(0, 0, 1, 1) == pytest.approx(157249.6034105) - assert ox.distance.euclidean_dist_vec(0, 0, 1, 1) == pytest.approx(1.4142135) - - # test folium with keyword arguments to pass to folium.PolyLine - gm = ox.plot_graph_folium(G, popup_attribute="name", color="#333333", weight=5, opacity=0.7) - rm = ox.plot_route_folium(G, route, color="#cc0000", weight=5, opacity=0.7) + assert ox.distance.great_circle(0, 0, 1, 1) == pytest.approx(157249.6034105) + assert ox.distance.euclidean(0, 0, 1, 1) == pytest.approx(1.4142135) - # test calling folium plotters with FeatureGroup instead of Map, and extra kwargs - fg = folium.FeatureGroup(name="legend name", show=True) - gm = ox.plot_graph_folium(G, graph_map=fg) - assert isinstance(gm, folium.FeatureGroup) - rm = ox.plot_route_folium(G, route, route_map=fg, tooltip="x") - assert isinstance(rm, folium.FeatureGroup) - - -def test_plots(): +def test_plots() -> None: """Test visualization methods.""" G = ox.graph_from_point(location_point, dist=500, network_type="drive") Gp = ox.project_graph(G) G = ox.project_graph(G, to_latlong=True) # test getting colors - co = ox.plot.get_colors(n=5, return_hex=True) + co1 = ox.plot.get_colors(n=5, cmap="plasma", start=0.1, stop=0.9, alpha=0.5) + co2 = ox.plot.get_colors(n=5, cmap="plasma", start=0.1, stop=0.9, alpha=None) nc = ox.plot.get_node_colors_by_attr(G, "x") ec = ox.plot.get_edge_colors_by_attr(G, "length", num_bins=5) @@ -386,59 +401,60 @@ def test_plots(): # figure-ground plots fig, ax = ox.plot_figure_ground(G=G) - fig, ax = ox.plot_figure_ground(point=location_point, dist=500, network_type="drive") - fig, ax = ox.plot_figure_ground(address=address, dist=500, network_type="bike") -def test_find_nearest(): +def test_nearest() -> None: """Test nearest node/edge searching.""" # get graph and x/y coords to search G = ox.graph_from_point(location_point, dist=500, network_type="drive", simplify=False) Gp = ox.project_graph(G) - points = ox.utils_geo.sample_points(ox.get_undirected(Gp), 5) + points = ox.utils_geo.sample_points(ox.convert.to_undirected(Gp), 5) X = points.x.to_numpy() Y = points.y.to_numpy() # get nearest nodes + _ = ox.distance.nearest_nodes(G, X, Y, return_dist=True) + _ = ox.distance.nearest_nodes(G, X, Y, return_dist=False) nn0, dist0 = ox.distance.nearest_nodes(G, X[0], Y[0], return_dist=True) - nn1, dist1 = ox.distance.nearest_nodes(Gp, X[0], Y[0], return_dist=True) + nn1 = ox.distance.nearest_nodes(Gp, X[0], Y[0], return_dist=False) # get nearest edge - ne0 = ox.distance.nearest_edges(Gp, X[0], Y[0], interpolate=None) - ne1 = ox.distance.nearest_edges(Gp, X[0], Y[0], interpolate=50) - ne2 = ox.distance.nearest_edges(G, X[0], Y[0], interpolate=50, return_dist=True) + _ = ox.distance.nearest_edges(Gp, X, Y, return_dist=False) + _ = ox.distance.nearest_edges(Gp, X, Y, return_dist=True) + _ = ox.distance.nearest_edges(Gp, X[0], Y[0], return_dist=False) + _ = ox.distance.nearest_edges(Gp, X[0], Y[0], return_dist=True) -def test_api_endpoints(): +def test_endpoints() -> None: """Test different API endpoints.""" - default_timeout = ox.settings.timeout + default_requests_timeout = ox.settings.requests_timeout default_key = ox.settings.nominatim_key - default_nominatim_endpoint = ox.settings.nominatim_endpoint - default_overpass_endpoint = ox.settings.overpass_endpoint + default_nominatim_url = ox.settings.nominatim_url + default_overpass_url = ox.settings.overpass_url default_overpass_rate_limit = ox.settings.overpass_rate_limit # test good and bad DNS resolution - ox.settings.timeout = 1 - ip = ox._downloader._resolve_host_via_doh("overpass-api.de") - ip = ox._downloader._resolve_host_via_doh("AAAAAAAAAAA") + ox.settings.requests_timeout = 1 + ip = ox._http._resolve_host_via_doh("overpass-api.de") + ip = ox._http._resolve_host_via_doh("AAAAAAAAAAA") _doh_url_template_default = ox.settings.doh_url_template ox.settings.doh_url_template = "http://aaaaaa.hostdoesntexist.org/nothinguseful" - ip = ox._downloader._resolve_host_via_doh("overpass-api.de") + ip = ox._http._resolve_host_via_doh("overpass-api.de") ox.settings.doh_url_template = None - ip = ox._downloader._resolve_host_via_doh("overpass-api.de") + ip = ox._http._resolve_host_via_doh("overpass-api.de") ox.settings.doh_url_template = _doh_url_template_default # Test changing the Overpass endpoint. # This should fail because we didn't provide a valid endpoint ox.settings.overpass_rate_limit = False - ox.settings.overpass_endpoint = "http://NOT_A_VALID_ENDPOINT/api/" + ox.settings.overpass_url = "http://NOT_A_VALID_ENDPOINT/api/" with pytest.raises(ConnectionError, match="Max retries exceeded with url"): G = ox.graph_from_place(place1, network_type="all") ox.settings.overpass_rate_limit = default_overpass_rate_limit - ox.settings.timeout = default_timeout + ox.settings.requests_timeout = default_requests_timeout - params = OrderedDict() + params: OrderedDict[str, int | str] = OrderedDict() params["format"] = "json" params["address_details"] = 0 @@ -456,32 +472,39 @@ def test_api_endpoints(): params["address_details"] = 0 params["osm_ids"] = "W68876073" + # good call response_json = ox._nominatim._nominatim_request(params=params, request_type="lookup") + # bad call + with pytest.raises( + ox._errors.InsufficientResponseError, + match="Nominatim API did not return a list of results", + ): + response_json = ox._nominatim._nominatim_request(params=params, request_type="search") + + # query must be a str if by_osmid=True + with pytest.raises(TypeError, match="`query` must be a string if `by_osmid` is True"): + ox.geocode_to_gdf(query={"City": "Boston"}, by_osmid=True) + # Invalid nominatim query type - with pytest.raises(ValueError, match="Nominatim request_type must be"): + with pytest.raises(ValueError, match="Nominatim `request_type` must be"): response_json = ox._nominatim._nominatim_request(params=params, request_type="xyz") # Searching on public nominatim should work even if a (bad) key was provided ox.settings.nominatim_key = "NOT_A_KEY" - response_json = ox._nominatim._nominatim_request(params=params, request_type="search") + response_json = ox._nominatim._nominatim_request(params=params, request_type="lookup") ox.settings.nominatim_key = default_key - ox.settings.nominatim_endpoint = default_nominatim_endpoint - ox.settings.overpass_endpoint = default_overpass_endpoint + ox.settings.nominatim_url = default_nominatim_url + ox.settings.overpass_url = default_overpass_url -def test_graph_save_load(): +def test_save_load() -> None: # noqa: PLR0915 """Test saving/loading graphs to/from disk.""" - fp = Path(ox.settings.data_folder) / "graph.graphml" - - # save graph as shapefile and geopackage G = ox.graph_from_point(location_point, dist=500, network_type="drive") - ox.save_graph_shapefile(G, directed=True) - ox.save_graph_shapefile(G, filepath=Path(ox.settings.data_folder) / "graph_shapefile") - ox.save_graph_geopackage(G, directed=False) # save/load geopackage and convert graph to/from node/edge GeoDataFrames + ox.save_graph_geopackage(G, directed=False) fp = ".temp/data/graph-dir.gpkg" ox.save_graph_geopackage(G, filepath=fp, directed=True) gdf_nodes1 = gpd.read_file(fp, layer="nodes").set_index("osmid") @@ -489,13 +512,14 @@ def test_graph_save_load(): G2 = ox.graph_from_gdfs(gdf_nodes1, gdf_edges1) G2 = ox.graph_from_gdfs(gdf_nodes1, gdf_edges1, graph_attrs=G.graph) gdf_nodes2, gdf_edges2 = ox.graph_to_gdfs(G2) + _ = list(ox.utils_geo.interpolate_points(gdf_edges2["geometry"].iloc[0], 0.001)) assert set(gdf_nodes1.index) == set(gdf_nodes2.index) == set(G.nodes) == set(G2.nodes) assert set(gdf_edges1.index) == set(gdf_edges2.index) == set(G.edges) == set(G2.edges) # test code branches that should raise exceptions - with pytest.raises(ValueError, match="you must request nodes or edges or both"): + with pytest.raises(ValueError, match="You must request nodes or edges or both"): ox.graph_to_gdfs(G2, nodes=False, edges=False) - with pytest.raises(ValueError, match="invalid literal for boolean"): + with pytest.raises(ValueError, match="Invalid literal for boolean"): ox.io._convert_bool_string("T") # create random boolean graph/node/edge attributes @@ -562,35 +586,39 @@ def test_graph_save_load(): G = ox.load_graphml(graphml_str=data, node_dtypes=nd, edge_dtypes=ed) -def test_graph_from_functions(): +def test_graph_from() -> None: """Test downloading graphs from Overpass.""" # test subdividing a large geometry (raises a UserWarning) bbox = ox.utils_geo.bbox_from_point((0, 0), dist=1e5, project_utm=True) - poly = ox.utils_geo.bbox_to_poly(*bbox) + poly = ox.utils_geo.bbox_to_poly(bbox) _ = ox.utils_geo._consolidate_subdivide_geometry(poly) # graph from bounding box - _ = ox.utils_geo.bbox_from_point(location_point, project_utm=True, return_crs=True) - north, south, east, west = ox.utils_geo.bbox_from_point(location_point, dist=500) - G = ox.graph_from_bbox(north, south, east, west, network_type="drive") - G = ox.graph_from_bbox( - north, south, east, west, network_type="drive_service", truncate_by_edge=True - ) + _ = ox.utils_geo.bbox_from_point(location_point, dist=1000, project_utm=True, return_crs=True) + bbox = ox.utils_geo.bbox_from_point(location_point, dist=500) + G = ox.graph_from_bbox(bbox, network_type="drive") + G = ox.graph_from_bbox(bbox, network_type="drive_service", truncate_by_edge=True) # truncate graph by bounding box - north, south, east, west = ox.utils_geo.bbox_from_point(location_point, dist=400) - G = ox.truncate.truncate_graph_bbox(G, north, south, east, west, min_num=3) - G = ox.utils_graph.get_largest_component(G, strongly=True) + bbox = ox.utils_geo.bbox_from_point(location_point, dist=400) + G = ox.truncate.truncate_graph_bbox(G, bbox) + G = ox.truncate.largest_component(G, strongly=True) # graph from address G = ox.graph_from_address(address=address, dist=500, dist_type="bbox", network_type="bike") # graph from list of places - G = ox.graph_from_place([place1], network_type="all", buffer_dist=0, clean_periphery=False) + G = ox.graph_from_place([place1], which_result=[None], network_type="all") # graph from polygon G = ox.graph_from_polygon(polygon, network_type="walk", truncate_by_edge=True, simplify=False) - G = ox.simplify_graph(G, strict=False, remove_rings=False, track_merged=True) + G = ox.simplify_graph( + G, + node_attrs_include=["junction", "ref"], + edge_attrs_differ=["osmid"], + remove_rings=False, + track_merged=True, + ) # test custom query filter cf = ( @@ -602,49 +630,69 @@ def test_graph_from_functions(): '["access"!~"private"]' ) G = ox.graph_from_point( - location_point, dist=500, custom_filter=cf, dist_type="bbox", network_type="all" + location_point, + dist=500, + custom_filter=cf, + dist_type="bbox", + network_type="all_public", ) - ox.settings.memory = "1073741824" + ox.settings.overpass_memory = 1073741824 G = ox.graph_from_point( location_point, dist=500, dist_type="network", - network_type="all_private", + network_type="all", ) -def test_features(): +def test_features() -> None: """Test downloading features from Overpass.""" - # geometries_from_bbox - bounding box query to return no data + bbox = ox.utils_geo.bbox_from_point(location_point, dist=500) + tags1: dict[str, bool | str | list[str]] = {"landuse": True, "building": True, "highway": True} + + with pytest.raises(ValueError, match="The geometry of `polygon` is invalid."): + ox.features.features_from_polygon(Polygon(((0, 0), (0, 0), (0, 0), (0, 0))), tags={}) + with suppress_type_checks(), pytest.raises(TypeError): + ox.features.features_from_polygon(Point(0, 0), tags={}) + + # test cache_only_mode + ox.settings.cache_only_mode = True + with pytest.raises(ox._errors.CacheOnlyInterruptError, match="Interrupted because"): + _ = ox.features_from_bbox(bbox, tags=tags1) + ox.settings.cache_only_mode = False + + # features_from_bbox - bounding box query to return no data with pytest.raises(ox._errors.InsufficientResponseError): - gdf = ox.geometries_from_bbox(-2.000, -2.001, -2.000, -2.001, tags={"building": True}) + gdf = ox.features_from_bbox(bbox=(-2.000, -2.001, -2.000, -2.001), tags={"building": True}) - # geometries_from_bbox - successful - north, south, east, west = ox.utils_geo.bbox_from_point(location_point, dist=500) - tags = {"landuse": True, "building": True, "highway": True} - gdf = ox.geometries_from_bbox(north, south, east, west, tags=tags) + # features_from_bbox - successful + gdf = ox.features_from_bbox(bbox, tags=tags1) fig, ax = ox.plot_footprints(gdf) fig, ax = ox.plot_footprints(gdf, ax=ax, bbox=(10, 0, 10, 0)) - # geometries_from_point - tests multipolygon creation - gdf = ox.geometries_from_point((48.15, 10.02), tags={"landuse": True}, dist=2000) + # features_from_point - tests multipolygon creation + gdf = ox.utils_geo.bbox_from_point(location_point, dist=500) - # geometries_from_place - includes test of list of places - tags = {"amenity": True, "landuse": ["retail", "commercial"], "highway": "bus_stop"} - gdf = ox.geometries_from_place(place1, tags=tags, buffer_dist=0) - gdf = ox.geometries_from_place([place1], tags=tags) + # features_from_place - includes test of list of places + tags2: dict[str, bool | str | list[str]] = { + "amenity": True, + "landuse": ["retail", "commercial"], + "highway": "bus_stop", + } + gdf = ox.features_from_place(place1, tags=tags2) + gdf = ox.features_from_place([place1], which_result=[None], tags=tags2) - # geometries_from_polygon + # features_from_polygon polygon = ox.geocode_to_gdf(place1).geometry.iloc[0] - ox.geometries_from_polygon(polygon, tags) + ox.features_from_polygon(polygon, tags2) - # geometries_from_address - includes testing overpass settings and snapshot from 2019 + # features_from_address - includes testing overpass settings and snapshot from 2019 ox.settings.overpass_settings = '[out:json][timeout:200][date:"2019-10-28T19:20:00Z"]' - gdf = ox.geometries_from_address(address, tags=tags) + gdf = ox.features_from_address(address, tags=tags2, dist=1000) - # geometries_from_xml - tests error handling of clipped XMLs with incomplete geometry - gdf = ox.geometries_from_xml("tests/input_data/planet_10.068,48.135_10.071,48.137.osm") + # features_from_xml - tests error handling of clipped XMLs with incomplete geometry + gdf = ox.features_from_xml("tests/input_data/planet_10.068,48.135_10.071,48.137.osm") # test loading a geodataframe from a local .osm xml file with bz2.BZ2File("tests/input_data/West-Oakland.osm.bz2") as f: @@ -652,6 +700,6 @@ def test_features(): os.write(handle, f.read()) os.close(handle) for filename in ("tests/input_data/West-Oakland.osm.bz2", temp_filename): - gdf = ox.geometries_from_xml(filename) + gdf = ox.features_from_xml(filename) assert "Willow Street" in gdf["name"].to_numpy() Path.unlink(Path(temp_filename))