diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..0c5a5b175e93e14f123110703b8c3f417db889d9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.10 + +RUN useradd -m -u 1000 user && python -m pip install --upgrade pip +USER user +ENV PATH="/home/user/.local/bin:$PATH" + +WORKDIR /app + +COPY --chown=user ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt + +COPY --chown=user . /app +ENV MCP_TRANSPORT=http +ENV MCP_PORT=7860 + +EXPOSE 7860 + +CMD ["python", "osmnx/mcp_output/start_mcp.py"] diff --git a/README.md b/README.md index 0b81e1e5a113ccdcce9a6cfdbfd1a59f0af402ef..6db81be9e9bb79de661191e3a02c376084f0881d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,32 @@ --- -title: Osmnx -emoji: 💻 -colorFrom: indigo +title: Osmnx MCP +emoji: 🤖 +colorFrom: blue colorTo: purple sdk: docker +sdk_version: "4.26.0" +app_file: app.py pinned: false --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# Osmnx MCP Service + +Auto-generated MCP service for osmnx. + +## Usage + +``` +https://None-osmnx-mcp.hf.space/mcp +``` + +## Connect with Cursor + +```json +{ + "mcpServers": { + "osmnx": { + "url": "https://None-osmnx-mcp.hf.space/mcp" + } + } +} +``` diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..58a60ecf64373da2b7b95e2ef37185f7206469a3 --- /dev/null +++ b/app.py @@ -0,0 +1,45 @@ +from fastapi import FastAPI +import os +import sys + +mcp_plugin_path = os.path.join(os.path.dirname(__file__), "osmnx", "mcp_output", "mcp_plugin") +sys.path.insert(0, mcp_plugin_path) + +app = FastAPI( + title="Osmnx MCP Service", + description="Auto-generated MCP service for osmnx", + version="1.0.0" +) + +@app.get("/") +def root(): + return { + "service": "Osmnx MCP Service", + "version": "1.0.0", + "status": "running", + "transport": os.environ.get("MCP_TRANSPORT", "http") + } + +@app.get("/health") +def health_check(): + return {"status": "healthy", "service": "osmnx MCP"} + +@app.get("/tools") +def list_tools(): + try: + from mcp_service import create_app + mcp_app = create_app() + tools = [] + for tool_name, tool_func in mcp_app.tools.items(): + tools.append({ + "name": tool_name, + "description": tool_func.__doc__ or "No description available" + }) + return {"tools": tools} + except Exception as e: + return {"error": f"Failed to load tools: {str(e)}"} + +if __name__ == "__main__": + import uvicorn + port = int(os.environ.get("PORT", 7860)) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/osmnx/mcp_output/README_MCP.md b/osmnx/mcp_output/README_MCP.md new file mode 100644 index 0000000000000000000000000000000000000000..e2663dae10c94210487f45930fb954916a96ddb5 --- /dev/null +++ b/osmnx/mcp_output/README_MCP.md @@ -0,0 +1,70 @@ +# OSMnx MCP (Model Context Protocol) Service + +## Project Introduction + +OSMnx is a Python package designed for downloading, modeling, analyzing, and visualizing street networks and other geospatial features from OpenStreetMap (OSM). It provides a simple interface to retrieve complex urban data and convert it into network graphs and other usable formats for spatial analysis. Key functionalities include graph creation, network analysis, routing, and visualization. + +## Installation Method + +To install OSMnx, ensure you have Python installed, then use pip to install the package along with its dependencies: + +- Required dependencies: `networkx`, `matplotlib`, `geopandas`, `shapely`, `requests` +- Optional dependency: `folium` + +Install OSMnx via pip: + +``` +pip install osmnx +``` + +## Quick Start + +Here's a quick example to get you started with OSMnx: + +1. **Create a graph from a place name:** + + ```python + import osmnx as ox + G = ox.graph_from_place('Piedmont, California, USA', network_type='drive') + ``` + +2. **Plot the graph:** + + ```python + ox.plot_graph(G) + ``` + +3. **Calculate distances:** + + ```python + distance = ox.distance.great_circle_vec(lat1, lon1, lat2, lon2) + ``` + +## Available Tools and Endpoints List + +- **Graph Creation:** + - `graph_from_place()`: Create a graph from a place name. + - `graph_from_address()`: Create a graph from an address. + - `graph_from_point()`: Create a graph from a geographic point. + +- **Visualization:** + - `plot_graph()`: Visualize the graph. + - `plot_footprints()`: Plot building footprints. + +- **Distance Calculations:** + - `great_circle_vec()`: Calculate great-circle distance between points. + - `euclidean_dist_vec()`: Calculate Euclidean distance between points. + +## Common Issues and Notes + +- **Dependencies:** Ensure all required dependencies are installed. Use the optional `folium` for interactive map visualizations. +- **Environment:** OSMnx is compatible with Python 3.6 and above. Ensure your environment is set up accordingly. +- **Performance:** Large datasets may require significant memory and processing power. Consider using a machine with adequate resources for large-scale analyses. + +## Reference Links or Documentation + +- [OSMnx GitHub Repository](https://github.com/gboeing/osmnx) +- [OSMnx Documentation](https://osmnx.readthedocs.io/en/stable/) +- [OSMnx User Guide](https://osmnx.readthedocs.io/en/stable/user_guide.html) + +For further details on the OSMnx MCP (Model Context Protocol) service, refer to the documentation and explore the various functionalities it offers for geospatial analysis and visualization. \ No newline at end of file diff --git a/osmnx/mcp_output/analysis.json b/osmnx/mcp_output/analysis.json new file mode 100644 index 0000000000000000000000000000000000000000..66ceca3ad3bb9529e8135a43b9131d97f7cab752 --- /dev/null +++ b/osmnx/mcp_output/analysis.json @@ -0,0 +1,251 @@ +{ + "summary": { + "repository_url": "https://github.com/gboeing/osmnx", + "summary": "Imported via zip fallback, file count: 49", + "file_tree": { + ".github/ISSUE_TEMPLATE/bug_report.yml": { + "size": 4139 + }, + ".github/ISSUE_TEMPLATE/config.yml": { + "size": 28 + }, + ".github/ISSUE_TEMPLATE/feature_proposal.yml": { + "size": 2518 + }, + ".github/dependabot.yml": { + "size": 173 + }, + ".github/pull_request_template.md": { + "size": 946 + }, + ".github/workflows/build-publish-docker.yml": { + "size": 1519 + }, + ".github/workflows/build-publish-pypi.yml": { + "size": 1213 + }, + ".github/workflows/ci.yml": { + "size": 2027 + }, + ".github/workflows/test-docs-linkcheck.yml": { + "size": 1014 + }, + ".github/workflows/test-latest-deps.yml": { + "size": 1214 + }, + ".github/workflows/test-minimum-deps.yml": { + "size": 1319 + }, + ".pre-commit-config.yaml": { + "size": 1390 + }, + "CHANGELOG.md": { + "size": 31709 + }, + "CONTRIBUTING.md": { + "size": 3569 + }, + "LICENSE.txt": { + "size": 1109 + }, + "README.md": { + "size": 2558 + }, + "docs/.readthedocs.yaml": { + "size": 223 + }, + "docs/requirements-docs.txt": { + "size": 45 + }, + "docs/source/conf.py": { + "size": 1842 + }, + "osmnx/__init__.py": { + "size": 1189 + }, + "osmnx/_api_v1.py": { + "size": 2724 + }, + "osmnx/_errors.py": { + "size": 610 + }, + "osmnx/_http.py": { + "size": 11518 + }, + "osmnx/_nominatim.py": { + "size": 4900 + }, + "osmnx/_osm_xml.py": { + "size": 16252 + }, + "osmnx/_overpass.py": { + "size": 18059 + }, + "osmnx/_validate.py": { + "size": 13542 + }, + "osmnx/bearing.py": { + "size": 10769 + }, + "osmnx/convert.py": { + "size": 18490 + }, + "osmnx/distance.py": { + "size": 16700 + }, + "osmnx/elevation.py": { + "size": 11330 + }, + "osmnx/features.py": { + "size": 28647 + }, + "osmnx/geocoder.py": { + "size": 8631 + }, + "osmnx/graph.py": { + "size": 33070 + }, + "osmnx/io.py": { + "size": 15477 + }, + "osmnx/plot.py": { + "size": 33836 + }, + "osmnx/projection.py": { + "size": 6381 + }, + "osmnx/routing.py": { + "size": 21734 + }, + "osmnx/settings.py": { + "size": 8327 + }, + "osmnx/simplification.py": { + "size": 33518 + }, + "osmnx/stats.py": { + "size": 13492 + }, + "osmnx/truncate.py": { + "size": 6097 + }, + "osmnx/utils.py": { + "size": 6198 + }, + "osmnx/utils_geo.py": { + "size": 13934 + }, + "pyproject.toml": { + "size": 3463 + }, + "tests/.yamllint.yml": { + "size": 182 + }, + "tests/README.md": { + "size": 1703 + }, + "tests/test_osmnx.py": { + "size": 36044 + }, + "tests/verify_min_deps.py": { + "size": 1575 + } + }, + "processed_by": "zip_fallback", + "success": true + }, + "structure": { + "packages": [ + "source.osmnx" + ] + }, + "dependencies": { + "has_environment_yml": false, + "has_requirements_txt": false, + "pyproject": true, + "setup_cfg": false, + "setup_py": false + }, + "entry_points": { + "imports": [], + "cli": [], + "modules": [] + }, + "llm_analysis": { + "core_modules": [ + { + "package": "source.osmnx", + "module": "osmnx.graph", + "functions": [ + "graph_from_place", + "graph_from_address", + "graph_from_point" + ], + "classes": [ + "Graph" + ], + "description": "Functions and classes for creating and manipulating graphs from OpenStreetMap data." + }, + { + "package": "source.osmnx", + "module": "osmnx.plot", + "functions": [ + "plot_graph", + "plot_footprints" + ], + "classes": [], + "description": "Functions for visualizing graphs and geographic data." + }, + { + "package": "source.osmnx", + "module": "osmnx.distance", + "functions": [ + "great_circle_vec", + "euclidean_dist_vec" + ], + "classes": [], + "description": "Functions for calculating distances between geographic points." + } + ], + "cli_commands": [], + "import_strategy": { + "primary": "import", + "fallback": "blackbox", + "confidence": 0.9 + }, + "dependencies": { + "required": [ + "networkx", + "matplotlib", + "geopandas", + "shapely", + "requests" + ], + "optional": [ + "folium" + ] + }, + "risk_assessment": { + "import_feasibility": 0.9, + "intrusiveness_risk": "low", + "complexity": "medium" + } + }, + "deepwiki_analysis": { + "repo_url": "https://github.com/gboeing/osmnx", + "repo_name": "osmnx", + "content": "gboeing/osmnx\nInstallation and Setup\nGetting Started\nArchitecture and Data Flow\nData Acquisition\nConfiguration and Settings\nCore Functionality\nGraph Creation and Manipulation\nNetwork Analysis\nRouting and Path Finding\nVisualization\nAdvanced Features\nElevation Data\nProjection and Coordinate Systems\nData Import and Export\nDevelopment and Contributing\nTesting and Quality Assurance\nCI/CD Pipeline\nDocker Environment\nVersion History and Roadmap\nCHANGELOG.md\nLICENSE.txt\ndocs/source/conf.py\ndocs/source/index.rst\ndocs/source/internals-reference.rst\ndocs/source/user-reference.rst\nosmnx/__init__.py\nosmnx/_version.py\nOSMnx is a Python package for downloading, modeling, analyzing, and visualizing street networks and other geospatial features from OpenStreetMap (OSM). It provides a simple interface to retrieve complex urban data and convert it into network graphs and other usable formats for spatial analysis. This document introduces the OSMnx system architecture and its core functionality. For installation instructions, seeInstallation and Setup, and for a quick start guide, seeGetting Started.\nPurpose and Scope\nOSMnx enables users to:\nDownload and model walking, driving, or biking networks with a single line of code\nWork with urban amenities/points of interest, building footprints, and transit infrastructure\nAnalyze network metrics and spatial characteristics\nIncorporate elevation data for 3D network analysis and grade calculations\nCalculate and visualize street orientation patterns and entropy\nPerform routing and trip planning with customizable impedance values\nProject and visualize networks and analysis results\nSources:README.md10-12docs/source/index.rst4-5\nSystem Architecture\nCore Components of OSMnx\nOSMnx SystemUser ApplicationOSMnx APIData Acquisition LayerProcessing LayerAnalysis LayerVisualization LayerI/O LayerOverpass API Client(_overpass)Nominatim API Client(_nominatim)HTTP & Caching(_http)graph.py(Network Creation)projection.py(Coordinate Systems)simplification.py(Network Cleanup)convert.py(Data Conversion)distance.py(Distance Calculations)routing.py(Path Finding)stats.py(Network Metrics)bearing.py(Street Orientations)elevation.py(Elevation Data)plot.py(Map Visualization)io.py(Save/Load Graphs)_osm_xml.py(XML Handling)settings.py(Configuration)utils.py(Utilities)OpenStreetMapData\nOSMnx System\nUser Application\nData Acquisition Layer\nProcessing Layer\nAnalysis Layer\nVisualization Layer\nOverpass API Client(_overpass)\nNominatim API Client(_nominatim)\nHTTP & Caching(_http)\ngraph.py(Network Creation)\nprojection.py(Coordinate Systems)\nsimplification.py(Network Cleanup)\nconvert.py(Data Conversion)\ndistance.py(Distance Calculations)\nrouting.py(Path Finding)\nstats.py(Network Metrics)\nbearing.py(Street Orientations)\nelevation.py(Elevation Data)\nplot.py(Map Visualization)\nio.py(Save/Load Graphs)\n_osm_xml.py(XML Handling)\nsettings.py(Configuration)\nutils.py(Utilities)\nOpenStreetMapData\nSources:osmnx/__init__.py10-53docs/source/user-reference.rst\nOutputAnalysisGraph ProcessingData AcquisitionInput MethodsUser InputDefine Query Typegraph_from_place()graph_from_address()graph_from_point()graph_from_bbox()graph_from_polygon()graph_from_xml()geocoder.py_nominatim.py_overpass.py_http.pyOpenStreetMapData_osm_xml.pyNetworkX MultiDiGraphproject_graph()simplify_graph()consolidate_intersections()Add Attributes(speed, travel time,elevation, bearings)Path Finding(shortest_path,k_shortest_paths)Network Analysis(basic_stats)plot_graph()plot_graph_route()save_graphml()save_graph_geopackage()save_graph_xml()\nGraph Processing\nData Acquisition\nInput Methods\nDefine Query Type\ngraph_from_place()\ngraph_from_address()\ngraph_from_point()\ngraph_from_bbox()\ngraph_from_polygon()\ngraph_from_xml()\ngeocoder.py\n_nominatim.py\n_overpass.py\nOpenStreetMapData\n_osm_xml.py\nNetworkX MultiDiGraph\nproject_graph()\nsimplify_graph()\nconsolidate_intersections()\nAdd Attributes(speed, travel time,elevation, bearings)\nPath Finding(shortest_path,k_shortest_paths)\nNetwork Analysis(basic_stats)\nplot_graph()plot_graph_route()\nsave_graphml()save_graph_geopackage()save_graph_xml()\nSources:osmnx/__init__.py10-53CHANGELOG.md\nCore Functionality\nGraph Creation\nOSMnx provides multiple functions for downloading and creating street network graphs:\ngraph_from_place()\ngraph_from_address()\ngraph_from_point()\ngraph_from_bbox()\ngraph_from_polygon()\ngraph_from_xml()\nSources:osmnx/__init__.py27-32\nNetwork Types\nOSMnx can retrieve different types of street networks depending on thenetwork_typeparameter:\nnetwork_type\nNetwork Typeswalk(Pedestrian)bike(Cycling)drive(Driving)drive_service(Driving + Service Roads)all(All Public Ways)all_public(All Public Ways)\nNetwork Types\nwalk(Pedestrian)\nbike(Cycling)\ndrive(Driving)\ndrive_service(Driving + Service Roads)\nall(All Public Ways)\nall_public(All Public Ways)\nSources:CHANGELOG.md79-81\nData Processing and Analysis\nAfter creating a graph, OSMnx provides tools for processing, enriching, and analyzing the network:\nproject_graph()\nsimplify_graph()\nconsolidate_intersections()\nadd_edge_lengths()\nadd_edge_speeds()\nadd_edge_travel_times()\nadd_edge_bearings()\nadd_edge_grades()\nadd_node_elevations_google()\nadd_node_elevations_raster()\nshortest_path()\nk_shortest_paths()\nbasic_stats()\nSources:osmnx/__init__.py10-53CHANGELOG.md\nVisualization\nOSMnx provides multiple plotting functions to visualize networks and analysis results:\nplot_graph()\nplot_graph_route()\nplot_graph_routes()\nplot_footprints()\nplot_figure_ground()\nplot_orientation()\nSources:osmnx/__init__.py37-42\nOSMnx can save and load graphs in various formats:\nsave_graphml()\nload_graphml()\nsave_graph_geopackage()\nsave_graph_xml()\nSources:osmnx/__init__.py33-36\nVersion and Development\nOSMnx is currently at version 2.0.2, released under the MIT license. The package is actively maintained with regular updates and improvements. For version history and roadmap information, seeVersion History and Roadmap.\nSources:osmnx/_version.py3LICENSE.txtCHANGELOG.md\nRefresh this wiki\nOn this page\nPurpose and Scope\nSystem Architecture\nCore Components of OSMnx\nCore Functionality\nGraph Creation\nNetwork Types\nData Processing and Analysis\nVisualization\nVersion and Development", + "model": "gpt-4o-2024-08-06", + "source": "selenium", + "success": true + }, + "deepwiki_options": { + "enabled": true, + "model": "gpt-4o-2024-08-06" + }, + "risk": { + "import_feasibility": 0.9, + "intrusiveness_risk": "low", + "complexity": "medium" + } +} \ No newline at end of file diff --git a/osmnx/mcp_output/diff_report.md b/osmnx/mcp_output/diff_report.md new file mode 100644 index 0000000000000000000000000000000000000000..5a384fe4ffdba6989e9eaf2a3830a7f6e3c5a14f --- /dev/null +++ b/osmnx/mcp_output/diff_report.md @@ -0,0 +1,63 @@ +# OSMnx Project Difference Report + +**Date:** February 6, 2026 +**Time:** 14:18:16 +**Repository:** osmnx +**Project Type:** Python Library +**Intrusiveness:** None +**Workflow Status:** Success +**Test Status:** Failed + +## Project Overview + +OSMnx is a Python library designed to facilitate the acquisition, construction, analysis, and visualization of street networks from OpenStreetMap. It provides tools for geospatial analysis and urban planning, making it a valuable resource for researchers and developers working with spatial data. + +## Difference Analysis + +### New Files + +In this update, 8 new files have been added to the repository. These files likely introduce new features or enhancements to the existing functionality of the OSMnx library. However, no existing files were modified, indicating that the core functionality remains unchanged. + +### Modified Files + +There were no modifications to existing files. This suggests that the new features or enhancements were implemented in a way that does not alter the current codebase, maintaining backward compatibility. + +## Technical Analysis + +### Workflow Status + +The workflow status is marked as successful, indicating that the automated processes for building, testing, and deploying the project were executed without errors. This suggests that the integration of new files was handled smoothly. + +### Test Status + +The test status is marked as failed, which is a critical issue. This failure indicates that the new additions may have introduced bugs or that the existing test suite does not adequately cover the new functionality. It is essential to address these test failures to ensure the reliability and stability of the library. + +## Recommendations and Improvements + +1. **Investigate Test Failures:** Conduct a thorough investigation into the test failures to identify the root causes. This may involve reviewing the new files for potential bugs or updating the test suite to cover new functionalities. + +2. **Enhance Test Coverage:** Ensure that the test suite is comprehensive and includes tests for all new features. This will help in maintaining the integrity of the library and prevent future test failures. + +3. **Documentation Update:** Update the project documentation to reflect the new features and enhancements. This will assist users in understanding and utilizing the new capabilities of the library. + +4. **Code Review:** Conduct a code review of the new files to ensure they adhere to the project's coding standards and best practices. This can help in identifying potential issues early. + +## Deployment Information + +The deployment process appears to have been successful, as indicated by the workflow status. However, given the test failures, it is advisable to hold off on deploying the new version to production until the issues are resolved. + +## Future Planning + +1. **Bug Fixes:** Prioritize fixing the test failures and any identified bugs in the new files. + +2. **Feature Expansion:** Consider expanding the new features based on user feedback and requirements. + +3. **Community Engagement:** Engage with the community to gather feedback on the new features and identify areas for improvement. + +4. **Regular Updates:** Plan for regular updates to the library to incorporate new features, bug fixes, and improvements. + +## Conclusion + +The recent update to the OSMnx project introduces new features through the addition of 8 new files. While the integration process was successful, the test failures highlight the need for immediate attention to ensure the library's reliability. By addressing these issues and enhancing test coverage, the project can continue to provide valuable tools for geospatial analysis and urban planning. + +--- \ No newline at end of file diff --git a/osmnx/mcp_output/mcp_plugin/__init__.py b/osmnx/mcp_output/mcp_plugin/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/osmnx/mcp_output/mcp_plugin/adapter.py b/osmnx/mcp_output/mcp_plugin/adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..bf68397adda3b77f47aeb053d7e7fb03c7f6a08b --- /dev/null +++ b/osmnx/mcp_output/mcp_plugin/adapter.py @@ -0,0 +1,242 @@ +import os +import sys + +# Path settings +source_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "source") +sys.path.insert(0, source_path) + +# Import statements +try: + from osmnx import ( + graph_from_place, graph_from_address, graph_from_point, graph_from_bbox, + graph_from_polygon, graph_from_xml, project_graph, simplify_graph, + consolidate_intersections, add_edge_lengths, add_edge_speeds, + add_edge_travel_times, add_edge_bearings, add_edge_grades, + add_node_elevations_google, add_node_elevations_raster, shortest_path, + k_shortest_paths, basic_stats, plot_graph, plot_graph_route, + plot_graph_routes, plot_footprints, plot_figure_ground, plot_orientation, + save_graphml, load_graphml, save_graph_geopackage, save_graph_xml + ) + import_success = True +except ImportError as e: + import_success = False + import_error = str(e) + +class Adapter: + """ + Adapter class for interfacing with the OSMnx library. + Provides methods for graph creation, manipulation, analysis, and visualization. + """ + + def __init__(self): + self.mode = "import" if import_success else "fallback" + + # -------------------- Graph Creation Methods -------------------- + + def create_graph_from_place(self, place_name, network_type='all'): + """ + Create a graph from a place name. + + :param place_name: Name of the place to create the graph from. + :param network_type: Type of network to create. + :return: Dictionary with status and graph object or error message. + """ + try: + graph = graph_from_place(place_name, network_type=network_type) + return {"status": "success", "graph": graph} + except Exception as e: + return {"status": "error", "message": str(e)} + + def create_graph_from_address(self, address, network_type='all'): + """ + Create a graph from an address. + + :param address: Address to create the graph from. + :param network_type: Type of network to create. + :return: Dictionary with status and graph object or error message. + """ + try: + graph = graph_from_address(address, network_type=network_type) + return {"status": "success", "graph": graph} + except Exception as e: + return {"status": "error", "message": str(e)} + + def create_graph_from_point(self, point, dist=1000, network_type='all'): + """ + Create a graph from a geographical point. + + :param point: Tuple of (latitude, longitude). + :param dist: Distance around the point to create the graph. + :param network_type: Type of network to create. + :return: Dictionary with status and graph object or error message. + """ + try: + graph = graph_from_point(point, dist=dist, network_type=network_type) + return {"status": "success", "graph": graph} + except Exception as e: + return {"status": "error", "message": str(e)} + + def create_graph_from_bbox(self, north, south, east, west, network_type='all'): + """ + Create a graph from a bounding box. + + :param north: Northern latitude. + :param south: Southern latitude. + :param east: Eastern longitude. + :param west: Western longitude. + :param network_type: Type of network to create. + :return: Dictionary with status and graph object or error message. + """ + try: + graph = graph_from_bbox(north, south, east, west, network_type=network_type) + return {"status": "success", "graph": graph} + except Exception as e: + return {"status": "error", "message": str(e)} + + def create_graph_from_polygon(self, polygon, network_type='all'): + """ + Create a graph from a polygon. + + :param polygon: Polygon geometry. + :param network_type: Type of network to create. + :return: Dictionary with status and graph object or error message. + """ + try: + graph = graph_from_polygon(polygon, network_type=network_type) + return {"status": "success", "graph": graph} + except Exception as e: + return {"status": "error", "message": str(e)} + + def create_graph_from_xml(self, filepath, network_type='all'): + """ + Create a graph from an XML file. + + :param filepath: Path to the XML file. + :param network_type: Type of network to create. + :return: Dictionary with status and graph object or error message. + """ + try: + graph = graph_from_xml(filepath, network_type=network_type) + return {"status": "success", "graph": graph} + except Exception as e: + return {"status": "error", "message": str(e)} + + # -------------------- Graph Manipulation Methods -------------------- + + def project_graph(self, graph): + """ + Project a graph to UTM. + + :param graph: Graph to project. + :return: Dictionary with status and projected graph or error message. + """ + try: + projected_graph = project_graph(graph) + return {"status": "success", "projected_graph": projected_graph} + except Exception as e: + return {"status": "error", "message": str(e)} + + def simplify_graph(self, graph): + """ + Simplify a graph. + + :param graph: Graph to simplify. + :return: Dictionary with status and simplified graph or error message. + """ + try: + simplified_graph = simplify_graph(graph) + return {"status": "success", "simplified_graph": simplified_graph} + except Exception as e: + return {"status": "error", "message": str(e)} + + def consolidate_intersections(self, graph, tolerance=10, rebuild_graph=True): + """ + Consolidate intersections in a graph. + + :param graph: Graph to process. + :param tolerance: Tolerance for consolidating intersections. + :param rebuild_graph: Whether to rebuild the graph after consolidation. + :return: Dictionary with status and processed graph or error message. + """ + try: + consolidated_graph = consolidate_intersections(graph, tolerance=tolerance, rebuild_graph=rebuild_graph) + return {"status": "success", "consolidated_graph": consolidated_graph} + except Exception as e: + return {"status": "error", "message": str(e)} + + # -------------------- Graph Analysis Methods -------------------- + + def calculate_basic_stats(self, graph): + """ + Calculate basic statistics of a graph. + + :param graph: Graph to analyze. + :return: Dictionary with status and statistics or error message. + """ + try: + stats = basic_stats(graph) + return {"status": "success", "stats": stats} + except Exception as e: + return {"status": "error", "message": str(e)} + + # -------------------- Graph Visualization Methods -------------------- + + def plot_graph(self, graph, **kwargs): + """ + Plot a graph. + + :param graph: Graph to plot. + :param kwargs: Additional plotting parameters. + :return: Dictionary with status and plot or error message. + """ + try: + plot = plot_graph(graph, **kwargs) + return {"status": "success", "plot": plot} + except Exception as e: + return {"status": "error", "message": str(e)} + + # -------------------- Graph I/O Methods -------------------- + + def save_graphml(self, graph, filepath): + """ + Save a graph to GraphML format. + + :param graph: Graph to save. + :param filepath: Path to save the GraphML file. + :return: Dictionary with status or error message. + """ + try: + save_graphml(graph, filepath) + return {"status": "success"} + except Exception as e: + return {"status": "error", "message": str(e)} + + def load_graphml(self, filepath): + """ + Load a graph from GraphML format. + + :param filepath: Path to the GraphML file. + :return: Dictionary with status and loaded graph or error message. + """ + try: + graph = load_graphml(filepath) + return {"status": "success", "graph": graph} + except Exception as e: + return {"status": "error", "message": str(e)} + + # -------------------- Fallback Handling -------------------- + + def handle_import_failure(self): + """ + Handle import failure gracefully. + + :return: Dictionary with status and error message. + """ + if not import_success: + return {"status": "error", "message": f"Failed to import OSMnx: {import_error}. Please ensure the package is installed correctly."} + return {"status": "success"} + +# Example usage: +# adapter = Adapter() +# result = adapter.create_graph_from_place("Piedmont, California, USA") +# print(result) \ No newline at end of file diff --git a/osmnx/mcp_output/mcp_plugin/main.py b/osmnx/mcp_output/mcp_plugin/main.py new file mode 100644 index 0000000000000000000000000000000000000000..fca6ec384e22f703b287550e94cc00baaaa4c4a7 --- /dev/null +++ b/osmnx/mcp_output/mcp_plugin/main.py @@ -0,0 +1,13 @@ +""" +MCP Service Auto-Wrapper - Auto-generated +""" +from mcp_service import create_app + +def main(): + """Main entry point""" + app = create_app() + return app + +if __name__ == "__main__": + app = main() + app.run() \ No newline at end of file diff --git a/osmnx/mcp_output/mcp_plugin/mcp_service.py b/osmnx/mcp_output/mcp_plugin/mcp_service.py new file mode 100644 index 0000000000000000000000000000000000000000..427661bfbad06c83f07a565c5d6e4c7c5ac8538a --- /dev/null +++ b/osmnx/mcp_output/mcp_plugin/mcp_service.py @@ -0,0 +1,79 @@ +import os +import sys + +# Add the local source directory to sys.path +source_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "source") +if source_path not in sys.path: + sys.path.insert(0, source_path) + +from fastmcp import FastMCP +from osmnx import graph, routing, plot, stats + +mcp = FastMCP("osmnx_service") + +@mcp.tool(name="create_graph_from_place", description="Create a graph from a place name") +def create_graph_from_place(place_name: str, network_type: str) -> dict: + """ + Create a graph from a place name. + + :param place_name: The name of the place to create the graph from. + :param network_type: The type of network to create (e.g., 'walk', 'bike', 'drive'). + :return: A dictionary with success, result, or error fields. + """ + try: + G = graph.graph_from_place(place_name, network_type=network_type) + return {"success": True, "result": G, "error": None} + except Exception as e: + return {"success": False, "result": None, "error": str(e)} + +@mcp.tool(name="shortest_path", description="Find the shortest path between two nodes") +def shortest_path(G, origin: int, destination: int) -> dict: + """ + Find the shortest path between two nodes in a graph. + + :param G: The graph to search. + :param origin: The origin node ID. + :param destination: The destination node ID. + :return: A dictionary with success, result, or error fields. + """ + try: + path = routing.shortest_path(G, origin, destination) + return {"success": True, "result": path, "error": None} + except Exception as e: + return {"success": False, "result": None, "error": str(e)} + +@mcp.tool(name="plot_graph", description="Plot a graph") +def plot_graph(G) -> dict: + """ + Plot a graph. + + :param G: The graph to plot. + :return: A dictionary with success, result, or error fields. + """ + try: + fig, ax = plot.plot_graph(G) + return {"success": True, "result": (fig, ax), "error": None} + except Exception as e: + return {"success": False, "result": None, "error": str(e)} + +@mcp.tool(name="calculate_basic_stats", description="Calculate basic statistics of a graph") +def calculate_basic_stats(G) -> dict: + """ + Calculate basic statistics of a graph. + + :param G: The graph to analyze. + :return: A dictionary with success, result, or error fields. + """ + try: + stats_result = stats.basic_stats(G) + return {"success": True, "result": stats_result, "error": None} + except Exception as e: + return {"success": False, "result": None, "error": str(e)} + +def create_app() -> FastMCP: + """ + Create and return the FastMCP application instance. + + :return: The FastMCP instance. + """ + return mcp \ No newline at end of file diff --git a/osmnx/mcp_output/requirements.txt b/osmnx/mcp_output/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..1d24d2e4ce946d859ccfe00993b821f2cfc61c2e --- /dev/null +++ b/osmnx/mcp_output/requirements.txt @@ -0,0 +1,11 @@ +fastmcp +fastapi +uvicorn[standard] +pydantic>=2.0.0 +geopandas>=1.0.1 +networkx>=2.5 +numpy>=1.24 +pandas>=1.5 +requests>=2.27 +shapely>=2.0 +matplotlib diff --git a/osmnx/mcp_output/start_mcp.py b/osmnx/mcp_output/start_mcp.py new file mode 100644 index 0000000000000000000000000000000000000000..fc7fcbd9646ad53f089fc94af8129043a703325a --- /dev/null +++ b/osmnx/mcp_output/start_mcp.py @@ -0,0 +1,30 @@ + +""" +MCP Service Startup Entry +""" +import sys +import os + +project_root = os.path.dirname(os.path.abspath(__file__)) +mcp_plugin_dir = os.path.join(project_root, "mcp_plugin") +if mcp_plugin_dir not in sys.path: + sys.path.insert(0, mcp_plugin_dir) + +from mcp_service import create_app + +def main(): + """Start FastMCP service""" + app = create_app() + # Use environment variable to configure port, default 8000 + port = int(os.environ.get("MCP_PORT", "8000")) + + # Choose transport mode based on environment variable + transport = os.environ.get("MCP_TRANSPORT", "stdio") + if transport == "http": + app.run(transport="http", host="0.0.0.0", port=port) + else: + # Default to STDIO mode + app.run() + +if __name__ == "__main__": + main() diff --git a/osmnx/mcp_output/workflow_summary.json b/osmnx/mcp_output/workflow_summary.json new file mode 100644 index 0000000000000000000000000000000000000000..0294be4b5f19adb842d6ae8b09ed070e8650fc29 --- /dev/null +++ b/osmnx/mcp_output/workflow_summary.json @@ -0,0 +1,194 @@ +{ + "repository": { + "name": "osmnx", + "url": "https://github.com/gboeing/osmnx", + "local_path": "/export/zxcpu1/shiweijie/code/ghh/Code2MCP/workspace/osmnx", + "description": "Python library", + "features": "Basic functionality", + "tech_stack": "Python", + "stars": 0, + "forks": 0, + "language": "Python", + "last_updated": "", + "complexity": "medium", + "intrusiveness_risk": "low" + }, + "execution": { + "start_time": 1770358542.3952017, + "end_time": 1770358630.1692348, + "duration": 87.7740330696106, + "status": "success", + "workflow_status": "success", + "nodes_executed": [ + "download", + "analysis", + "env", + "generate", + "run", + "review", + "finalize" + ], + "total_files_processed": 1, + "environment_type": "unknown", + "llm_calls": 0, + "deepwiki_calls": 0 + }, + "tests": { + "original_project": { + "passed": false, + "details": {}, + "test_coverage": "100%", + "execution_time": 0, + "test_files": [] + }, + "mcp_plugin": { + "passed": true, + "details": {}, + "service_health": "healthy", + "startup_time": 0, + "transport_mode": "stdio", + "fastmcp_version": "unknown", + "mcp_version": "unknown" + } + }, + "analysis": { + "structure": { + "packages": [ + "source.osmnx" + ] + }, + "dependencies": { + "has_environment_yml": false, + "has_requirements_txt": false, + "pyproject": true, + "setup_cfg": false, + "setup_py": false + }, + "entry_points": { + "imports": [], + "cli": [], + "modules": [] + }, + "risk_assessment": { + "import_feasibility": 0.9, + "intrusiveness_risk": "low", + "complexity": "medium" + }, + "deepwiki_analysis": { + "repo_url": "https://github.com/gboeing/osmnx", + "repo_name": "osmnx", + "content": "gboeing/osmnx\nInstallation and Setup\nGetting Started\nArchitecture and Data Flow\nData Acquisition\nConfiguration and Settings\nCore Functionality\nGraph Creation and Manipulation\nNetwork Analysis\nRouting and Path Finding\nVisualization\nAdvanced Features\nElevation Data\nProjection and Coordinate Systems\nData Import and Export\nDevelopment and Contributing\nTesting and Quality Assurance\nCI/CD Pipeline\nDocker Environment\nVersion History and Roadmap\nCHANGELOG.md\nLICENSE.txt\ndocs/source/conf.py\ndocs/source/index.rst\ndocs/source/internals-reference.rst\ndocs/source/user-reference.rst\nosmnx/__init__.py\nosmnx/_version.py\nOSMnx is a Python package for downloading, modeling, analyzing, and visualizing street networks and other geospatial features from OpenStreetMap (OSM). It provides a simple interface to retrieve complex urban data and convert it into network graphs and other usable formats for spatial analysis. This document introduces the OSMnx system architecture and its core functionality. For installation instructions, seeInstallation and Setup, and for a quick start guide, seeGetting Started.\nPurpose and Scope\nOSMnx enables users to:\nDownload and model walking, driving, or biking networks with a single line of code\nWork with urban amenities/points of interest, building footprints, and transit infrastructure\nAnalyze network metrics and spatial characteristics\nIncorporate elevation data for 3D network analysis and grade calculations\nCalculate and visualize street orientation patterns and entropy\nPerform routing and trip planning with customizable impedance values\nProject and visualize networks and analysis results\nSources:README.md10-12docs/source/index.rst4-5\nSystem Architecture\nCore Components of OSMnx\nOSMnx SystemUser ApplicationOSMnx APIData Acquisition LayerProcessing LayerAnalysis LayerVisualization LayerI/O LayerOverpass API Client(_overpass)Nominatim API Client(_nominatim)HTTP & Caching(_http)graph.py(Network Creation)projection.py(Coordinate Systems)simplification.py(Network Cleanup)convert.py(Data Conversion)distance.py(Distance Calculations)routing.py(Path Finding)stats.py(Network Metrics)bearing.py(Street Orientations)elevation.py(Elevation Data)plot.py(Map Visualization)io.py(Save/Load Graphs)_osm_xml.py(XML Handling)settings.py(Configuration)utils.py(Utilities)OpenStreetMapData\nOSMnx System\nUser Application\nData Acquisition Layer\nProcessing Layer\nAnalysis Layer\nVisualization Layer\nOverpass API Client(_overpass)\nNominatim API Client(_nominatim)\nHTTP & Caching(_http)\ngraph.py(Network Creation)\nprojection.py(Coordinate Systems)\nsimplification.py(Network Cleanup)\nconvert.py(Data Conversion)\ndistance.py(Distance Calculations)\nrouting.py(Path Finding)\nstats.py(Network Metrics)\nbearing.py(Street Orientations)\nelevation.py(Elevation Data)\nplot.py(Map Visualization)\nio.py(Save/Load Graphs)\n_osm_xml.py(XML Handling)\nsettings.py(Configuration)\nutils.py(Utilities)\nOpenStreetMapData\nSources:osmnx/__init__.py10-53docs/source/user-reference.rst\nOutputAnalysisGraph ProcessingData AcquisitionInput MethodsUser InputDefine Query Typegraph_from_place()graph_from_address()graph_from_point()graph_from_bbox()graph_from_polygon()graph_from_xml()geocoder.py_nominatim.py_overpass.py_http.pyOpenStreetMapData_osm_xml.pyNetworkX MultiDiGraphproject_graph()simplify_graph()consolidate_intersections()Add Attributes(speed, travel time,elevation, bearings)Path Finding(shortest_path,k_shortest_paths)Network Analysis(basic_stats)plot_graph()plot_graph_route()save_graphml()save_graph_geopackage()save_graph_xml()\nGraph Processing\nData Acquisition\nInput Methods\nDefine Query Type\ngraph_from_place()\ngraph_from_address()\ngraph_from_point()\ngraph_from_bbox()\ngraph_from_polygon()\ngraph_from_xml()\ngeocoder.py\n_nominatim.py\n_overpass.py\nOpenStreetMapData\n_osm_xml.py\nNetworkX MultiDiGraph\nproject_graph()\nsimplify_graph()\nconsolidate_intersections()\nAdd Attributes(speed, travel time,elevation, bearings)\nPath Finding(shortest_path,k_shortest_paths)\nNetwork Analysis(basic_stats)\nplot_graph()plot_graph_route()\nsave_graphml()save_graph_geopackage()save_graph_xml()\nSources:osmnx/__init__.py10-53CHANGELOG.md\nCore Functionality\nGraph Creation\nOSMnx provides multiple functions for downloading and creating street network graphs:\ngraph_from_place()\ngraph_from_address()\ngraph_from_point()\ngraph_from_bbox()\ngraph_from_polygon()\ngraph_from_xml()\nSources:osmnx/__init__.py27-32\nNetwork Types\nOSMnx can retrieve different types of street networks depending on thenetwork_typeparameter:\nnetwork_type\nNetwork Typeswalk(Pedestrian)bike(Cycling)drive(Driving)drive_service(Driving + Service Roads)all(All Public Ways)all_public(All Public Ways)\nNetwork Types\nwalk(Pedestrian)\nbike(Cycling)\ndrive(Driving)\ndrive_service(Driving + Service Roads)\nall(All Public Ways)\nall_public(All Public Ways)\nSources:CHANGELOG.md79-81\nData Processing and Analysis\nAfter creating a graph, OSMnx provides tools for processing, enriching, and analyzing the network:\nproject_graph()\nsimplify_graph()\nconsolidate_intersections()\nadd_edge_lengths()\nadd_edge_speeds()\nadd_edge_travel_times()\nadd_edge_bearings()\nadd_edge_grades()\nadd_node_elevations_google()\nadd_node_elevations_raster()\nshortest_path()\nk_shortest_paths()\nbasic_stats()\nSources:osmnx/__init__.py10-53CHANGELOG.md\nVisualization\nOSMnx provides multiple plotting functions to visualize networks and analysis results:\nplot_graph()\nplot_graph_route()\nplot_graph_routes()\nplot_footprints()\nplot_figure_ground()\nplot_orientation()\nSources:osmnx/__init__.py37-42\nOSMnx can save and load graphs in various formats:\nsave_graphml()\nload_graphml()\nsave_graph_geopackage()\nsave_graph_xml()\nSources:osmnx/__init__.py33-36\nVersion and Development\nOSMnx is currently at version 2.0.2, released under the MIT license. The package is actively maintained with regular updates and improvements. For version history and roadmap information, seeVersion History and Roadmap.\nSources:osmnx/_version.py3LICENSE.txtCHANGELOG.md\nRefresh this wiki\nOn this page\nPurpose and Scope\nSystem Architecture\nCore Components of OSMnx\nCore Functionality\nGraph Creation\nNetwork Types\nData Processing and Analysis\nVisualization\nVersion and Development", + "model": "gpt-4o-2024-08-06", + "source": "selenium", + "success": true + }, + "code_complexity": { + "cyclomatic_complexity": "medium", + "cognitive_complexity": "medium", + "maintainability_index": 75 + }, + "security_analysis": { + "vulnerabilities_found": 0, + "security_score": 85, + "recommendations": [] + } + }, + "plugin_generation": { + "files_created": [ + "mcp_output/start_mcp.py", + "mcp_output/mcp_plugin/__init__.py", + "mcp_output/mcp_plugin/mcp_service.py", + "mcp_output/mcp_plugin/adapter.py", + "mcp_output/mcp_plugin/main.py", + "mcp_output/requirements.txt", + "mcp_output/README_MCP.md" + ], + "main_entry": "start_mcp.py", + "requirements": [ + "fastmcp>=0.1.0", + "pydantic>=2.0.0" + ], + "readme_path": "/export/zxcpu1/shiweijie/code/ghh/Code2MCP/workspace/osmnx/mcp_output/README_MCP.md", + "adapter_mode": "import", + "total_lines_of_code": 0, + "generated_files_size": 0, + "tool_endpoints": 0, + "supported_features": [ + "Basic functionality" + ], + "generated_tools": [ + "Basic tools", + "Health check tools", + "Version info tools" + ] + }, + "code_review": {}, + "errors": [], + "warnings": [], + "recommendations": [ + "Improve test coverage by adding more unit tests", + "Ensure all dependencies are clearly defined in a requirements.txt or environment.yml file", + "Optimize large files like osmnx/plot.py and osmnx/simplification.py for better performance", + "Enhance documentation for core modules and functions", + "Implement continuous integration to automate testing and deployment", + "Review and refactor code for better readability and maintainability", + "Consider adding more CLI commands for ease of use", + "Update the README to include more detailed setup and usage instructions", + "Regularly update the CHANGELOG to reflect recent changes and improvements", + "Conduct a code review to identify potential areas for optimization and bug fixes." + ], + "performance_metrics": { + "memory_usage_mb": 0, + "cpu_usage_percent": 0, + "response_time_ms": 0, + "throughput_requests_per_second": 0 + }, + "deployment_info": { + "supported_platforms": [ + "Linux", + "Windows", + "macOS" + ], + "python_versions": [ + "3.8", + "3.9", + "3.10", + "3.11", + "3.12" + ], + "deployment_methods": [ + "Docker", + "pip", + "conda" + ], + "monitoring_support": true, + "logging_configuration": "structured" + }, + "execution_analysis": { + "success_factors": [ + "Successful execution of all workflow nodes", + "Healthy service status of the MCP plugin" + ], + "failure_reasons": [], + "overall_assessment": "excellent", + "node_performance": { + "download_time": "Efficient, completed without issues", + "analysis_time": "Completed successfully, medium complexity", + "generation_time": "Efficient, generated necessary files", + "test_time": "Original project tests failed, MCP plugin tests passed" + }, + "resource_usage": { + "memory_efficiency": "Not explicitly measured, assumed efficient due to lack of issues", + "cpu_efficiency": "Not explicitly measured, assumed efficient due to lack of issues", + "disk_usage": "Minimal, as only one file was processed" + } + }, + "technical_quality": { + "code_quality_score": 75, + "architecture_score": 80, + "performance_score": 70, + "maintainability_score": 75, + "security_score": 85, + "scalability_score": 70 + } +} \ No newline at end of file diff --git a/osmnx/source/.pre-commit-config.yaml b/osmnx/source/.pre-commit-config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..02f2081dc7abea8411621fe2ebd2433b3e638b39 --- /dev/null +++ b/osmnx/source/.pre-commit-config.yaml @@ -0,0 +1,53 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-added-large-files + args: [--maxkb=50] + - id: check-ast + - id: check-case-conflict + - 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: detect-private-key + - id: end-of-file-fixer + - id: fix-byte-order-marker + - id: mixed-line-ending + - id: no-commit-to-branch + - id: pretty-format-json + args: [--autofix] + - id: trailing-whitespace + + - repo: https://github.com/adrienverge/yamllint + rev: v1.38.0 + hooks: + - id: yamllint + args: [--strict, --config-file=./tests/.yamllint.yml] + + - repo: https://github.com/numpy/numpydoc + rev: v1.10.0 + hooks: + - id: numpydoc-validation + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.13 + hooks: + - id: ruff-check + args: [--fix, --show-fixes] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.19.1 + hooks: + - id: mypy + additional_dependencies: + - matplotlib + - pandas-stubs + - pytest + - scipy-stubs + - types-requests diff --git a/osmnx/source/CHANGELOG.md b/osmnx/source/CHANGELOG.md new file mode 100644 index 0000000000000000000000000000000000000000..4d9b171a96f0ad592a4a4d3355104037e79ddfe6 --- /dev/null +++ b/osmnx/source/CHANGELOG.md @@ -0,0 +1,690 @@ +# Changelog + +## 2.1.0 (TBD) + +- add Python 3.14 support (#1336) +- drop Python 3.9 and 3.10 support (#1322 #1336) +- add validation functions to verify that a graph or GeoDataFrame satisfies OSMnx expectations (#1317) + +## 2.0.7 (2025-11-25) + +- fix TypeError in _getaddrinfo wrapper when host passed both positionally and as keyword (#1340) +- allow interpolate_points function to run on MultiLineString input (#1341) + +## 2.0.6 (2025-08-11) + +- handle relations with missing member geometries when loading features from XML (#1334) +- exclude ways tagged "rest_area" or "services" when downloading network data (#1328) + +## 2.0.5 (2025-07-05) + +- fix bug that prevents querying or projecting in polar regions (#1324 #1326) +- improve module exposure for better code introspection (#1308) +- add "all" optional dependency extra (#1313) +- bump minimum required patch versions of optional extras to earliest versions with macosx/arm64 wheels (#1313) + +## 2.0.4 (2025-06-11) + +- fix bug in features module when elements have pre-existing geometry tags (#1298) +- fix bug in save_graphml function where Gephi compatibility mode erases node attributes (#1300) +- bump minimum required minor versions of optional extras to earliest versions with linux/amd64 wheels (#1296) + +## 2.0.3 (2025-05-06) + +- ensure geocoder results are sorted by importance (#1290) +- update official reference paper and citations (#1293) + +## 2.0.2 (2025-03-25) + +- fix bug in parsing time when calculating pause duration between requests (#1277) +- fix bug in round-robin DNS resolution by safely handling missing host argument (#1282) +- fix bug where consolidate_intersections function would mutate the passed-in graph (#1273) +- add graph-level "consolidated" attribute if consolidate_intersections has been run (#1273) +- provide user-friendly error message if consolidate_intersections is run more than once (#1273) +- refactor internals of caching and pausing between requests (#1279) +- streamline internal handling of radians throughout package (#1281) +- improve docstrings (#1272 #1274) + +## 2.0.1 (2025-01-01) + +- fix error message when elevation module's optional dependencies are missing (#1250) +- update "walk" network_type to filter out ways whose sidewalks are mapped separately (#1254) + +## 2.0.0 (2024-11-24) + +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) +- add Python 3.13 support (#1223) +- drop Python 3.8 support (#1106) +- bump minimum required numpy version to 1.22 for typing support (#1133 #1198) +- bump minimum required versions of geopandas to 1.0 and pandas to 1.4 for union_all support (#1179 #1198) +- replace gdal optional dependency with rio-vrt optional dependency (#1203) +- improve docstrings throughout package (#1116) +- improve logging and warnings throughout package (#1125) +- improve error messages throughout package (#1131) +- improve internal file handling context management (#1226 #1227) +- refactor features module for speed improvement and memory efficiency (#1157 #1205) +- 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 utils_geo.buffer_geometry helper function (#1214) +- 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) +- better virtual raster handling in elevation module (#1236) +- use system's default start method when multiprocessing (#1237) +- allow analysis of MultiDiGraph directional edge bearings and orientation (#1139) +- allow graph union queries through the custom_filter argument (#1204) +- 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) +- make bounding box coordinate order consistently left, bottom, right, top (#1196) +- 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.4 (2024-07-24) + +- pin maximum dependency versions for remaining v1 releases +- add warning to note that the order of bounding box coordinates will change in v2 + +## 1.9.3 (2024-05-01) + +- update the official package reference paper (#1169) +- rename network_types "all" -> "all_public" and "all_private" -> "all" for clarity (#1164) +- deprecate the obsolete "all_private" network_type name (#1164) + +## 1.9.2 (2024-04-02) + +- deprecate and replace settings module's default_accept_language, default_referer, and default_user_agent settings (#1138) +- deprecate and replace settings module's memory, nominatim_endpoint, overpass_endpoint, and timeout settings (#1138) +- deprecate save_graph_xml function's renamed or obsolete parameters (#1138) +- deprecate graph_from_xml tags and polygon function parameters (#1146) +- deprecate simplify_graph function's endpoint_attrs argument and replace it with edge_attrs_differ (#1146) +- deprecate utils_graph.get_digraph function and replace it with covert.to_digraph function (#1146) +- deprecate utils_graph.get_undirected function and replace it with covert.to_undirected function (#1146) +- deprecate utils_graph.graph_to_gdfs function and replace it with covert.graph_to_gdfs function (#1146) +- deprecate utils_graph.graph_from_gdfs function and replace it with covert.graph_from_gdfs function (#1146) +- deprecate utils_graph.remove_isolated_nodes function (#1156) +- deprecate utils_graph.get_largest_component function and replace it with truncate.largest_component function (#1146) +- deprecate utils_graph.route_to_gdf function and replace it with routing.route_to_gdf function (#1146) +- deprecate speed module and move all of its functionality to the routing module (#1146) + +## 1.9.1 (2024-02-01) + +- fix deprecation warning in simplification.simplify_graph function (#1126) + +## 1.9.0 (2024-01-31) + +- add endpoint_attrs argument to simplification.simplify_graph function to flexibly relax strictness (#1117) +- fix a bug in the features module's polygon handling (#1104) +- update obsolete numpy random number generation (#1108) +- make deprecation warnings FutureWarnings (#1124) +- update warning messages to note that deprecated code will be removed in v2.0.0 (#1111) +- deprecate strict argument in simplification.simplify_graph function in favor of new endpoint_attrs argument (#1117) +- deprecate north, south, east, west arguments throughout package in favor of bbox tuple argument (#1112) +- deprecate return_coords argument in graph.graph_from_address function (#1105) +- deprecate return_hex argument in plot.get_colors function (#1109) +- deprecate address, point, network_type, edge_color, and smooth_joints arguments in plot.plot_figure_ground function (#1121) + +## 1.8.1 (2023-12-31) + +- fix a bug arising from the save_graph_xml function (#1093) +- warn user if their query area is significantly larger than max query area size (#1101) +- refactor utils_geo module and deprecate quadrat_width and min_num function arguments (#1100) +- under-the-hood code clean-up (#1092 #1099 #1103) + +## 1.8.0 (2023-11-30) + +- formally support Python 3.12 (#1082) +- fix Windows-specific character encoding issue when reading XML files (#1084) +- resolve pandas and gdal future warnings (#1089) +- use spawn instead of fork for multiprocessing to resolve Python 3.12 deprecation warning (#1089) +- rename add_node_elevations_google function's max_locations_per_batch parameter, with deprecation warning (#1088) +- move add_node_elevations_google function's url_template parameter to settings module, with deprecation warning (#1088) + +## 1.7.1 (2023-10-29) + +- fix references to latitude and longitude parameters as lat and lon consistently across package (#1068 #1069) +- fix handling of dict and set attribute types when reloading GraphML files (#1075 #1077) + +## 1.7.0 (2023-10-11) + +- improve automatic UTM handling in the projection module (#1059) +- add a to_latlong parameter to the projection.project_graph function for API consistency (#1057) +- workaround for pytest issue with printing to terminal window on Windows (#1064) +- refactor the distance module and add a new routing module (#1063) +- move shortest_path and k_shortest_paths functions to new routing module, with deprecation warning (#1063) +- rename great_circle_vec and euclidean_dist_vec functions to great_circle and euclidean, with deprecation warning (#1063) +- under-the-hood code clean-up (#1047) + +## 1.6.0 (2023-07-28) + +- fix DNS resolution in Dask clusters (#1039) +- improve memory efficiency during features GeoDataFrame creation (#1043) +- handle the settings.cache_only_mode option in the features module (#1043) +- deprecate the buffer_dist and clean_periphery function parameters throughout package (#1044) +- add more descriptive exceptions: ResponseStatusCodeError and GraphSimplificationError (#1041) +- replace CacheOnlyModeInterrupt exception with CacheOnlyInterruptError exception (#1041) +- replace EmptyOverpassResponse exception with InsufficientResponseError exception (#1041) +- refactor elevation module (#1042 #1043) +- refactor the \_downloader module and add new \_overpass and \_nominatim modules (#1043) +- under-the-hood code clean-up (#1036 #1037 #1038) + +## 1.5.1 (2023-07-08) + +- improve memory efficiency during graph creation (#1021 #1029) +- improve log messaging (#1032) +- add version number to XML generator attribute in save_graph_xml (#1031) +- warn user if loading a .osm XML file generated by OSMnx itself (#1031) +- add style keyword argument to citation function (#1034) + +## 1.5.0 (2023-06-28) + +- fix bug in save_graph_xml due to roundabout ways (#986 #999) +- fix GeoPandas future warning (#1012) +- make API key properly optional in elevation.add_node_elevations_google function (#999) +- rename geometries module as features module and deprecate geometries module (#1007 #1011) +- remove private \_polygon_features module and move its data to features module (#994) +- make the internal downloader module private (#1010) +- deprecate interpolate parameter in distance.nearest_edges function (#1010) +- move save_graph_xml function to io module with deprecation warning in osm_xml module (#1017) +- migrate from setup.py, setup.cfg, and requirements.txt to pyproject.toml (#1002) +- pin optional dependencies to minimum required versions (#995) +- expand and reorganize the documentation (#993) + +## 1.4.0 (2023-06-11) + +- verify edge weight attribute values before solving shortest paths (#967) +- provide consistent error when no data elements are returned from Overpass (#960) +- add route_to_gdf function to utils_graph module to return a GeoDataFrame of the edges in a path (#957) +- deprecate the get_route_edge_attributes function in favor of the new route_to_gdf function (#957) +- deprecate folium module in favor of using geopandas.GeoDataFrame.explore directly (#957) +- deprecate precision parameter in bearing, distance, elevation, and speed modules' functions (#981) +- deprecate utils_geo.round_geometry_coords function (#981) +- move plot_orientation function from bearing module to plot module (#956) +- make matplotlib an optional dependency required only for the plot module (#976) +- drop pyproj package dependency (#980) + +## 1.3.1.post0 (2023-05-26) + +- restore Python 3.8 compatibility (#965) + +## 1.3.1 (2023-05-24) + +- improve DNS resolution when using proxies or on networks blocking DNS-over-HTTPS (#924 #953) +- improve processing of per-lane values when adding edge speeds (#944 #955) +- improve file writing in save_graph_xml function (#917 #961) +- ensure node coordinates are non-null and convertible to float in the add_edge_lengths function (#950) +- ignore ways tagged highway=no or highway=razed in built-in filters (#938) +- do not assume an edge with key=0 exists between each node pair when simplifying graph (#921) +- drop dateutil package dependency (#919) + +## 1.3.0 (2023-01-01) + +- fully support Shapely 2.0 and drop support for Shapely 1.x (#900) +- drop RTree package dependency (#900) +- much faster nearest edges search using STRTree index (#900) +- allow using alternative Google Maps compatible elevation APIs, such as Open Topo Data (#901 #903) +- optionally track merged_edges as a new edge attribute in simplify_graph function (#892 #909) + +## 1.2.3 (2022-12-14) + +- fix bug that added unsimplified edge geometry attributes when projecting +- hard code Google DNS IP address +- resolve matplotlib deprecation warning +- deprecate save_graph_shapefile function + +## 1.2.2 (2022-08-05) + +- fix compatibility with rasterio 1.3 +- fix API version when saving OSM XML +- resolve shapely deprecation warning + +## 1.2.1 (2022-06-16) + +- fix rate limit checking and pausing on newest versions of Overpass API +- allow add_edge_lengths function to be run on a subset of edges +- resolve pandas deprecation warning + +## 1.2.0 (2022-05-23) + +- add ability to load GraphML string data to the load_graphml function +- add "reversed" edge attribute to support node-order-dependent edge attributes +- add new edge_color and edge_linewidth arguments to plot_footprints function +- fix nearest_edges function selecting arbitrary edge when bounding boxes overlap +- fix get_digraph function's parallel edge handling +- fix pandas and geopandas version compatibility +- fix log output appearing in Jupyter notebooks on Unix-like systems +- remove old functions and arguments previously deprecated in v1.1 +- deprecate utils.config function in favor of using settings module directly + +## 1.1.2 (2021-11-17) + +- fix geocoding when no geojson is returned +- fix graph simplification to properly handle travel_time edge attributes +- fix streets per node not being calculated when clean_periphery=False +- allow user-defined aggregation function when imputing missing edge speeds +- allow user to configure requests package keyword arguments when connecting to APIs +- faster graph projection by calculating UTM zone number with a computationally cheaper method +- improve efficiency of quadrat-based geometry cutting +- fall back on google dns resolution when necessary if using a proxy +- move count_streets_per_node function to stats module +- resolve shapely and geopandas deprecation warnings + +## 1.1.1 (2021-05-19) + +- fix overpass status endpoint checks with explicit IP address resolution +- fix slot management on local overpass instances by optionally disabling rate limiting +- parallelize shortest_path calculation for multiple origins/destinations + +## 1.1.0 (2021-05-01) + +- add graph-constrained spatial sampling function +- add add_node_elevations_raster function to add node elevations from local raster file(s) +- add add_node_elevations_google function and deprecate old add_node_elevations function +- add faster streamlined nearest_nodes and nearest_edges functions to distance module +- deprecate old get_nearest_node, get_nearest_nodes, get_nearest_edge, and get_nearest_edges +- add utils_geo.interpolate_points function and deprecate redistribute_vertices in favor of it +- add vectorized calculate_bearing function and deprecate get_bearing in favor of it +- expose individual street network stats functions in stats module +- deprecate the extended_stats function in stats module +- add network orientation and entropy stats functions to bearing module +- add plot_orientation function to bearing module to polar histograms of graph edge bearings +- add route_linewidths parameter to plot_graph_routes function +- handle relations of type "boundary" in geometries module +- multi-index GeoDataFrames returned from geometries module by element type and osmid +- ensure all nodes have integer IDs after graph intersection consolidation +- vectorize add_edge_lengths, add_edge_grades, and add_edge_bearings functions +- improve save_graph_xml speed +- improve geocoder module error messages +- improve handling of node geometry when converting graph to/from GeoDataFrames +- fix network_type filters allowing ways tagged "bus_guideway" +- fix handling of boolean type conversion in load_graphml +- fix truncate_graph_dist retaining unreachable nodes +- fix bug in consolidate_intersections when pygeos is installed +- move add_edge_lengths function from utils_graph to distance module +- remove descartes dependency in line with geopandas + +## 1.0.1 (2021-01-13) + +- fix network_type filters allowing ways tagged "planned" +- fix "drive" network_type allowing some alleys +- fix intersection consolidation for compatibility with v1.0 node ids/indexing +- fix python 3.6 compatibility +- deprecate folium polyline styling arguments + +## 1.0.0 (2021-01-01) + +- set use_cache=True by default +- add ability to query a place by OSM ID in geocoder.geocode_to_gdf function +- add optional setting for download/cache-only mode +- replace md5 with sha1 for cache filename hashing +- replace streets_per_node graph attribute with equivalent street_count node attribute +- remove redundant osmid node attribute +- make graph_to_gdfs multi-index the edges GeoDataFrame by u, v, key +- refactor consolidate_intersections function for better speed and efficiency +- refactor count_streets_per_node function for better speed and efficiency +- refactor folium module for better speed and efficiency +- refactor get_undirected functionality for better speed and efficiency +- extract all private/internal .osm XML functionality into new osm_xml module +- deprecate io.save_graph_xml with warning (function moved to osm_xml module) +- remove internal \_is_simplified function +- remove deprecated pois module +- remove deprecated footprints module +- remove deprecated utils_graph.induce_subgraph function +- remove deprecated node_type parameter from io.load_graphml function + +## 0.16.2 (2020-11-17) + +- improve graph_from_gdfs speed and efficiency +- improve plot_route_folium speed and efficiency +- fix remove_isolated_nodes function mutating the passed-in graph +- fix gephi compatibility in save_graphml +- add customizable node/edge attribute data type arguments to load_graphml +- deprecate old node_type argument in load_graphml +- expose bidirectional_network_types via config function + +## 0.16.1 (2020-10-05) + +- fix handling graphs with no intersections in consolidate_intersections +- fix consolidate_intersections returning GeoSeries without CRS attribute +- fix response caching to save only when status code is 200 +- fix elevation module's grade absolute value calculation when grade is null +- move shortest path functions from utils_graph module to distance module + +## 0.16.0 (2020-09-07) + +- new geometries module for creating GeoDataFrames from tag/value queries +- deprecate old pois and footprints modules (replaced by geometries module) +- auto-select first Polygon/MultiPolygon when geocoding with which_result=None +- new k*shortest_paths function to solve \_k* shortest paths from origin to destination +- new shortest_path convenience function +- new get_digraph function to correctly convert MultiDiGraph to DiGraph +- miscellaneous performance improvements and optimizations +- deprecate induce_subgraph function +- remove deprecated boundaries module (replaced by geocoder module in v0.15.0) +- remove deprecated utils_geo.geocode function (replaced by geocoder.geocode function in v0.15.0) + +## 0.15.1 (2020-07-03) + +- fix geopandas future warnings + +## 0.15.0 (2020-06-30) + +- improve plotting defaults and streamline plot module speed and efficiency +- improve color handling in plot module +- improve route plotting +- plot_graph_routes function now accepts multiple route colors +- allow multiple elevation API providers +- consolidate_intersections replaces update_edge_lengths param with reconnect_edges param +- fix geopackage file saving after consolidating intersections +- add new geocoder module and move utils_geo.geocode function into it +- replace gdf_from_place/s functions with geocoder.geocode_to_gdf +- deprecate boundaries module +- remove deprecated timeout, memory, custom_settings, and max_query_area_size function params +- remove deprecated plotting params and plot_shape function + +## 0.14.1 (2020-06-09) + +- fix simplification of graphs with long rural roads +- reduce memory footprint of graph simplification +- remove disconnected self-contained rings from graph by default when simplifying +- improve speed and efficiency of project_graph, graph_to_gdfs, and graph_from_gdfs +- improve attribute value conversion in load_graphml +- expose precision parameter for adding bearings, elevations, speeds, and travel times +- fix config function clobber behavior +- fix graph periphery cleaning when clean_periphery=True but simplify=False +- rename settings useful_tags_path to the more appropriate useful_tags_way +- deprecate the timeout, memory, custom_settings, and max_query_area_size function params +- the params above are now accessible via config function and settings module +- deprecate old plot params and plot_shape function +- remove previously deprecated infrastructure parameter in favor of custom_filter + +## 0.14.0 (2020-06-03) + +- better geometry subdividing for huge OSM queries +- better handling of maxspeed list values for simplified graphs +- downloader only retrieves url response from cache if no server remark +- deprecate graph creation infrastructure parameter in favor of flexible custom_filter +- remove deprecated functions: graph_from_file, clean_intersections, gdfs_to_graph + +## 0.13.0 (2020-05-25) + +- major refactor of entire package +- clean up API and namespace +- new consolidate_intersections function with topological option +- new speed module to calculate graph edge speeds and travel times +- generalize POIs module to query with a flexible tags dict +- allow folium functions to accept FeatureGroup and kwargs +- all graph saving functions now take a filepath argument instead of folder/filename +- save shapefiles in single folder containing both nodes and edges +- optionally return distance and/or geometry in nearest edge search +- expose timeout and memory parameters in pois and footprints modules +- define default crs via epsg code instead of proj4 string +- update and simplify logging with timestamps +- graph metadata: add creation date and version, remove name +- replace inconsistent distance parameters with consistent dist parameters +- deprecate old clean_intersections function in favor of new consolidate_intersections +- deprecate old gdfs_to_graph function in favor of graph_from_gdfs +- deprecate old graph_from_file function in favor of graph_from_xml +- rename save_as_osm function -> save_graph_xml for consistency +- rename save_load module -> io +- remove old save_gdf_shapefile function +- drop support for python 3.5 and lower + +## 0.12.1 (2020-05-01) + +- fix handling relations with missing type tag +- fix save_graph_geopackage handling numeric attributes +- fix load_graphml handling elevation and grade attributes +- improve edge finding algorithms to return edge key +- more informative graph_from_file data load error message +- refactor url-in-cache checking +- add timestamp helper function +- documentation improvements + +## 0.12 (2020-04-10) + +- add ability to save graph as geopackage file +- add truncate_by_edge implementation in truncate_graph_polygon +- allow flexible overpass settings (e.g., to query by date) +- better handling of invalid footprint geometries +- geocode function now uses nominatim_request function +- improve .osm xml output +- improve one-way street handling +- fix graph projection overwriting original lat/lng +- fix redistribute_vertices function for MultiLineStrings + +## 0.11.4 (2020-01-31) + +- fix .osm xml output +- fix for pandas 1.0 + +## 0.11.3 (2020-01-09) + +- fix errant print statement + +## 0.11.2 (2020-01-07) + +- fix .osm xml output +- fix geopandas future compatibility + +## 0.11.1 (2020-01-01) + +- fix get_nearest_edges search when not using a spatial index + +## 0.11 (2019-12-04) + +- drop formal python 2 support +- refactor all modules for cleaner package organization +- make stats betweenness centrality compatible with networkx>=2.4 +- allow configurable overpass and nominatim endpoints +- allow gdf_from_places to take a which_result list argument +- handle zero-division in street grade calculation +- better footprint relation handling +- improve network type queries for better filtering +- fix pois_from_polygon returning points outside polygon + +## 0.10 (2019-05-08) + +- remove deprecated buildings module +- filter steps ways out of bike queries +- convert CRS-handling to proj4 strings +- save graph to xml-formatted .osm file +- minor refactoring + +## 0.9 (2019-01-28) + +- deprecate buildings module and replace with generalized footprints module +- improve handling of multipolygon footprints +- new function to find nearest edge(s), given coordinates +- add "search," "reverse," and "lookup" nominatim queries +- use unprojected graphs for figure-ground plotting functions +- allow non-integer osmid values for custom data +- improve get_route_edge_attributes function +- improve color mapping by node/edge attribute value +- make bidirectional network types explicit +- networkx compatibility fixes to resolve warnings + +## 0.8.2 (2018-09-19) + +- add python 3.7 compatibility +- add convenience function to plot several routes over the same map +- optimize graph truncation to bounding box +- give self-loops a null bearing when calculating edge bearings +- make accept-language http header explicit and configurable +- add citation function +- refactor POI module + +## 0.8.1 (2018-05-17) + +- add Gephi compatibility argument for saving GraphML +- handle square bracket encapsulated strings when loading GraphML + +## 0.8 (2018-05-05) + +- add ability to retrieve points of interest +- improve performance for retrieving huge geographies' street networks +- fix building footprint retrieval query syntax +- minor bug fixes + +## 0.7.4 (2018-04-05) + +- add fast nearest-nodes search +- allow custom network query filters +- allow create_graph to return graph with no edges +- improve figure_ground joint smoothing +- fix handling of parallel edges when making multidigraph undirected +- generalize same-geometry checker +- improve detection of prior topology simplification +- custom error types for finer-grained handling + +## 0.7.3 (2018-03-12) + +- turn off x- and y-axes to improve plotting appearance +- make floating-point precision and rounding more sensible +- improve OS path handling cross-platform +- replace great-circle distance calculator with haversine +- add access filter as configurable setting +- improve performance of inducing subgraphs +- fix utils.get_largest_component for networkx 2.2 compatibility +- fix config settings namespacing + +## 0.7.2 (2018-02-15) + +- compatibility with networkx 2.1 + +## 0.7.1 (2018-02-04) + +- fix documentation build +- ignore ways marked access=no + +## 0.7 (2018-02-01) + +- ability to load a graph from a .osm file +- change datum from NAD83 to WGS84 +- make roundabouts one-way +- conformal plotting for unprojected graphs +- fix folium web maps rendering + +## 0.6 (2017-10-02) + +- migrate to the networkx 2.0 API + +## 0.5.4 (2017-09-16) + +- add optional cleaned intersections count to basic stats +- allow circuity to be calculated for projected or unprojected networks +- various code clean-up and refactoring + +## 0.5.3 (2017-07-22) + +- add requirements files to distribution + +## 0.5.2 (2017-07-22) + +- add ability to download other infrastructures besides just roads/paths (e.g., rail lines, power lines, etc.) +- calculate graph edges' bearings +- add ability to get nearest node by great circle or euclidean distance +- move examples/demo notebooks to new repo: osmnx-examples +- fix docstrings +- fix building footprint downloads that require multiple calls for large areas +- fix missing MultiPolygon import in buildings module + +## 0.5.1 (2017-05-12) + +- functionality to clean-up and consolidate complex intersections +- let save_gdf_shapefile save building footprint GeoDataFrames +- set node color correctly in figure-ground diagrams + +## 0.5 (2017-04-25) + +- add elevation module to get node elevations and street grades +- new color sequence creation and conversion functions in plot module +- new function to get a path's edge attribute values +- gracefully handle subpolygons that are invalid or have zero area +- make truncate_graph_polygon work on projected graphs +- plot_shape accepts a color or a list of colors +- make all requests to Overpass API set custom user-agent and referer +- rewrite algorithms to convert multidigraphs to multigraphs + +## 0.4.1 (2017-04-01) + +- fix load_graphml so we can save a graph again after loading it +- fix load_graphml so edge oneway attribute is not always set to True +- buildings module gets buildings stored in OSM as relations as well as ways +- fix figure-ground diagram saving to make perfect square and smooth joints +- add optional graph argument to plot_figure_ground +- suppress jupyter notebook deprecation warnings + +## 0.4 (2017-03-01) + +- plot entire networks with folium +- plot routes on top of networks with folium +- vectorize all great circle calculations +- new geocode function in utils +- remove geopy dependency +- refactor modules +- simplify before truncating by distance when getting graph by point and network distance +- project geometries, GeoDataFrames, and graphs to a passed-in CRS + +## 0.3.1 (2017-02-15) + +- clean up docstrings throughout +- remove network code vestiges from buildings.py + +## 0.3 (2017-01-29) + +- add route plotting with folium +- add downloading and visualization of building footprints +- updates for compatibility with matplotlib 2.0 + +## 0.2.2 (2017-01-20) + +- fixes for compatibility with networkx 2.0's new API +- make png default image save format +- figure-ground plots collect street network from a wider area + +## 0.2.1 (2017-01-11) + +- add license file to dist package + +## 0.2 (2017-01-10) + +- refactor modules +- add graph to GDF and GDF to graph functions +- add encoding argument to save_graph_shapefile +- add unit tests and continuous integration + +## 0.1 (2016-12-19) + +- add street width attribute for ways from OSM + +## 0.1b2 (2016-11-29) + +- make simplification error messages explicit + +## 0.1b1 (2016-11-28) + +- process land use and area tags from OSM +- make intersection error messages clear + +## 0.1a1 (2016-11-07) + +- first pre-release diff --git a/osmnx/source/CITATION.cff b/osmnx/source/CITATION.cff new file mode 100644 index 0000000000000000000000000000000000000000..daeffa1443fa980dc5ee0f5d0244825150df3131 --- /dev/null +++ b/osmnx/source/CITATION.cff @@ -0,0 +1,32 @@ +cff-version: 1.2.0 +title: OSMnx +message: If you use OSMnx, please cite the preferred-citation below. +type: software +authors: + - family-names: Boeing + given-names: Geoff + orcid: https://orcid.org/0000-0003-1851-6411 +identifiers: + - type: doi + value: 10.1111/gean.70009 + description: Official reference paper +repository-code: https://github.com/gboeing/osmnx +url: https://osmnx.readthedocs.org +abstract: >- + OSMnx is a Python package to easily download, model, analyze, and visualize + street networks and other geospatial features from OpenStreetMap. +license: MIT +preferred-citation: + type: article + authors: + - family-names: Boeing + given-names: Geoff + orcid: https://orcid.org/0000-0003-1851-6411 + year: 2025 + title: Modeling and Analyzing Urban Networks and Amenities with OSMnx + journal: Geographical Analysis + volume: 57 + issue: 4 + start: 567 + end: 577 + doi: 10.1111/gean.70009 diff --git a/osmnx/source/CONTRIBUTING.md b/osmnx/source/CONTRIBUTING.md new file mode 100644 index 0000000000000000000000000000000000000000..24e587bcbe0e236aa48d4cc3a6bb28fb8f619608 --- /dev/null +++ b/osmnx/source/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# 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 on which it depends. Thank you for contributing! + +## 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. + +## If you have an installation problem + +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 have a feature proposal + +The OSMnx project follows three principles when considering 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. + +- 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. + +## If you found a bug + +- 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 standalone example so others can independently and completely reproduce the bug. + +## Creating a minimal standalone reproducible example + +We need a minimal standalone example code snippet to be able to reproduce and in turn troubleshoot your problem (also provide the resulting complete error traceback if your code generates an error). This code snippet must be: + +_Minimal_: the absolute fewest lines of code necessary to reproduce your problem without any extraneous code or data unrelated to your specific problem. This usually requires reworking your code and data down to just a few lines necessary to generate the error. + +_Standalone_: all imports, data, and variables must be completely defined within the snippet itself so others can independently run it from top to bottom by copying/pasting it into a Python interpreter. Do not link to or load external files and do not provide screenshots of code or error messages: provide all code, data, and tracebacks inline as text. + +If you're unsure how to create a good reproducible example, read + [this guide](https://matthewrocklin.com/blog/work/2018/02/28/minimal-bug-reports). diff --git a/osmnx/source/LICENSE.txt b/osmnx/source/LICENSE.txt new file mode 100644 index 0000000000000000000000000000000000000000..81aea8b83d8f4d9a79f8543ef6bc669fb97d265c --- /dev/null +++ b/osmnx/source/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016-2025 Geoff Boeing https://geoffboeing.com/ + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/osmnx/source/README.md b/osmnx/source/README.md new file mode 100644 index 0000000000000000000000000000000000000000..c2adfcfee5ef48174be787f0c4b2814d46bf9266 --- /dev/null +++ b/osmnx/source/README.md @@ -0,0 +1,33 @@ +# OSMnx + +[![PyPI Version](https://badge.fury.io/py/osmnx.svg)](https://pypi.org/project/osmnx/) +[![PyPI Downloads](https://static.pepy.tech/personalized-badge/osmnx?period=total&units=international_system&left_color=grey&right_color=brightgreen&left_text=downloads)](https://pepy.tech/project/osmnx) +[![Documentation Status](https://readthedocs.org/projects/osmnx/badge/?version=latest)](https://osmnx.readthedocs.io/) +[![Build Status](https://github.com/gboeing/osmnx/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/gboeing/osmnx/actions/workflows/ci.yml) +[![Coverage Status](https://codecov.io/gh/gboeing/osmnx/branch/main/graph/badge.svg)](https://codecov.io/gh/gboeing/osmnx) + +**OSMnx** is a Python package to easily download, model, analyze, and visualize street networks and other geospatial features from OpenStreetMap. You can download and model walking, driving, or biking networks with a single line of code then analyze and visualize them. You can just as easily work with urban amenities/points of interest, building footprints, transit stops, elevation data, street orientations, speed/travel time, and routing. + +## Citation + +If you use OSMnx in your work, please cite the paper: + +Boeing, G. (2025). [Modeling and Analyzing Urban Networks and Amenities with OSMnx](https://doi.org/10.1111/gean.70009). *Geographical Analysis* 57 (4), 567-577. doi:10.1111/gean.70009 + +## Getting Started + +First read the [Getting Started](https://osmnx.readthedocs.io/en/stable/getting-started.html) guide for an introduction to the package and FAQ. + +Then work through the [Examples Gallery](https://github.com/gboeing/osmnx-examples) for step-by-step tutorials and sample code. + +## Installation + +Follow the [Installation](https://osmnx.readthedocs.io/en/stable/installation.html) guide to install OSMnx. + +## Support + +If you have any trouble, consult the [User Reference](https://osmnx.readthedocs.io/en/stable/user-reference.html). The OSMnx repository is hosted on [GitHub](https://github.com/gboeing/osmnx). If you have a "how-to" or usage question, please ask it on [StackOverflow](https://stackoverflow.com/search?q=osmnx), as we reserve the repository's issue tracker for bug tracking and feature development. + +## License + +OSMnx is open source and licensed under the MIT license. OpenStreetMap's open data [license](https://www.openstreetmap.org/copyright/) requires that derivative works provide proper attribution. Refer to the [Getting Started](https://osmnx.readthedocs.io/en/stable/getting-started.html) guide for usage limitations. diff --git a/osmnx/source/__init__.py b/osmnx/source/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..dd01e00497196db403bbafefcd2ae2a6c3557dfa --- /dev/null +++ b/osmnx/source/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +""" +osmnx Project Package Initialization File +""" diff --git a/osmnx/source/docs/.readthedocs.yaml b/osmnx/source/docs/.readthedocs.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ca8ca242d561474ad8e4fbda084ece9358921075 --- /dev/null +++ b/osmnx/source/docs/.readthedocs.yaml @@ -0,0 +1,16 @@ +version: 2 + +build: + os: ubuntu-lts-latest + tools: + python: '3' + +formats: all + +python: + install: + - requirements: ./docs/requirements-docs.txt + +sphinx: + configuration: ./docs/source/conf.py + fail_on_warning: true diff --git a/osmnx/source/docs/Makefile b/osmnx/source/docs/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..d0c3cbf1020d5c292abdedf27627c6abe25e2293 --- /dev/null +++ b/osmnx/source/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/osmnx/source/docs/make.bat b/osmnx/source/docs/make.bat new file mode 100644 index 0000000000000000000000000000000000000000..747ffb7b3033659bdd2d1e6eae41ecb00358a45e --- /dev/null +++ b/osmnx/source/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/osmnx/source/docs/requirements-docs.txt b/osmnx/source/docs/requirements-docs.txt new file mode 100644 index 0000000000000000000000000000000000000000..7ef0d8faf7c8befddfa20de45156892aa0ddc0c1 --- /dev/null +++ b/osmnx/source/docs/requirements-docs.txt @@ -0,0 +1,4 @@ +furo +sphinx>=7 +sphinx-autodoc-typehints +-e . diff --git a/osmnx/source/docs/source/conf.py b/osmnx/source/docs/source/conf.py new file mode 100644 index 0000000000000000000000000000000000000000..7a7c3f52f859c4066c7eceeec03721efdaa57d17 --- /dev/null +++ b/osmnx/source/docs/source/conf.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python +""" +Configuration file for the Sphinx documentation builder. + +For the full list of built-in configuration values, see the documentation: +https://www.sphinx-doc.org/en/master/usage/configuration.html +""" + +import sys +from pathlib import Path +from tomllib import load as toml_load + +# project info +author = "Geoff Boeing" +copyright = "2016-2025, 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 +with Path("../../pyproject.toml").open("rb") as f: + pyproject = toml_load(f) +version = release = pyproject["project"]["version"] + +# mock import all required + optional dependency packages because readthedocs +# does not have them installed +autodoc_mock_imports = [ + "geopandas", + "matplotlib", + "networkx", + "numpy", + "pandas", + "rasterio", + "requests", + "rio-vrt", + "scipy", + "shapely", + "sklearn", +] + +# linkcheck for some DOI redirects gets HTTP 403 in CI environment +linkcheck_ignore = [r"https://doi\.org/.*"] + +# 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", "sphinx_autodoc_typehints"] +html_static_path: list[str] = [] +html_theme = "furo" +language = "en" +needs_sphinx = "7" # match version from pyproject.toml optional-dependencies +root_doc = "index" +source_suffix = ".rst" +templates_path: list[str] = [] diff --git a/osmnx/source/docs/source/further-reading.rst b/osmnx/source/docs/source/further-reading.rst new file mode 100644 index 0000000000000000000000000000000000000000..f7fb4378eba6c07fa5f039621f4f8d39d5b4ed6d --- /dev/null +++ b/osmnx/source/docs/source/further-reading.rst @@ -0,0 +1,40 @@ +Further Reading +=============== + +Boeing, G. (2025). `Modeling and Analyzing Urban Networks and Amenities with OSMnx`_. *Geographical Analysis* 57 (4), 567-577. doi:10.1111/gean.70009 + +This is the official reference paper and citation for the OSMnx package. + +.. _Modeling and Analyzing Urban Networks and Amenities with OSMnx: https://doi.org/10.1111/gean.70009 + +---- + +Boeing, G. (2025). `Topological Graph Simplification Solutions to the Street Intersection Miscount Problem`_. *Transactions in GIS* 29 (3), e70037. doi:10.1111/tgis.70037 + +This paper describes and validates the algorithms implemented in OSMnx's :code:`simplification` module and explains why graph simplification is necessary to accurately measure intersection density, street segment length, node degree, etc. + +.. _Topological Graph Simplification Solutions to the Street Intersection Miscount Problem: https://doi.org/10.1111/tgis.70037 + +---- + +Boeing, G. (2021). `Street Network Models and Indicators for Every Urban Area in the World`_. *Geographical Analysis* 54 (3), 519-535. doi:10.1111/gean.12281 + +This study uses OSMnx to model and analyze the street networks of every urban area in the world: over 160 million OpenStreetMap street network nodes and over 320 million edges across 8,914 urban areas in 178 countries. + +.. _Street Network Models and Indicators for Every Urban Area in the World: https://geoffboeing.com/publications/street-network-models-indicators-world/ + +---- + +Boeing, G. (2020). `The Right Tools for the Job: The Case for Spatial Science Tool-Building`_. *Transactions in GIS* 24 (5), 1299-1314. doi:10.1111/tgis.12678 + +This paper was presented as the 8th annual Transactions in GIS plenary address at the American Association of Geographers annual meeting in Washington, DC. It describes the early development of OSMnx and reviews its use in scientific research over the previous few years. + +.. _The Right Tools for the Job\: The Case for Spatial Science Tool-Building: https://geoffboeing.com/publications/right-tools-for-job/ + +---- + +Boeing, G. (2020). `Planarity and Street Network Representation in Urban Form Analysis`_. *Environment and Planning B: Urban Analytics and City Science* 47 (5), 855-869. doi:10.1177/2399808318802941 + +This paper demonstrates the need for nonplanar graphs when modeling urban street networks, which was one of the original motivations for developing OSMnx. + +.. _Planarity and Street Network Representation in Urban Form Analysis: https://geoffboeing.com/publications/planarity-street-network-representation/ diff --git a/osmnx/source/docs/source/getting-started.rst b/osmnx/source/docs/source/getting-started.rst new file mode 100644 index 0000000000000000000000000000000000000000..095c23d4108d640f7660ccd3978fbca5bdb8e649 --- /dev/null +++ b/osmnx/source/docs/source/getting-started.rst @@ -0,0 +1,184 @@ +Getting Started +=============== + +Get Started in 4 Steps +---------------------- + +1. Install OSMnx by following the :doc:`installation` guide. + +2. Read the :ref:`introducing-osmnx` section on this page. + +3. Work through the OSMnx `Examples Gallery`_ for step-by-step tutorials and sample code. + +4. Consult the :doc:`user-reference` for complete details on using the package. + +Finally, if you're not already familiar with `NetworkX`_ and `GeoPandas`_, make sure you read their user guides as OSMnx uses their data structures. + +.. _introducing-osmnx: + +Introducing OSMnx +----------------- + +This quick introduction explains key concepts and the basic functionality of OSMnx. + +Overview +^^^^^^^^ + +OSMnx is pronounced as the initialism: "oh-ess-em-en-ex". It is built on top of NetworkX and GeoPandas, and interacts with `OpenStreetMap`_ APIs to: + +* Download and model street networks or other infrastructure anywhere in the world with a single line of code +* Download geospatial features (e.g., political boundaries, building footprints, grocery stores, transit stops) as a GeoDataFrame +* Query by city name, polygon, bounding box, or point/address + distance +* Model driving, walking, biking, and other travel modes +* Attach node elevations from a local raster file or web service and calculate edge grades +* Impute missing speeds and calculate graph edge travel times +* Simplify and correct the network's topology to clean-up nodes and consolidate complex intersections +* Fast map-matching of points, routes, or trajectories to nearest graph edges or nodes +* Save/load network to/from disk as GraphML, GeoPackage, or OSM XML file +* Conduct topological and spatial analyses to automatically calculate dozens of indicators +* Calculate and visualize street bearings and orientations +* Calculate and visualize shortest-path routes that minimize distance, travel time, elevation, etc +* Explore street networks and geospatial features as a static map or interactive web map +* Visualize travel distance and travel time with isoline and isochrone maps +* Plot figure-ground diagrams of street networks and building footprints + +The OSMnx `Examples Gallery`_ contains tutorials and demonstrations of all these features, and package usage is detailed in the :doc:`user-reference`. + +Configuration +^^^^^^^^^^^^^ + +You can configure OSMnx using the ``settings`` module. Here you can adjust logging behavior, caching, server endpoints, and more. You can also configure OSMnx to retrieve historical snapshots of OpenStreetMap data as of a certain date. + +Read more about the :ref:`settings ` module in the User Reference. + +Geocoding and Querying +^^^^^^^^^^^^^^^^^^^^^^ + +OSMnx geocodes place names and addresses with the OpenStreetMap `Nominatim`_ API. You can use the ``geocoder`` module to geocode place names or addresses to lat-lon coordinates. Or, you can retrieve place boundaries or any other OpenStreetMap elements by name or ID. Read more about the :ref:`geocoder ` module in the User Reference. + +Using the ``features`` and ``graph`` modules, as described below, you can download data by lat-lon point, address, bounding box, bounding polygon, or place name (e.g., neighborhood, city, county, etc). + +Urban Amenities +^^^^^^^^^^^^^^^ + +Using OSMnx's ``features`` module, you can search for and download any geospatial `features`_ (such as building footprints, grocery stores, schools, public parks, transit stops, etc) from the OpenStreetMap `Overpass`_ API as a GeoPandas GeoDataFrame. This uses OpenStreetMap `tags`_ to search for matching `elements`_. + +Read more about the :ref:`features ` module in the User Reference. + +Modeling a Network +^^^^^^^^^^^^^^^^^^ + +Using OSMnx's ``graph`` module, you can retrieve any spatial network data (such as streets, paths, rail, canals, etc) from the Overpass API and model them as NetworkX `MultiDiGraphs`_. + +In short, MultiDiGraphs are nonplanar directed graphs with possible self-loops and parallel edges. Thus, a one-way street will be represented with a single directed edge from node *u* to node *v*, but a bidirectional street will be represented with two reciprocal directed edges (with identical geometries): one from node *u* to node *v* and another from *v* to *u*, to represent both possible directions of flow. Because these graphs are nonplanar, they correctly model the topology of interchanges, bridges, and tunnels. That is, edge crossings in a two-dimensional plane are not intersections in an OSMnx model unless they represent true junctions in the three-dimensional real world. + +The ``graph`` 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`_. Under the hood, OSMnx does several things to generate the best possible model. It initially creates a 500m-buffered graph before truncating it to your desired query area, to ensure accurate streets-per-node stats and to attenuate graph perimeter effects. By default, it returns the largest weakly connected component. It also simplifies the graph topology as discussed below. + +Read more about the :ref:`graph ` module in the User Reference and refer to the official reference paper at the :doc:`further-reading` page for complete modeling details. + +Topology Clean-Up +^^^^^^^^^^^^^^^^^ + +The ``simplification`` module automatically processes the network's topology from the original raw OpenStreetMap data, such that nodes represent intersections/dead-ends and edges represent the street segments that link them. This takes two primary forms: graph simplification and intersection consolidation. + +**Graph simplification** cleans up the graph's topology so that nodes represent intersections or dead-ends and edges represent street segments. This is important because in OpenStreetMap raw data, ways comprise sets of straight-line segments between nodes: that is, nodes are vertices for streets' curving line geometries, not just intersections and dead-ends. By default, OSMnx simplifies this topology by discarding non-intersection/dead-end nodes while retaining the complete true edge geometry as an edge attribute. When multiple OpenStreetMap ways are merged into a single graph edge, the ways' attribute values can be aggregated into a single value. + +**Intersection consolidation** is important because many real-world street networks feature complex intersections and traffic circles, resulting in a cluster of graph nodes where there is really just one true intersection as we would think of it in transportation or urban design. Similarly, divided roads are often represented by separate centerline edges: the intersection of two divided roads thus creates 4 nodes, representing where each edge intersects a perpendicular edge, but these 4 nodes represent a single intersection in the real world. OSMnx can consolidate such complex intersections into a single node and optionally rebuild the graph's edge topology accordingly. When multiple OpenStreetMap nodes are merged into a single graph node, the nodes' attribute values can be aggregated into a single value. + +Read more about the :ref:`simplification ` module in the User Reference. + +Model Attributes +^^^^^^^^^^^^^^^^ + +An OSMnx model has some standard required attributes, plus some optional attributes. The latter are sometimes present based on the source OSM data's tagging, the ``settings`` module configuration, and any processing you may have done to add additional attributes (as noted in various functions' documentation). + +As a NetworkX `MultiDiGraph`_ object, it has top-level ``graph``, ``nodes``, and ``edges`` attributes. The ``graph`` attribute dictionary must contain a "crs" key defining its coordinate reference system. The ``nodes`` are identified by OSM ID and each must contain a ``data`` attribute dictionary that must have "x" and "y" keys defining its coordinates and a "street_count" key defining how many physical streets are incident to it. The ``edges`` are identified by a 3-tuple of "u" (source node ID), "v" (target node ID), and "key" (to differentiate parallel edges), and each must contain a ``data`` attribute dictionary that must have an "osmid" key defining its OSM ID and a "length" key defining its length in meters. + +The OSMnx ``graph`` module automatically creates MultiDiGraphs with these required attributes, plus additional optional attributes based on the ``settings`` module configuration. If you instead manually create your own graph model, make sure it has these required attributes at a minimum. + +Convert, Project, Save +^^^^^^^^^^^^^^^^^^^^^^ + +OSMnx's ``convert`` module can convert a MultiDiGraph to a `DiGraph`_ if you prefer a directed representation of the network without any parallel edges, or to a `MultiGraph`_ if you need an undirected representation for use with functions or algorithms that only accept a MultiGraph object. If you just want a fully bidirectional graph (such as for a walking network), just configure the ``settings`` module's ``bidirectional_network_types`` before creating your graph. + +The ``convert`` module can also convert a MultiDiGraph to/from GeoPandas node and edge `GeoDataFrames`_. The nodes GeoDataFrame is indexed by OSM ID and the edges GeoDataFrame is multi-indexed by ``u, v, key`` just like a NetworkX edge. This allows you to load arbitrary node/edge ShapeFiles or GeoPackage layers as GeoDataFrames then model them as a MultiDiGraph for graph analysis. The ``convert`` module exposes validation functions to verify that your MultiDiGraph or GeoDataFrames satisfy OSMnx requirements. Read more about the :ref:`convert ` module in the User Reference. + +You can easily project your graph to different coordinate reference systems using the ``projection`` module. If you're unsure which `CRS`_ you want to project to, OSMnx can automatically determine an appropriate UTM CRS for you. Read more about the :ref:`projection ` module in the User Reference. + +Using the ``io`` module, you can save your graph to disk as a GraphML file (to load into other network analysis software), a GeoPackage (to load into other GIS software), or an OSM XML file. Use the GraphML format whenever saving a graph for later work with OSMnx. Read more about the :ref:`io ` module in the User Reference. + +Network Measures +^^^^^^^^^^^^^^^^ + +You can use the ``stats`` module to calculate a variety of geometric and topological measures as well as street network bearing and orientation statistics. These measures define streets as the edges in an undirected representation of the graph to prevent double-counting bidirectional edges of a two-way street. You can easily generate common stats in transportation studies, urban design, and network science, including intersection density, circuity, average node degree (connectedness), betweenness centrality, and much more. Read more about the :ref:`stats ` module in the User Reference. + +You can also use NetworkX directly to calculate additional topological network measures. + +Working with Elevation +^^^^^^^^^^^^^^^^^^^^^^ + +The ``elevation`` module lets you automatically attach elevations to the graph's nodes from a local raster file or the Google Maps `Elevation API`_ (or equivalent web API with a compatible interface). You can also calculate edge grades (i.e., rise-over-run) and analyze the steepness of certain streets or routes. + +Read more about the :ref:`elevation ` module in the User Reference. + +Routing +^^^^^^^ + +The ``distance`` module can find the nearest node(s) or edge(s) to coordinates using a fast spatial index. The ``routing`` module can solve shortest paths for network routing, parallelized with multiprocessing, using different weights (e.g., distance, travel time, elevation change, etc). It can also impute missing speeds to the graph edges. This imputation can obviously be imprecise, so the user can override it by passing in arguments that define local speed limits. It can also calculate free-flow travel times for each edge. + +Read more about the :ref:`distance ` and :ref:`routing ` modules in the User Reference. + +Visualization +^^^^^^^^^^^^^ + +You can plot graphs, routes, network figure-ground diagrams, building footprints, and street network orientation rose diagrams (aka, polar histograms) with the ``plot`` module. You can also explore street networks, routes, or geospatial features as interactive `Folium`_ web maps. + +Read more about the :ref:`plot ` module in the User Reference. + +Usage Limits +^^^^^^^^^^^^ + +Refer to the `Nominatim Usage Policy`_ and `Overpass Commons`_ documentation for API usage limits and restrictions to which you must adhere. If you configure OSMnx to use an alternative API instance, ensure you understand and follow their policies. If you feel you need to exceed these limits, consider installing your own hosted instance and setting OSMnx to use it. + +More Info +--------- + +All of this functionality is demonstrated step-by-step in the OSMnx `Examples Gallery`_, and usage is detailed in the :doc:`user-reference`. Feature development details are in the `Changelog`_. Consult the :doc:`further-reading` resources for additional technical details and research. + +Frequently Asked Questions +-------------------------- + +*How do I install OSMnx?* Follow the :doc:`installation` guide. + +*How do I use OSMnx?* Check out the step-by-step tutorials in the OSMnx `Examples Gallery`_. + +*How does this or that function work?* Consult the :doc:`user-reference`. + +*What can I do with OSMnx?* Check out recent `projects`_ that use OSMnx. + +*I have a usage question.* Please ask it on `StackOverflow`_. + + +.. _Changelog: https://github.com/gboeing/osmnx/blob/main/CHANGELOG.md +.. _CRS: https://en.wikipedia.org/wiki/Coordinate_reference_system +.. _DiGraph: https://networkx.org/documentation/stable/reference/classes/digraph.html +.. _elements: https://wiki.openstreetmap.org/wiki/Elements +.. _Elevation API: https://developers.google.com/maps/documentation/elevation +.. _Examples Gallery: https://github.com/gboeing/osmnx-examples +.. _features: https://wiki.openstreetmap.org/wiki/Map_features +.. _Folium: https://python-visualization.github.io/folium/ +.. _GeoDataFrames: https://geopandas.org/en/stable/docs/reference/geodataframe.html +.. _GeoPandas: https://geopandas.org +.. _MultiDiGraph: https://networkx.org/documentation/stable/reference/classes/multidigraph.html +.. _MultiDiGraphs: https://networkx.org/documentation/stable/reference/classes/multidigraph.html +.. _MultiGraph: https://networkx.org/documentation/stable/reference/classes/multigraph.html +.. _NetworkX: https://networkx.org +.. _Nominatim: https://nominatim.org +.. _Nominatim Usage Policy: https://operations.osmfoundation.org/policies/nominatim/ +.. _OpenStreetMap: https://www.openstreetmap.org +.. _Overpass: https://wiki.openstreetmap.org/wiki/Overpass_API +.. _Overpass Commons: https://dev.overpass-api.de/overpass-doc/en/preface/commons.html +.. _Overpass QL: https://wiki.openstreetmap.org/wiki/Overpass_API/Overpass_QL +.. _projects: https://geoffboeing.com/2018/03/osmnx-features-roundup +.. _StackOverflow: https://stackoverflow.com/search?q=osmnx +.. _tags: https://wiki.openstreetmap.org/wiki/Tags diff --git a/osmnx/source/docs/source/index.rst b/osmnx/source/docs/source/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..4f2af697860167bd827084a9108079abdb76d83f --- /dev/null +++ b/osmnx/source/docs/source/index.rst @@ -0,0 +1,84 @@ +OSMnx |version| +=============== + +**OSMnx** is a Python package to easily download, model, analyze, and visualize street networks and other geospatial features from OpenStreetMap. You can download and model walking, driving, or biking networks with a single line of code then analyze and visualize them. You can just as easily work with urban amenities/points of interest, building footprints, transit stops, elevation data, street orientations, speed/travel time, and routing. + +.. _migration guide: https://github.com/gboeing/osmnx/issues/1123 + +Citation +-------- + +If you use OSMnx in your work, please cite the paper: + +Boeing, G. (2025). `Modeling and Analyzing Urban Networks and Amenities with OSMnx`_. *Geographical Analysis* 57 (4), 567-577. doi:10.1111/gean.70009 + +.. _Modeling and Analyzing Urban Networks and Amenities with OSMnx: https://doi.org/10.1111/gean.70009 + + +Getting Started +--------------- + +First read the :doc:`getting-started` guide for an introduction to the package and FAQ. + +Then work through the `Examples Gallery`_ for step-by-step tutorials and sample code. + +.. _Examples Gallery: https://github.com/gboeing/osmnx-examples + + +Installation +------------ + +Follow the :doc:`installation` guide to install OSMnx. + + +Support +------- +If you have any trouble, consult the :doc:`user-reference`. The OSMnx repository is hosted on `GitHub`_. If you have a "how-to" or usage question, please ask it on `StackOverflow`_, as we reserve the repository's issue tracker for bug tracking and feature development. + +.. _GitHub: https://github.com/gboeing/osmnx +.. _StackOverflow: https://stackoverflow.com/search?q=osmnx + + +License +------- + +OSMnx is open source and licensed under the MIT license. OpenStreetMap's open data `license`_ requires that derivative works provide proper attribution. Refer to the :doc:`getting-started` guide for usage limitations. + +.. _license: https://www.openstreetmap.org/copyright + + +User Guides +----------- + +.. toctree:: + :maxdepth: 1 + + installation + +.. toctree:: + :maxdepth: 1 + + getting-started + +.. toctree:: + :maxdepth: 1 + + user-reference + +.. toctree:: + :maxdepth: 1 + + internals-reference + +.. toctree:: + :maxdepth: 1 + + further-reading + + +Indices +------- + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/osmnx/source/docs/source/installation.rst b/osmnx/source/docs/source/installation.rst new file mode 100644 index 0000000000000000000000000000000000000000..f079caf6020895d8c68dfb7337e614d0369802d0 --- /dev/null +++ b/osmnx/source/docs/source/installation.rst @@ -0,0 +1,37 @@ +Installation +============ + +Conda +----- + +The foolproof way to install OSMnx is with `conda`_ or `mamba`_: + +.. code-block:: shell + + conda create --strict-channel-priority -c conda-forge -n ox osmnx + +This creates a new conda environment and installs OSMnx into it, via the conda-forge channel. If you want other packages, such as :code:`jupyterlab`, installed in this environment as well, just add their names after :code:`osmnx` above. To upgrade OSMnx to a newer release, remove the conda environment you created and then create a new one again, as above. See the `conda-forge`_ documentation for details. + +Docker +------ + +You can run OSMnx + JupyterLab directly from the official OSMnx `Docker`_ image. + +Pip +--- + +You can also install OSMnx with `uv`_ or `pip`_ (into a virtual environment): + +.. code-block:: shell + + pip install osmnx + +OSMnx is written in pure Python and distributed on `PyPI`_. Its installation alone is thus trivially simple if you have its dependencies installed and tested on your system. However, OSMnx depends on other packages that in turn depend on compiled C/C++ libraries, which may present some challenges depending on your specific system's configuration. If precompiled binaries are not available for your system, you may need to compile and configure those dependencies by following their installation instructions. So, if you're not sure what you're doing, just follow the conda instructions above to avoid installation problems. + +.. _conda: https://conda.io/ +.. _conda-forge: https://conda-forge.org/ +.. _Docker: https://hub.docker.com/r/gboeing/osmnx +.. _mamba: https://mamba.readthedocs.io/ +.. _pip: https://pip.pypa.io/ +.. _PyPI: https://pypi.org/project/osmnx/ +.. _uv: https://docs.astral.sh/uv/ diff --git a/osmnx/source/docs/source/internals-reference.rst b/osmnx/source/docs/source/internals-reference.rst new file mode 100644 index 0000000000000000000000000000000000000000..ca97355c5b4c078c88e244d1704dc0027a8a9fbe --- /dev/null +++ b/osmnx/source/docs/source/internals-reference.rst @@ -0,0 +1,196 @@ +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_v1 module +-------------------- + +.. automodule:: osmnx._api_v1 + :members: + :private-members: + :noindex: + +osmnx.bearing module +-------------------- + +.. automodule:: osmnx.bearing + :members: + :private-members: + :noindex: + +osmnx.convert module +--------------------- + +.. automodule:: osmnx.convert + :members: + :private-members: + :noindex: + +osmnx.distance module +--------------------- + +.. automodule:: osmnx.distance + :members: + :private-members: + :noindex: + +osmnx.elevation module +---------------------- + +.. automodule:: osmnx.elevation + :members: + :private-members: + :noindex: + +osmnx._errors module +-------------------- + +.. automodule:: osmnx._errors + :members: + :private-members: + :noindex: + +osmnx.features module +--------------------- + +.. automodule:: osmnx.features + :members: + :private-members: + :noindex: + +osmnx.geocoder module +--------------------- + +.. automodule:: osmnx.geocoder + :members: + :private-members: + :noindex: + +osmnx.graph module +------------------ + +.. automodule:: osmnx.graph + :members: + :private-members: + :noindex: + +osmnx._http module +------------------ + +.. automodule:: osmnx._http + :members: + :private-members: + :noindex: + +osmnx.io module +--------------- + +.. automodule:: osmnx.io + :members: + :private-members: + :noindex: + +osmnx._nominatim module +----------------------- + +.. automodule:: osmnx._nominatim + :members: + :private-members: + :noindex: + +osmnx._osm_xml module +--------------------- + +.. automodule:: osmnx._osm_xml + :members: + :private-members: + :noindex: + +osmnx._overpass module +---------------------- + +.. automodule:: osmnx._overpass + :members: + :private-members: + :noindex: + +osmnx.plot module +----------------- + +.. automodule:: osmnx.plot + :members: + :private-members: + :noindex: + +osmnx.projection module +----------------------- + +.. automodule:: osmnx.projection + :members: + :private-members: + :noindex: + +osmnx.routing module +----------------------- + +.. automodule:: osmnx.routing + :members: + :private-members: + :noindex: + +osmnx.settings module +--------------------- + +.. automodule:: osmnx.settings + :members: + :private-members: + :noindex: + +osmnx.simplification module +--------------------------- + +.. automodule:: osmnx.simplification + :members: + :private-members: + :noindex: + +osmnx.stats module +------------------ + +.. automodule:: osmnx.stats + :members: + :private-members: + :noindex: + +osmnx.truncate module +--------------------- + +.. automodule:: osmnx.truncate + :members: + :private-members: + :noindex: + +osmnx.utils module +------------------ + +.. automodule:: osmnx.utils + :members: + :private-members: + :noindex: + +osmnx.utils_geo module +---------------------- + +.. automodule:: osmnx.utils_geo + :members: + :private-members: + :noindex: + +osmnx._validate module +---------------------- + +.. automodule:: osmnx._validate + :members: + :private-members: + :noindex: diff --git a/osmnx/source/docs/source/user-reference.rst b/osmnx/source/docs/source/user-reference.rst new file mode 100644 index 0000000000000000000000000000000000000000..e61d9f42ed49e0866a0fb09a3c410680b3d4c9bd --- /dev/null +++ b/osmnx/source/docs/source/user-reference.rst @@ -0,0 +1,142 @@ +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. + +.. _migration guide: https://github.com/gboeing/osmnx/issues/1123 + +.. _osmnx-bearing-module: + +osmnx.bearing module +-------------------- + +.. automodule:: osmnx.bearing + :members: + +.. _osmnx-convert-module: + +osmnx.convert module +-------------------- + +.. automodule:: osmnx.convert + :members: + +.. _osmnx-distance-module: + +osmnx.distance module +--------------------- + +.. automodule:: osmnx.distance + :members: + +.. _osmnx-elevation-module: + +osmnx.elevation module +---------------------- + +.. automodule:: osmnx.elevation + :members: + +.. _osmnx-features-module: + +osmnx.features module +--------------------- + +.. automodule:: osmnx.features + :members: + +.. _osmnx-geocoder-module: + +osmnx.geocoder module +--------------------- + +.. automodule:: osmnx.geocoder + :members: + +.. _osmnx-graph-module: + +osmnx.graph module +------------------ + +.. automodule:: osmnx.graph + :members: + +.. _osmnx-io-module: + +osmnx.io module +--------------- + +.. automodule:: osmnx.io + :members: + +.. _osmnx-plot-module: + +osmnx.plot module +----------------- + +.. automodule:: osmnx.plot + :members: + +.. _osmnx-projection-module: + +osmnx.projection module +----------------------- + +.. automodule:: osmnx.projection + :members: + +.. _osmnx-routing-module: + +osmnx.routing module +----------------------- + +.. automodule:: osmnx.routing + :members: + +.. _osmnx-settings-module: + +osmnx.settings module +--------------------- + +.. automodule:: osmnx.settings + :members: + +.. _osmnx-simplification-module: + +osmnx.simplification module +--------------------------- + +.. automodule:: osmnx.simplification + :members: + +.. _osmnx-stats-module: + +osmnx.stats module +------------------ + +.. automodule:: osmnx.stats + :members: + +.. _osmnx-truncate-module: + +osmnx.truncate module +--------------------- + +.. automodule:: osmnx.truncate + :members: + +.. _osmnx-utils-module: + +osmnx.utils module +------------------ + +.. automodule:: osmnx.utils + :members: + +.. _osmnx-utils_geo-module: + +osmnx.utils_geo module +---------------------- + +.. automodule:: osmnx.utils_geo + :members: diff --git a/osmnx/source/environments/create_conda_env.sh b/osmnx/source/environments/create_conda_env.sh new file mode 100644 index 0000000000000000000000000000000000000000..09476c00866d6bf77db7e1c9de2fcd83f4ffbba0 --- /dev/null +++ b/osmnx/source/environments/create_conda_env.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -euo pipefail +echo "Run conda deactivate before running this script." +ENV=ox +ENV_PATH=$(conda info --base)/envs/$ENV +PACKAGE=osmnx +uv --version +eval "$(conda shell.bash hook)" +conda deactivate +conda env remove --yes -n $ENV || true +conda create --yes -c conda-forge --strict-channel-priority -n $ENV python +eval "$(conda shell.bash hook)" +conda activate $ENV +uv export --no-build --all-extras --all-groups > ./environments/requirements-temp.txt +uv pip install --no-build --strict -r ./environments/requirements-temp.txt +rm -f ./environments/requirements-temp.txt +python -m pip --python "$ENV_PATH" uninstall $PACKAGE --yes +python -m pip --python "$ENV_PATH" install -e . +python -m ipykernel install --prefix "$ENV_PATH" --name $ENV --display-name "Python ($ENV)" +conda list -n $ENV +python -m pip --python "$ENV_PATH" check +jupyter kernelspec list +ipython -c "import $PACKAGE; print('$PACKAGE version', $PACKAGE.__version__)" diff --git a/osmnx/source/environments/docker/Dockerfile b/osmnx/source/environments/docker/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..2a495ac0199b1f16f28501e21a65d9f98b931680 --- /dev/null +++ b/osmnx/source/environments/docker/Dockerfile @@ -0,0 +1,24 @@ +FROM jupyter/base-notebook +LABEL maintainer="Geoff Boeing " +LABEL url="https://osmnx.readthedocs.io" +LABEL description="OSMnx is a Python package to easily download, model, analyze, and visualize street networks and other geospatial features from OpenStreetMap." + +# expose $TARGETPLATFORM to the install.sh script +ARG TARGETPLATFORM + +# copy uv binaries and package files needed for installation +COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /uvx /bin/ +COPY --chown=jovyan --chmod=0755 ./environments/docker/install.sh ./osmnx/ +COPY --chown=jovyan --chmod=0755 ./osmnx/ ./osmnx/osmnx/ +COPY --chown=jovyan --chmod=0755 ./LICENSE.txt ./osmnx/ +COPY --chown=jovyan --chmod=0755 ./pyproject.toml ./osmnx/ +COPY --chown=jovyan --chmod=0755 ./README.md ./osmnx/ + +# install and configure everything in one RUN to keep image tidy +RUN cd ./osmnx && bash install.sh + +# set jupyter working directory to map to mounted volume +WORKDIR /home/jovyan/work + +# set default command to launch when container is run +CMD ["jupyter", "lab", "--ip='0.0.0.0'", "--port=8888", "--no-browser", "--NotebookApp.token=''", "--NotebookApp.password=''"] diff --git a/osmnx/source/environments/docker/build_image.sh b/osmnx/source/environments/docker/build_image.sh new file mode 100644 index 0000000000000000000000000000000000000000..975a4cfa4b03b113a74fbfdeb3726f264c69c9ec --- /dev/null +++ b/osmnx/source/environments/docker/build_image.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -euo pipefail +echo "Run this script from the repository root." +docker login +docker buildx build --progress=plain --no-cache --pull --push --platform=linux/amd64,linux/arm64 -f ./environments/docker/Dockerfile -t gboeing/osmnx:test . +IMPORTED_VERSION=$(docker run --rm gboeing/osmnx:test /bin/bash -c "ipython -c \"import osmnx; print(osmnx.__version__)\"") +echo "Imported $IMPORTED_VERSION" diff --git a/osmnx/source/environments/docker/install.sh b/osmnx/source/environments/docker/install.sh new file mode 100644 index 0000000000000000000000000000000000000000..7ec8ca0e75fe68239e90e7af3e98cd104374c43c --- /dev/null +++ b/osmnx/source/environments/docker/install.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -euo pipefail + +# rasterio doesn't provide linux/arm64 wheels, so if the target platform is +# linux/arm64, don't install this optional dependency (attempting to build +# it rather than install the wheel will also fail). +# see https://github.com/rasterio/rasterio-wheels/issues/69 +if [[ "$TARGETPLATFORM" == "linux/arm64" ]] +then + NOEXTRA="--no-extra=raster --no-extra=all" +else + NOEXTRA="" +fi + +# install all requirements into the existing system environment +uv export --no-cache --no-build --all-extras $NOEXTRA --group examples > requirements-temp.txt +uv pip install --no-cache --no-build --system --compile-bytecode --strict -r requirements-temp.txt +rm -f requirements-temp.txt +uv cache clean +python --version +ipython -c "import osmnx; print('OSMnx version', osmnx.__version__)" diff --git a/osmnx/source/osmnx/__init__.py b/osmnx/source/osmnx/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e2d73290788c17c2f58ed4a50960609d294b0c68 --- /dev/null +++ b/osmnx/source/osmnx/__init__.py @@ -0,0 +1,37 @@ +# ruff: noqa: D205 # numpydoc ignore=SS06 +""" +OSMnx is a Python package to easily download, model, analyze, and visualize +street networks and other geospatial features from OpenStreetMap. + +Full documentation at: https://osmnx.readthedocs.io + +If you use OSMnx in your work, please cite: https://doi.org/10.1111/gean.70009 +""" + +from importlib.metadata import version as metadata_version + +# expose the package version +__version__ = metadata_version("osmnx") + +# expose the package's public modules +from . import _errors as _errors +from . import bearing as bearing +from . import convert as convert +from . import distance as distance +from . import elevation as elevation +from . import features as features +from . import geocoder as geocoder +from . import graph as graph +from . import io as io +from . import plot as plot +from . import projection as projection +from . import routing as routing +from . import settings as settings +from . import simplification as simplification +from . import stats as stats +from . import truncate as truncate +from . import utils as utils +from . import utils_geo as utils_geo + +# expose the old v1 API for backwards compatibility +from ._api_v1 import * # noqa: F403 diff --git a/osmnx/source/osmnx/_api_v1.py b/osmnx/source/osmnx/_api_v1.py new file mode 100644 index 0000000000000000000000000000000000000000..8cb93fb52daca872a8c02b18c929be39bf0940e8 --- /dev/null +++ b/osmnx/source/osmnx/_api_v1.py @@ -0,0 +1,53 @@ +# ruff: noqa: PLC0414 +""" +Expose the old v1 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/source/osmnx/_errors.py b/osmnx/source/osmnx/_errors.py new file mode 100644 index 0000000000000000000000000000000000000000..cef1b57bfdcf3815a46bd0dc493bdddcee9d2505 --- /dev/null +++ b/osmnx/source/osmnx/_errors.py @@ -0,0 +1,21 @@ +"""Define custom errors and exceptions.""" + + +class CacheOnlyInterruptError(InterruptedError): + """Exception for `settings.cache_only_mode=True` interruption.""" + + +class GraphSimplificationError(ValueError): + """Exception for a problem with graph simplification.""" + + +class ValidationError(ValueError): + """Exception for failed graph or node/edge GeoDataFrame validation.""" + + +class InsufficientResponseError(ValueError): + """Exception for empty or too few results in server response.""" + + +class ResponseStatusCodeError(ValueError): + """Exception for an unhandled server response status code.""" diff --git a/osmnx/source/osmnx/_http.py b/osmnx/source/osmnx/_http.py new file mode 100644 index 0000000000000000000000000000000000000000..a4d2840ad430046694a8b9a4956d1907196c8d6e --- /dev/null +++ b/osmnx/source/osmnx/_http.py @@ -0,0 +1,332 @@ +"""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 + +import requests +from requests.exceptions import JSONDecodeError + +from . import settings +from . import utils +from ._errors import InsufficientResponseError +from ._errors import ResponseStatusCodeError + +# capture getaddrinfo function to use original later after mutating it +_original_getaddrinfo = socket.getaddrinfo + + +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. + + If 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, `ok` is True, `response_json` + is not None, and `response_json` does not contain a server "remark." + + Users should always pass OrderedDicts instead of dicts of parameters into + request functions, so the parameters remain in the same order each time, + producing the same URL string, and thus the same hash. Otherwise you will + get a cache miss when the URL's parameters appeared in a different order. + + Parameters + ---------- + url + The URL of the request. + response_json + The JSON HTTP response. + ok + A `requests.response.ok` value. + """ + if settings.use_cache: + if not ok: # pragma: no cover + msg = "Did not save to cache because HTTP status code is not OK" + utils.log(msg, level=lg.WARNING) + elif isinstance(response_json, dict) and ("remark" in response_json): # pragma: no cover + msg = f"Did not save to cache because response contains remark: {response_json['remark']!r}" + utils.log(msg, lg.WARNING) + else: + # create cache folder on disk if it doesn't already exist + cache_filepath = _resolve_cache_filepath(url) + cache_filepath.parent.mkdir(parents=True, exist_ok=True) + cache_filepath.write_text(json.dumps(response_json), encoding="utf-8") + msg = f"Saved response to cache file {str(cache_filepath)!r}" + utils.log(msg, level=lg.INFO) + + +def _resolve_cache_filepath(key: str, extension: str = "json") -> Path: + """ + Determine a cache key's corresponding cache file path. + + This uses the configured `settings.cache_folder` and calculates the 160 + bit SHA-1 hash digest (40 hexadecimal characters) of `key` to determine a + succinct but unique cache filename. + + Parameters + ---------- + key + The key for which to generate a cache file path, for example, a URL. + extension + The desired cache file's extension. + + Returns + ------- + cache_filepath + Cache file path corresponding to `key`. + """ + digest = sha1(key.encode("utf-8")).hexdigest() # noqa: S324 + return Path(settings.cache_folder) / f"{digest}.{extension}" + + +def _check_cache(key: str) -> Path | None: + """ + Check if a key exists in the cache, and return its cache file path if so. + + Parameters + ---------- + key + The key to look for in the cache. + + Returns + ------- + cache_filepath + Filepath to cached data for `key` if it exists, otherwise None. + """ + cache_filepath = _resolve_cache_filepath(key) + return cache_filepath if cache_filepath.is_file() else None + + +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. + + A cache hit returns the data. A cache miss returns None. + + Parameters + ---------- + url + The URL of the request. + + Returns + ------- + response_json + The cached response for `url` if it exists, 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 = _check_cache(url) + if cache_filepath is not None: + response_json: dict[str, Any] | list[dict[str, Any]] + response_json = json.loads(cache_filepath.read_text(encoding="utf-8")) + 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: str | None = None, + referer: str | None = None, + accept_language: str | None = None, +) -> dict[str, str]: + """ + Update the default requests HTTP headers with OSMnx information. + + Parameters + ---------- + 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 + The updated HTTP headers. + """ + if user_agent is None: + user_agent = settings.http_user_agent + if referer is None: + referer = settings.http_referer + if accept_language is None: + accept_language = settings.http_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: str) -> str: + """ + Resolve hostname to IP address via Google's public DNS-over-HTTPS API. + + Necessary fallback as socket.gethostbyname will not always work when using + a proxy. See https://developers.google.com/speed/public-dns/docs/doh/json + If the user has set `settings.doh_url_template=None` or if resolution + fails (e.g., due to local network blocking DNS-over-HTTPS) the hostname + itself will be returned instead. Note that this means that server slot + management may be violated: see `_config_dns` documentation for details. + + Parameters + ---------- + hostname + The hostname to consistently resolve the IP address of. + + Returns + ------- + ip_address + Resolved IP address of host, or hostname itself if resolution failed. + """ + if settings.doh_url_template is None: + # if user has set the url template to None, return hostname itself + 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=settings.requests_timeout) + data = response.json() + + # if we cannot reach DoH server or resolve host, return hostname itself + except requests.exceptions.RequestException: # pragma: no cover + utils.log(err_msg, level=lg.ERROR) + return hostname + + # if there were no request exceptions, return + else: + if response.ok and data["Status"] == 0: + # status 0 means NOERROR, so return the IP address + 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 + utils.log(err_msg, level=lg.ERROR) + return hostname + + +def _config_dns(url: str) -> None: + """ + Force socket.getaddrinfo to use IP address instead of hostname. + + Resolves URL's hostname to an IP address so that we use the same server + for both 1) checking the necessary pause duration and 2) sending the query + itself even if there is round-robin redirecting among multiple server + machines on the server-side. Mutates the getaddrinfo function so it uses + the same IP address everytime it finds the hostname in the URL. + + For example, the server overpass-api.de just redirects to one of the other + servers (currently gall.openstreetmap.de and lambert.openstreetmap.de). So + if we check the status endpoint of overpass-api.de, we may see results for + server gall, but when we submit the query itself it gets redirected to + server lambert. This could result in violating server lambert's slot + management timing. + + Parameters + ---------- + url + The URL to consistently resolve the IP address of. + """ + hostname = _hostname_from_url(url) + try: + ip = socket.gethostbyname(hostname) + except socket.gaierror: # pragma: no cover + # may occur when using a proxy, so instead resolve IP address via DoH + 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: Any, **kwargs: Any) -> Any: # noqa: ANN401 + if hostname == next(iter(args), kwargs.get("host")): + # remove "host" from kwargs to avoid TypeError with positional argument + kwargs.pop("host", None) + msg = f"Resolved {hostname!r} to {ip!r}" + utils.log(msg, level=lg.INFO) + return _original_getaddrinfo(ip, *args[1:], **kwargs) + + # otherwise + return _original_getaddrinfo(*args, **kwargs) + + socket.getaddrinfo = _getaddrinfo + + +def _hostname_from_url(url: str) -> str: + """ + Extract the hostname (domain) from a URL. + + Parameters + ---------- + url + The url from which to extract the hostname. + + Returns + ------- + hostname + The extracted hostname (domain). + """ + return urlparse(url).netloc.split(":")[0] + + +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 + The response object. + + Returns + ------- + 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 hostname + hostname = _hostname_from_url(response.url) + size_kb = len(response.content) / 1000 + msg = f"Downloaded {size_kb:,.1f}kB from {hostname!r} with status {response.status_code}" + utils.log(msg, level=lg.INFO) + + # parse the response to JSON and log/raise exceptions + try: + response_json: dict[str, Any] | list[dict[str, Any]] = response.json() + except JSONDecodeError as e: # pragma: no cover + msg = f"{hostname!r} responded: {response.status_code} {response.reason} {response.text}" + utils.log(msg, level=lg.ERROR) + if response.ok: + raise InsufficientResponseError(msg) from e + raise ResponseStatusCodeError(msg) from e + + # log any remarks if they exist + if isinstance(response_json, dict) and "remark" in response_json: # pragma: no cover + msg = f"{hostname!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"{hostname!r} returned HTTP status code {response.status_code}" + utils.log(msg, level=lg.WARNING) + + return response_json diff --git a/osmnx/source/osmnx/_nominatim.py b/osmnx/source/osmnx/_nominatim.py new file mode 100644 index 0000000000000000000000000000000000000000..e73eda381225252b6bdfa62e6b42de8d24edad41 --- /dev/null +++ b/osmnx/source/osmnx/_nominatim.py @@ -0,0 +1,151 @@ +"""Tools to work with the Nominatim API.""" + +from __future__ import annotations + +import logging as lg +import time +from collections import OrderedDict +from typing import Any + +import requests + +from . import _http +from . import settings +from . import utils +from ._errors import InsufficientResponseError + + +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 + 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 + The Nominatim API's response. + """ + # define the parameters + params: OrderedDict[str, int | str] = OrderedDict() + params["format"] = "json" + 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 + + else: + # if not querying by OSM ID, use the search endpoint + request_type = "search" + + # prevent OSM from deduping so we get precise number of results + params["dedupe"] = 0 + params["limit"] = limit + + if isinstance(query, str): + params["q"] = query + elif isinstance(query, dict): + # add query keys in alphabetical order so URL is the same string + # each time, for caching purposes + for key in sorted(query): + params[key] = query[key] + else: # pragma: no cover + 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: OrderedDict[str, int | str], + *, + request_type: str = "search", +) -> list[dict[str, Any]]: + """ + Send a HTTP GET request to the Nominatim API and return response. + + Parameters + ---------- + params + Key-value pairs of parameters. + request_type + Which Nominatim API endpoint to query, one of {"search", "reverse", + "lookup"}. + + Returns + ------- + response_json + The Nominatim API's response. + """ + if request_type not in {"search", "reverse", "lookup"}: # pragma: no cover + 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 = 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 + + # how long to pause before request, in seconds. Per the Nominatim usage + # policy: "an absolute maximum of 1 request per second" is allowed. + pause = 1 + hostname = _http._hostname_from_url(url) + msg = f"Pausing {pause} second(s) before making HTTP GET request to {hostname!r}" + utils.log(msg, level=lg.INFO) + time.sleep(pause) + + # transmit the HTTP GET request + msg = f"Get {prepared_url} with timeout={settings.requests_timeout}" + utils.log(msg, level=lg.INFO) + response = requests.get( + url, + params=params, + 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 + error_pause = 55 + msg = ( + f"{hostname!r} responded {response.status_code} {response.reason}: " + f"we'll retry in {error_pause} secs" + ) + utils.log(msg, level=lg.WARNING) + time.sleep(error_pause) + return _nominatim_request(params, request_type=request_type) + + 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/source/osmnx/_osm_xml.py b/osmnx/source/osmnx/_osm_xml.py new file mode 100644 index 0000000000000000000000000000000000000000..5cefa82b0555c2fbcddd19f5016470847bad840a --- /dev/null +++ b/osmnx/source/osmnx/_osm_xml.py @@ -0,0 +1,439 @@ +""" +Read/write OSM XML files. + +For file format information see https://wiki.openstreetmap.org/wiki/OSM_XML +""" + +from __future__ import annotations + +import bz2 +import gzip +import logging as lg +from contextlib import contextmanager +from importlib.metadata import version as metadata_version +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 + +if TYPE_CHECKING: + from collections.abc import Iterator + 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 {metadata_version('osmnx')}", + "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: + self._element: dict[str, Any] | None = None + self.object: dict[str, Any] = {"elements": []} + + def startElement(self, name: str, attrs: AttributesImpl) -> None: # noqa: 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: N802 + if name in {"node", "way", "relation"}: + self.object["elements"].append(self._element) + + +@contextmanager +def _open_file(filepath: Path, encoding: str) -> Iterator[TextIO]: + """ + Open a file and return a file object, optionally handling bz2 or gz files. + + Uses a wrapper context manager to yield the file object to ensure the file + will always get closed when the caller is finished with it. + + Parameters + ---------- + filepath + Path to file. + encoding + The file's character encoding. + + Returns + ------- + file + The file handle. + """ + if filepath.suffix == ".bz2": + with bz2.open(filepath, mode="rt", encoding=encoding) as file: + yield file + elif filepath.suffix == ".gz": + with gzip.open(filepath, mode="rt", encoding=encoding) as file: + yield file + else: + with filepath.open(mode="rt", encoding=encoding) as file: + yield file + + +def _overpass_json_from_xml(filepath: 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. + """ + with _open_file(filepath, encoding) as file: + # warn if this XML file was generated by OSMnx itself + root_attrs = etree_parse(file).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) + + # move back to beginning of file, then parse XML to Overpass-like JSON + file.seek(0) + handler = _OSMContentHandler() + sax_parse(file, 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. + """ + # 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.union_all().bounds] + bounds = dict(zip(["minlon", "minlat", "maxlon", "maxlat"], coords, strict=True)) + + # 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. + """ + 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. + """ + 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 {osmid!r} for topological sort" + utils.log(msg, level=lg.WARNING) + + return ordered_nodes diff --git a/osmnx/source/osmnx/_overpass.py b/osmnx/source/osmnx/_overpass.py new file mode 100644 index 0000000000000000000000000000000000000000..d71fb5e55a65e8d7587dcfac4ba205eb2f02d68a --- /dev/null +++ b/osmnx/source/osmnx/_overpass.py @@ -0,0 +1,493 @@ +"""Tools to work with the Overpass API.""" + +from __future__ import annotations + +import datetime as dt +import logging as lg +import time +from collections import OrderedDict +from typing import TYPE_CHECKING +from typing import Any + +import numpy as np +import requests +from requests.exceptions import ConnectionError as RequestsConnectionError + +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_network_filter(network_type: str) -> str: + """ + 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, + excluding those that represent areas either explicitly (area=yes) or by + convention (rest_area, services). + + "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 + {"all", "all_public", "bike", "drive", "drive_service", "walk"} + What type of street network to retrieve. + + Returns + ------- + way_filter + The Overpass query filter. + """ + # 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 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|' + f"cycleway|elevator|escalator|footway|no|path|pedestrian|planned|platform|" + f'proposed|raceway|razed|rest_area|service|services|steps|track"]' + f'["motor_vehicle"!~"no"]["motorcar"!~"no"]' + f'["service"!~"alley|driveway|emergency_access|parking|parking_aisle|private"]' + ) + + # drive+service: allow ways tagged 'service' but filter out certain types + filters["drive_service"] = ( + f'["highway"]["area"!~"yes"]{settings.default_access}' + f'["highway"!~"abandoned|bridleway|bus_guideway|construction|corridor|' + f"cycleway|elevator|escalator|footway|no|path|pedestrian|planned|platform|" + f'proposed|raceway|razed|rest_area|services|steps|track"]' + f'["motor_vehicle"!~"no"]["motorcar"!~"no"]' + f'["service"!~"emergency_access|parking|parking_aisle|private"]' + ) + + # walking: filter out cycle ways, motor ways, private ways, and anything + # 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|' + f'platform|proposed|raceway|razed|rest_area|services"]' + f'["foot"!~"no"]["service"!~"private"]' + f'["sidewalk"!~"separate"]["sidewalk:both"!~"separate"]' + f'["sidewalk:left"!~"separate"]["sidewalk:right"!~"separate"]' + ) + + # biking: filter out foot ways, motor ways, private ways, and anything + # tagged biking=no + filters["bike"] = ( + f'["highway"]["area"!~"yes"]{settings.default_access}' + f'["highway"!~"abandoned|bus_guideway|construction|corridor|elevator|' + f"escalator|footway|motor|no|planned|platform|proposed|raceway|razed|" + f'rest_area|services|steps"]' + f'["bicycle"!~"no"]["service"!~"private"]' + ) + + # 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|' + f'razed|rest_area|services"]' + f'["service"!~"private"]' + ) + + # to download all ways, including private-access ones, just filter out + # everything not currently in use + filters["all"] = ( + '["highway"]["area"!~"yes"]["highway"!~"abandoned|construction|no|planned|' + 'platform|proposed|raceway|razed|rest_area|services"]' + ) + + if network_type in filters: + way_filter = filters[network_type] + else: # pragma: no cover + msg = f"Unrecognized network_type {network_type!r}." + raise ValueError(msg) + + return way_filter + + +def _get_overpass_pause( + base_endpoint: str, + *, + recursion_pause: float = 5, + default_pause: float = 60, +) -> float: + """ + Retrieve a pause duration from the Overpass API status endpoint. + + Check the Overpass API status endpoint to determine how long to wait until + the next slot is available. You can disable this via the `settings` + module's `overpass_rate_limit` setting. + + Parameters + ---------- + base_endpoint + Base Overpass API URL (without "/status" at the end). + recursion_pause + How long to wait between recursive calls if the server is currently + running a query. + default_pause + If a fatal error occurs, fall back on this liberal pause duration. + + Returns + ------- + pause + The current pause duration specified by the Overpass status endpoint. + """ + # if overpass rate limiting is False, then there is zero pause + if not settings.overpass_rate_limit: + return 0 + + url = base_endpoint.rstrip("/") + "/status" + + # try to retrieve the URL + try: + response = requests.get( + url, + headers=_http._get_http_headers(), + timeout=settings.requests_timeout, + **settings.requests_kwargs, + ) + response_text = response.text + except RequestsConnectionError as e: # pragma: no cover + # cannot reach status endpoint: log error and return default duration + msg = f"Unable to reach {url}, {e}" + utils.log(msg, level=lg.ERROR) + return default_pause + + # try to parse the output + try: + status = response_text.split("\n")[4] + status_first_part = status.split(" ")[0] + except (AttributeError, IndexError, ValueError): # pragma: no cover + # cannot parse output: log error and return default duration + msg = f"Unable to parse {url} response: {response_text}" + utils.log(msg, level=lg.ERROR) + return default_pause + + # determine the current status of the server + try: + # if first token is numeric, it's how many slots you have available, + # no wait required + _ = 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_part == "Slot": + utc_time_str = status.split(" ")[3] + pattern = "%Y-%m-%dT%H:%M:%SZ," + utc_time = dt.datetime.strptime(utc_time_str, pattern).replace(tzinfo=dt.UTC) + utc_now = dt.datetime.now(tz=dt.UTC) + seconds = int(np.ceil((utc_time - utc_now).total_seconds())) + pause = max(seconds, 1) + + # if first token is 'Currently', it is currently running a query so + # check back in recursion_pause seconds + elif status_first_part == "Currently": + time.sleep(recursion_pause) + pause = _get_overpass_pause(base_endpoint) + + # any other status is unrecognized: log error, return default duration + else: + msg = f"Unrecognized server status: {status!r}" + utils.log(msg, level=lg.ERROR) + return default_pause + + return pause + + +def _make_overpass_settings() -> str: + """ + Make settings string to send in Overpass query. + + Returns + ------- + overpass_settings + The `settings.overpass_settings` string formatted with "timeout" and + "maxsize" values. + """ + maxsize = "" if settings.overpass_memory is None else f"[maxsize:{settings.overpass_memory}]" + return settings.overpass_settings.format(timeout=settings.requests_timeout, maxsize=maxsize) + + +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 + max size (in meters), project back to lat-lon, then get a list of + polygon(s) exterior coordinates. Ignore interior ("holes") coordinates. + + Parameters + ---------- + polygon + The (Multi)Polygon to convert to exterior coordinate strings. + + Returns + ------- + coord_strs + Exterior coordinates of polygon(s). + """ + # subdivide the polygon if its area exceeds max size + # this results in a multipolygon of 1+ constituent polygons + poly_proj, crs_proj = projection.project_geometry(polygon) + multi_poly_proj = utils_geo._consolidate_subdivide_geometry(poly_proj) + 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 (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, strict=True)] + coord_strs.append(" ".join(coord_list)) + + return coord_strs + + +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 tags. + + Parameters + ---------- + polygon_coord_str + The lat lon coordinates. + tags + Tags used for finding elements in the search area. + + Returns + ------- + query + The Overpass features 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." + if not isinstance(tags, dict): # pragma: no cover + raise TypeError(err_msg) + + tags_dict: dict[str, bool | str | list[str]] = {} + for key, value in tags.items(): + if isinstance(value, bool): + tags_dict[key] = value + + elif isinstance(value, str): + tags_dict[key] = [value] + + elif isinstance(value, list): + if not all(isinstance(s, str) for s in value): # pragma: no cover + raise TypeError(err_msg) + tags_dict[key] = value + + else: # pragma: no cover + raise TypeError(err_msg) + + # convert the tags dict into a list of {tag:value} dicts + 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}) + else: + for value_item in value: + tags_list.append({key: value_item}) # noqa: PERF401 + + # add node/way/relation query components one at a time + components = [] + for d in tags_list: + for key, value in d.items(): + if isinstance(value, bool): + # if bool (ie, True) just pass the key, no value + tag_str = f"[{key!r}](poly:{polygon_coord_str!r});(._;>;);" + else: + # otherwise, pass "key"="value" + tag_str = f"[{key!r}={value!r}](poly:{polygon_coord_str!r});(._;>;);" + + for kind in ("node", "way", "relation"): + components.append(f"({kind}{tag_str});") # noqa: PERF401 + + # finalize query and return + components_str = "".join(components) + return f"{overpass_settings};({components_str});out;" + + +def _download_overpass_network( + polygon: Polygon | MultiPolygon, + network_type: str, + custom_filter: str | list[str] | None, +) -> Iterator[dict[str, Any]]: + """ + Retrieve networked ways and nodes within boundary from the Overpass API. + + Parameters + ---------- + 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 + JSON response from the Overpass server. + """ + # create filter(s) to exclude certain kinds of ways based on the requested + # network_type, if provided, otherwise use custom_filter + way_filters = [] + if isinstance(custom_filter, list): + way_filters = custom_filter + elif isinstance(custom_filter, str): + way_filters = [custom_filter] + else: + way_filters = [_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) + 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: + for way_filter in way_filters: + 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: Polygon, + tags: dict[str, bool | str | list[str]], +) -> Iterator[dict[str, Any]]: + """ + Retrieve OSM features within some boundary polygon from the Overpass API. + + Parameters + ---------- + polygon + Boundary to retrieve elements within. + tags + Tags used for finding elements in the selected area. + + Yields + ------ + 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) + 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_features_query(polygon_coord_str, tags) + yield _overpass_request(OrderedDict(data=query_str)) + + +def _overpass_request(data: OrderedDict[str, Any]) -> dict[str, Any]: + """ + Send a HTTP POST request to the Overpass API and return response. + + Parameters + ---------- + data + Key-value pairs of parameters. + + Returns + ------- + response_json + The Overpass API's response. + """ + # resolve url to same IP even if there is server round-robin redirecting + _http._config_dns(settings.overpass_url) + + # prepare the Overpass API URL and see if request already exists in cache + 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 + pause = _get_overpass_pause(settings.overpass_url) + hostname = _http._hostname_from_url(url) + msg = f"Pausing {pause} second(s) before making HTTP POST request to {hostname!r}" + utils.log(msg, level=lg.INFO) + time.sleep(pause) + + # transmit the HTTP POST request + msg = f"Post {prepared_url} with timeout={settings.requests_timeout}" + utils.log(msg, level=lg.INFO) + response = requests.post( + url, + data=data, + 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 + error_pause = 55 + msg = ( + f"{hostname!r} responded {response.status_code} {response.reason}: " + f"we'll retry in {error_pause} secs" + ) + utils.log(msg, level=lg.WARNING) + time.sleep(error_pause) + return _overpass_request(data) + + 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/source/osmnx/_validate.py b/osmnx/source/osmnx/_validate.py new file mode 100644 index 0000000000000000000000000000000000000000..5a15fc8351198442d1d7db7825838edfc923228b --- /dev/null +++ b/osmnx/source/osmnx/_validate.py @@ -0,0 +1,387 @@ +"""Validate that graphs and GeoDataFrames satisfy OSMnx expectations.""" + +from __future__ import annotations + +import logging as lg +from numbers import Real +from warnings import warn + +import geopandas as gpd +import networkx as nx +import numpy as np + +from ._errors import ValidationError +from .utils import log + + +def _verify_numeric_edge_attribute(G: nx.MultiDiGraph, attr: str, *, strict: bool = True) -> None: + """ + Verify attribute values are numeric and non-null across graph edges. + + Raises a ValidationError if this attribute contains non-numeric + values, and issues a UserWarning if this attribute is missing or null on + any edges. + + Parameters + ---------- + G + Input graph. + attr + Name of the edge attribute to verify. + strict + If `True`, elevate warnings to errors. + """ + is_valid = True + valid_msg = "Verified {attr!r} values are numeric and non-null across graph edges." + warn_msg = "" + err_msg = "" + + try: + values_float = (np.array(tuple(G.edges(data=attr)))[:, 2]).astype(float) + if np.isnan(values_float).any(): + warn_msg += f"The attribute {attr!r} is missing or null on some edges." + if strict: + is_valid = False + except ValueError: + err_msg += f"The edge attribute {attr!r} contains non-numeric values." + is_valid = False + + _report_validation(is_valid, valid_msg, warn_msg, err_msg) + + +def _validate_features_gdf(gdf: gpd.GeoDataFrame) -> None: + """ + Validate that features GeoDataFrame satisfies OSMnx expectations. + + Raises a `ValidationError` if validation fails. + + Parameters + ---------- + gdf + GeoDataFrame of features uniquely multi-indexed by + `(element_type, osmid)`. + """ + is_valid = True + valid_msg = "Validated features GeoDataFrame." + warn_msg = "" + err_msg = "" + + # ensure gdf is uniquely indexed + if not gdf.index.is_unique: + err_msg += "`gdf` must be uniquely indexed. " + is_valid = False + + # ensure gdf is multi-indexed with 2 levels (element_type and osmid) and + # that the element types are all either node, way, or relation + features_index_levels = 2 + check1 = gdf.index.nlevels == features_index_levels + element_types = set(gdf.index.get_level_values(0)) + check2 = element_types.issubset({"node", "way", "relation"}) + if not (check1 and check2): + err_msg += "`gdf` must be multi-indexed by `(element_type, osmid)`. " + is_valid = False + + # ensure gdf has an active geometry column with all valid non-null geoms + if (gdf.active_geometry_name is None) or ( + gdf.geometry.isna() | gdf.geometry.is_empty | ~gdf.geometry.is_valid + ).any(): + err_msg += "`gdf` must contain valid, non-null geometries`. " + is_valid = False + + _report_validation(is_valid, valid_msg, warn_msg, err_msg) + + +def _validate_node_edge_gdfs( + gdf_nodes: gpd.GeoDataFrame, + gdf_edges: gpd.GeoDataFrame, + *, + strict: bool = True, +) -> None: + """ + Validate that node/edge GeoDataFrames can be converted to a MultiDiGraph. + + Raises a `ValidationError` if validation fails. + + Parameters + ---------- + gdf_nodes + GeoDataFrame of graph nodes uniquely indexed by `osmid`. + gdf_edges + GeoDataFrame of graph edges uniquely multi-indexed by `(u, v, key)`. + strict + If `True`, elevate warnings to errors. + """ + is_valid = True + valid_msg = "Validated that node/edge GeoDataFrames can be converted to a MultiDiGraph." + warn_msg = "" + err_msg = "" + + # ensure type is GeoDataFrame + if not (isinstance(gdf_nodes, gpd.GeoDataFrame) and isinstance(gdf_edges, gpd.GeoDataFrame)): + # if they are not both GeoDataFrames + err_msg += "`gdf_nodes` and `gdf_edges` must be GeoDataFrames. " + is_valid = False + # if they are both GeoDataFrames... + # warn user if geometry values differ from coordinates in x/y columns, + # because we ignore the geometry column + elif gdf_nodes.active_geometry_name is not None: + msg = ( + "Will ignore 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 += msg + if strict: + is_valid = False + except ValueError: + # warn if geometry column contains non-point geometry types + warn_msg += msg + if strict: + is_valid = False + + # ensure gdf_nodes has x and y columns representing node geometries + if not ("x" in gdf_nodes.columns and "y" in gdf_nodes.columns): + err_msg += "`gdf_nodes` must have 'x' and 'y' columns. " + is_valid = False + + # ensure gdf_nodes and gdf_edges are uniquely indexed + if not (gdf_nodes.index.is_unique and gdf_edges.index.is_unique): + err_msg += "`gdf_nodes` and `gdf_edges` must each be uniquely indexed. " + is_valid = False + + # ensure 1) gdf_edges are multi-indexed with 3 levels and 2) that its u + # and v values (first two index levels) all appear among gdf_nodes index + edges_index_levels = 3 + check1 = gdf_edges.index.nlevels == edges_index_levels + try: + uv = set(gdf_edges.index.get_level_values(0)) | set(gdf_edges.index.get_level_values(1)) + check2 = uv.issubset(set(gdf_nodes.index)) + except IndexError: + check2 = False + if not (check1 and check2): + err_msg += "`gdf_edges` must be multi-indexed by `(u, v, key)`. " + is_valid = False + + _report_validation(is_valid, valid_msg, warn_msg, err_msg) + + +def _validate_nodes(G: nx.MultiDiGraph, strict: bool) -> tuple[bool, str, str]: # noqa: FBT001 + """ + Validate that a graph's nodes satisfy OSMnx expectations. + + Parameters + ---------- + G + The input graph. + strict + If `True`, elevate warnings to errors. + + Returns + ------- + is_valid, err_msg, warn_msg + Whether validation passed, plus any error or warning messages. + """ + # assume nodes are valid but try to falsify that through a series of tests + is_valid = True + err_msg = "" + warn_msg = "" + + # ERR: must have at least 1 node + if not len(G.nodes) > 0: + err_msg += "G must have at least 1 node. " + is_valid = False + + # otherwise, it has at least 1 node, so validate the node attributes + else: + # ERR: nodes must have "x" and "y" data attributes + if not all("x" in d and "y" in d for d in dict(G.nodes(data=True)).values()): + err_msg += "Nodes must have 'x' and 'y' data attributes. " + is_valid = False + + # WARN: nodes' "x" and "y" data attributes should be type Real + valid_xs = all(isinstance(x, Real) for x in nx.get_node_attributes(G, name="x").values()) + valid_ys = all(isinstance(y, Real) for y in nx.get_node_attributes(G, name="y").values()) + if not (valid_xs and valid_ys): + warn_msg += "Node 'x' and 'y' data attributes should be numeric. " + if strict: + is_valid = False + + # WARN: nodes should have "street_count" data attributes + if not all("street_count" in d for d in dict(G.nodes(data=True)).values()): + warn_msg += "Nodes should have 'street_count' data attributes. " + if strict: + is_valid = False + + # WARN: nodes' "x" and "y" data attributes should be type Real + valid_xs = all(isinstance(x, Real) for x in nx.get_node_attributes(G, name="x").values()) + valid_ys = all(isinstance(y, Real) for y in nx.get_node_attributes(G, name="y").values()) + if not (valid_xs and valid_ys): + warn_msg += "Node 'x' and 'y' data attributes should be numeric. " + if strict: + is_valid = False + + # WARN: node IDs should be type int + if not all(isinstance(n, int) for n in G.nodes): + warn_msg += "Node IDs should be type int. " + if strict: + is_valid = False + + return is_valid, err_msg, warn_msg + + +def _validate_edges(G: nx.MultiDiGraph, strict: bool) -> tuple[bool, str, str]: # noqa: FBT001 + """ + Validate that a graph's edges satisfy OSMnx expectations. + + Parameters + ---------- + G + The input graph. + strict + If `True`, elevate warnings to errors. + + Returns + ------- + is_valid, err_msg, warn_msg + Whether validation passed, plus any error or warning messages. + """ + # assume edges are valid but try to falsify that through a series of tests + is_valid = True + err_msg = "" + warn_msg = "" + + # ERR: must have at least 1 edge + if not len(G.edges) > 0: + err_msg += "G must have at least 1 edge. " + is_valid = False + + # otherwise, it has at least 1 edge, so validate the edge attributes + else: + # ERR: edges must have "osmid" data attributes + edge_osmids = nx.get_edge_attributes(G, name="osmid") + if set(edge_osmids) != set(G.edges): + err_msg += "Edges must have 'osmid' data attributes. " + is_valid = False + + # WARN: edge "osmid" data attributes should be type int or list[int] + if not all(isinstance(x, (int, list)) for x in edge_osmids.values()): + warn_msg += "Edge 'osmid' data attributes should be type `int` or `list[int]`. " + if strict: + is_valid = False + + # ERR: edges must have "length" data attributes + edge_lengths = nx.get_edge_attributes(G, name="length") + if set(edge_lengths) != set(G.edges): + err_msg += "Edges must have 'length' data attributes. " + is_valid = False + + # WARN: edge "length" data attributes should be numeric + if not all(isinstance(x, Real) for x in edge_lengths.values()): + warn_msg += "Edge 'length' data attributes should be numeric. " + if strict: + is_valid = False + + return is_valid, err_msg, warn_msg + + +def _validate_graph_attrs(G: nx.MultiDiGraph) -> tuple[bool, str, str]: + """ + Validate that a graph's attributes satisfy OSMnx expectations. + + Parameters + ---------- + G + The input graph. + + Returns + ------- + is_valid, err_msg, warn_msg + Whether validation passed, plus any error or warning messages. + """ + # assume G is valid but try to falsify that through a series of tests + is_valid = True + err_msg = "" + warn_msg = "" + + # ERR: must be a NetworkX MultiDiGraph + if not isinstance(G, nx.MultiDiGraph): + err_msg += "G must be a NetworkX MultiDiGraph. " + is_valid = False + + # ERR: must have top-level graph, nodes, and edges attributes + if not (hasattr(G, "graph") and hasattr(G, "nodes") and hasattr(G, "edges")): + err_msg += "G must have top-level graph, nodes, and edges attributes. " + is_valid = False + + # ERR: graph attr dict must have a "crs" key defining its CRS + crs = getattr(G, "graph", {}).get("crs") + if crs is None: + err_msg += "G.graph must have a 'crs' data attribute. " + is_valid = False + + # ERR: graph attr dict "crs" value must be a valid pyproj CRS + else: + try: + _ = gpd.GeoSeries(crs=crs).crs + except RuntimeError: # RuntimeError is parent of pyproj CRSError + err_msg += "G.graph['crs'] must be a valid CRS. " + is_valid = False + + return is_valid, err_msg, warn_msg + + +def _validate_graph(G: nx.MultiDiGraph, *, strict: bool = True) -> None: + """ + Validate that a graph object satisfies OSMnx expectations. + + Raises `ox._errors.ValidationError` if validation fails. + + Parameters + ---------- + G + The input graph. + strict + If `True`, elevate warnings to errors. + """ + # validate graph, nodes, and edges + is_valid_graph, err_msg_graph, warn_msg_graph = _validate_graph_attrs(G) + is_valid_nodes, err_msg_nodes, warn_msg_nodes = _validate_nodes(G, strict) + is_valid_edges, err_msg_edges, warn_msg_edges = _validate_edges(G, strict) + + # report results + is_valid = is_valid_graph and is_valid_nodes and is_valid_edges + err_msg = err_msg_graph + err_msg_nodes + err_msg_edges + warn_msg = warn_msg_graph + warn_msg_nodes + warn_msg_edges + valid_msg = "Successfully validated graph." + _report_validation(is_valid, valid_msg, warn_msg, err_msg) + + +def _report_validation(is_valid: bool, valid_msg: str, warn_msg: str, err_msg: str) -> None: # noqa: FBT001 + """ + Report validation results by logging, warning, or raising an exception. + + Parameters + ---------- + is_valid + Whether or not the validation succeeded. + valid_msg + The message to log if validation succeeded. + warn_msg + Any warning messages to log and either issue a warning or include in + error message. + err_msg + Any error messages to include when raising exception if validation + failed. + """ + if is_valid: + log(valid_msg, level=lg.INFO) + if warn_msg != "": + log(warn_msg, level=lg.WARNING) + warn(warn_msg, category=UserWarning, stacklevel=2) + else: + log(err_msg + warn_msg, level=lg.ERROR) + raise ValidationError(err_msg + warn_msg) diff --git a/osmnx/source/osmnx/bearing.py b/osmnx/source/osmnx/bearing.py new file mode 100644 index 0000000000000000000000000000000000000000..f100e74fdd51fdb2bee22d5c22bc6ecae4bc7401 --- /dev/null +++ b/osmnx/source/osmnx/bearing.py @@ -0,0 +1,302 @@ +"""Calculate graph edge bearings and orientation entropy.""" + +from __future__ import annotations + +from types import ModuleType +from typing import TYPE_CHECKING +from typing import overload +from warnings import warn + +import networkx as nx +import numpy as np +import numpy.typing as npt + +from . import projection + +if TYPE_CHECKING: + from types import ModuleType + +# scipy is an optional dependency for entropy calculation +scipy: ModuleType | None +try: + import scipy +except ImportError: # pragma: no cover + scipy = None + + +# 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. The bearing represents the clockwise angle in degrees + between north and the geodesic line from `(lat1, lon1)` to `(lat2, lon2)`. + + Parameters + ---------- + 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 + The bearing(s) in decimal degrees. + """ + # get the latitudes and the difference in longitudes, all in radians + lat1 = np.deg2rad(lat1) + lat2 = np.deg2rad(lat2) + delta_lon = np.deg2rad(lon2 - lon1) + + # calculate initial bearing from -180 degrees to +180 degrees + y = np.sin(delta_lon) * np.cos(lat2) + x = np.cos(lat1) * np.sin(lat2) - np.sin(lat1) * np.cos(lat2) * np.cos(delta_lon) + initial_bearing = np.rad2deg(np.arctan2(y, x)) + + # normalize to 0-360 degrees to get compass bearing + bearing: float | npt.NDArray[np.float64] = initial_bearing % 360 + return bearing + + +def add_edge_bearings(G: nx.MultiDiGraph) -> nx.MultiDiGraph: + """ + 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 `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 + Unprojected graph. + + Returns + ------- + G + Graph with `bearing` attributes on the edges. + """ + if projection.is_projected(G.graph["crs"]): # pragma: no cover + msg = "Graph must be unprojected to add edge bearings." + raise ValueError(msg) + + # extract edge IDs and corresponding coordinates from their nodes + uvk = [(u, v, k) for u, v, k in G.edges if u != v] + x = G.nodes(data="x") + y = G.nodes(data="y") + coords = np.array([(y[u], x[u], y[v], x[v]) for u, v, k in uvk]) + + # calculate bearings then set as edge attributes + bearings = calculate_bearing(coords[:, 0], coords[:, 1], coords[:, 2], coords[:, 3]) + values = zip(uvk, bearings, strict=True) + nx.set_edge_attributes(G, dict(values), name="bearing") + + return G + + +def orientation_entropy( + G: nx.MultiGraph | nx.MultiDiGraph, + *, + num_bins: int = 36, + min_length: float = 0, + weight: str | None = None, +) -> float: + """ + Calculate graph's orientation entropy. + + Orientation entropy is the Shannon entropy of the graphs' edges' bearings + across evenly spaced bins. 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 more info see: Boeing, G. 2019. "Urban Spatial Order: Street Network + Orientation, Configuration, and Entropy." Applied Network Science, 4 (1), + 67. https://doi.org/10.1007/s41109-019-0189-1 + + Parameters + ---------- + 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 + 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 as an optional dependency to calculate entropy." + raise ImportError(msg) + bin_counts, _ = _bearings_distribution(G, num_bins, min_length, weight) + entropy: float = scipy.stats.entropy(bin_counts) + return entropy + + +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 graph's edge bearings. + + 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 + ---------- + 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, weights + The edge bearings of `G` and their corresponding weights. + """ + if projection.is_projected(G.graph["crs"]): # pragma: no cover + msg = "Graph must be unprojected to analyze edge bearings." + raise ValueError(msg) + bearings = [] + 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: + 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.floating], npt.NDArray[np.floating]]: + """ + Compute distribution of bearings across evenly spaced bins. + + Prevents bin-edge effects around common values like 0 degrees and 90 + degrees by initially creating twice as many bins as desired, then merging + them in pairs. For example, if `num_bins=36` is provided, then each bin + will represent 10 degrees around the compass, with the first bin + representing 355 degrees to 5 degrees. + + Parameters + ---------- + 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_centers + Counts of bearings per bin and the bins' centers in degrees. Both + arrays are of length `num_bins`. + """ + # 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.linspace(0, 360, num_split_bins + 1) + + bearings, weights = _extract_edge_bearings(G, min_length, weight) + split_bin_counts, split_bin_edges = np.histogram( + bearings, + bins=split_bin_edges, + weights=weights, + ) + + # 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] + + # 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/source/osmnx/convert.py b/osmnx/source/osmnx/convert.py new file mode 100644 index 0000000000000000000000000000000000000000..fa1d4fcec1ce8efce785c2e80c2764c415db14e2 --- /dev/null +++ b/osmnx/source/osmnx/convert.py @@ -0,0 +1,564 @@ +"""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 + +import geopandas as gpd +import networkx as nx +import pandas as pd +from shapely import LineString +from shapely import Point + +from . import _validate +from . import utils + + +def validate_graph(G: nx.MultiDiGraph, *, strict: bool = True) -> None: + """ + Validate that a graph object satisfies OSMnx expectations. + + Raises `ox._errors.GraphValidationError` if validation fails. + + Parameters + ---------- + G + The input graph. + strict + If `True`, enforce optional rules in addition to required rules. These + optional rules primarily enforce expected attribute data types. + """ + _validate._validate_graph(G, strict=strict) + + +def validate_node_edge_gdfs( + gdf_nodes: gpd.GeoDataFrame, + gdf_edges: gpd.GeoDataFrame, + *, + strict: bool = True, +) -> None: + """ + Validate that node/edge GeoDataFrames can be converted to a MultiDiGraph. + + Raises a `ValidationError` if validation fails. + + Parameters + ---------- + gdf_nodes + GeoDataFrame of graph nodes uniquely indexed by `osmid`. + gdf_edges + GeoDataFrame of graph edges uniquely multi-indexed by `(u, v, key)`. + strict + If `True`, elevate warnings to errors. + """ + _validate._validate_node_edge_gdfs(gdf_nodes, gdf_edges, strict=strict) + + +def validate_features_gdf(gdf: gpd.GeoDataFrame) -> None: + """ + Validate that features GeoDataFrame satisfies OSMnx expectations. + + Raises a `ValidationError` if validation fails. + + Parameters + ---------- + gdf + GeoDataFrame of features uniquely multi-indexed by + `(element_type, osmid)`. + """ + _validate._validate_features_gdf(gdf) + + +# 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 MultiGraph or MultiDiGraph to node and/or edge GeoDataFrames. + + This function is the inverse of `graph_from_gdfs`. + + Parameters + ---------- + 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 + ------- + 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 len(G.nodes) == 0: # pragma: no cover + msg = "Graph contains no nodes." + raise ValueError(msg) + + uvk, data = zip(*G.nodes(data=True), strict=True) + + if node_geometry: + # convert node x/y attributes to Points for geometry column + node_geoms = (Point(d["x"], d["y"]) for d in data) + gdf_nodes = gpd.GeoDataFrame(data, index=uvk, crs=crs, geometry=list(node_geoms)) + else: + gdf_nodes = gpd.GeoDataFrame(data, index=uvk) + + gdf_nodes.index = gdf_nodes.index.rename("osmid") + msg = "Created nodes GeoDataFrame from graph" + utils.log(msg, level=lg.INFO) + + if 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), strict=True) + + if fill_edge_geometry: + node_coords = {n: (G.nodes[n]["x"], G.nodes[n]["y"]) for n in G} + edge_geoms = ( + d.get("geometry", LineString((node_coords[u], node_coords[v]))) + for u, v, _, d in G.edges(keys=True, data=True) + ) + gdf_edges = gpd.GeoDataFrame(data, crs=crs, geometry=list(edge_geoms)) + + else: + gdf_edges = gpd.GeoDataFrame(data) + if "geometry" not in gdf_edges.columns: + # if no edges have a geometry attribute, create null column + gdf_edges = gdf_edges.set_geometry([None] * len(gdf_edges)) + gdf_edges = gdf_edges.set_crs(crs) + + # add u, v, key attributes as index + gdf_edges["u"] = u + gdf_edges["v"] = v + gdf_edges["key"] = k + gdf_edges = gdf_edges.set_index(["u", "v", "key"]) + + msg = "Created edges GeoDataFrame from graph" + utils.log(msg, level=lg.INFO) + + if nodes and edges: + return gdf_nodes, gdf_edges + + if nodes: + return gdf_nodes + + if edges: + return gdf_edges + + # otherwise + msg = "You must request nodes or edges or both." + raise ValueError(msg) + + +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. 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. + + Note that any `geometry` attribute on `gdf_nodes` is discarded, since `x` + and `y` provide the necessary node geometry information instead. + + Parameters + ---------- + 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 + The converted MultiDiGraph. + """ + validate_node_edge_gdfs(gdf_nodes, gdf_edges) + + # drop geometry column from gdf_nodes (since 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. + if gdf_nodes.active_geometry_name is None: # pragma: no cover + df_nodes = pd.DataFrame(gdf_nodes) + else: + df_nodes = gdf_nodes.drop(columns=gdf_nodes.active_geometry_name) + + # create graph and add graph-level attribute dict + if graph_attrs is None: + graph_attrs = {"crs": gdf_edges.crs} + G = nx.MultiDiGraph(**graph_attrs) + + # add edges and their attributes to graph, but filter out null attribute + # values so that edges only get attributes with non-null values + attr_names = gdf_edges.columns.to_list() + for (u, v, k), attr_vals in zip(gdf_edges.index, gdf_edges.to_numpy(), strict=True): + data_all = zip(attr_names, attr_vals, strict=True) + data = {name: val for name, val in data_all if isinstance(val, list) or pd.notna(val)} + 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(df_nodes.index) - set(G.nodes)) + + # now all nodes are added, so set nodes' attributes + for col in df_nodes.columns: + nx.set_node_attributes(G, name=col, values=df_nodes[col].dropna()) + + msg = "Created graph from node/edge GeoDataFrames" + utils.log(msg, level=lg.INFO) + return G + + +def to_digraph(G: nx.MultiDiGraph, *, weight: str = "length") -> nx.DiGraph: + """ + Convert MultiDiGraph to DiGraph. + + Chooses between parallel edges by minimizing `weight` attribute value. See + also `to_undirected` to convert MultiDiGraph to MultiGraph. + + Parameters + ---------- + G + Input graph. + weight + Attribute value to minimize when choosing between parallel edges. + + Returns + ------- + D + The converted DiGraph. + """ + # make a copy to not mutate original graph object caller passed in + G = G.copy() + 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 G.number_of_edges(u, v) > 1) + + # among all sets of parallel edges, remove all except the one with the + # minimum "weight" attribute value + for u, v in set(parallels): + k_min, _ = min(G.get_edge_data(u, v).items(), key=lambda x: x[1][weight]) + to_remove.extend((u, v, k) for k in G[u][v] if k != k_min) + + G.remove_edges_from(to_remove) + msg = "Converted MultiDiGraph to DiGraph" + utils.log(msg, level=lg.INFO) + + return nx.DiGraph(G) + + +def to_undirected(G: nx.MultiDiGraph) -> nx.MultiGraph: + """ + Convert MultiDiGraph to undirected MultiGraph. + + This function has a limited use case: it allows you to create a MultiGraph + for use with functions/algorithms that only accept a MultiGraph object. + Rather, if you want a fully bidirectional graph (such as for a walking + network), configure the `settings` module's `bidirectional_network_types` + before creating your graph to generate a fully bidirectional MultiDiGraph. + + This function maintains parallel edges only if their geometries differ. + See also `to_digraph` to convert MultiDiGraph to DiGraph. + + Parameters + ---------- + G + Input graph. + + Returns + ------- + Gu + The converted MultiGraph. + """ + # make a copy to not mutate original graph object caller passed in + G = G.copy() + + # set from/to nodes before making graph undirected + for u, v, d in G.edges(data=True): + d["from"] = u + d["to"] = v + + # add geometry if missing, to compare parallel edges' geometries + if "geometry" not in d: + point_u = (G.nodes[u]["x"], G.nodes[u]["y"]) + point_v = (G.nodes[v]["x"], G.nodes[v]["y"]) + d["geometry"] = LineString([point_u, point_v]) + + # increment parallel edges' keys so we don't retain only one edge of sets + # of true parallel edges when we convert from MultiDiGraph to MultiGraph + G = _update_edge_keys(G) + + # convert MultiDiGraph to MultiGraph, retaining edges in both directions + # of parallel edges and self-loops for now + 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 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 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 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 = Gu.edges[u1, v1, key2] + if _is_duplicate_edge(data1, data2): + duplicate_edges.add((u1, v1, key2)) + + Gu.remove_edges_from(duplicate_edges) + msg = "Converted MultiDiGraph to undirected MultiGraph" + utils.log(msg, level=lg.INFO) + + return Gu + + +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`. + + Parameters + ---------- + data1 + The first edge's attribute data. + data2 + The second edge's attribute data. + + Returns + ------- + is_dupe + True if `osmid` and `geometry` are the same, otherwise False. + """ + is_dupe = False + + # if either edge's osmid contains multiple values (due to simplification) + # compare them as sets to see if they contain the same values + osmid1 = set(data1["osmid"]) if isinstance(data1["osmid"], list) else data1["osmid"] + osmid2 = set(data2["osmid"]) if isinstance(data2["osmid"], list) else data2["osmid"] + + # if they contain the same osmid or set of osmids (due to simplification) + if osmid1 == osmid2: + # if both edges have geometry attributes and they match each other + if ("geometry" in data1) and ("geometry" in data2): + if _is_same_geometry(data1["geometry"], data2["geometry"]): + is_dupe = True + + # if neither edge has a geometry attribute + elif ("geometry" not in data1) and ("geometry" not in data2): + is_dupe = True + + # if one edge has geometry attribute but the other doesn't: not dupes + else: + pass + + return is_dupe + + +def _is_same_geometry(ls1: LineString, ls2: LineString) -> bool: + """ + Determine if two LineString geometries are the same (in either direction). + + Check both the normal and reversed orders of their constituent points. + + Parameters + ---------- + ls1 + The first LineString geometry. + ls2 + The second LineString geometry. + + Returns + ------- + is_same + True if geometries are the same in either direction, otherwise False. + """ + # extract coordinates from each LineString geometry + geom1 = [tuple(coords) for coords in ls1.xy] + geom2 = [tuple(coords) for coords in ls2.xy] + + # reverse the first LineString's coordinates' direction + 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) + + +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 + 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. + + Parameters + ---------- + G + Input graph. + + Returns + ------- + G + Graph with incremented keys where needed. + """ + # 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] + mask = edges["uvk"].duplicated(keep=False) + dupes = edges[mask].dropna(subset=["geometry"]) + + different_streets = [] + groups = dupes[["geometry", "uvk"]].groupby("uvk") + + # for each group of duplicate edges + for _, group in groups: + # for each pair of edges within this group + for geom1, geom2 in itertools.combinations(group["geometry"], 2): + # if they don't have the same geometry, flag them as different + # streets: flag edge uvk, but not edge vuk, otherwise we would + # increment both their keys and they'll still duplicate each other + if not _is_same_geometry(geom1, geom2): + different_streets.append(group.index[0]) + + # for each unique different street, increment its key to make it unique + for u, v, k in set(different_streets): + new_key = max(list(G[u][v]) + list(G[v][u])) + 1 + G.add_edge(u, v, key=new_key, **G.get_edge_data(u, v, k)) + G.remove_edge(u, v, key=k) + + return G diff --git a/osmnx/source/osmnx/distance.py b/osmnx/source/osmnx/distance.py new file mode 100644 index 0000000000000000000000000000000000000000..0d6cfa1ee1f6dafa56cf3e9ca1c269904526aa31 --- /dev/null +++ b/osmnx/source/osmnx/distance.py @@ -0,0 +1,545 @@ +"""Calculate distances and find nearest graph node/edge(s) to point(s).""" + +from __future__ import annotations + +import logging as lg +from collections.abc import Iterable +from typing import TYPE_CHECKING +from typing import Literal +from typing import overload + +import networkx as nx +import numpy as np +import numpy.typing as npt +from shapely import Point +from shapely.strtree import STRtree + +from . import convert +from . import projection +from . import utils + +if TYPE_CHECKING: + from types import ModuleType + +# scipy is optional dependency for projected nearest-neighbor search +scipy: ModuleType | None +try: + import scipy +except ImportError: # pragma: no cover + scipy = None + +# scikit-learn is optional dependency for unprojected nearest-neighbor search +try: + from sklearn.neighbors import BallTree +except ImportError: # pragma: no cover + BallTree = None + +EARTH_RADIUS_M = 6_371_009 + + +# 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. + + Vectorized function to calculate the great-circle distance between two + points' coordinates or between arrays of points' coordinates using the + haversine formula. Expects coordinates in decimal degrees. + + Parameters + ---------- + 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 + Distance from each `(lat1, lon1)` point to each `(lat2, lon2)` point + in units of `earth_radius`. + """ + y1 = np.deg2rad(lat1) + y2 = np.deg2rad(lat2) + delta_y = y2 - y1 + + x1 = np.deg2rad(lon1) + x2 = np.deg2rad(lon2) + delta_x = x2 - x1 + + h = np.sin(delta_y / 2) ** 2 + np.cos(y1) * np.cos(y2) * np.sin(delta_x / 2) ** 2 + h = np.minimum(1, h) # protect against floating point errors + arc = 2 * np.arcsin(np.sqrt(h)) + + # return distance in units of 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: 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. + + Vectorized function to calculate the Euclidean distance between two + points' coordinates or between arrays of points' coordinates. For accurate + results, use projected coordinates rather than decimal degrees. + + Parameters + ---------- + 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 + Distance from each `(x1, y1)` point to each `(x2, y2)` point in same + units as the points' coordinates. + """ + # pythagorean theorem + dist: float | npt.NDArray[np.float64] = ((x1 - x2) ** 2 + (y1 - y2) ** 2) ** 0.5 + return dist + + +def add_edge_lengths( + G: nx.MultiDiGraph, + *, + edges: Iterable[tuple[int, int, int]] | None = None, +) -> nx.MultiDiGraph: + """ + 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 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 + lengths as the great-circle distance from node `u` to node `v`. When + 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 (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. + 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 + 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 + Graph with `length` attributes on the 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]) + 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]) + dists[np.isnan(dists)] = 0 + nx.set_edge_attributes(G, values=dict(zip(uvk, dists, strict=True)), name="length") + + msg = "Added length attributes to graph edges" + utils.log(msg, level=lg.INFO) + return G + + +# 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 function will return the + nearest node to that point. If `X` and `Y` are iterables of coordinate + values, it will return the nearest node to each point. + + This function is vectorized: if you have many points to search for, pass + them in one call as numpy arrays (avoid using loops) to maximize runtime + speed. If the graph is projected, it uses a k-d tree for Euclidean nearest + neighbor search, which requires that scipy is installed as an optional + dependency. If the graph is unprojected, it uses a ball tree for haversine + nearest neighbor search, which requires that scikit-learn is installed as + an optional dependency. + + Parameters + ---------- + 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) + Nearest node ID(s) or optionally a tuple of ID(s) and distance(s) + between each point and its nearest node. + """ + # make coordinates arrays whether user passed iterable values or not + if not (isinstance(X, Iterable) and isinstance(Y, Iterable)): + is_scalar = True + 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_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 scipy is None: # pragma: no cover + msg = "scipy must be installed as an optional dependency to search a projected graph." + raise ImportError(msg) + dist_array, pos = scipy.spatial.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 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_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 + if is_scalar: + 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_array, dist_array + # otherwise + 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 function will return the + nearest edge to that point. If `X` and `Y` are iterables of coordinate + values, it will return the nearest edge to each point. + + This function is vectorized: if you have many points to search for, pass + them in one call as numpy arrays (avoid using loops) to maximize runtime + speed. It 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 projected points. + + Parameters + ---------- + 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), in same units as graph and points. + + Returns + ------- + 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. + """ + # make coordinates arrays whether user passed iterable values or not + if not (isinstance(X, Iterable) and isinstance(Y, Iterable)): + is_scalar = True + 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_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] + + # 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_arr, Y_arr, strict=True)] + 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_array = geoms.iloc[pos].index.to_numpy() + + # convert results to correct types for return + if is_scalar: + 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_array, dist_array + # otherwise + return ne_array diff --git a/osmnx/source/osmnx/elevation.py b/osmnx/source/osmnx/elevation.py new file mode 100644 index 0000000000000000000000000000000000000000..fd4ed8b3253e3e950771b8b2df1028a1f61c0a90 --- /dev/null +++ b/osmnx/source/osmnx/elevation.py @@ -0,0 +1,331 @@ +"""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 pathlib import Path +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 _http +from . import convert +from . import settings +from . import utils +from ._errors import InsufficientResponseError + +if TYPE_CHECKING: + from collections.abc import Iterable + +# rasterio and rio-vrt are optional dependencies for raster querying +try: + import rasterio +except ImportError: # pragma: no cover + rasterio = None +try: + from rio_vrt import build_vrt +except ImportError: # pragma: no cover + build_vrt = None + + +def add_edge_grades(G: nx.MultiDiGraph, *, add_absolute: bool = True) -> nx.MultiDiGraph: + """ + Calculate and add `grade` attributes to all graph edges. + + 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` and `length` attributes before using this + function. + + See also the `add_node_elevations_raster` and `add_node_elevations_google` + functions. + + Parameters + ---------- + G + Graph with `elevation` node attributes. + add_absolute + If True, also add absolute value of grade as `grade_abs` attribute. + + Returns + ------- + G + Graph with `grade` (and optionally `grade_abs`) attributes on the + edges. + """ + elev_lookup = G.nodes(data="elevation") + u, v, k, lengths = zip(*G.edges(keys=True, data="length"), strict=True) + uvk = tuple(zip(u, v, k, strict=True)) + + # 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) + nx.set_edge_attributes(G, dict(zip(uvk, grades, strict=True)), 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), strict=True)), name="grade_abs") + + msg = "Added grade attributes to all edges" + utils.log(msg, level=lg.INFO) + return G + + +def _query_raster( + nodes: pd.DataFrame, + filepath: str | Path, + band: int, +) -> Iterable[tuple[int, Any]]: + """ + Query a raster file for values at coordinates in DataFrame x/y columns. + + Parameters + ---------- + 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 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: + values = np.array(tuple(raster.sample(nodes.to_numpy(), band)), dtype=float).squeeze() + values[values == raster.nodata] = np.nan + return zip(nodes.index, values, strict=True) + + +def _build_vrt_file(raster_paths: Iterable[str | Path]) -> Path: + """ + Build a virtual raster file compositing multiple individual raster files. + + See also https://gdal.org/en/stable/drivers/raster/vrt.html + + Parameters + ---------- + raster_paths + The paths to the raster files. + + Returns + ------- + vrt_path + The path to the VRT file. + """ + if build_vrt is None: # pragma: no cover + msg = "rio-vrt must be installed as an optional dependency to build VRTs." + raise ImportError(msg) + + # determine VRT cache filepath, from stringified sorted raster filepaths + raster_paths = sorted(raster_paths) + vrt_path = _http._resolve_cache_filepath(str(raster_paths), "vrt") + + # build the VRT file if it doesn't already exist in the cache + if not vrt_path.is_file(): + msg = f"Building VRT for {len(raster_paths):,} rasters at {str(vrt_path)!r}..." + utils.log(msg, level=lg.INFO) + vrt_path.parent.mkdir(parents=True, exist_ok=True) + build_vrt(vrt_path, raster_paths) + + return vrt_path + + +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` attributes to all nodes from local raster file(s). + + 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 + 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 multiprocessing. If None, use all + available. If you are multiprocessing, make sure you protect your + entry point: see the Python docs for details. + + Returns + ------- + G + Graph with `elevation` attributes on the nodes. + """ + if rasterio is None: # pragma: no cover + msg = "rasterio must be installed as an optional dependency to query rasters." + raise ImportError(msg) + + # if multiple filepaths are passed in, compose them as a virtual raster + if not isinstance(filepath, (str, Path)): + filepath = _build_vrt_file(filepath) + + if cpus is None: + cpus = mp.cpu_count() + cpus = min(cpus, mp.cpu_count()) + msg = f"Attaching elevations with {cpus} CPUs..." + utils.log(msg, level=lg.INFO) + + nodes = convert.graph_to_gdfs(G, edges=False, node_geometry=False)[["x", "y"]] + if cpus == 1: + elevs = dict(_query_raster(nodes, filepath, band)) + else: + # divide nodes into equal-sized chunks for multiprocessing + size = int(np.ceil(len(nodes) / cpus)) + args = ((nodes.iloc[i : i + size], filepath, band) for i in range(0, len(nodes), size)) + with mp.get_context().Pool(cpus) as pool: + results = pool.starmap_async(_query_raster, args).get() + elevs = {k: v for kv in results for k, v in kv} + + nx.set_node_attributes(G, elevs, name="elevation") + msg = "Added elevation data from raster to all nodes" + utils.log(msg, level=lg.INFO) + return G + + +def add_node_elevations_google( + G: nx.MultiDiGraph, + *, + api_key: str | None = None, + batch_size: int = 512, + pause: float = 0, +) -> nx.MultiDiGraph: + """ + Add `elevation` (meters) attributes to all nodes using a web API. + + By default this uses the Google Maps Elevation API, but you could instead + use any equivalent API with the same interface and response format (such + as the Open Topo Data API or the Open-Elevation API) via the `settings` + module's `elevation_url_template`. Adjust the `batch_size` and `pause` + arguments as needed for the provider. 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 interface and format 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 + 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 + Graph with `elevation` attributes on the nodes. + """ + # 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)) + hostname = _http._hostname_from_url(settings.elevation_url_template) + + msg = f"Requesting node elevations from {hostname!r} in {n_calls} request(s)" + utils.log(msg, level=lg.INFO) + + # 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), batch_size): + chunk = node_points.iloc[i : i + batch_size] + locations = "|".join(chunk) + 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) + if "results" in response_json and len(response_json["results"]) > 0: + results.extend(response_json["results"]) + else: + raise InsufficientResponseError(str(response_json)) + + # 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 {hostname!r}" + 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) + + # 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] + nx.set_node_attributes(G, name="elevation", values=df_elev["elevation"].to_dict()) + msg = f"Added elevation data from {hostname!r} to all nodes." + utils.log(msg, level=lg.INFO) + + return G + + +def _elevation_request(url: str, pause: float) -> dict[str, Any]: + """ + Send a HTTP GET request to a Google Maps-style elevation API. + + Parameters + ---------- + url + URL of API endpoint, populated with request data. + pause + How long to pause in seconds before request. + + Returns + ------- + response_json + The elevation API's response. + """ + # check if request already exists in cache + cached_response_json = _http._retrieve_from_cache(url) + if isinstance(cached_response_json, dict): + return cached_response_json + + # pause then request this URL + hostname = _http._hostname_from_url(url) + msg = f"Pausing {pause} second(s) before making HTTP GET request to {hostname!r}" + utils.log(msg, level=lg.INFO) + time.sleep(pause) + + # transmit the HTTP GET request + msg = f"Get {url} with timeout={settings.requests_timeout}" + utils.log(msg, level=lg.INFO) + response = requests.get( + url, + timeout=settings.requests_timeout, + headers=_http._get_http_headers(), + **settings.requests_kwargs, + ) + + 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/source/osmnx/features.py b/osmnx/source/osmnx/features.py new file mode 100644 index 0000000000000000000000000000000000000000..833cbc4736df54670160a47d68c5e9790666a42d --- /dev/null +++ b/osmnx/source/osmnx/features.py @@ -0,0 +1,734 @@ +""" +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, +then construct a GeoDataFrame of them. You can use this module to query for +nodes, ways, and relations (the latter of type "multipolygon" or "boundary" +only) by passing a dictionary of desired OSM tags. + +For more details, see https://wiki.openstreetmap.org/wiki/Map_features and +https://wiki.openstreetmap.org/wiki/Elements + +Refer to the Getting Started guide for usage limitations. +""" + +from __future__ import annotations + +import logging as lg +from pathlib import Path +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 import prepare +from shapely.errors import GEOSException +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 settings +from . import utils +from . import utils_geo +from ._errors import CacheOnlyInterruptError +from ._errors import InsufficientResponseError + +if TYPE_CHECKING: + from collections.abc import Iterable + +# 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"}, + "area": {"polygon": "all"}, + "area:highway": {"polygon": "all"}, + "barrier": { + "polygon": "passlist", + "values": {"city_wall", "ditch", "hedge", "retaining_wall", "spikes"}, + }, + "boundary": {"polygon": "all"}, + "building": {"polygon": "all"}, + "building:part": {"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( + bbox: tuple[float, float, float, float], + tags: dict[str, bool | str | list[str]], +) -> gpd.GeoDataFrame: + """ + 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. This function searches for + features using tags. For more details, see: + https://wiki.openstreetmap.org/wiki/Map_features + + Parameters + ---------- + bbox + Bounding box as `(left, bottom, right, top)`. 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 + The features, multi-indexed by element type and OSM ID. + """ + # 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: tuple[float, float], + tags: dict[str, bool | str | list[str]], + dist: float, +) -> gpd.GeoDataFrame: + """ + 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. This function searches for + features using tags. For more details, see: + https://wiki.openstreetmap.org/wiki/Map_features + + Parameters + ---------- + 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 + The features, multi-indexed by element type and OSM ID. + """ + # 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) + + +def features_from_address( + address: str, + tags: dict[str, bool | str | list[str]], + dist: float, +) -> gpd.GeoDataFrame: + """ + 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. This function searches for + features using tags. For more details, see: + https://wiki.openstreetmap.org/wiki/Map_features + + Parameters + ---------- + 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 + The features, multi-indexed by element type and OSM ID. + """ + # 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: 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: + """ + 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 + instead get features within it using the `features_from_address` + function, which geocodes the place name to a point and gets the features + 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 OSM ID of the place, you can retrieve its boundary polygon using the + `geocode_to_gdf` function, then pass it to the `features_from_polygon` + function. + + 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 searches for + features using tags. For more details, see: + https://wiki.openstreetmap.org/wiki/Map_features + + Parameters + ---------- + 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. + + Returns + ------- + gdf + The features, multi-indexed by element type and OSM ID. + """ + # extract the geometry from the GeoDataFrame to use in query + polygon = geocoder.geocode_to_gdf(query, which_result=which_result).union_all() + 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: Polygon | MultiPolygon, + tags: dict[str, bool | str | list[str]], +) -> gpd.GeoDataFrame: + """ + 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. This function searches for + features using tags. For more details, see: + https://wiki.openstreetmap.org/wiki/Map_features + + Parameters + ---------- + 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 + The features, multi-indexed by element type and OSM ID. + """ + # verify that the geometry is valid and is a Polygon/MultiPolygon + if not polygon.is_valid: + msg = "The geometry of `polygon` is invalid." + raise ValueError(msg) + + if not isinstance(polygon, (Polygon, MultiPolygon)): + msg = ( + "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) + + # retrieve the data from Overpass then turn it into a GeoDataFrame + response_jsons = _overpass._download_overpass_features(polygon, tags) + return _create_gdf(response_jsons, polygon, tags) + + +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 from data in an OSM XML file. + + 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 + Path to file containing OSM XML data. + polygon + Spatial boundaries to optionally filter the final GeoDataFrame. + tags + Query tags to optionally filter the final GeoDataFrame. + encoding + The OSM XML file's character encoding. + + Returns + ------- + gdf + The features, multi-indexed by element type and OSM ID. + """ + # if tags or polygon is None, create an empty object to skip filtering + if tags is None: + tags = {} + if polygon is None: + polygon = Polygon() + + # transmogrify OSM XML file to JSON then create GeoDataFrame from it + response_jsons = [_osm_xml._overpass_json_from_xml(Path(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: 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 + 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 + GeoDataFrame of features with tags and geometry columns. + """ + # consume response_jsons generator to download data from server + elements = [] + response_count = 0 + for response_json in response_jsons: + response_count += 1 + if not settings.cache_only_mode: + elements.extend(response_json["elements"]) + + 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) + + # convert the elements into a GeoDataFrame of features + gdf = ( + gpd.GeoDataFrame( + data=_process_features(elements, set(tags.keys())), + geometry="geometry", + crs=settings.default_crs, + ) + .set_index(["element", "id"]) + .sort_index() + ) + return _filter_features(gdf, polygon, tags) + + +def _process_features( + elements: list[dict[str, Any]], + query_tag_keys: set[str], +) -> list[dict[str, Any]]: + """ + Convert node/way/relation elements into features with geometries. + + Parameters + ---------- + 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 + ------- + features + The features with geometries. + """ + 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. remove any geometry tags + # (they shouldn't exist) or they'll overwrite our geom attributes later + for element in elements: + element.get("tags", {}).pop("geometry", None) + 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) + + return 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: + """ + Build a way's geometry from its constituent nodes' coordinates. + + 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_ways + + Parameters + ---------- + 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 + ------- + geometry + The way's 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 + + # create the way geometry from its constituent nodes' coordinates + try: + 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() + + +def _build_relation_geometry( + members: list[dict[str, Any]], + way_geoms: dict[int, LineString | Polygon], +) -> Polygon | MultiPolygon: + """ + Build a relation's geometry from its constituent member ways' geometries. + + 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 + ---------- + members + The members constituting the relation. + way_geoms + Keyed by OSM way ID with values of their geometries. + + Returns + ------- + geometry + The relation's geometry. + """ + inner_linestrings = [] + outer_linestrings = [] + inner_polygons = [] + outer_polygons = [] + + # sort member geometries by member role and geometry type + for member in members: + if member["type"] == "way": + geom = way_geoms.get(member["ref"]) + if geom is None: + # a member's geometry may be missing when loaded from XML, if + # so, we cannot build this relation's complete geometry, so + # just return a null geometry to be removed at final filtering + msg = f"Cannot build relation geometry, missing member way {member['ref']}" + utils.log(msg, level=lg.WARNING) + return Polygon() + 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) + if merged_outer_linestrings.geom_type == "LineString": + merged_outer_linestrings = MultiLineString([merged_outer_linestrings]) + for merged_outer_linestring in merged_outer_linestrings.geoms: + outer_polygons += polygonize(merged_outer_linestring) + + # merge/polygonize inner linestring fragments then add to inner polygons + merged_inner_linestrings = linemerge(inner_linestrings) + if merged_inner_linestrings.geom_type == "LineString": + merged_inner_linestrings = MultiLineString([merged_inner_linestrings]) + for merged_inner_linestring in merged_inner_linestrings.geoms: + inner_polygons += polygonize(merged_inner_linestring) + + # remove holes from polygons, if any, then retun + return _remove_polygon_holes(outer_polygons, inner_polygons) + + +def _remove_polygon_holes( + outer_polygons: list[Polygon], + inner_polygons: list[Polygon], +) -> Polygon | MultiPolygon: + """ + Subtract inner holes from outer polygons. + + This allows possible island polygons within a larger polygon's holes. + + Parameters + ---------- + 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 + The geometry minus inner 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: + # otherwise, remove from each outer poly all inner polys it contains + polygons_with_holes = [] + for outer in outer_polygons: + prepare(outer) + holes = [inner for inner in inner_polygons if outer.contains(inner)] + polygons_with_holes.append(outer.difference(unary_union(holes))) + geometry = unary_union(polygons_with_holes) + + # ensure returned geometry is a Polygon or MultiPolygon + if isinstance(geometry, (Polygon, MultiPolygon)): + return geometry + return Polygon() + + +def _filter_features( + gdf: gpd.GeoDataFrame, + polygon: Polygon | MultiPolygon, + tags: dict[str, bool | str | list[str]], +) -> gpd.GeoDataFrame: + """ + Filter features GeoDataFrame by spatial boundaries and query tags. + + If the `polygon` and `tags` arguments are empty objects, the final + GeoDataFrame will not be filtered accordingly. + + Parameters + ---------- + 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 + Filtered GeoDataFrame of features. + """ + # 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() + + # 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) + + # 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) + + msg = f"{len(gdf):,} features in the final GeoDataFrame" + utils.log(msg, level=lg.INFO) + return gdf diff --git a/osmnx/source/osmnx/geocoder.py b/osmnx/source/osmnx/geocoder.py new file mode 100644 index 0000000000000000000000000000000000000000..a009f81b98989e01cc7ab25b7fcaa1f0be1d4cd5 --- /dev/null +++ b/osmnx/source/osmnx/geocoder.py @@ -0,0 +1,244 @@ +""" +Geocode place names or addresses or retrieve OSM elements by place name or ID. + +This module uses the Nominatim API's "search" and "lookup" endpoints. For more +details see https://wiki.openstreetmap.org/wiki/Elements and +https://nominatim.org/. +""" + +from __future__ import annotations + +import logging as lg +from collections import OrderedDict +from typing import Any + +import geopandas as gpd +import pandas as pd + +from . import _nominatim +from . import settings +from . import utils +from ._errors import InsufficientResponseError + + +def geocode(query: str) -> tuple[float, float]: + """ + Geocode place names or addresses to `(lat, lon)` with the Nominatim API. + + This geocodes the query via the Nominatim "search" endpoint. + + Parameters + ---------- + query + The query string to geocode. + + Returns + ------- + point + The `(lat, lon)` coordinates returned by the geocoder. + """ + # define the parameters + params: OrderedDict[str, int | str] = OrderedDict() + params["format"] = "json" + params["limit"] = 1 + params["dedupe"] = 0 # prevent deduping to get precise number of results + params["q"] = query + response_json = _nominatim._nominatim_request(params=params) + + # if results were returned, parse lat and lon out of the result + if response_json and "lat" in response_json[0] and "lon" in response_json[0]: + lat = float(response_json[0]["lat"]) + lon = float(response_json[0]["lon"]) + point = (lat, lon) + + 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}." + raise InsufficientResponseError(msg) + + +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. + + If searching by place name, the `query` argument can be a string or + structured dict, or a list of such strings/dicts to send to the geocoder. + This uses the Nominatim "search" endpoint to geocode the place name to the + best-matching OSM element, then returns that element and its attribute + data. + + You can instead query by OSM ID by passing `by_osmid=True`. This uses the + Nominatim "lookup" endpoint to retrieve the OSM element with that ID. In + this case, the function treats the `query` argument as an OSM ID (or list + of OSM IDs), which must be prepended with their types: node (N), way (W), + 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 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 + 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 (sorted by importance) 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 + GeoDataFrame with one row for each query 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(q_list) != len(wr_list): # pragma: no cover + msg = "`which_result` length must equal `query` length." + raise ValueError(msg) + + # 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, strict=True) + ) + 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: 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 + 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 + 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) + + # ensure geocoder results are sorted from most to least important + results = sorted(results, key=lambda x: x["importance"], reverse=True) + + # choose the right result from the JSON response + if len(results) == 0: + # if no results were returned, raise error + msg = f"Nominatim geocoder returned 0 results for query {query!r}." + raise InsufficientResponseError(msg) + + if by_osmid: + # if searching by OSM ID, always take the first (ie, only) result + result = results[0] + + elif which_result is None: + # else, if which_result=None, auto-select the first (Multi)Polygon + 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 + result = results[which_result - 1] + + 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}`." + raise InsufficientResponseError(msg) + + # if we got a non (Multi)Polygon geometry type (like a point), log warning + geom_type = result["geojson"]["type"] + if geom_type not in {"Polygon", "MultiPolygon"}: + msg = f"Nominatim geocoder returned a {geom_type} as the geometry for query {query!r}" + utils.log(msg, level=lg.WARNING) + + # build the GeoJSON feature from the chosen result + bottom, top, left, right = result["boundingbox"] + feature = { + "type": "Feature", + "geometry": result["geojson"], + "properties": { + "bbox_west": left, + "bbox_south": bottom, + "bbox_east": right, + "bbox_north": top, + }, + } + + # add the other attributes we retrieved + for attr in result: + if attr not in {"address", "boundingbox", "geojson", "icon", "licence"}: + feature["properties"][attr] = result[attr] + + # create and return the GeoDataFrame + gdf = gpd.GeoDataFrame.from_features([feature]) + cols = ["lat", "lon", "bbox_north", "bbox_south", "bbox_east", "bbox_west"] + gdf[cols] = gdf[cols].astype(float) + return gdf + + +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 + Results from the Nominatim API. + + Returns + ------- + 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, raise an error + raise TypeError diff --git a/osmnx/source/osmnx/graph.py b/osmnx/source/osmnx/graph.py new file mode 100644 index 0000000000000000000000000000000000000000..3233e85983999bd8348757b8ac690455c5b3bc4e --- /dev/null +++ b/osmnx/source/osmnx/graph.py @@ -0,0 +1,863 @@ +""" +Download and create graphs from OpenStreetMap data. + +Refer to the Getting Started guide for usage limitations. +""" + +from __future__ import annotations + +import logging as lg +from collections.abc import Iterable +from importlib.metadata import version as metadata_version +from itertools import groupby +from itertools import pairwise +from pathlib import Path +from typing import TYPE_CHECKING +from typing import Any + +import networkx as nx +from shapely import MultiPolygon +from shapely import Polygon + +from . import _osm_xml +from . import _overpass +from . import distance +from . import geocoder +from . import projection +from . import settings +from . import simplification +from . import stats +from . import truncate +from . import utils +from . import utils_geo +from ._errors import CacheOnlyInterruptError +from ._errors import InsufficientResponseError + +if TYPE_CHECKING: + from collections.abc import Iterable + + +def graph_from_bbox( + 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 | list[str] | None = None, +) -> nx.MultiDiGraph: + """ + Download and create a graph within a lat-lon bounding box. + + 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. If you want a fully bidirectional network, ensure your + `network_type` is in `settings.bidirectional_network_types` before + creating your graph. 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 customizations. + + Parameters + ---------- + bbox + Bounding box as `(left, bottom, right, top)`. 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 the outside bounding box if at least one of + the node's neighbors lies 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"]'`. If `str`, + the intersection of keys/values will be used, e.g., `'[maxspeed=50][lanes=2]'` + will return all ways having both maxspeed of 50 and two lanes. If + `list`, the union of the `list` items will be used, e.g., + `['[maxspeed=50]', '[lanes=2]']` will return all ways having either + maximum speed of 50 or two lanes. Also pass in a `network_type` that + is in `settings.bidirectional_network_types` if you want the graph to + be fully bidirectional. + + Returns + ------- + G + The resulting MultiDiGraph. + + Notes + ----- + Very large query areas use the `utils_geo._consolidate_subdivide_geometry` + function to automatically make multiple requests: see that function's + documentation for caveats. + """ + # convert bounding box to a polygon + polygon = utils_geo.bbox_to_poly(bbox) + + # create graph using this polygon geometry + G = graph_from_polygon( + polygon, + network_type=network_type, + simplify=simplify, + retain_all=retain_all, + truncate_by_edge=truncate_by_edge, + custom_filter=custom_filter, + ) + + 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: 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 | list[str] | None = None, +) -> nx.MultiDiGraph: + """ + 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. + + 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. If you want a fully bidirectional network, ensure your + `network_type` is in `settings.bidirectional_network_types` before + creating your graph. 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 customizations. + + Parameters + ---------- + 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 the outside bounding box if at least one of + the node's neighbors lies 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"]'`. If `str`, + the intersection of keys/values will be used, e.g., `'[maxspeed=50][lanes=2]'` + will return all ways having both maxspeed of 50 and two lanes. If + `list`, the union of the `list` items will be used, e.g., + `['[maxspeed=50]', '[lanes=2]']` will return all ways having either + maximum speed of 50 or two lanes. Also pass in a `network_type` that + is in `settings.bidirectional_network_types` if you want the graph to + be fully bidirectional. + + Returns + ------- + G + The resulting MultiDiGraph. + + Notes + ----- + Very large query areas use the `utils_geo._consolidate_subdivide_geometry` + function to automatically make multiple requests: see that function's + documentation for caveats. + """ + if dist_type not in {"bbox", "network"}: # pragma: no cover + msg = "`dist_type` must be 'bbox' or 'network'." + raise ValueError(msg) + + # create bounding box from center point and distance in each direction + bbox = utils_geo.bbox_from_point(center_point, dist) + + # create a graph from the bounding box + G = graph_from_bbox( + bbox, + network_type=network_type, + simplify=simplify, + retain_all=retain_all, + truncate_by_edge=truncate_by_edge, + custom_filter=custom_filter, + ) + + if dist_type == "network": + # 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) + + 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: 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 | list[str] | None = None, +) -> nx.MultiDiGraph: + """ + Download and create a graph within some distance of an address. + + 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. If you want a fully bidirectional network, ensure your + `network_type` is in `settings.bidirectional_network_types` before + creating your graph. 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 customizations. + + Parameters + ---------- + 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 the outside bounding box if at least one of + the node's neighbors lies 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"]'`. If `str`, + the intersection of keys/values will be used, e.g., `'[maxspeed=50][lanes=2]'` + will return all ways having both maxspeed of 50 and two lanes. If + `list`, the union of the `list` items will be used, e.g., + `['[maxspeed=50]', '[lanes=2]']` will return all ways having either + maximum speed of 50 or two lanes. Also pass in a `network_type` that + is in `settings.bidirectional_network_types` if you want the graph to + be fully bidirectional. + + Returns + ------- + G + The resulting MultiDiGraph. + + Notes + ----- + Very large query areas use the `utils_geo._consolidate_subdivide_geometry` + function to automatically make multiple requests: see that function's + documentation for caveats. + """ + # geocode the address string to a (lat, lon) point + point = geocoder.geocode(address) + + # then create a graph from this point + G = graph_from_point( + point, + dist, + dist_type=dist_type, + network_type=network_type, + simplify=simplify, + retain_all=retain_all, + truncate_by_edge=truncate_by_edge, + custom_filter=custom_filter, + ) + + 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: 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 | list[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, + 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 OSM ID of the place, you can retrieve its boundary polygon using the + `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. + + 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. If you want a fully bidirectional network, ensure your + `network_type` is in `settings.bidirectional_network_types` before + creating your graph. 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 customizations. + + Parameters + ---------- + 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 the place boundary polygon(s) if at + least one of the node's neighbors lies within the polygon(s). + 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. + custom_filter + A custom ways filter to be used instead of the `network_type` presets, + e.g. `'["power"~"line"]' or '["highway"~"motorway|trunk"]'`. If `str`, + the intersection of keys/values will be used, e.g., `'[maxspeed=50][lanes=2]'` + will return all ways having both maxspeed of 50 and two lanes. If + `list`, the union of the `list` items will be used, e.g., + `['[maxspeed=50]', '[lanes=2]']` will return all ways having either + maximum speed of 50 or two lanes. Also pass in a `network_type` that + is in `settings.bidirectional_network_types` if you want the graph to + be fully bidirectional. + + Returns + ------- + G + The resulting MultiDiGraph. + + Notes + ----- + Very large query areas use the `utils_geo._consolidate_subdivide_geometry` + function to automatically make multiple requests: see that function's + documentation for caveats. + """ + # extract the geometry from the GeoDataFrame to use in query + polygon = geocoder.geocode_to_gdf(query, which_result=which_result).union_all() + 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( + polygon, + network_type=network_type, + simplify=simplify, + retain_all=retain_all, + truncate_by_edge=truncate_by_edge, + custom_filter=custom_filter, + ) + + 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: Polygon | MultiPolygon, + *, + network_type: str = "all", + simplify: bool = True, + retain_all: bool = False, + truncate_by_edge: bool = False, + custom_filter: str | list[str] | None = None, +) -> nx.MultiDiGraph: + """ + Download and create a graph within the boundaries of a (Multi)Polygon. + + 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. If you want a fully bidirectional network, ensure your + `network_type` is in `settings.bidirectional_network_types` before + creating your graph. 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 customizations. + + Parameters + ---------- + 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 `polygon` if at least one of the node's + neighbors lies within `polygon`. + custom_filter + A custom ways filter to be used instead of the `network_type` presets, + e.g. `'["power"~"line"]' or '["highway"~"motorway|trunk"]'`. If `str`, + the intersection of keys/values will be used, e.g., `'[maxspeed=50][lanes=2]'` + will return all ways having both maxspeed of 50 and two lanes. If + `list`, the union of the `list` items will be used, e.g., + `['[maxspeed=50]', '[lanes=2]']` will return all ways having either + maximum speed of 50 or two lanes. Also pass in a `network_type` that + is in `settings.bidirectional_network_types` if you want the graph to + be fully bidirectional. + + Returns + ------- + G + The resulting MultiDiGraph. + + Notes + ----- + Very large query areas use the `utils_geo._consolidate_subdivide_geometry` + function to automatically make multiple requests: see that function's + documentation for caveats. + """ + # 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 of `polygon` is invalid." + raise ValueError(msg) + if not isinstance(polygon, (Polygon, MultiPolygon)): # pragma: no cover + msg = ( + "Geometry must be a shapely Polygon or MultiPolygon. If you " + "requested graph 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." + ) + raise TypeError(msg) + + # 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) + + # 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) + + # 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: str | Path, + *, + bidirectional: bool = False, + simplify: bool = True, + retain_all: bool = False, + encoding: str = "utf-8", +) -> nx.MultiDiGraph: + """ + 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. + + 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 + 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 + The resulting MultiDiGraph. + """ + # transmogrify file of OSM XML data into JSON + response_jsons = [_osm_xml._overpass_json_from_xml(Path(filepath), encoding)] + + # create graph using this response JSON + 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) + + 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: Iterable[dict[str, Any]], + bidirectional: bool, # noqa: FBT001 +) -> nx.MultiDiGraph: + """ + 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 + `distance.add_edge_lengths` function. + + Parameters + ---------- + response_jsons + Iterable of JSON responses from the Overpass API. + bidirectional + If True, create bidirectional edges for one-way streets. + + Returns + ------- + G + The resulting MultiDiGraph. + """ + # 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. if + # cache_only_mode, just consume response_jsons then continue next loop. + # otherwise, extract nodes and paths from the downloaded OSM data. + response_count = 0 + for response_json in response_jsons: + response_count += 1 + if not settings.cache_only_mode: + nodes_temp, paths_temp = _parse_nodes_paths(response_json) + nodes.update(nodes_temp) + paths.update(paths_temp) + + 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`." + raise CacheOnlyInterruptError(msg) + + # ensure we got some node/way data back from the server request(s) + if (len(nodes) == 0) and (len(paths) == 0): # pragma: no cover + msg = "No data elements in server response. Check query location/filters and log." + raise InsufficientResponseError(msg) + + # create the MultiDiGraph and set its graph-level attributes + metadata = { + "created_date": utils.ts(), + "created_with": f"OSMnx {metadata_version('osmnx')}", + "crs": settings.default_crs, + } + G = nx.MultiDiGraph(**metadata) + + # add each OSM node and way (a path of edges) to the graph + 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) + + 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: + G = distance.add_edge_lengths(G) + + return G + + +def _convert_node(element: dict[str, Any]) -> dict[str, Any]: + """ + Convert an OSM node element into the format for a NetworkX node. + + Parameters + ---------- + element + OSM element of type "node". + + Returns + ------- + node + The converted node. + """ + node = {"y": element["lat"], "x": element["lon"]} + if "tags" in element: + for useful_tag in settings.useful_tags_node: + if useful_tag in element["tags"]: + node[useful_tag] = element["tags"][useful_tag] + return node + + +def _convert_path(element: dict[str, Any]) -> dict[str, Any]: + """ + Convert an OSM way element into the format for a NetworkX path. + + Parameters + ---------- + element + OSM element of type "way". + + Returns + ------- + path + The converted path. + """ + path = {"osmid": element["id"]} + + # remove any consecutive duplicate elements in the list of nodes + path["nodes"] = [group[0] for group in groupby(element["nodes"])] + + if "tags" in element: + for useful_tag in settings.useful_tags_way: + if useful_tag in element["tags"]: + path[useful_tag] = element["tags"][useful_tag] + return path + + +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 + JSON response from the Overpass API. + + Returns + ------- + nodes, paths + Each dict's keys are OSM IDs and values are dicts of attributes. + """ + nodes = {} + paths = {} + for element in response_json["elements"]: + if element["type"] == "node": + nodes[element["id"]] = _convert_node(element) + elif element["type"] == "way": + paths[element["id"]] = _convert_path(element) + + return nodes, paths + + +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 + ---------- + 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 + ------- + is_one_way + True if path allows travel in only one direction, otherwise False. + """ + # rule 1 + if settings.all_oneway: + # if globally configured to set every edge one-way, then it's one-way + return True + + # rule 2 + if bidirectional: + # 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 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 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 attrs and attrs["junction"] == "roundabout": # noqa: SIM103 + # roundabouts are also one-way but are not explicitly tagged as such + return True + + # otherwise, if no rule passed then this path is not tagged as a one-way + return False + + +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 + ---------- + 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 + ------- + is_reversed + True if nodes' order should be reversed, otherwise False. + """ + return "oneway" in attrs and attrs["oneway"] in reversed_values + + +def _add_paths( + G: nx.MultiDiGraph, + paths: Iterable[dict[str, Any]], + bidirectional: bool, # noqa: FBT001 +) -> None: + """ + Add OSM paths to the graph as edges. + + Parameters + ---------- + 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. + """ + # the values OSM uses in its 'oneway' tag to denote True, and to denote + # travel can only occur in the opposite direction of the node order. see: + # https://wiki.openstreetmap.org/wiki/Key:oneway + # https://www.geofabrik.de/de/data/geofabrik-osm-gis-standard-0.7.pdf + oneway_values = {"yes", "true", "1", "-1", "reverse", "T", "F"} + reversed_values = {"-1", "reverse", "T"} + + for path in paths: + # extract/remove the ordered list of nodes from this path element so + # we don't add it as a superfluous attribute to the edge later + nodes = path.pop("nodes") + + # reverse the order of nodes in the path if this path is both one-way + # and only allows travel in the opposite direction of nodes' order + is_one_way = _is_path_one_way(path, bidirectional, oneway_values) + if is_one_way and _is_path_reversed(path, reversed_values): + nodes.reverse() + + # set the oneway attribute, but only if when not forcing all edges to + # oneway with the all_oneway setting. With the all_oneway setting, you + # want to preserve the original OSM oneway attribute for later clarity + if not settings.all_oneway: + path["oneway"] = is_one_way + + # zip path nodes to get (u, v) tuples like [(0,1), (1,2), (2,3)]. + edges = list(pairwise(nodes)) + + # add all the edge tuples and give them the path's tag:value attrs + path["reversed"] = False + G.add_edges_from(edges, **path) + + # if the path is NOT one-way, reverse direction of each edge and add + # this path going the opposite direction too + if not is_one_way: + path["reversed"] = True + G.add_edges_from([(v, u) for u, v in edges], **path) diff --git a/osmnx/source/osmnx/io.py b/osmnx/source/osmnx/io.py new file mode 100644 index 0000000000000000000000000000000000000000..aaeeed30064bc6b4b2ea8a48dc0a12634794df13 --- /dev/null +++ b/osmnx/source/osmnx/io.py @@ -0,0 +1,448 @@ +"""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 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 settings +from . import utils + +if TYPE_CHECKING: + import geopandas as gpd + + +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 + 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. + """ + # default filepath if none was provided + filepath = Path(settings.data_folder) / "graph.gpkg" if filepath is None else Path(filepath) + + # if save folder does not already exist, create it + filepath.parent.mkdir(parents=True, exist_ok=True) + + # 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) + + # 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) + + msg = f"Saved graph as GeoPackage at {str(filepath)!r}" + utils.log(msg, level=lg.INFO) + + +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 + 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. + """ + # default filepath if none was provided + filepath = Path(settings.data_folder) / "graph.graphml" if filepath is None else Path(filepath) + + # if save folder does not already exist, create it + filepath.parent.mkdir(parents=True, exist_ok=True) + + # make a copy to not mutate original graph object caller passed in + G = G.copy() + + if gephi: + # for gephi compatibility, each edge's key must be unique as an id + uvkd = [(u, v, k, d) for k, (u, v, d) in enumerate(G.edges(keys=False, data=True))] + G.clear_edges() + G.add_edges_from(uvkd) + + # stringify all the graph attribute values + for attr, value in G.graph.items(): + G.graph[attr] = str(value) + + # stringify all the node attribute values + for _, data in G.nodes(data=True): + for attr, value in data.items(): + data[attr] = str(value) + + # stringify all the edge attribute values + for _, _, data in G.edges(keys=False, data=True): + for attr, value in data.items(): + data[attr] = str(value) + + nx.write_graphml(G, path=filepath, encoding=encoding) + msg = f"Saved graph as GraphML file at {str(filepath)!r}" + utils.log(msg, level=lg.INFO) + + +def load_graphml( + 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. + + This function converts node, edge, and graph-level attributes (serialized + as strings) to their appropriate data types. These can be customized as + needed by passing in dtypes arguments providing types or custom converter + functions. For example, if you want to convert some attribute's values to + `bool`, consider using the built-in `ox.io._convert_bool_string` function + to properly handle "True"/"False" string literals as True/False booleans: + `ox.load_graphml(fp, node_dtypes={my_attr: ox.io._convert_bool_string})`. + + If you manually configured the `all_oneway=True` setting, you may need to + manually specify here that edge `oneway` attributes should be type `str`. + + Note that you must pass one and only one of `filepath` or `graphml_str`. + If passing `graphml_str`, you may need to decode the bytes read from your + file before converting to string to pass to this function. + + Parameters + ---------- + 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 + The loaded MultiDiGraph. + """ + if (filepath is None and graphml_str is None) or ( + filepath is not None and graphml_str is not None + ): # pragma: no cover + msg = "You must pass one and only one of `filepath` or `graphml_str`." + raise ValueError(msg) + + # specify default graph/node/edge attribute values' data types + default_graph_dtypes = { + "consolidated": _convert_bool_string, + "simplified": _convert_bool_string, + } + default_node_dtypes = { + "elevation": float, + "elevation_res": float, + "osmid": int, + "street_count": int, + "x": float, + "y": float, + } + default_edge_dtypes = { + "bearing": float, + "grade": float, + "grade_abs": float, + "length": float, + "oneway": _convert_bool_string, + "osmid": int, + "reversed": _convert_bool_string, + "speed_kph": float, + "travel_time": float, + } + + # override default graph/node/edge attr types with user-passed types, if any + if graph_dtypes is not None: + default_graph_dtypes.update(graph_dtypes) + if node_dtypes is not None: + default_node_dtypes.update(node_dtypes) + if edge_dtypes is not None: + default_edge_dtypes.update(edge_dtypes) + + if filepath is not None: + # read the graphml file from disk + source = filepath + G = nx.read_graphml( + 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, + ) + + # convert graph/node/edge 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) + + msg = f"Loaded graph with {len(G)} nodes and {len(G.edges)} edges from {str(source)!r}" + utils.log(msg, level=lg.INFO) + return G + + +def save_graph_xml( + 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 XML file. + + 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, 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 + ---------- + 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. + """ + _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 + Graph to convert the graph-level attributes of. + dtypes + Dict of graph-level attribute names:types. + + Returns + ------- + G + The graph with its graph-level attributes' types converted. + """ + # remove node_default and edge_default metadata keys if they exist + G.graph.pop("node_default", None) + G.graph.pop("edge_default", None) + + for attr in G.graph.keys() & dtypes.keys(): + G.graph[attr] = dtypes[attr](G.graph[attr]) + + return G + + +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 + Graph to convert the node attributes of. + dtypes + Dict of node attribute names:types. + + Returns + ------- + G + The graph with its nodes' attributes' types converted. + """ + for _, data in G.nodes(data=True): + # first, eval stringified lists, dicts, or sets to convert them to objects + # lists, dicts, or sets would be custom attribute types added by a user + for attr, value in data.items(): + if (value.startswith("[") and value.endswith("]")) or ( + value.startswith("{") and value.endswith("}") + ): + with contextlib.suppress(SyntaxError, ValueError): + data[attr] = ast.literal_eval(value) + + for attr in data.keys() & dtypes.keys(): + data[attr] = dtypes[attr](data[attr]) + return G + + +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 + Graph to convert the edge attributes of. + dtypes + Dict of edge attribute names:types. + + Returns + ------- + G + The graph with its edges' attributes' types converted. + """ + # for each edge in the graph, eval attribute value lists and convert types + for _, _, data in G.edges(data=True, keys=False): + # remove extraneous "id" attribute added by graphml saving + data.pop("id", None) + + # first, eval stringified lists, dicts, or sets to convert them to objects + # edge attributes might have a single value, or a list if simplified + # dicts or sets would be custom attribute types added by a user + for attr, value in data.items(): + if (value.startswith("[") and value.endswith("]")) or ( + value.startswith("{") and value.endswith("}") + ): + with contextlib.suppress(SyntaxError, ValueError): + data[attr] = ast.literal_eval(value) + + # next, convert attribute value types if attribute appears in dtypes + for attr in data.keys() & dtypes.keys(): + if isinstance(data[attr], list): + # if it's a list, eval it then convert each item + data[attr] = [dtypes[attr](item) for item in data[attr]] + else: + # otherwise, just convert the single value + data[attr] = dtypes[attr](data[attr]) + + # if "geometry" attr exists, convert its well-known text to LineString + if "geometry" in data: + data["geometry"] = wkt.loads(data["geometry"]) + + return G + + +def _convert_bool_string(value: bool | str) -> bool: # noqa: FBT001 + """ + Convert a "True" or "False" string literal to corresponding boolean type. + + This is necessary because Python will otherwise parse the string "False" + to the boolean value True, that is, `bool("False") == True`. This function + raises a ValueError if a value other than "True" or "False" is passed. + + If the value is already a boolean, this function just returns it, to + accommodate usage when the value was originally inside a stringified list. + + Parameters + ---------- + value + The string to convert to bool. + + Returns + ------- + bool_value + The boolean equivalent of the string literal. + """ + 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}." + raise ValueError(msg) + + +def _stringify_nonnumeric_cols(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame: + """ + Make every non-numeric GeoDataFrame column (besides geometry) a string. + + This allows proper serializing via Fiona of GeoDataFrames with mixed types + such as strings and ints in the same column. + + Parameters + ---------- + gdf + GeoDataFrame to stringify non-numeric columns of. + + Returns + ------- + 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"): + if not pd.api.types.is_numeric_dtype(gdf[col]): + gdf[col] = gdf[col].fillna("").astype(str) + + return gdf diff --git a/osmnx/source/osmnx/plot.py b/osmnx/source/osmnx/plot.py new file mode 100644 index 0000000000000000000000000000000000000000..d42dd9ca3ce2d22694f250d9df1fb6cc7e349b36 --- /dev/null +++ b/osmnx/source/osmnx/plot.py @@ -0,0 +1,1068 @@ +"""Visualize street networks, routes, orientations, and geospatial features.""" + +from __future__ import annotations + +import itertools +import logging as lg +from collections.abc import Iterable +from collections.abc import Sequence +from pathlib import Path +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 +import pandas as pd + +from . import bearing +from . import convert +from . import projection +from . import settings +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: TC002 + from matplotlib.figure import Figure # noqa: TC002 + from matplotlib.projections.polar import PolarAxes # noqa: TC002 + + mpl_available = True + +except ImportError: # pragma: no cover + mpl_available = False + + +def get_colors( + n: int, + *, + cmap: str = "viridis", + start: float = 0, + stop: float = 1, + alpha: float | None = None, +) -> list[str]: + """ + Return `n` evenly-spaced colors from a matplotlib colormap. + + Parameters + ---------- + n + How many colors to sample. + cmap + Name of the matplotlib colormap from which to sample the colors. + start + Where to start sampling from the colorspace (from 0 to 1). + stop + Where to end sampling from 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. + + Returns + ------- + color_list + The sampled colors. + """ + _verify_mpl() + color_gen = (colormaps[cmap](x) for x in np.linspace(start, stop, n)) + keep_alpha = alpha is not None + if keep_alpha: + 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: 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: + """ + Return colors based on nodes' numerical attribute values. + + Parameters + ---------- + 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 + 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 + Labels are node IDs, values are colors as hex strings. + """ + 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: 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: + """ + Return colors based on edges' numerical attribute values. + + Parameters + ---------- + 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 + 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 + Labels are `(u, v, k)` edge IDs, values are colors as hex strings. + """ + 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( # 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 + 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 `(left, bottom, right, top)`. 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 + The resulting matplotlib figure and axes objects. + """ + _verify_mpl() + 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." + raise ValueError(msg) + + # create fig, ax as needed + 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 + gdf_edges = convert.graph_to_gdfs(G, nodes=False)["geometry"] + ax = gdf_edges.plot(ax=ax, color=edge_color, lw=edge_linewidth, alpha=edge_alpha, zorder=1) + + 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( + x=gdf_nodes["x"], + y=gdf_nodes["y"], + s=node_size, + c=node_color, + alpha=node_alpha, + edgecolor=node_edgecolor, + zorder=node_zorder, + ) + + # get spatial extents from bbox parameter or the edges' geometries + padding = 0.0 + if bbox is None: + try: + left, bottom, right, top = gdf_edges.total_bounds + except NameError: + left, bottom = gdf_nodes.min() + right, top = gdf_nodes.max() + bbox = left, bottom, right, top + padding = 0.02 # pad 2% to not cut off peripheral nodes' circles + + # configure axes appearance, save/show figure as specified, and return + ax = _config_ax(ax, G.graph["crs"], bbox, padding) + 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: 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 path along a graph. + + Parameters + ---------- + 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`. + + Returns + ------- + fig, ax + The resulting matplotlib figure and axes objects. + """ + _verify_mpl() + if ax is None: + # plot the graph but not the route, and override any user show/close + # args for now: we'll do that later + overrides = {"show", "save", "close"} + 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 # type: ignore[assignment] + + # scatterplot origin and destination points (first/last nodes in route) + 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 = [] + y = [] + for u, v in itertools.pairwise(route): + # if there are parallel edges, select the shortest in length + data = min(G.get_edge_data(u, v).values(), key=lambda d: d["length"]) + if "geometry" in data: + # if geometry attribute exists, add all its coords to list + xs, ys = data["geometry"].xy + x.extend(xs) + y.extend(ys) + else: + # otherwise, the edge is a straight line from node to node + x.extend((G.nodes[u]["x"], G.nodes[v]["x"])) + y.extend((G.nodes[u]["y"], G.nodes[v]["y"])) + 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 = {"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=fig, ax=ax, **kwargs) + return fig, ax + + +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 multiple paths along a graph. + + Parameters + ---------- + 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`. + + Returns + ------- + fig, ax + The resulting matplotlib figure and axes objects. + """ + # 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 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 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 + overrides = {"route", "route_color", "route_linewidth", "show", "save", "close"} + kwargs = {k: v for k, v in pgr_kwargs.items() if k not in overrides} + fig, ax = plot_graph_route( + G, + route=routes[0], + route_color=route_colors[0], + route_linewidth=route_linewidths[0], + show=False, + save=False, + close=False, + **kwargs, + ) + + # plot the subsequent routes on top of existing ax + overrides.update({"ax"}) + kwargs = {k: v for k, v in pgr_kwargs.items() if k not in overrides} + r_rc_rlw = zip(routes[1:], route_colors[1:], route_linewidths[1:], strict=True) + for route, route_color, route_linewidth in r_rc_rlw: + fig, ax = plot_graph_route( + G, + route=route, + route_color=route_color, + route_linewidth=route_linewidth, + show=False, + save=False, + close=False, + ax=ax, + **kwargs, + ) + + # save and show the figure as specified, passing relevant kwargs + 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=fig, ax=ax, **kwargs) + return fig, ax + + +def plot_figure_ground( + 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 + An unprojected graph. + dist + How many meters to extend plot's bounding box 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`. + + Returns + ------- + fig, ax + The resulting matplotlib figure and axes objects. + """ + _verify_mpl() + + # if user did not pass in custom street widths, define default values + if street_widths is None: + street_widths = { + "footway": 1.5, + "steps": 1.5, + "pedestrian": 1.5, + "service": 1.5, + "path": 1.5, + "track": 1.5, + "motorway": 6, + } + + # we need an undirected graph to find every edge incident on a node + Gu = convert.to_undirected(G) + + # for each edge, get a linewidth according to street type + edge_linewidths = [] + for _, _, d in Gu.edges(keys=False, data=True): + street_type = d["highway"][0] if isinstance(d["highway"], list) else d["highway"] + if street_type in street_widths: + edge_linewidths.append(street_widths[street_type]) + else: + edge_linewidths.append(default_width) + + # 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 + node_geoms = convert.graph_to_gdfs(Gu, edges=False, node_geometry=True).union_all() + 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"} + kwargs = {k: v for k, v in pg_kwargs.items() if k not in overrides} + fig, ax = plot_graph( + G=Gu, + bbox=bbox, + node_size=node_sizes, + node_color=color, + edge_color=color, + edge_linewidth=edge_linewidths, + **kwargs, + ) + return fig, ax + + +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 + 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 `(left, bottom, right, top)`. 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 + The resulting matplotlib figure and axes objects. + """ + _verify_mpl() + 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, + ) + + # determine figure extents + if bbox is None: + bbox = tuple(gdf.total_bounds) + + # configure axes appearance, save/show figure as specified, and return + ax = _config_ax(ax, gdf.crs, bbox, 0) + 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( # 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 edge bearings. + + 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), + 67. https://doi.org/10.1007/s41109-019-0189-1 + + Parameters + ---------- + 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 + The resulting matplotlib figure and polar axes objects. + """ + _verify_mpl() + + if title_font is None: + title_font = {"family": "DejaVu Sans", "size": 24, "weight": "bold"} + if xtick_font is None: + xtick_font = { + "family": "DejaVu Sans", + "size": 10, + "weight": "bold", + "alpha": 1.0, + "zorder": 3, + } + + # 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 + positions = np.deg2rad(bin_centers) + + # width: make bars fill the circumference without gaps or overlaps + width = 2 * np.pi / num_bins + + # radius: how long to make each bar. set bar length so either the bar area + # (ie, via sqrt) or the bar height is proportional to the bin's frequency + bin_frequency = bin_counts / bin_counts.sum() + radius = np.sqrt(bin_frequency) if area else bin_frequency + + # 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()) + + # configure the y-ticks and remove their labels + ax.set_yticks(np.linspace(0, radius.max(), 5)) + ax.set_yticklabels(labels="") + + # configure the x-ticks and their labels + xticklabels = ["N", "", "E", "", "S", "", "W", ""] + ax.set_xticks(ax.get_xticks()) + ax.set_xticklabels(labels=xticklabels, fontdict=xtick_font) + ax.tick_params(axis="x", which="major", pad=-2) + + # draw the bars + ax.bar( + positions, + height=radius, + width=width, + align="center", + bottom=0, + zorder=2, + color=color, + edgecolor=edgecolor, + linewidth=linewidth, + alpha=alpha, + ) + + if title: + ax.set_title(title, y=title_y, fontdict=title_font) + fig.tight_layout() + return fig, ax + + +def _get_colors_by_value( + vals: pd.Series, + num_bins: int | None, + cmap: str, + start: float, + stop: float, + na_color: str, + equal_size: bool, # noqa: FBT001 +) -> pd.Series: + """ + Map colors to the values in a Series of node/edge attribute values. + + Parameters + ---------- + 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 + 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 + 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) + + if num_bins is None: + # calculate min/max values based on start/stop and data range + vals_min = vals.dropna().min() + vals_max = vals.dropna().max() + full_range = (vals_max - vals_min) / (stop - start) + full_min = vals_min - full_range * start + full_max = full_min + full_range + + # 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).map(colors.to_hex) + color_series.loc[pd.isna(vals)] = na_color + + else: + # otherwise, bin values then assign colors to bins + 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: 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 arguments. + + Parameters + ---------- + 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 + The matplotlib figure and axes objects. + """ + fig.canvas.draw() + fig.canvas.flush_events() + + if save: + # default filepath, if none provided + fp = Path(settings.imgs_folder) / "image.png" if filepath is None else Path(filepath) + + # if save folder does not already exist, create it + fp.parent.mkdir(parents=True, exist_ok=True) + + # get the file extension and figure facecolor + 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) + 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 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(fp, dpi=dpi, bbox_inches=extent, format=ext, facecolor=fc, transparent=True) + fig.set_frameon(False) # and turn it back off again + + msg = f"Saved figure to disk at {str(fp)!r}" + utils.log(msg, level=lg.INFO) + + if show: + plt.show() + + if close: + plt.close() + + return fig, ax + + +def _config_ax(ax: Axes, crs: Any, bbox: tuple[float, float, float, float], padding: float) -> Axes: # noqa: ANN401 + """ + Configure a matplotlib axes instance for display. + + Parameters + ---------- + ax + The axes instance. + crs + The coordinate reference system of the plotted geometries. + bbox + Bounding box as `(left, bottom, right, top)`. + padding + Relative padding to add around `bbox`. + + Returns + ------- + ax + The configured matplotlib axes object. + """ + # set the axes view limits to bbox + relative padding + left, bottom, right, top = bbox + padding_ns = (top - bottom) * padding + padding_ew = (right - left) * padding + ax.set_ylim((bottom - padding_ns, top + padding_ns)) + ax.set_xlim((left - padding_ew, right + padding_ew)) + + # set margins to zero, point ticks inward, turn off ax border and x/y axis + # 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()] # type: ignore[func-returns-value] + ax.get_xaxis().set_visible(False) + ax.get_yaxis().set_visible(False) + + # set aspect ratio + if projection.is_projected(crs): + # if projected, make equal aspect ratio + ax.set_aspect("equal") + else: + # if not projected, conform aspect ratio to not stretch plot + cos_lat = np.cos(np.deg2rad((bottom + top) / 2)) + ax.set_aspect(1 / cos_lat) + + return ax + + +# 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 + The resulting matplotlib figure and axes objects. + """ + 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 imported.""" + 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/source/osmnx/projection.py b/osmnx/source/osmnx/projection.py new file mode 100644 index 0000000000000000000000000000000000000000..167d5d9d48eb11afbf26cc2c19c13eb82f4d52af --- /dev/null +++ b/osmnx/source/osmnx/projection.py @@ -0,0 +1,200 @@ +"""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: Any) -> bool: # noqa: ANN401 + """ + Determine if a coordinate reference system is projected or not. + + Parameters + ---------- + 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 + True if `crs` is projected, otherwise False. + """ + return bool(gpd.GeoSeries(crs=crs).crs.is_projected) + + +def project_geometry( + geom: 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 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 (or UPS) zone given `geometry`'s bounds. + + Parameters + ---------- + geom + 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 + ------- + geom_proj, crs + The projected geometry and its new CRS. + """ + if crs is None: + crs = settings.default_crs + + gdf = gpd.GeoDataFrame(geometry=[geom], crs=crs) + gdf_proj = project_gdf(gdf, to_crs=to_crs, to_latlong=to_latlong) + geom_proj = gdf_proj["geometry"].iloc[0] + return geom_proj, gdf_proj.crs + + +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 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 (or UPS) zone given `gdf`'s bounds. + + Parameters + ---------- + 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 + The projected GeoDataFrame. + """ + 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 + if to_latlong: + to_crs = settings.default_crs + + # else if to_crs is None, project gdf to an appropriate UTM zone + elif to_crs is None: + # if polygon is outside UTM limits (80 deg south, 84 deg north), then + # we must use universal polar stereographic coordinate system instead + UTM_SOUTH_LIMIT = -80 + UTM_NORTH_LIMIT = 84 + if gdf.total_bounds[1] < UTM_SOUTH_LIMIT: + to_crs = "epsg:32761" + elif gdf.total_bounds[3] > UTM_NORTH_LIMIT: + to_crs = "epsg:32661" + else: + # otherwise, we're within UTM limits, so determine UTM zone + to_crs = gdf.estimate_utm_crs() + + # project the gdf + gdf_proj = gdf.to_crs(to_crs) + crs_desc = f"{gdf_proj.crs.to_string()} / {gdf_proj.crs.name}" + + msg = f"Projected GeoDataFrame to {crs_desc!r}" + utils.log(msg, level=lg.INFO) + return gdf_proj + + +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 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 (or UPS) zone given `geometry`'s bounds. + + Parameters + ---------- + 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 + The projected graph. + """ + if to_latlong: + to_crs = settings.default_crs + + # STEP 1: PROJECT THE NODES + gdf_nodes = convert.graph_to_gdfs(G, edges=False) + + # 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 + + # STEP 2: PROJECT THE EDGES + 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) + else: + # 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) + + # 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, graph_attrs=G.graph) + G_proj.graph["crs"] = to_crs + + 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/source/osmnx/py.typed b/osmnx/source/osmnx/py.typed new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/osmnx/source/osmnx/routing.py b/osmnx/source/osmnx/routing.py new file mode 100644 index 0000000000000000000000000000000000000000..3adfb82380f6ac976e93417cdddd86c616600cfc --- /dev/null +++ b/osmnx/source/osmnx/routing.py @@ -0,0 +1,710 @@ +"""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 Callable +from collections.abc import Iterable +from collections.abc import Iterator +from typing import TYPE_CHECKING +from typing import Any +from typing import overload + +import networkx as nx +import numpy as np +import pandas as pd + +from . import _validate +from . import convert +from . import utils + +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 + Input graph. + route + Node IDs constituting the path. + weight + Attribute value to minimize when choosing between parallel edges. + + Returns + ------- + gdf_edges + The ordered edges in the path. + """ + pairs = itertools.pairwise(route) + 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] + + +# 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). + + Uses Dijkstra's algorithm. If `orig` and `dest` are single node IDs, this + will return a list of the nodes constituting the shortest path between + them. If `orig` and `dest` are lists of node IDs, this will return a list + of lists of the nodes constituting the shortest path between each + origin-destination pair. If a path cannot be solved, this will return None + for that path. You can parallelize solving multiple paths with the `cpus` + parameter, but be careful to not exceed your available RAM. + + See also `k_shortest_paths` to solve multiple shortest paths between a + single origin and destination. For additional functionality or different + solver algorithms, use NetworkX directly. + + Parameters + ---------- + 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 multiprocessing. If None, use all + available. If you are multiprocessing, make sure you protect your + entry point: see the Python docs for details. + + Returns + ------- + path + The node IDs constituting the shortest path, or, if `orig` and `dest` + are both iterable, then a list of such paths. + """ + _validate._verify_numeric_edge_attribute(G, weight, strict=False) + + # if neither orig nor dest is iterable, just return the shortest path + 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 (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." + raise ValueError(msg) + + # determine how many cpu cores to use + if cpus is None: + cpus = mp.cpu_count() + cpus = min(cpus, mp.cpu_count()) + + 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: + paths = [_single_shortest_path(G, o, d, weight) for o, d in zip(orig, dest, strict=True)] + + # if multi-threading, calculate shortest paths in parallel + else: + args = ((G, o, d, weight) for o, d in zip(orig, dest, strict=True)) + with mp.get_context().Pool(cpus) as pool: + paths = pool.starmap_async(_single_shortest_path, args).get() + + return paths + + +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. + + Uses Yen's algorithm. See also `shortest_path` to solve just the one + shortest path. + + Parameters + ---------- + 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 + The node IDs constituting the next-shortest path. + """ + _validate._verify_numeric_edge_attribute(G, weight, strict=False) + 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: 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 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 + Input graph. + orig + Origin node ID. + dest + Destination node ID. + weight + Edge attribute to minimize when solving shortest path. + + Returns + ------- + path + The node IDs constituting the shortest path. + """ + try: + return list(nx.shortest_path(G, orig, dest, weight=weight, method="dijkstra")) + except nx.exception.NetworkXNoPath: # pragma: no cover + msg = f"Cannot solve path from {orig} to {dest}" + utils.log(msg, level=lg.WARNING) + return None + + +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. + + By default, this imputes free-flow travel speeds for all edges via the + mean `maxspeed` value of the edges of each highway type. For highway types + in the graph that have no `maxspeed` value on any edge, it assigns the + mean of all `maxspeed` values in graph. + + This default mean-imputation can obviously be imprecise, and the user can + override it by passing in `hwy_speeds` and/or `fallback` arguments that + correspond to local speed limit standards. The user can also specify a + different aggregation function (such as the median) to impute missing + values from the observed values. + + If edge `maxspeed` attribute has "mph" in it, value will automatically be + converted from miles per hour to km per hour. Any other speed units should + be manually converted to km per hour prior to running this function, + otherwise there could be unexpected results. If "mph" does not appear in + the edge's maxspeed attribute string, then function assumes kph, per OSM + guidelines: https://wiki.openstreetmap.org/wiki/Map_Features/Units + + If you wish to set all edge speeds to a single constant value (such as for + a walking network), use `nx.set_edge_attributes` to set the `speed_kph` + attribute value directly, rather than using this function. + + Parameters + ---------- + 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 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 + Graph with `speed_kph` attributes on all edges. + """ + if fallback is None: + fallback = np.nan + + edges = convert.graph_to_gdfs(G, nodes=False, fill_edge_geometry=False) + + # collapse any highway lists (can happen during graph simplification) + # into string values simply by keeping just the first element of the list + edges["highway"] = edges["highway"].map(lambda x: x[0] if isinstance(x, list) else x) + + if "maxspeed" in edges.columns: + # collapse any maxspeed lists (can happen during graph simplification) + # into a single value + edges["maxspeed"] = edges["maxspeed"].apply(_collapse_multiple_maxspeed_values, agg=agg) + + # create speed_kph by cleaning maxspeed strings and converting mph to + # kph if necessary + edges["speed_kph"] = edges["maxspeed"].astype(str).map(_clean_maxspeed).astype(float) + else: + # if no edges in graph had a maxspeed attribute + edges["speed_kph"] = None + + # if user provided hwy_speeds, use them as default values, otherwise + # initialize an empty series to populate with values + hwy_speed_avg = pd.Series(dtype=float) if hwy_speeds is None else pd.Series(hwy_speeds).dropna() + + # for each highway type that caller did not provide in hwy_speeds, impute + # speed of type by taking the mean of the preexisting speed values of that + # highway type + for hwy, group in edges.groupby("highway"): + if hwy not in hwy_speed_avg: + hwy_speed_avg.loc[hwy] = agg(group["speed_kph"]) + + # if any highway types had no preexisting speed values, impute their speed + # with fallback value provided by caller. if fallback=np.nan, impute speed + # as the mean speed of all highway types that did have preexisting values + hwy_speed_avg = hwy_speed_avg.fillna(fallback).fillna(agg(hwy_speed_avg)) + + # for each edge missing speed data, assign it the imputed value for its + # highway type + speed_kph = ( + edges[["highway", "speed_kph"]].set_index("highway").iloc[:, 0].fillna(hwy_speed_avg) + ) + + # all speeds will be null if edges had no preexisting maxspeed data and + # 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 " + "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.to_numpy() + nx.set_edge_attributes(G, values=edges["speed_kph"], name="speed_kph") + + return G + + +def add_edge_travel_times(G: nx.MultiDiGraph) -> nx.MultiDiGraph: + """ + Add edge travel time (seconds) to graph as new `travel_time` edge attributes. + + Calculates free-flow travel time along each edge, based on `length` and + `speed_kph` attributes. Note: run `add_edge_speeds` first to generate the + `speed_kph` attribute. All edges must have `length` and `speed_kph` + attributes and all their values must be non-null. + + Parameters + ---------- + G + Input graph. + + Returns + ------- + G + Graph with `travel_time` attributes on all edges. + """ + 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." + 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." + raise ValueError(msg) + + # convert distance meters to km, and speed km per hour to km per second + distance_km = edges["length"] / 1000 + speed_km_sec = edges["speed_kph"] / (60 * 60) + + # calculate edge travel time in seconds + travel_time = distance_km / speed_km_sec + + # add travel time attribute to graph edges + 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: 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. 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 + 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 + 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 + try: + clean_values = [] + for value in values: + match = re.match(pattern, value) + 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 float(agg(clean_values)) + + except (ValueError, AttributeError): + # if not valid numeric string, try looking it up as implicit value + return _IMPLICIT_MAXSPEEDS.get(maxspeed) + + +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 + 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 + ------- + 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): + return value + + # otherwise, it is a list, so process it + try: + # clean/convert each value in list as needed then aggregate + values = [_clean_maxspeed(x) for x in value] + collapsed: float | None = float(agg(pd.Series(values).dropna())) + except ValueError: + return None + else: + # return that single aggregated value if it's non-null, otherwise None + if not pd.isna(collapsed): + return collapsed + return None diff --git a/osmnx/source/osmnx/settings.py b/osmnx/source/osmnx/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..67ac8a1cca1cd3fa49c4c145fbcfbaa6b33ab876 --- /dev/null +++ b/osmnx/source/osmnx/settings.py @@ -0,0 +1,184 @@ +""" +Global settings that can be configured by the user. + +all_oneway : bool + 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 | 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 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 | Path + Path to folder to save/load graph files by default. Default is `"./data"`. +default_access : str + 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" 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"`. +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}"` +elevation_url_template : str + Endpoint of the Google Maps Elevation API (or equivalent), containing + up to two parameters, in order: `locations` and `key`. Default is: + `"https://maps.googleapis.com/maps/api/elevation/json?locations={locations}&key={key}"`. + As alternative free examples, the Open Topo Data API would be: + `"https://api.opentopodata.org/v1/aster30m?locations={locations}"` + and the Open-Elevation API would be: + `"https://api.open-elevation.com/api/v1/lookup?locations={locations}"`. +http_accept_language : str + HTTP header accept-language. Default is `"en"`. Note that Nominatim's + 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 + `"OSMnx Python package (https://github.com/gboeing/osmnx)"`. +http_user_agent : str + HTTP header user-agent. Default is + `"OSMnx Python package (https://github.com/gboeing/osmnx)"`. +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`. +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`. +log_name : str + Name of the logger. Default is `"OSMnx"`. +logs_folder : str | Path + Path to folder in which to save log files. Default is `"./logs"`. +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`. +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/"`. +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. + Default is `None`. +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 + without rate limiting. Default is `True`. +overpass_settings : str + Settings string for Overpass queries. Default is + `"[out:json][timeout:{timeout}]{maxsize}"`. By default, the {timeout} and + {maxsize} values are set dynamically by OSMnx when used. + To query, for example, historical OSM data as of a certain date: + `'[out:json][timeout:90][date:"2019-10-28T19:20:00Z"]'`. Use with caution. +overpass_url : str + The base API url to use for Overpass queries. Default is + `"https://overpass-api.de/api"`. +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, + verify, and proxies can be found in the requests package advanced docs. + Default is `{}`. +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`. +use_cache : bool + 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 `["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 `["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: 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: 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", + "est_width", + "highway", + "junction", + "landuse", + "lanes", + "maxspeed", + "name", + "oneway", + "ref", + "service", + "tunnel", + "width", +] diff --git a/osmnx/source/osmnx/simplification.py b/osmnx/source/osmnx/simplification.py new file mode 100644 index 0000000000000000000000000000000000000000..8072ecda05e93499d1eae8e449cc08dc18ac45f4 --- /dev/null +++ b/osmnx/source/osmnx/simplification.py @@ -0,0 +1,788 @@ +"""Simplify, correct, and consolidate spatial graph nodes and edges.""" + +from __future__ import annotations + +import itertools +import logging as lg +from typing import TYPE_CHECKING +from typing import Any + +import geopandas as gpd +import networkx as nx +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: 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. `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). + + 2) Or, it has no incoming edges or no outgoing edges (ie, all its incident + edges are inbound or all its incident edges are outbound). + + 3) Or, it does not have exactly two neighbors and degree of 2 or 4. + + 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 + `edge_attrs_differ`. + + Parameters + ---------- + G + Input graph. + node + The ID of the node to check. + 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 + ------- + endpoint + True if node is an endpoint, otherwise False. + """ + neighbors = set(list(G.predecessors(node)) + list(G.successors(node))) + n = len(neighbors) + d = G.degree(node) + + # RULE 1 + # if the node appears in its list of neighbors, it self-loops: this is + # always an endpoint + if node in neighbors: + return True + + # RULE 2 + # if node has no incoming edges or no outgoing edges, it is an endpoint + if G.out_degree(node) == 0 or G.in_degree(node) == 0: + return True + + # RULE 3 + # else, if it does NOT have 2 neighbors AND either 2 or 4 directed edges, + # it is an endpoint. either it has 1 or 3+ neighbors, in which case it is + # a dead-end or an intersection of multiple streets or it has 2 neighbors + # but 3 degree (indicating a change from oneway to twoway) or more than 4 + # degree (indicating a parallel edge) and thus is an endpoint + if not ((n == 2) and (d in {2, 4})): # noqa: PLR2004 + 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 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: + return True + + # if none of the preceding rules passed, then it is not an endpoint + return False + + +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 + Input graph. + endpoint + The 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 + 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. + """ + # start building path from endpoint node through its successor + path = [endpoint, endpoint_successor] + + # for each successor of the endpoint's successor + for this_successor in G.successors(endpoint_successor): + successor = this_successor + if successor not in path: + # if this successor is already in the path, ignore it, otherwise add + # it to the path + path.append(successor) + while successor not in endpoints: + # find successors (of current successor) not in path + successors = [n for n in G.successors(successor) if n not in path] + + # 99%+ of the time there will be only 1 successor: add to path + if len(successors) == 1: + successor = successors[0] + path.append(successor) + + # handle relatively rare cases or OSM digitization quirks + elif len(successors) == 0: + 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] + + # otherwise, this can happen due to OSM digitization error + # where a one-way street turns into a two-way here, but + # duplicate incoming one-way edges are present + msg = f"Unexpected simplify pattern handled near {successor}" + utils.log(msg, level=lg.WARNING) + return path + else: # pragma: no cover + # 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}." + raise GraphSimplificationError(msg) + + # if this successor is an endpoint, we've completed the path + return path + + # if endpoint_successor has no successors not already in the path, return + # the current path: this is usually due to a digitization quirk on OSM + return path + + +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. + + The path is ordered from the first endpoint, through the interstitial nodes, + to the second endpoint. + + Parameters + ---------- + 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 + """ + # first identify all the nodes that are 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: + for successor in G.successors(endpoint): + if successor not in endpoints: + # if endpoint node's successor is not an endpoint, build path + # from the endpoint node, through the successor, and on to the + # next endpoint node + yield _build_path(G, endpoint, successor, endpoints) + + +def _remove_rings( + G: nx.MultiDiGraph, + node_attrs_include: Iterable[str] | None, + edge_attrs_differ: Iterable[str] | None, +) -> nx.MultiDiGraph: + """ + Remove all graph components that consist only of a single chordless cycle. + + 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 + 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 + Graph with all chordless cycle components removed. + """ + to_remove = set() + for wcc in nx.weakly_connected_components(G): + 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, 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 algorithm is described in the journal article: Boeing, G. 2025. + "Topological Graph Simplification Solutions to the Street Intersection + Miscount Problem." Transactions in GIS, 29 (3), e70037. + https://doi.org/10.1111/tgis.70037 + + 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 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 `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 + 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 + ------- + Gs + Topologically simplified graph, with a new `geometry` attribute on + each simplified edge. + """ + if G.graph.get("simplified"): # pragma: no cover + msg = "This graph has already been simplified, cannot simplify it again." + raise GraphSimplificationError(msg) + + msg = "Begin topologically simplifying the graph..." + utils.log(msg, level=lg.INFO) + + # 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() + initial_node_count = len(G) + initial_edge_count = len(G.edges) + all_nodes_to_remove = [] + all_edges_to_add = [] + + # generate each path that needs to be simplified + 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: dict[str, Any] = {} + for u, v in itertools.pairwise(path): + if track_merged: + # keep track of the edges that were merged + merged_edges.append((u, v)) + + # there should rarely be multiple edges between interstitial nodes + # usually happens if OSM has duplicate ways digitized for just one + # street... we will keep only one of the edges (see below) + edge_count = G.number_of_edges(u, v) + if edge_count != 1: + 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 = 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 + # value list + path_attributes[attr].append(edge_data[attr]) + else: + # if this key doesn't already exist, set the value to a list + # containing the one value + path_attributes[attr] = [edge_data[attr]] + + # consolidate the path's edge segments' attribute values + for attr_name, attr_values in path_attributes.items(): + if attr_name in edge_attr_aggs: + # if this attribute's values must be aggregated, do so now + agg_func = edge_attr_aggs[attr_name] + path_attributes[attr_name] = agg_func(attr_values) + elif len(set(attr_values)) == 1: + # if there's only 1 unique value, keep that single value + path_attributes[attr_name] = attr_values[0] + else: + # otherwise, if there are multiple uniques, keep one of each + path_attributes[attr_name] = list(set(attr_values)) + + # 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], + ) + + if track_merged: + # add the merged edges as a new attribute of the simplified edge + path_attributes["merged_edges"] = merged_edges + + # 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}, + ) + + # for each edge to add in the list we assembled, create a new edge between + # the origin and destination + for edge in all_edges_to_add: + G.add_edge(edge["origin"], edge["destination"], **edge["attr_dict"]) + + # finally remove all the interstitial nodes between the new edges + G.remove_nodes_from(set(all_nodes_to_remove)) + + if remove_rings: + G = _remove_rings(G, node_attrs_include, edge_attrs_differ) + + # mark the graph as having been simplified + G.graph["simplified"] = True + + msg = ( + f"Simplified graph: {initial_node_count:,} to {len(G):,} nodes, " + f"{initial_edge_count:,} to {len(G.edges):,} edges" + ) + utils.log(msg, level=lg.INFO) + return G + + +def consolidate_intersections( + 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. + + This algorithm is described in the journal article: Boeing, G. 2025. + "Topological Graph Simplification Solutions to the Street Intersection + Miscount Problem." Transactions in GIS, 29 (3), e70037. + https://doi.org/10.1111/tgis.70037 + + Merges nearby nodes and returns either their centroids or a rebuilt graph + with consolidated intersections and reconnected edge geometries. The + `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` is False, it uses a purely geometric (and relatively + fast) algorithm to identify "geometrically close" nodes, merge them, and + 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 "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 + each edge intersects a perpendicular edge. These 4 nodes represent a + single intersection in the real world. A similar situation occurs with + roundabouts and traffic circles. This function consolidates nearby nodes + by buffering them to an arbitrary distance, merging overlapping buffers, + and taking their centroid. + + Parameters + ---------- + 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 + ------- + Gc 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. + """ + # make a copy to not mutate original graph object caller passed in + G = G.copy() + + # if dead_ends is False, discard dead-ends to retain only intersections + if not dead_ends: + spn = stats.streets_per_node(G) + dead_end_nodes = [node for node, count in spn.items() if count <= 1] + G.remove_nodes_from(dead_end_nodes) + + if rebuild_graph: + 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, + node_attr_aggs, + ) + + # otherwise, if we're not rebuilding the graph + if len(G) == 0: + # if graph has no nodes, just return empty GeoSeries + return gpd.GeoSeries(crs=G.graph["crs"]) + + # otherwise, return the centroids of the merged intersection polygons + return _merge_nodes_geometric(G, tolerance).centroid + + +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 + 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 + The merged overlapping polygons of the buffered nodes. + """ + 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"]).union_all() + else: + # buffer nodes then merge overlapping geometries + merged = gdf_nodes.buffer(tolerance).union_all() + + # 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. + + Parameters + ---------- + 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": "mean"}`. + + Returns + ------- + Gc + A rebuilt graph with consolidated intersections and (optionally) + reconnected edge geometries. + """ + if G.graph.get("consolidated"): # pragma: no cover + msg = "This graph has already been consolidated, cannot consolidate it again." + raise GraphSimplificationError(msg) + + # default node attributes to aggregate upon consolidation + if node_attr_aggs is None: + node_attr_aggs = {"elevation": "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 + node_clusters = gpd.GeoDataFrame(geometry=_merge_nodes_geometric(G, tolerance)) + centroids = node_clusters.centroid + node_clusters["x"] = centroids.x + node_clusters["y"] = centroids.y + + # STEP 2 + # 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).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) + + # STEP 3 + # if a cluster contains multiple components (i.e., it's not connected) + # 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). + 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))) + if len(wccs) > 1: + # if there are multiple components in this cluster + for suffix, wcc in enumerate(wccs): + # set subcluster xy to the centroid of just these nodes + idx = list(wcc) + subcluster_centroid = node_points.loc[idx].union_all().centroid + gdf.loc[idx, "x"] = subcluster_centroid.x + gdf.loc[idx, "y"] = subcluster_centroid.y + # move to subcluster by appending suffix to cluster label + gdf.loc[idx, "cluster"] = f"{cluster_label}-{suffix}" + + # give nodes unique integer IDs (subclusters with suffixes are strings) + gdf["cluster"] = gdf["cluster"].factorize()[0] + + # STEP 4 + # create new empty graph and copy over misc graph data + Gc = nx.MultiDiGraph() + Gc.graph = G.graph + + # STEP 5 + # create a new node for each cluster of merged nodes + # regroup now that we potentially have new cluster labels from step 3 + groups = gdf.groupby("cluster") + for cluster_label, nodes_subset in groups: + osmids = nodes_subset.index.to_list() + if len(osmids) == 1: + # if cluster is a single node, add that node to new graph + osmid = osmids[0] + Gc.add_node(cluster_label, osmid_original=osmid, **G.nodes[osmid]) + else: + # 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) + + # mark the graph as having been consolidated + G.graph["consolidated"] = True + + 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 Gc + + # STEP 6 + # create new edge from cluster to cluster for each edge in original graph + gdf_edges = convert.graph_to_gdfs(G, nodes=False) + for u, v, k, data in G.edges(keys=True, data=True): + u2 = gdf.loc[u, "cluster"] + v2 = gdf.loc[v, "cluster"] + + # only create the edge if we're not connecting the cluster + # to itself, but always add original self-loops + if (u2 != v2) or (u == v): + data["u_original"] = u + data["v_original"] = v + if "geometry" not in data: + data["geometry"] = gdf_edges.loc[(u, v, k), "geometry"] + Gc.add_edge(u2, v2, **data) + + # STEP 7 + # for every group of merged nodes with more than 1 node in it, extend the + # edge geometries to reach the new node point + for cluster_label, nodes_subset in groups: + # but only if there were multiple nodes merged together, + # otherwise it's the same old edge as in original graph + if len(nodes_subset) > 1: + # get coords of merged nodes point centroid to prepend or + # append to the old edge geom's coords + 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(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(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) + Gc.edges[u, v, k]["geometry"] = new_geom + + # update the edge length attribute, given the new geometry + 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 Gc diff --git a/osmnx/source/osmnx/stats.py b/osmnx/source/osmnx/stats.py new file mode 100644 index 0000000000000000000000000000000000000000..62cd42cabe8fd1ea841f3b73eca10edb208759a0 --- /dev/null +++ b/osmnx/source/osmnx/stats.py @@ -0,0 +1,416 @@ +""" +Calculate geometric and topological network measures. + +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. 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. +""" + +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 + +from . import convert +from . import distance +from . import projection +from . import simplification +from . import utils + +if TYPE_CHECKING: + from collections.abc import Iterable + + +def streets_per_node(G: nx.MultiDiGraph) -> dict[int, int]: + """ + Retrieve nodes' `street_count` attribute values. + + See also the `count_streets_per_node` function for the calculation. + + Parameters + ---------- + G + Input graph. + + Returns + ------- + spn + Dictionary with node ID keys and street count values. + """ + # 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): + msg = "Graph nodes changed since `street_count`s were calculated" + utils.log(msg, level=lg.WARNING) + return spn + + +def streets_per_node_avg(G: nx.MultiDiGraph) -> float: + """ + Calculate graph's average count of streets per node. + + Parameters + ---------- + G + Input graph. + + Returns + ------- + spna + Average count of streets per node. + """ + spn_vals = streets_per_node(G).values() + return float(sum(spn_vals) / len(G.nodes)) + + +def streets_per_node_counts(G: nx.MultiDiGraph) -> dict[int, int]: + """ + Calculate streets-per-node counts. + + Parameters + ---------- + G + Input graph. + + Returns + ------- + 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: nx.MultiDiGraph) -> dict[int, float]: + """ + Calculate streets-per-node proportions. + + Parameters + ---------- + G + Input graph. + + Returns + ------- + 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: nx.MultiDiGraph, *, min_streets: int = 2) -> int: + """ + Count the intersections in a graph. + + Intersections are defined as nodes with at least `min_streets` number of + streets incident on them. + + Parameters + ---------- + G + Input graph. + min_streets + A node must have at least `min_streets` incident on them to count as + an intersection. + + Returns + ------- + count + Count of intersections in graph. + """ + spn = streets_per_node(G) + node_ids = set(G.nodes) + 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: nx.MultiGraph) -> int: + """ + Count the street segments in a graph. + + Parameters + ---------- + Gu + Undirected input graph. + + Returns + ------- + count + Count of street segments in graph. + """ + if nx.is_directed(Gu): # pragma: no cover + msg = "`Gu` must be undirected." + raise ValueError(msg) + return len(Gu.edges) + + +def street_length_total(Gu: nx.MultiGraph) -> float: + """ + Calculate graph's total street segment length. + + Parameters + ---------- + Gu + Undirected input graph. + + Returns + ------- + length + Total length (meters) of streets in graph. + """ + if nx.is_directed(Gu): # pragma: no cover + msg = "`Gu` must be undirected." + raise ValueError(msg) + return float(sum(d["length"] for u, v, d in Gu.edges(data=True))) + + +def edge_length_total(G: nx.MultiGraph) -> float: + """ + Calculate graph's total edge length. + + Parameters + ---------- + G + Input graph. + + Returns + ------- + length + Total length (meters) of edges in graph. + """ + return float(sum(d["length"] for u, v, d in G.edges(data=True))) + + +def self_loop_proportion(Gu: nx.MultiGraph) -> float: + """ + Calculate percent of edges that are self-loops in a graph. + + A self-loop is defined as an edge from node `u` to node `v` where `u==v`. + + Parameters + ---------- + Gu + Undirected input graph. + + Returns + ------- + proportion + Proportion of graph edges that are self-loops. + """ + if nx.is_directed(Gu): # pragma: no cover + msg = "`Gu` must be undirected." + raise ValueError(msg) + return float(sum(u == v for u, v, k in Gu.edges) / len(Gu.edges)) + + +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 + Undirected input graph. + + Returns + ------- + circuity_avg + The graph's average undirected edge circuity. + """ + if nx.is_directed(Gu): # pragma: no cover + msg = "`Gu` must be undirected." + raise ValueError(msg) + + # extract the edges' endpoint nodes' coordinates + 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] + x2 = coords[:, 3] + + # calculate straight-line distances as euclidean distances if projected or + # great-circle distances if unprojected + if projection.is_projected(Gu.graph["crs"]): + sl_dists = distance.euclidean(y1=y1, x1=x1, y2=y2, x2=x2) + else: + sl_dists = distance.great_circle(lat1=y1, lon1=x1, lat2=y2, lon2=x2) + + # return the ratio, handling possible division by zero + sl_dists_total = sl_dists[~np.isnan(sl_dists)].sum() + try: + return float(edge_length_total(Gu) / sl_dists_total) + except ZeroDivisionError: + return 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. + + This function uses an undirected representation of the graph and special + handling of self-loops to accurately count physical streets rather than + directed edges. Note: this function is automatically run by all the + `graph.graph_from_x` functions prior to truncating the graph to the + requested boundaries, to add accurate `street_count` attributes to each + node even if some of its neighbors are outside the requested graph + boundaries. + + Parameters + ---------- + 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 + Counts of how many physical streets connect to each node, with keys = + node ids and values = counts. + """ + if nodes is None: + nodes = G.nodes + + # get one copy of each self-loop edge, because bi-directional self-loops + # 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, 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] + + # make list of all unique edges including each parallel edge unless the + # parallel edge is a self-loop, in which case we don't double-count it + 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 = chain.from_iterable(all_unique_edges) + counts = Counter(edges_flat) + streets_per_node = {node: counts[node] for node in nodes} + + msg = "Counted undirected street segments incident on each node" + utils.log(msg, level=lg.INFO) + return streets_per_node + + +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. + + Density measures are only calculated if `area` is provided and clean + intersection measures are only calculated if `clean_int_tol` is provided. + + Parameters + ---------- + 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. + + Returns + ------- + 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 + - `edge_density_km` - `edge_length_total` per sq km + - `edge_length_avg` - `edge_length_total / m` + - `edge_length_total` - see `edge_length_total` function documentation + - `intersection_count` - see `intersection_count` function documentation + - `intersection_density_km` - `intersection_count` per sq km + - `k_avg` - graph's average node degree (in-degree and out-degree) + - `m` - count of edges in graph + - `n` - count of nodes in graph + - `node_density_km` - `n` per sq km + - `self_loop_proportion` - see `self_loop_proportion` function documentation + - `street_density_km` - `street_length_total` per sq km + - `street_length_avg` - `street_length_total / street_segment_count` + - `street_length_total` - see `street_length_total` function documentation + - `street_segment_count` - see `street_segment_count` function documentation + - `streets_per_node_avg` - see `streets_per_node_avg` function documentation + - `streets_per_node_counts` - see `streets_per_node_counts` function documentation + - `streets_per_node_proportions` - see `streets_per_node_proportions` function documentation + """ + Gu = convert.to_undirected(G) + stats: dict[str, Any] = {} + + stats["n"] = len(G.nodes) + stats["m"] = len(G.edges) + stats["k_avg"] = 2 * stats["m"] / stats["n"] + stats["edge_length_total"] = edge_length_total(G) + stats["edge_length_avg"] = stats["edge_length_total"] / stats["m"] + stats["streets_per_node_avg"] = streets_per_node_avg(G) + stats["streets_per_node_counts"] = streets_per_node_counts(G) + stats["streets_per_node_proportions"] = streets_per_node_proportions(G) + stats["intersection_count"] = intersection_count(G) + stats["street_length_total"] = street_length_total(Gu) + stats["street_segment_count"] = street_segment_count(Gu) + stats["street_length_avg"] = stats["street_length_total"] / stats["street_segment_count"] + stats["circuity_avg"] = circuity_avg(Gu) + stats["self_loop_proportion"] = self_loop_proportion(Gu) + + # calculate clean intersection counts if requested + if clean_int_tol: + stats["clean_intersection_count"] = len( + simplification.consolidate_intersections( + G, + tolerance=clean_int_tol, + rebuild_graph=False, + dead_ends=False, + ), + ) + + # can only calculate density measures if area was provided + if area is not None: + area_km = area / 1_000_000 # convert m^2 to km^2 + stats["node_density_km"] = stats["n"] / area_km + stats["intersection_density_km"] = stats["intersection_count"] / area_km + stats["edge_density_km"] = stats["edge_length_total"] / area_km + stats["street_density_km"] = stats["street_length_total"] / area_km + if clean_int_tol: + stats["clean_intersection_density_km"] = stats["clean_intersection_count"] / area_km + + return stats diff --git a/osmnx/source/osmnx/truncate.py b/osmnx/source/osmnx/truncate.py new file mode 100644 index 0000000000000000000000000000000000000000..3e587c1e986013b33d4c2e1388d74edb0acfc432 --- /dev/null +++ b/osmnx/source/osmnx/truncate.py @@ -0,0 +1,201 @@ +"""Truncate graph by distance, bounding box, or polygon.""" + +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 + +if TYPE_CHECKING: + from shapely import MultiPolygon + from shapely import Polygon + + +def truncate_graph_dist( + G: nx.MultiDiGraph, + source_node: int, + dist: float, + *, + weight: str = "length", +) -> nx.MultiDiGraph: + """ + Remove from a graph every node beyond some network distance from a 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 + 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 + 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 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) + + msg = f"Truncated graph by {weight}-weighted network distance" + utils.log(msg, level=lg.INFO) + return G + + +def truncate_graph_bbox( + G: nx.MultiDiGraph, + bbox: tuple[float, float, float, float], + *, + truncate_by_edge: bool = False, +) -> nx.MultiDiGraph: + """ + Remove from a graph every node that falls outside a bounding box. + + Parameters + ---------- + G + Input graph. + bbox + Bounding box as `(left, bottom, right, top)`. + 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 + The truncated graph. + """ + # convert bounding box to a polygon, then truncate + polygon = utils_geo.bbox_to_poly(bbox=bbox) + 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: nx.MultiDiGraph, + polygon: Polygon | MultiPolygon, + *, + truncate_by_edge: bool = False, +) -> nx.MultiDiGraph: + """ + Remove from a graph every node that falls outside a (Multi)Polygon. + + Parameters + ---------- + 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 + The truncated graph. + """ + 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"] + to_keep = utils_geo._intersect_index_quadrats(gs_nodes, polygon) + + 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." + raise ValueError(msg) + + # now identify all nodes whose point geometries lie outside the polygon + gs_nodes_outside_poly = gs_nodes[~gs_nodes.index.isin(to_keep)] + nodes_outside_poly = set(gs_nodes_outside_poly.index) + + if truncate_by_edge: + # retain nodes outside boundary polygon if at least one of node's + # neighbors is within the polygon + nodes_to_remove = set() + for node in nodes_outside_poly: + # if all the neighbors of this node also lie outside polygon, then + # mark this node for removal + neighbors = set(G.successors(node)) | set(G.predecessors(node)) + if neighbors.issubset(nodes_outside_poly): + nodes_to_remove.add(node) + else: + nodes_to_remove = nodes_outside_poly + + # now remove from the graph all those nodes that lie outside the polygon + # make a copy to not mutate original graph object caller passed in + G = G.copy() + G.remove_nodes_from(nodes_to_remove) + msg = f"Removed {len(nodes_to_remove):,} nodes outside polygon" + utils.log(msg, level=lg.INFO) + + msg = "Truncated graph by polygon" + utils.log(msg, level=lg.INFO) + return G + + +def largest_component(G: nx.MultiDiGraph, *, strongly: bool = False) -> nx.MultiDiGraph: + """ + Return `G`'s largest weakly or strongly connected component as a graph. + + Parameters + ---------- + G + Input graph. + strongly + If True, return the largest strongly connected component. Otherwise + return the largest weakly connected component. + + Returns + ------- + G + The largest connected component subgraph of the original graph. + """ + if strongly: + kind = "strongly" + is_connected = nx.is_strongly_connected + connected_components = nx.strongly_connected_components + else: + kind = "weakly" + is_connected = nx.is_weakly_connected + connected_components = nx.weakly_connected_components + + if not is_connected(G): + # get all the connected components in graph then identify the largest + largest_cc = max(connected_components(G), key=len) + n = len(G) + + # induce (frozen) subgraph then unfreeze it by making new MultiDiGraph + G = nx.MultiDiGraph(G.subgraph(largest_cc)) + + 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/source/osmnx/utils.py b/osmnx/source/osmnx/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..5d3935496da7f60ee1bfb96c95a65296b34542ab --- /dev/null +++ b/osmnx/source/osmnx/utils.py @@ -0,0 +1,191 @@ +"""General utility functions.""" + +from __future__ import annotations + +import datetime as dt +import logging as lg +import os +import sys +import unicodedata as ud +from contextlib import redirect_stdout +from pathlib import Path + +from . import settings + + +def citation(style: str = "bibtex") -> None: + """ + Print the OSMnx package's citation information. + + Boeing, G. (2025). Modeling and Analyzing Urban Networks and Amenities with + OSMnx. Geographical Analysis, 57(4), 567-577. doi:10.1111/gean.70009 + + Parameters + ---------- + style + {"apa", "bibtex", "ieee"} + The citation format, either APA or BibTeX or IEEE. + """ + if style == "apa": + msg = ( + "Boeing, G. (2025). Modeling and Analyzing Urban Networks and Amenities " + "with OSMnx. Geographical Analysis, 57(4), 567-577. doi:10.1111/gean.70009" + ) + elif style == "bibtex": + msg = ( + "@article{boeing_osmnx_2025,\n" + " author = {Boeing, Geoff},\n" + " title = {{Modeling and Analyzing Urban Networks and Amenities with OSMnx}},\n" + " journal = {Geographical Analysis},\n" + " year = {2025}\n" + " volume = {57},\n" + " number = {4},\n" + " pages = {567--577},\n" + " doi = {10.1111/gean.70009},\n" + "}" + ) + elif style == "ieee": + msg = ( + 'G. Boeing, "Modeling and Analyzing Urban Networks and Amenities with OSMnx," ' + "Geographical Analysis, vol. 57, no. 4, pp. 567-577, 2025, doi: 10.1111/gean.70009." + ) + + else: # pragma: no cover + err_msg = f"Invalid citation style {style!r}." + raise ValueError(err_msg) + + print(msg) # noqa: T201 + + +def ts(style: str = "datetime", template: str | None = None) -> str: + """ + Return current local timestamp as a string. + + Parameters + ---------- + 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 + ------- + timestamp + The current 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"Invalid timestamp style {style!r}." + raise ValueError(msg) + + return template.format(dt.datetime.now().astimezone()) + + +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`. + + Parameters + ---------- + 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. + """ + if level is None: + level = settings.log_level + if name is None: + name = settings.log_name + if filename is None: + filename = settings.log_filename + + # if logging to file is turned on + if settings.log_file: + # get the current logger (or create a new one, if none), then log + # message at requested level + logger = _get_logger(name=name, filename=filename) + if level == lg.DEBUG: + logger.debug(message) + elif level == lg.INFO: + logger.info(message) + elif level == lg.WARNING: + logger.warning(message) + elif level == lg.ERROR: + logger.error(message) + + # if logging to console (terminal window) is turned on + if settings.log_console: + # prepend timestamp then convert to ASCII for Windows command prompts + message = f"{ts()} {message}" + message = ud.normalize("NFKD", message).encode("ascii", errors="replace").decode() + + try: + # 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()) # type: ignore[union-attr] + sys.stdout._original_stdstream_copy = None # type: ignore[union-attr] + with redirect_stdout(sys.__stdout__): + print(message, file=sys.__stdout__, flush=True) + except OSError: + # handle pytest on Windows raising OSError from sys.__stdout__ + print(message, flush=True) # noqa: T201 + + +def _get_logger(name: str, filename: str) -> lg.Logger: + """ + Create a logger or return the current one if already instantiated. + + Parameters + ---------- + name + Name of the logger. + filename + Name of the log file, without file extension. + + Returns + ------- + logger + The logger. + """ + logger = lg.getLogger(name) + + # 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(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(lg.DEBUG) + + return logger diff --git a/osmnx/source/osmnx/utils_geo.py b/osmnx/source/osmnx/utils_geo.py new file mode 100644 index 0000000000000000000000000000000000000000..2c5aa9ffda6ee658fd5ce11e9651120935f52477 --- /dev/null +++ b/osmnx/source/osmnx/utils_geo.py @@ -0,0 +1,441 @@ +"""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 import Geometry +from shapely import LineString +from shapely import MultiLineString +from shapely import MultiPolygon +from shapely import Polygon +from shapely.ops import split + +from . import convert +from . import projection +from . import settings +from . import utils + +if TYPE_CHECKING: + from collections.abc import Iterator + + import geopandas as gpd + + +def buffer_geometry(geom: Geometry, dist: float) -> Geometry: + """ + Buffer an unprojected Shapely geometry by some distance in meters. + + Parameters + ---------- + geom + The geometry to be buffered. Coordinates should be in unprojected + latitude-longitude degrees (EPSG:4326). + dist + The buffer distance in meters. + + Returns + ------- + geometry_buff + The (also unprojected) buffered geometry. + """ + geom_proj, crs_proj = projection.project_geometry(geom) + geom_buff, _ = projection.project_geometry( + geom=geom_proj.buffer(dist), + crs=crs_proj, + to_latlong=True, + ) + return geom_buff + + +def sample_points(G: nx.MultiGraph, n: int) -> gpd.GeoSeries: + """ + Randomly sample points constrained to a spatial graph. + + This generates a graph-constrained uniform random sample of points. Unlike + typical spatially uniform random sampling, this method accounts for the + graph's geometry. And unlike equal-length edge segmenting, this method + guarantees uniform randomness. + + Parameters + ---------- + G + Graph from which to sample points. Should be undirected (to avoid + oversampling bidirectional edges) and projected (for accurate point + interpolation). + n + How many points to sample. + + Returns + ------- + points + 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 + 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) + lines = gdf_edges.loc[idx, "geometry"] + return lines.interpolate(np.random.default_rng().random(n), normalized=True) + + +def interpolate_points( + geom: LineString | MultiLineString, + dist: float, +) -> Iterator[tuple[float, float]]: + """ + Interpolate evenly spaced points along a LineString. + + The spacing is approximate because the LineString's length may not be + evenly divisible by it. + + Parameters + ---------- + geom + A LineString geometry. + dist + Spacing distance between interpolated points, in same units as `geom`. + Smaller values accordingly generate more points. + + Yields + ------ + point + Interpolated point's `(x, y)` coordinates. + """ + if isinstance(geom, (LineString, MultiLineString)): + num_vert = max(round(geom.length / dist), 1) + for n in range(num_vert + 1): + point = geom.interpolate(n / num_vert, normalized=True) + yield point.x, point.y + else: # pragma: no cover + msg = "`geom` must be a LineString." + raise TypeError(msg) + + +def _consolidate_subdivide_geometry(geom: Polygon | MultiPolygon) -> MultiPolygon: + """ + 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 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 + if necessary. + + Parameters + ---------- + geom + The projected (in meter units) geometry to consolidate and subdivide. + + Returns + ------- + geom + The resulting consolidated and subdivided geometry. + """ + if not isinstance(geom, (Polygon, MultiPolygon)): # pragma: no cover + 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 + # 2) a MultiPolygon, then get the convex hull around the geometry + mqas = settings.max_query_area_size + if isinstance(geom, MultiPolygon) or (isinstance(geom, Polygon) and geom.area > mqas): + geom = geom.convex_hull + + # warn user if they passed a geometry with area much larger than max size + ratio = int(geom.area / mqas) + warning_threshold = 10 + if ratio > warning_threshold: + msg = ( + f"This area is {ratio:,} times your configured Overpass max query " + "area size. It will automatically be divided up into multiple " + "sub-queries accordingly. This may take a long time." + ) + 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 + if geom.area > mqas: + geom = _quadrat_cut_geometry(geom, quadrat_width=np.sqrt(mqas)) + + if isinstance(geom, Polygon): + geom = MultiPolygon([geom]) + + return geom + + +def _quadrat_cut_geometry(geom: Polygon | MultiPolygon, quadrat_width: float) -> MultiPolygon: + """ + Split a Polygon or MultiPolygon up into sub-polygons of a specified size. + + Parameters + ---------- + geom + 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 + ------- + geom + The resulting split-up geometry. + """ + # min number of dividing lines (3 produces a grid of 4 quadrat squares) + min_num = 3 + + # create n evenly spaced points between the min and max x and y bounds + left, bottom, right, top = geom.bounds + x_num = int(np.ceil((right - left) / quadrat_width) + 1) + y_num = int(np.ceil((top - bottom) / quadrat_width) + 1) + x_points = np.linspace(left, right, num=max(x_num, min_num)) + y_points = np.linspace(bottom, top, num=max(y_num, min_num)) + + # create a quadrat grid of lines at each of the evenly spaced points + vertical_lines = [LineString([(x, y_points[0]), (x, y_points[-1])]) for x in x_points] + horizont_lines = [LineString([(x_points[0], y), (x_points[-1], y)]) for y in y_points] + lines = vertical_lines + horizont_lines + + # recursively split the geometry by each quadrat line + geoms = [geom] + for line in lines: + # split polygon by line if they intersect, otherwise just keep it + split_geoms = [split(g, line).geoms if g.intersects(line) else [g] for g in geoms] + # now flatten the list and process these split geoms on the next line in the list of lines + geoms = [g for g_list in split_geoms for g in g_list] + + return MultiPolygon(geoms) + + +def _intersect_index_quadrats( + geoms: gpd.GeoSeries, + polygon: Polygon | MultiPolygon, +) -> set[Any]: + """ + Identify geometries that intersect a (Multi)Polygon. + + Uses an r-tree spatial index and cuts polygon up into smaller sub-polygons + for r-tree acceleration. Ensure that geometries and polygon are in the + same coordinate reference system. + + Parameters + ---------- + geoms + The geometries to intersect with the polygon. + polygon + The polygon to intersect with the geometries. + + Returns + ------- + geoms_in_poly + The index labels of the geometries that intersected the polygon. + """ + # create an r-tree spatial index for the geometries + rtree = geoms.sindex + msg = f"Built r-tree spatial index for {len(geoms):,} 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) + 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 + # from those approximate ones + geoms_in_poly = set() + for poly in multipoly.geoms: + poly_buff = poly.buffer(0) + if poly_buff.is_valid and poly_buff.area > 0: + possible_matches_iloc = rtree.intersection(poly_buff.bounds) + possible_matches = geoms.iloc[list(possible_matches_iloc)] + precise_matches = possible_matches[possible_matches.intersects(poly_buff)] + geoms_in_poly.update(precise_matches.index) + + msg = f"Identified {len(geoms_in_poly):,} geometries inside polygon" + utils.log(msg, level=lg.INFO) + return geoms_in_poly + + +# 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. + + Create a bounding box some distance (in meters) in each direction (top, + bottom, right, and left) from the center point and optionally project it. + + Parameters + ---------- + 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 + `(left, bottom, right, top)` or `((left, bottom, right, top), crs)`. + """ + EARTH_RADIUS_M = 6_371_009 # meters + lat, lon = point + + delta_lat = np.rad2deg(dist / EARTH_RADIUS_M) + delta_lon = np.rad2deg(dist / EARTH_RADIUS_M) / np.cos(np.deg2rad(lat)) + top = lat + delta_lat + bottom = lat - delta_lat + right = lon + delta_lon + left = lon - delta_lon + bbox = left, bottom, right, top + + if project_utm: + bbox_poly = bbox_to_poly(bbox=bbox) + bbox_proj, crs_proj = projection.project_geometry(bbox_poly) + bbox = bbox_proj.bounds + + msg = f"Created bbox {dist} meters from {point}: {bbox}" + utils.log(msg, level=lg.INFO) + + if project_utm and return_crs: + return bbox, crs_proj + + # otherwise + return bbox + + +def bbox_to_poly(bbox: tuple[float, float, float, float]) -> Polygon: + """ + Convert bounding box coordinates to Shapely Polygon. + + Parameters + ---------- + bbox + Bounding box as `(left, bottom, right, top)`. + + Returns + ------- + polygon + The resulting bounding box polygon. + """ + left, bottom, right, top = bbox + return Polygon([(left, bottom), (right, bottom), (right, top), (left, top)]) diff --git a/osmnx/source/pyproject.toml b/osmnx/source/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..988501fc0f7e701ae3986975633570f9a8251506 --- /dev/null +++ b/osmnx/source/pyproject.toml @@ -0,0 +1,114 @@ +[build-system] +build-backend = "uv_build" +requires = ["uv_build"] + +[project] +authors = [{ name = "Geoff Boeing", email = "boeing@usc.edu" }] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Scientific/Engineering :: GIS", + "Topic :: Scientific/Engineering :: Information Analysis", + "Topic :: Scientific/Engineering :: Mathematics", + "Topic :: Scientific/Engineering :: Physics", + "Topic :: Scientific/Engineering :: Visualization", + "Typing :: Typed", +] +dependencies = [ + "geopandas>=1.0.1", + "networkx>=2.5", + "numpy>=1.24", + "pandas>=1.5", + "requests>=2.27", + "shapely>=2.0", +] +description = "Download, model, analyze, and visualize street networks and other geospatial features from OpenStreetMap" +keywords = ["GIS", "Networks", "OpenStreetMap", "Routing"] +license = "MIT" +license-files = ["LICENSE.txt"] +maintainers = [{ name = "OSMnx contributors" }] +name = "osmnx" +readme = "README.md" +requires-python = ">=3.11" # match classifiers above and mypy version below +version = "2.1.0dev" + +[project.optional-dependencies] +all = ["osmnx[entropy,neighbors,raster,visualization]"] +entropy = ["scipy>=1.10"] +neighbors = ["scikit-learn>=1.2", "scipy>=1.10"] +raster = ["rasterio>=1.4", "rio-vrt>=0.3"] +visualization = ["matplotlib>=3.6"] + +[dependency-groups] +docs = ["furo>=2023", "sphinx>=7", "sphinx-autodoc-typehints>=2"] +examples = ["folium>=0.12", "jupyterlab>=3.0", "mapclassify>=2.5", "igraph>=0.11"] +lint = ["pip>=25", "prek>=0.2", "twine>=6", "validate-pyproject[all]>=0.24"] +test = ["lxml>=6", "pytest>=9", "pytest-cov>=6", "pytest-xdist>=3", "typeguard>=4"] + +[project.urls] +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.mypy] +cache_dir = "~/.cache/prek/cache/mypy" +enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] +ignore_missing_imports = true +python_version = "3.11" +strict = true +warn_no_return = true +warn_unreachable = true + +[tool.numpydoc_validation] +checks = ["all", "ES01", "EX01", "GL08", "PR04", "SA01"] + +[tool.pytest.ini_options] +addopts = ["-ra", "--verbose", "--maxfail=1", "--numprocesses=3", "--dist=loadgroup"] +cache_dir = "~/.cache/pytest" +filterwarnings = ["error", "ignore::UserWarning"] +log_level = "INFO" +minversion = 9 +strict = true +testpaths = ["tests"] + +[tool.ruff] +cache-dir = "~/.cache/prek/cache/ruff" +exclude = ["build/*"] +line-length = 100 + +[tool.ruff.lint] +extend-select = ["ALL"] +ignore = ["N803", "N806", "SLF001"] + +[tool.ruff.lint.isort] +force-single-line = true + +[tool.ruff.lint.mccabe] +max-complexity = 14 + +[tool.ruff.lint.pycodestyle] +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 + +[tool.uv] +required-version = "==0.9.*" # match version in environments/docker/Dockerfile + +[tool.uv.build-backend] +module-root = "" diff --git a/osmnx/source/tests/.yamllint.yml b/osmnx/source/tests/.yamllint.yml new file mode 100644 index 0000000000000000000000000000000000000000..e1effbebeb87331b89c1b793525ec8cdfdc40e19 --- /dev/null +++ b/osmnx/source/tests/.yamllint.yml @@ -0,0 +1,10 @@ +extends: default +rules: + document-start: disable + line-length: + max: 100 + quoted-strings: + quote-type: single + required: only-when-needed + truthy: + check-keys: false diff --git a/osmnx/source/tests/README.md b/osmnx/source/tests/README.md new file mode 100644 index 0000000000000000000000000000000000000000..e50fdd39a56a4e6ad324ccc78aa042208e3bc3e5 --- /dev/null +++ b/osmnx/source/tests/README.md @@ -0,0 +1,37 @@ +# OSMnx tests + +Read more about the project's standards in the [contributing guidelines](../CONTRIBUTING.md) and ensure that you have installed the necessary dev [dependencies](../pyproject.toml) for the test suite. + +## Code format + +Format the code per the project's style by running the pre-commit hooks: + +```shell +pre-commit install +pre-commit run -a +``` + +## Run tests + +Run the test suite locally by running (from the repository root): + +```shell +uv sync --all-extras --all-groups +bash ./tests/lint_test.sh +``` + +## Continuous integration + +Pull requests trigger continuous integration tests via GitHub Actions (see [workflow](../.github/workflows/ci.yml)), including the following steps: + +- build the docs and the package +- check code formatting +- lint the code and docstrings +- type check the code +- run tests and coverage + +## Releases + +To publish a new version, update `CHANGELOG.md` and edit the version number in `pyproject.toml`. If necessary, update the dates in `LICENSE.txt` and `docs/source/conf.py` and the dependency versions in `pyproject.toml`. Then tag the repository with its new semantic version, like `git tag -a "v1.2.3" -m "v1.2.3"`. + +Pushing the tags will trigger Github Actions to publish the distribution to PyPI (see [workflow](../.github/workflows/build-publish-pypi.yml)) and publish a new image to Docker Hub (see [workflow](../.github/workflows/build-publish-docker.yml)). The `regro-cf-autotick-bot` will open a pull request to update the conda-forge [feedstock](https://github.com/conda-forge/osmnx-feedstock): merge that PR to publish the distribution on conda-forge. Finally, update the [Examples Gallery](https://github.com/gboeing/osmnx-examples) to use the new version. diff --git a/osmnx/source/tests/input_data/West-Oakland.osm.bz2 b/osmnx/source/tests/input_data/West-Oakland.osm.bz2 new file mode 100644 index 0000000000000000000000000000000000000000..28fbf02c5f0f02e0b1e08b457803f7724e92f80a --- /dev/null +++ b/osmnx/source/tests/input_data/West-Oakland.osm.bz2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:92efe9ed4f803961e1b552d0e769fc10703814efa827e9a6fb013004e00bbeae +size 11717 diff --git a/osmnx/source/tests/input_data/elevation1.tif b/osmnx/source/tests/input_data/elevation1.tif new file mode 100644 index 0000000000000000000000000000000000000000..988d1ee332ee30314b91714bfaac5457fec41e5e Binary files /dev/null and b/osmnx/source/tests/input_data/elevation1.tif differ diff --git a/osmnx/source/tests/input_data/elevation2.tif b/osmnx/source/tests/input_data/elevation2.tif new file mode 100644 index 0000000000000000000000000000000000000000..5b307d1814cd3b79cd5cc2baf135b8b655c15fe5 Binary files /dev/null and b/osmnx/source/tests/input_data/elevation2.tif differ diff --git a/osmnx/source/tests/input_data/osm_schema.xsd b/osmnx/source/tests/input_data/osm_schema.xsd new file mode 100644 index 0000000000000000000000000000000000000000..5afea91dceb4f3e1c3c7bc97683c5221b4f24c5a --- /dev/null +++ b/osmnx/source/tests/input_data/osm_schema.xsd @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/osmnx/source/tests/input_data/planet_10.068,48.135_10.071,48.137.osm b/osmnx/source/tests/input_data/planet_10.068,48.135_10.071,48.137.osm new file mode 100644 index 0000000000000000000000000000000000000000..58a9755b70c8cc42fe486e09a6cce12eb7a89cc0 --- /dev/null +++ b/osmnx/source/tests/input_data/planet_10.068,48.135_10.071,48.137.osm @@ -0,0 +1,992 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/osmnx/source/tests/input_data/short.graphml b/osmnx/source/tests/input_data/short.graphml new file mode 100644 index 0000000000000000000000000000000000000000..6b93e6f86f1fd89c7aaf724fa3b9db6d5634a3ea --- /dev/null +++ b/osmnx/source/tests/input_data/short.graphml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + 39.9980178 + -86.3459007 + 3 + + + diff --git a/osmnx/source/tests/latest_lint_test.sh b/osmnx/source/tests/latest_lint_test.sh new file mode 100644 index 0000000000000000000000000000000000000000..23b57ccb346827a56ddf429840c2f2a0a39ddf5d --- /dev/null +++ b/osmnx/source/tests/latest_lint_test.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -euo pipefail + +# delete temp files and folders +rm -r -f ./.coverage* ./.pytest_cache ./.temp ./dist ./docs/build ./*/__pycache__ + +# activate the virtual environment with pre-releases +uv python pin 3.14 +uv sync --all-extras --all-groups --upgrade --prerelease=allow +source .venv/bin/activate + +# run tests +SKIP=no-commit-to-branch prek run --all-files +pytest --typeguard-packages=osmnx --cov=osmnx --cov-report=term-missing:skip-covered + +# restore the environment without pre-releases +uv python pin --rm +uv sync --all-extras --all-groups --upgrade + +# delete temp files and folders +rm -r -f ./.coverage* ./.pytest_cache ./.temp ./dist ./docs/build ./*/__pycache__ diff --git a/osmnx/source/tests/lint_test.sh b/osmnx/source/tests/lint_test.sh new file mode 100644 index 0000000000000000000000000000000000000000..729014fbda36072a83494df629c43616d7264d29 --- /dev/null +++ b/osmnx/source/tests/lint_test.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -euo pipefail + +# activate the virtual environment +source .venv/bin/activate + +# delete temp files and folders +rm -r -f ./.coverage* ./.pytest_cache ./.temp ./dist ./docs/build ./*/__pycache__ + +# run the pre-commit hooks for linting/formatting +SKIP=no-commit-to-branch prek run --all-files + +# build and validate the package +uv build +twine check --strict ./dist/* +validate-pyproject ./pyproject.toml + +# run the tests and report the test coverage +pytest --typeguard-packages=osmnx --cov=osmnx --cov-report=term-missing:skip-covered + +# build the docs and test that links are alive +sphinx-build -q -a -E -W --keep-going -b html ./docs/source ./docs/build/html +sphinx-build -q -a -E -W --keep-going -b linkcheck ./docs/source ./docs/build/linkcheck + +# delete temp files and folders +rm -r -f ./.coverage* ./.pytest_cache ./.temp ./dist ./docs/build ./*/__pycache__ diff --git a/osmnx/source/tests/minimal_lint_test.sh b/osmnx/source/tests/minimal_lint_test.sh new file mode 100644 index 0000000000000000000000000000000000000000..43a1241a3a6518e7e15e1552d7005b73b97ca7e5 --- /dev/null +++ b/osmnx/source/tests/minimal_lint_test.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -euo pipefail + +# delete temp files and folders +rm -r -f ./.coverage* ./.pytest_cache ./.temp ./dist ./docs/build ./*/__pycache__ + +# activate the virtual environment with pre-releases +uv python pin 3.11 +uv sync --all-extras --group test --upgrade --resolution=lowest-direct +source .venv/bin/activate + +# run tests +python ./tests/verify_min_deps.py +pytest -W ignore + +# restore the environment without pre-releases +uv python pin --rm +uv sync --all-extras --all-groups --upgrade + +# delete temp files and folders +rm -r -f ./.coverage* ./.pytest_cache ./.temp ./dist ./docs/build ./*/__pycache__ diff --git a/osmnx/source/tests/prune.sh b/osmnx/source/tests/prune.sh new file mode 100644 index 0000000000000000000000000000000000000000..acbc4d6907306111b33e19fc65061c5e442828c2 --- /dev/null +++ b/osmnx/source/tests/prune.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -euo pipefail + +# clean the caches of pip and uv +pip cache purge +uv cache clean + +# deactivate conda and clean its cache +eval "$(conda shell.bash hook)" +conda deactivate +conda clean --all --yes + +# remove unused docker images, data, and local volumes +docker image prune -af && docker system prune -af && docker volume prune -af + +# prune, repack, and garbage collect git +git remote prune origin +git repack -a -d -f --depth=250 --window=250 +git prune-packed +git reflog expire --expire=1.month.ago +git gc --aggressive diff --git a/osmnx/source/tests/test_osmnx.py b/osmnx/source/tests/test_osmnx.py new file mode 100644 index 0000000000000000000000000000000000000000..f474133454f00754ad53c6cda8122a28548ce574 --- /dev/null +++ b/osmnx/source/tests/test_osmnx.py @@ -0,0 +1,871 @@ +#!/usr/bin/env python +# ruff: noqa: F841, 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 + +mpl.use("Agg") + +import bz2 +import gzip +import logging as lg +import os +import tempfile +from collections import OrderedDict +from pathlib import Path + +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 as RequestsConnectionError +from shapely import Point +from shapely import Polygon +from shapely import wkt +from typeguard import suppress_type_checks + +import osmnx as ox + +ox.settings.log_console = True +ox.settings.log_file = True +ox.settings.use_cache = True +ox.settings.data_folder = ".temp/data" +ox.settings.logs_folder = ".temp/logs" +ox.settings.imgs_folder = ".temp/imgs" +ox.settings.cache_folder = ".temp/cache" + +# define queries to use throughout tests +location_point = (37.791427, -122.410018) +polar_point_south = (-84.5501149, -64.1500283) +polar_point_north = (85.0511092, -30.4142117) + +address = "Transamerica Pyramid, 600 Montgomery Street, San Francisco, California, USA" +place1 = {"city": "Piedmont", "state": "California", "country": "USA"} +polygon_wkt = ( + "POLYGON ((-122.262 37.869, -122.255 37.869, -122.255 37.874, " + "-122.262 37.874, -122.262 37.869))" +) +polygon = ox.utils_geo.buffer_geometry(geom=wkt.loads(polygon_wkt), dist=1) + + +@pytest.mark.xdist_group(name="group1") +def test_logging() -> None: + """Test the logger.""" + 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.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") + + +@pytest.mark.xdist_group(name="group1") +def test_exceptions() -> None: + """Test the custom errors.""" + message = "testing exception" + + with pytest.raises(ox._errors.CacheOnlyInterruptError): + raise ox._errors.CacheOnlyInterruptError(message) + + with pytest.raises(ox._errors.GraphSimplificationError): + raise ox._errors.GraphSimplificationError(message) + + with pytest.raises(ox._errors.ValidationError): + raise ox._errors.ValidationError(message) + + with pytest.raises(ox._errors.InsufficientResponseError): + raise ox._errors.InsufficientResponseError(message) + + with pytest.raises(ox._errors.ResponseStatusCodeError): + raise ox._errors.ResponseStatusCodeError(message) + + +@pytest.mark.xdist_group(name="group1") +def test_validating() -> None: # noqa: PLR0915 + """Test validating graph inputs and objects.""" + # validate graph edge attribute is numeric and non-null + G = nx.MultiDiGraph() + G.add_edge(0, 1) + with pytest.raises(ox._errors.ValidationError): + ox._validate._verify_numeric_edge_attribute(G, "length", strict=True) + + # features GeoDataFrame validation + # pass in gdf with missing geometries and non-unique, non-multi index + with pytest.raises(ox._errors.ValidationError): + ox.convert.validate_features_gdf(gpd.GeoDataFrame(index=[0, 0])) + + # node/edge GeoDataFrame validation + # pass in wrong types, bad indexes, and missing x/y columns + gdf_nodes = pd.DataFrame(index=[0, 0]) + gdf_edges = pd.DataFrame() + with suppress_type_checks(), pytest.raises(ox._errors.ValidationError): + ox.convert.validate_node_edge_gdfs(gdf_nodes, gdf_edges) + + # pass in non-Point node geometries + gdf_nodes = gpd.GeoDataFrame(geometry=[Polygon(), Polygon()]) + gdf_edges = gpd.GeoDataFrame() + with pytest.raises(ox._errors.ValidationError): + ox.convert.validate_node_edge_gdfs(gdf_nodes, gdf_edges) + + # pass in x/y not matching geometries + data = {"x": [0, 1], "y": [2, 3]} + gdf_nodes = gpd.GeoDataFrame(data=data, geometry=[Point((6, 7)), Point((8, 9))]) + gdf_edges = gpd.GeoDataFrame() + with pytest.raises(ox._errors.ValidationError): + ox.convert.validate_node_edge_gdfs(gdf_nodes, gdf_edges) + + # graph validation + # pass an empty non-MultiDiGraph + G = nx.Graph() + with suppress_type_checks(), pytest.raises(ox._errors.ValidationError): + ox.convert.validate_graph(G) + + # test missing top-level graph attribute and non-int node IDs + G = nx.MultiDiGraph() + del G.graph + G.add_edge("0", "1") + with pytest.raises(ox._errors.ValidationError): + ox.convert.validate_graph(G) + + # pass an empty MultiDiGraph with an invalid CRS + G = nx.MultiDiGraph() + G.graph["crs"] = "epsg:999999" + with pytest.raises(ox._errors.ValidationError): + ox.convert.validate_graph(G) + + # fix the CRS and add an edge + G.graph["crs"] = "epsg:4326" + G.add_edge(0, 1) + with pytest.raises(ox._errors.ValidationError): + ox.convert.validate_graph(G) + + # add required node attributes, but with invalid types + nx.set_node_attributes(G, values=None, name="x") + nx.set_node_attributes(G, values=None, name="y") + nx.set_node_attributes(G, values=None, name="street_count") + with pytest.raises(ox._errors.ValidationError): + ox.convert.validate_graph(G) + + # fix the invalid node attribute types + nx.set_node_attributes(G, values=0, name="x") + nx.set_node_attributes(G, values=0, name="y") + nx.set_node_attributes(G, values=None, name="street_count") + with pytest.raises(ox._errors.ValidationError): + ox.convert.validate_graph(G) + + # add required edge attributes, but with invalid types + nx.set_edge_attributes(G, values=None, name="osmid") + nx.set_edge_attributes(G, values=None, name="length") + with pytest.raises(ox._errors.ValidationError): + ox.convert.validate_graph(G) + + # fix the invalid node attribute types: should finally pass validation + nx.set_edge_attributes(G, values=[0], name="osmid") + nx.set_edge_attributes(G, values=1.5, name="length") + ox.convert.validate_graph(G) + + +@pytest.mark.xdist_group(name="group1") +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) + 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, California, USA") + + +@pytest.mark.xdist_group(name="group1") +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], 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]) + + # calculate stats + cspn = ox.stats.count_streets_per_node(G) + stats = ox.basic_stats(G) + stats = ox.basic_stats(G, area=1000) + stats = ox.basic_stats(G_proj, area=1000, clean_int_tol=15) + + # 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_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 = dict.fromkeys(G_proj.nodes, 5) + 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) + + +@pytest.mark.xdist_group(name="group1") +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") + _, ax = ox.plot.plot_orientation(Gu, area=True, title="Title") + _, _ = 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] + + +@pytest.mark.xdist_group(name="group1") +def test_osm_xml() -> None: + """Test working with .osm XML data.""" + # test loading a graph from a local .osm xml (and bz2 and gzip) file + node_id = 53098262 + neighbor_ids = 53092170, 53060438, 53027353, 667744075 + + # read the contents of the bzip2 file + path_bz2 = "tests/input_data/West-Oakland.osm.bz2" + with bz2.open(path_bz2, mode="rb") as f: + file_contents = f.read() + + # write the contents to a .osm file + path_osm_temp = path_bz2.strip(".bz2") + with Path(path_osm_temp).open("wb") as f: + f.write(file_contents) + + # write the contents to a gzip file + path_gz_temp = path_osm_temp + ".gz" + with gzip.open(path_gz_temp, mode="wb") as f: + f.write(file_contents) + + # load and test graph_from_xml across the .osm, .bz2, and .gz files + for filepath in (path_bz2, path_gz_temp, path_osm_temp): + G = ox.graph_from_xml(filepath) + ox.convert.validate_graph(G, strict=False) # non-strict because nodes lack street_count + assert node_id in G.nodes + + for neighbor_id in neighbor_ids: + edge_key = (node_id, neighbor_id, 0) + assert neighbor_id in G.nodes + assert edge_key in G.edges + assert G.edges[edge_key]["name"] in {"8th Street", "Willow Street"} + + # delete the temporary .osm and .gz files + Path.unlink(Path(path_osm_temp)) + Path.unlink(Path(path_gz_temp)) + + # 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"}) + + # 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) + + # 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) + ox.io.save_graph_xml(G) + _ = etree.parse(fp, parser=parser) + + # 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)) + ox.convert.validate_graph(Gc) + 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 + ox.convert.validate_graph(Gc, strict=False) # non-strict because nodes lack street_count + _ = etree.parse(fp, parser=parser) + + # restore settings + ox.settings.overpass_settings = default_overpass_settings + ox.settings.all_oneway = default_all_oneway + + +@pytest.mark.xdist_group(name="group1") +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="", batch_size=350) + + # add node elevations from Open Topo Data (works without API key) + ox.settings.elevation_url_template = ( + "https://api.opentopodata.org/v1/aster30m?locations={locations}&key={key}" + ) + _ = ox.elevation.add_node_elevations_google(G, batch_size=100, pause=1) + + # same thing again, to hit the cache + _ = ox.elevation.add_node_elevations_google(G, batch_size=100, pause=0) + + # 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) + assert pd.notna(pd.Series(dict(G.nodes(data="elevation")))).any() + + # add node elevations from multiple raster files (no nodes should be null) + 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) + + +@pytest.mark.xdist_group(name="group1") +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.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 + assert ox.routing._clean_maxspeed("100.2") == 100.2 + assert ox.routing._clean_maxspeed("100 km/h") == 100.0 + assert ox.routing._clean_maxspeed("100 mph") == pytest.approx(160.934) + assert ox.routing._clean_maxspeed("60|100") == 80 + 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.25685 + + # test collapsing invalid values: should return None + assert ox.routing._collapse_multiple_maxspeed_values(["mph", "kph"], np.mean) is None + + orig_x = np.array([-122.404771]) + dest_x = np.array([-122.401429]) + orig_y = np.array([37.794302]) + dest_y = np.array([37.794987]) + 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 + with pytest.raises(ValueError, match="contains non-numeric values"): + route1 = ox.shortest_path(G, orig_node, dest_node, weight="highway") + + # 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): + route2 = ox.shortest_path(G, [orig_node] * 2, [dest_node] * 3) + + # test missing weight (should raise warning) + route3 = ox.shortest_path(G, orig_node, dest_node, weight="time") + # test good weight + 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.routing.route_to_gdf(G, route5, weight="travel_time") + + _, _ = ox.plot_graph_route(G, route5, save=True) + + # test multiple origins-destinations + n = 5 + nodes = np.array(G.nodes) + 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.routing.k_shortest_paths(G, orig_node, dest_node, k=2, weight="travel_time") + _, _ = ox.plot_graph_routes(G, list(routes)) + + # test great circle and euclidean distance calculators + 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) + + +@pytest.mark.xdist_group(name="group1") +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 + 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) + + # plot and save to disk + filepath = Path(ox.settings.data_folder) / "test.svg" + _, ax = ox.plot_graph(G, show=False, save=True, close=True, filepath=filepath) + _, ax = ox.plot_graph(Gp, edge_linewidth=0, figsize=(5, 5), bgcolor="y") + _, ax = ox.plot_graph( + Gp, + ax=ax, + dpi=180, + node_color="k", + node_size=5, + node_alpha=0.1, + node_edgecolor="b", + node_zorder=5, + edge_color="r", + edge_linewidth=2, + edge_alpha=0.1, + show=False, + save=True, + close=True, + ) + + # figure-ground plots + _, _ = ox.plot_figure_ground(G=G) + + +@pytest.mark.xdist_group(name="group1") +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.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) + _, _ = ox.distance.nearest_nodes(G, X[0], Y[0], return_dist=True) + nn1 = ox.distance.nearest_nodes(Gp, X[0], Y[0], return_dist=False) + + # get nearest edge + _ = 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) + + +@pytest.mark.xdist_group(name="group1") +def test_endpoints() -> None: + """Test different API endpoints.""" + default_requests_timeout = ox.settings.requests_timeout + default_key = ox.settings.nominatim_key + 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.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._http._resolve_host_via_doh("overpass-api.de") + ox.settings.doh_url_template = None + 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_url = "http://NOT_A_VALID_ENDPOINT/api/" + with pytest.raises(RequestsConnectionError, 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.requests_timeout = default_requests_timeout + + params: OrderedDict[str, int | str] = OrderedDict() + params["format"] = "json" + params["address_details"] = 0 + + # Bad Address - should return an empty response + params["q"] = "AAAAAAAAAAA" + response_json = ox._nominatim._nominatim_request(params=params, request_type="search") + + # Good Address - should return a valid response with a valid osm_id + params["q"] = "Newcastle A186 Westgate Rd" + response_json = ox._nominatim._nominatim_request(params=params, request_type="search") + + # Lookup + params = OrderedDict() + params["format"] = "json" + 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"): + 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="lookup") + + ox.settings.nominatim_key = default_key + ox.settings.nominatim_url = default_nominatim_url + ox.settings.overpass_url = default_overpass_url + + +@pytest.mark.xdist_group(name="group1") +def test_save_load() -> None: # noqa: PLR0915 + """Test saving/loading graphs to/from disk.""" + G = ox.graph_from_point(location_point, dist=500, network_type="drive") + ox.convert.validate_graph(G) + + # 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") + gdf_edges1 = gpd.read_file(fp, layer="edges").set_index(["u", "v", "key"]) + G2 = ox.convert.graph_from_gdfs(gdf_nodes1, gdf_edges1) + ox.convert.validate_graph(G2, strict=False) # non-strict because osmid wasn't loaded as int + G2 = ox.graph_from_gdfs(gdf_nodes1, gdf_edges1, graph_attrs=G.graph) + ox.convert.validate_graph(G2, strict=False) # non-strict because osmid wasn't loaded as int + gdf_nodes2, gdf_edges2 = ox.convert.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"): + ox.graph_to_gdfs(G2, nodes=False, edges=False) + with pytest.raises(ValueError, match="Invalid literal for boolean"): + ox.io._convert_bool_string("T") + + # create random boolean graph/node/edge attributes + attr_name = "test_bool" + G.graph[attr_name] = False + bools = np.random.default_rng().integers(low=0, high=2, size=len(G.nodes)) + node_attrs = {n: bool(b) for n, b in zip(G.nodes, bools, strict=True)} + nx.set_node_attributes(G, node_attrs, attr_name) + bools = np.random.default_rng().integers(low=0, high=2, size=len(G.edges)) + edge_attrs = {n: bool(b) for n, b in zip(G.edges, bools, strict=True)} + nx.set_edge_attributes(G, edge_attrs, attr_name) + + # create list, set, and dict attributes for nodes and edges + rand_ints_nodes = np.random.default_rng().integers(low=0, high=10, size=len(G.nodes)) + rand_ints_edges = np.random.default_rng().integers(low=0, high=10, size=len(G.edges)) + list_node_attrs = {n: [n, int(r)] for n, r in zip(G.nodes, rand_ints_nodes, strict=True)} + nx.set_node_attributes(G, list_node_attrs, "test_list") + list_edge_attrs = {e: [e, int(r)] for e, r in zip(G.edges, rand_ints_edges, strict=True)} + nx.set_edge_attributes(G, list_edge_attrs, "test_list") + set_node_attrs = {n: {n, int(r)} for n, r in zip(G.nodes, rand_ints_nodes, strict=True)} + nx.set_node_attributes(G, set_node_attrs, "test_set") + set_edge_attrs = {e: {e, int(r)} for e, r in zip(G.edges, rand_ints_edges, strict=True)} + nx.set_edge_attributes(G, set_edge_attrs, "test_set") + dict_node_attrs = {n: {n: int(r)} for n, r in zip(G.nodes, rand_ints_nodes, strict=True)} + nx.set_node_attributes(G, dict_node_attrs, "test_dict") + dict_edge_attrs = {e: {e: int(r)} for e, r in zip(G.edges, rand_ints_edges, strict=True)} + nx.set_edge_attributes(G, dict_edge_attrs, "test_dict") + + # save/load graph as graphml file + ox.save_graphml(G, gephi=True) + ox.save_graphml(G, gephi=False) + ox.save_graphml(G, gephi=False, filepath=fp) + G2 = ox.load_graphml( + fp, + graph_dtypes={attr_name: ox.io._convert_bool_string}, + node_dtypes={attr_name: ox.io._convert_bool_string}, + edge_dtypes={attr_name: ox.io._convert_bool_string}, + ) + ox.convert.validate_graph(G2) + + # verify everything in G is equivalent in G2 + assert tuple(G.graph.keys()) == tuple(G2.graph.keys()) + assert tuple(G.graph.values()) == tuple(G2.graph.values()) + z = zip(G.nodes(data=True), G2.nodes(data=True), strict=True) + for (n1, d1), (n2, d2) in z: + assert n1 == n2 + assert tuple(d1.keys()) == tuple(d2.keys()) + assert tuple(d1.values()) == tuple(d2.values()) + z = zip(G.edges(keys=True, data=True), G2.edges(keys=True, data=True), strict=True) + for (u1, v1, k1, d1), (u2, v2, k2, d2) in z: + assert u1 == u2 + assert v1 == v2 + assert k1 == k2 + assert tuple(d1.keys()) == tuple(d2.keys()) + assert tuple(d1.values()) == tuple(d2.values()) + + # test custom data types + nd = {"osmid": str} + ed = {"length": str, "osmid": float} + G2 = ox.load_graphml(fp, node_dtypes=nd, edge_dtypes=ed) + ox.convert.validate_graph(G2, strict=False) # non-strict because of non-standard types + + # test loading graphml from a file stream + graphml = Path("tests/input_data/short.graphml").read_text(encoding="utf-8") + G = ox.load_graphml(graphml_str=graphml, node_dtypes=nd, edge_dtypes=ed) + + +@pytest.mark.xdist_group(name="group2") +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) + _ = ox.utils_geo._consolidate_subdivide_geometry(poly) + + # graph from bounding box + _ = 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") + ox.convert.validate_graph(G) + G = ox.graph_from_bbox(bbox, network_type="drive_service", truncate_by_edge=True) + ox.convert.validate_graph(G) + + # truncate graph by bounding box + bbox = ox.utils_geo.bbox_from_point(location_point, dist=400) + G = ox.truncate.truncate_graph_bbox(G, bbox) + ox.convert.validate_graph(G) + G = ox.truncate.largest_component(G, strongly=True) + ox.convert.validate_graph(G) + + # graph from address + G = ox.graph_from_address(address=address, dist=500, dist_type="bbox", network_type="bike") + ox.convert.validate_graph(G) + + # graph from list of places + G = ox.graph_from_place([place1], which_result=[None], network_type="all") + ox.convert.validate_graph(G) + + # graph from polygon + G = ox.graph_from_polygon(polygon, network_type="walk", truncate_by_edge=True, simplify=False) + ox.convert.validate_graph(G) + G = ox.simplify_graph( + G, + node_attrs_include=["junction", "ref"], + edge_attrs_differ=["osmid"], + remove_rings=False, + track_merged=True, + ) + ox.convert.validate_graph(G) + + # test custom query filter + cf = ( + '["highway"]' + '["area"!~"yes"]' + '["highway"!~"motor|proposed|construction|abandoned|platform|raceway"]' + '["foot"!~"no"]' + '["service"!~"private"]' + '["access"!~"private"]' + ) + G = ox.graph_from_point( + location_point, + dist=500, + custom_filter=cf, + dist_type="bbox", + network_type="all_public", + ) + ox.convert.validate_graph(G) + + # test union of multiple custom filters + cf_union = ['["highway"~"tertiary"]', '["railway"~"tram"]'] + G = ox.graph_from_point(location_point, dist=500, custom_filter=cf_union, retain_all=True) + ox.convert.validate_graph(G) + + ox.settings.overpass_memory = 1073741824 + G = ox.graph_from_point( + location_point, + dist=500, + dist_type="network", + network_type="all", + ) + ox.convert.validate_graph(G) + + +@pytest.mark.xdist_group(name="group3") +def test_features() -> None: + """Test downloading features from Overpass.""" + 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.features_from_bbox(bbox=(-2.001, -2.001, -2.000, -2.000), tags={"building": True}) + + # features_from_bbox - successful + gdf = ox.features_from_bbox(bbox, tags=tags1) + _, ax = ox.plot_footprints(gdf) + _, ax = ox.plot_footprints(gdf, ax=ax, bbox=(0, 0, 10, 10)) + + # features_from_bbox - test < -80 deg latitude + tags2: dict[str, bool | str | list[str]] = {"natural": True, "amenity": True} + bbox = ox.utils_geo.bbox_from_point(polar_point_south, dist=500) + gdf = ox.features_from_bbox(bbox, tags=tags2) + + # features_from_bbox - test > 84 deg latitude + bbox = ox.utils_geo.bbox_from_point(polar_point_north, dist=500) + gdf = ox.features_from_bbox(bbox, tags=tags2) + + # features_from_point - tests multipolygon creation + gdf = ox.utils_geo.bbox_from_point(location_point, dist=500) + + # features_from_place - includes test of list of places + tags3: dict[str, bool | str | list[str]] = { + "amenity": True, + "landuse": ["retail", "commercial"], + "highway": "bus_stop", + } + gdf = ox.features_from_place(place1, tags=tags3) + gdf = ox.features_from_place([place1], which_result=[None], tags=tags3) + + # features_from_polygon + polygon = ox.geocode_to_gdf(place1).geometry.iloc[0] + ox.features_from_polygon(polygon, tags3) + + # 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.features_from_address(address, tags=tags3, dist=1000) + + # 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: + handle, temp_filename = tempfile.mkstemp(suffix=".osm") + os.write(handle, f.read()) + os.close(handle) + for filename in ("tests/input_data/West-Oakland.osm.bz2", temp_filename): + gdf = ox.features_from_xml(filename) + assert "Willow Street" in gdf["name"].to_numpy() + Path.unlink(Path(temp_filename)) + + # test the "island within a hole" and "touching inner rings" use cases + # https://wiki.openstreetmap.org/wiki/Relation:multipolygon#Island_within_a_hole + # https://wiki.openstreetmap.org/wiki/Relation:multipolygon#Touching_inner_rings + outer1 = Polygon(((0, 0), (4, 0), (4, 4), (0, 4))) + inner1 = Polygon(((1, 1), (2, 1), (2, 3), (1, 3))) + inner2 = Polygon(((2, 1), (3, 1), (3, 3), (2, 3))) + outer2 = Polygon(((1.5, 1.5), (2.5, 1.5), (2.5, 2.5), (1.5, 2.5))) + outer_polygons = [outer1, outer2] + inner_polygons = [inner1, inner2] + result = ox.features._remove_polygon_holes(outer_polygons, inner_polygons) + geom_wkt = ( + "MULTIPOLYGON (((4 4, 4 0, 0 0, 0 4, 4 4), " + "(3 1, 3 3, 2 3, 1 3, 1 1, 2 1, 3 1)), " + "((2.5 2.5, 2.5 1.5, 1.5 1.5, 1.5 2.5, 2.5 2.5)))" + ) + assert result.equals(wkt.loads(geom_wkt)) diff --git a/osmnx/source/tests/verify_min_deps.py b/osmnx/source/tests/verify_min_deps.py new file mode 100644 index 0000000000000000000000000000000000000000..5abcc26f0942f22d67c2a8b8efd726e413ec0ae6 --- /dev/null +++ b/osmnx/source/tests/verify_min_deps.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python +"""Verify that installed dependencies match minimum dependency versions.""" + +from importlib.metadata import version as metadata_version +from itertools import chain +from pathlib import Path +from tomllib import load as toml_load + +from packaging.requirements import Requirement + +# load the pyproject.toml file +with Path("./pyproject.toml").open("rb") as f: + pyproject = toml_load(f) + +# extract and pin all required + optional dependencies from pyproject +deps = [Requirement(d) for d in pyproject["project"]["dependencies"]] +opts = [v for k, v in pyproject["project"]["optional-dependencies"].items() if k != "all"] +deps.extend({Requirement(o) for o in chain.from_iterable(opts)}) +requirements = {dep.name: next(iter(dep.specifier)).version for dep in deps} +requirements = dict(sorted(requirements.items())) + +# check that installed versions match minimum versions +ok_msg = "" +err_msg = "" +for package, required_version in requirements.items(): + installed_version = metadata_version(package) + if installed_version.startswith(required_version): + ok_msg += f"\nExpected {package} {required_version}, matches {installed_version}." + else: + err_msg += f"\nExpected {package} {required_version}, found {installed_version}." + +# print ok message or raise error with error message +if err_msg == "": + ok_msg = "Installed dependencies match minimum dependency versions." + ok_msg + print(ok_msg) # noqa: T201 +else: + err_msg = "Installed dependencies do not match minimum dependency versions." + err_msg + raise ImportError(err_msg) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..1d24d2e4ce946d859ccfe00993b821f2cfc61c2e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +fastmcp +fastapi +uvicorn[standard] +pydantic>=2.0.0 +geopandas>=1.0.1 +networkx>=2.5 +numpy>=1.24 +pandas>=1.5 +requests>=2.27 +shapely>=2.0 +matplotlib diff --git a/run_docker.ps1 b/run_docker.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..3452d1208ffd63b9f89f6a9a97c000ad9ae2d4b6 --- /dev/null +++ b/run_docker.ps1 @@ -0,0 +1,35 @@ +cd $PSScriptRoot + +$ErrorActionPreference = "Stop" + +$entryName = if ($env:MCP_ENTRY_NAME) { $env:MCP_ENTRY_NAME } else { "osmnx" } +$entryUrl = if ($env:MCP_ENTRY_URL) { $env:MCP_ENTRY_URL } else { "http://localhost:7860/mcp" } +$imageName = if ($env:MCP_IMAGE_NAME) { $env:MCP_IMAGE_NAME } else { "osmnx-mcp" } + +$mcpDir = Join-Path $env:USERPROFILE ".cursor" +$mcpPath = Join-Path $mcpDir "mcp.json" +if (!(Test-Path $mcpDir)) { New-Item -ItemType Directory -Path $mcpDir | Out-Null } + +$config = @{} +if (Test-Path $mcpPath) { + try { $config = Get-Content $mcpPath -Raw | ConvertFrom-Json } catch { $config = @{} } +} + +# Rebuild mcpServers as ordered and append the entry last +$serversOrdered = [ordered]@{} +if ($config -and ($config.PSObject.Properties.Name -contains "mcpServers") -and $config.mcpServers) { + $existing = $config.mcpServers + if ($existing -is [pscustomobject]) { + foreach ($p in $existing.PSObject.Properties) { if ($p.Name -ne $entryName) { $serversOrdered[$p.Name] = $p.Value } } + } elseif ($existing -is [System.Collections.IDictionary]) { + foreach ($k in $existing.Keys) { if ($k -ne $entryName) { $serversOrdered[$k] = $existing[$k] } } + } +} +$serversOrdered[$entryName] = @{ url = $entryUrl } +$config = @{ mcpServers = $serversOrdered } + +$config | ConvertTo-Json -Depth 10 | Set-Content -Path $mcpPath -Encoding UTF8 +Write-Host ("Updated $entryName in " + $mcpPath + " -> " + $entryUrl) + +docker build -t $imageName . +docker run --rm -p 7860:7860 $imageName diff --git a/run_docker.sh b/run_docker.sh new file mode 100644 index 0000000000000000000000000000000000000000..eb9ae89f7d200e29ccfb0da4d4bae4af27f8447e --- /dev/null +++ b/run_docker.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Switch to the directory where this script is located +cd "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +mcp_entry_name="${MCP_ENTRY_NAME:-osmnx}" +mcp_entry_url="${MCP_ENTRY_URL:-http://localhost:7860/mcp}" +mcp_dir="${HOME}/.cursor" +mcp_path="${mcp_dir}/mcp.json" +mkdir -p "${mcp_dir}" + +if command -v python3 >/dev/null 2>&1; then +python3 - "${mcp_path}" "${mcp_entry_name}" "${mcp_entry_url}" <<'PY' +import json, os, sys +path, name, url = sys.argv[1:4] +cfg = {"mcpServers": {}} +if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + cfg = json.load(f) + except Exception: + cfg = {"mcpServers": {}} +if not isinstance(cfg, dict): + cfg = {"mcpServers": {}} +servers = cfg.get("mcpServers") +if not isinstance(servers, dict): + servers = {} +ordered = {} +for k, v in servers.items(): + if k != name: + ordered[k] = v +ordered[name] = {"url": url} +cfg = {"mcpServers": ordered} +with open(path, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2, ensure_ascii=False) +PY +elif command -v python >/dev/null 2>&1; then +python - "${mcp_path}" "${mcp_entry_name}" "${mcp_entry_url}" <<'PY' +import json, os, sys +path, name, url = sys.argv[1:4] +cfg = {"mcpServers": {}} +if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + cfg = json.load(f) + except Exception: + cfg = {"mcpServers": {}} +if not isinstance(cfg, dict): + cfg = {"mcpServers": {}} +servers = cfg.get("mcpServers") +if not isinstance(servers, dict): + servers = {} +ordered = {} +for k, v in servers.items(): + if k != name: + ordered[k] = v +ordered[name] = {"url": url} +cfg = {"mcpServers": ordered} +with open(path, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2, ensure_ascii=False) +PY +elif command -v jq >/dev/null 2>&1; then + name="${mcp_entry_name}"; url="${mcp_entry_url}" + if [ -f "${mcp_path}" ]; then + tmp="$(mktemp)" + jq --arg name "$name" --arg url "$url" ' + .mcpServers = (.mcpServers // {}) + | .mcpServers as $s + | ($s | with_entries(select(.key != $name))) as $base + | .mcpServers = ($base + {($name): {"url": $url}}) + ' "${mcp_path}" > "${tmp}" && mv "${tmp}" "${mcp_path}" + else + printf '{ "mcpServers": { "%s": { "url": "%s" } } } +' "$name" "$url" > "${mcp_path}" + fi +else + echo "Warning: neither python nor jq found; skipped updating ~/.cursor/mcp.json" >&2 +fi + +docker build -t osmnx-mcp . +docker run --rm -p 7860:7860 osmnx-mcp