├── .flake8 ├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── .mypy.ini ├── CONTRIBUTING.rst ├── Dockerfile ├── LICENSE ├── README.rst ├── build_docs.ps1 ├── data ├── dublin.pickle ├── gis_osm_roads_free_1.zip ├── rivers.pickle ├── stbrice.osmnx.pickle ├── stbrice.pickle └── tipperary.txt ├── demo ├── __init__.py ├── edges.py ├── isochrones.py ├── main.py ├── rivers.py ├── routes.py └── utils.py ├── dev_setup.ps1 ├── docs ├── _static │ └── placeholder.txt ├── api │ ├── functions.rst │ ├── linearref.rst │ ├── loader.rst │ └── routing.rst ├── conf.py ├── examples.rst ├── images │ ├── bottle_network.png │ ├── circle_network.png │ ├── double_loop_network.png │ ├── dual_path_middle_network.png │ ├── dual_path_network.png │ ├── dual_path_network_manual.png │ ├── logo.png │ ├── loop_middle_network.png │ ├── p_network.png │ ├── reverse_network.png │ ├── reversed_loop_network.png │ ├── simple_network.png │ ├── single_edge_loop_network.png │ ├── t_network.png │ └── triple_loop_network.png ├── index.rst ├── introduction.rst ├── network_comparison.rst ├── network_loading.rst ├── osmnx.rst ├── presentation.rst ├── self_loops.rst ├── solver.rst └── talk.rst ├── images ├── edges.png ├── isochrones.png ├── logo-small.png ├── logo.png ├── rivers.png └── route.png ├── requirements.demo.txt ├── requirements.txt ├── run_local.ps1 ├── scripts ├── benchmarking.py ├── convert_to_linestring.py ├── create_network_images.py ├── load_from_shapefile.py ├── load_osm_network.py ├── ordered_path.py └── ptals.py ├── setup.py ├── tests ├── __init__.py ├── helper.py ├── networks.py ├── test_functions.py ├── test_linearref.py ├── test_loader.py ├── test_loops.py ├── test_osmnx_compat.py ├── test_routing.py ├── test_routing_ordered_path.py ├── test_splitting.py └── test_validator.py ├── wayfarer.pyproj └── wayfarer ├── __init__.py ├── functions.py ├── io.py ├── linearref.py ├── loader.py ├── loops.py ├── merger.py ├── osmnx_compat.py ├── py.typed ├── routing.py ├── splitter.py └── validator.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 160 3 | exclude = 4 | build, 5 | dist 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Wayfarer Python Package 2 | 3 | # see https://stackoverflow.com/a/56422603/179520 4 | env: 5 | PY_IGNORE_IMPORTMISMATCH: 1 6 | 7 | on: [push, pull_request] 8 | 9 | jobs: 10 | test: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.10", 3.11, 3.12] # osmnx 1.9.4 does not support Python 3.13 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | # https://docs.github.com/en/actions/guides/building-and-testing-python#caching-dependencies 24 | # caching of pip files 25 | - name: Cache pip 26 | uses: actions/cache@v2 27 | with: 28 | path: ~/.cache/pip 29 | key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }} 30 | restore-keys: | 31 | ${{ runner.os }}-pip- 32 | ${{ runner.os }}- 33 | - name: Install project and dependencies 34 | run: | 35 | python -m pip install --upgrade pip 36 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 37 | pip install . 38 | - name: Install demo dependencies 39 | run: | 40 | pip install -r requirements.demo.txt 41 | - name: Lint with flake8 42 | run: | 43 | flake8 . 44 | - name: Check types with mypy 45 | run: | 46 | mypy wayfarer tests scripts 47 | #- name: Test doctests with pytest 48 | # run: | 49 | # PYTHONPATH=. pytest --doctest-modules 50 | - name: Test with pytest 51 | run: | 52 | PYTHONPATH=. pytest --doctest-modules --cov wayfarer --cov-report= tests/ 53 | - name: Upload coverage data to coveralls.io 54 | run: | 55 | coveralls 56 | env: 57 | COVERALLS_FLAG_NAME: ${{ matrix.test-name }} 58 | COVERALLS_PARALLEL: true 59 | # get token from https://coveralls.io/github/compassinformatics/wayfarer 60 | # and then add to https://github.com/compassinformatics/wayfarer/settings/secrets/actions 61 | # COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | - name: Build wheel and source distributions 64 | run: | 65 | pip install wheel 66 | pip install build 67 | python -m build --wheel 68 | python -m build --sdist 69 | 70 | - name: Build docs 71 | run: | 72 | sphinx-build -b html docs build/html/ 73 | 74 | - name: Deploy gh-pages 75 | uses: peaceiris/actions-gh-pages@v3 76 | if: github.ref == 'refs/heads/main' 77 | with: 78 | github_token: ${{ secrets.GITHUB_TOKEN }} 79 | publish_dir: ./build/html 80 | destination_dir: . 81 | 82 | - name: Publish package 83 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') && matrix.python-version == '3.10' 84 | uses: pypa/gh-action-pypi-publish@release/v1 85 | with: 86 | # https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ 87 | user: __token__ 88 | password: ${{ secrets.PYPI_API_TOKEN }} 89 | 90 | # see https://github.com/marketplace/actions/coveralls-github-action 91 | # we can't use coveralls-github-action as it doesn't support xml output 92 | # see https://coveralls-python.readthedocs.io/en/latest/usage/configuration.html#github-actions-support 93 | coveralls: 94 | name: Indicate completion to coveralls.io 95 | needs: test 96 | runs-on: ubuntu-latest 97 | container: python:3-slim 98 | steps: 99 | - name: Finished 100 | run: | 101 | pip3 install --upgrade coveralls 102 | coveralls --finish 103 | env: 104 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 105 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # visual studio 132 | .vs/ 133 | /cache 134 | 135 | /docs-build -------------------------------------------------------------------------------- /.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | 3 | #[mypy-networkx.algorithms] 4 | #ignore_missing_imports = True 5 | 6 | [mypy-networkx.*] 7 | ignore_missing_imports = True 8 | 9 | [mypy-osmnx.*] 10 | ignore_missing_imports = True 11 | 12 | [mypy-shapely.*] 13 | ignore_missing_imports = True 14 | 15 | [mypy-odbcutils.*] 16 | ignore_missing_imports = True 17 | 18 | [mypy-fiona.*] 19 | ignore_missing_imports = True 20 | 21 | [mypy-geopandas.*] 22 | ignore_missing_imports = True 23 | 24 | [mypy-matplotlib.*] 25 | ignore_missing_imports = True 26 | 27 | [mypy-geojson.*] 28 | ignore_missing_imports = True 29 | 30 | [mypy-shapefile.*] 31 | ignore_missing_imports = True -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Development Setup 2 | ----------------- 3 | 4 | Create a virtual environment for development: 5 | 6 | C:\Python310\Scripts\virtualenv C:\VirtualEnvs\wayfarer 7 | C:\VirtualEnvs\wayfarer\Scripts\activate 8 | 9 | cd /D C:\GitHub\wayfarer 10 | pip install -r requirements-dev.txt 11 | 12 | pip install -e C:\GitHub\wayfarer 13 | 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # cd D:\GitHub\wayfarer 2 | # docker context use default 3 | # docker build . --tag wayfarer 4 | # docker run --publish 8000:8000 wayfarer 5 | # docker exec -it wayfarer bash 6 | 7 | FROM python:3.10-slim-buster 8 | 9 | RUN pip3 install wayfarer 10 | 11 | # add in the demo requirements 12 | COPY requirements.demo.txt requirements.txt 13 | RUN pip3 install -r requirements.txt 14 | 15 | ENV PYTHONPATH "${PYTHONPATH}:/" 16 | 17 | COPY ./demo /demo 18 | COPY ./data /data 19 | WORKDIR /demo 20 | 21 | EXPOSE 8000 22 | CMD uvicorn main:app --host 0.0.0.0 --port 8000 23 | 24 | # http://localhost:8000/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Compass Informatics 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Wayfarer 2 | ======== 3 | 4 | .. image:: ../images/logo-small.png 5 | :alt: wayfarer logo 6 | :align: right 7 | 8 | | |Version| |Coveralls| |Downloads| 9 | 10 | Wayfarer is Python library for creating and analysing geospatial networks using `NetworkX `_. 11 | 12 | See the `Online Demo `_ to see examples of use cases for the library. 13 | 14 | See the `Wayfarer Presentation `_ for an overview presentation. 15 | 16 | Functionality 17 | ------------- 18 | 19 | Its focus is on creating user interfaces for selecting and creating linear features such as roads and rivers in a network. 20 | Uses shortest routes to provide a user interface quickly mark up linear features. 21 | 22 | Routing 23 | +++++++ 24 | 25 | .. image:: ../images/route.png 26 | :alt: Route solving 27 | :align: right 28 | 29 | Dynamic splitting - accessibility scenarios. Changes to isochrones. 30 | 31 | Linear Referencing 32 | ++++++++++++++++++ 33 | 34 | Linear referencing is a powerful technique used in Geographic Information Systems (GIS) to locate geographic features and events along a 35 | linear feature such as a road, river or pipeline. 36 | 37 | + Easy analysis and reporting of data along linear features 38 | + Efficient data storage - no need to duplicate linear features 39 | 40 | Why Use Wayfarer? 41 | ----------------- 42 | 43 | + Works with any data source (any database, flat files, Python dictionaries) 44 | + Compatibility with `osmnx `_ 45 | + Access to all of the NetworkX `Algorithms `_ 46 | + Allow an easy user interface for marking up features on linear referenced features. 47 | 48 | Comparison with Alternatives 49 | ---------------------------- 50 | 51 | + `pgRouting `_ - requires users to have a good understanding of SQL, PostGIS, and network analysis concepts. 52 | This can make it difficult for beginners to get started. PgRouting is designed to work with PostgreSQL and PostGIS, 53 | so users may need to convert their data to these formats before they can use the tool. 54 | This can be time-consuming and may require additional software or expertise. More familiar with Python than Postgres and SQL 55 | + Network Analyst is an extension for ArcGIS 56 | + GraphHopper 57 | + OSRM 58 | + OSMNX is a Python library for working with OpenStreetMap data and generating street networks. It includes tools for routing and network analysis, including shortest-path algorithms and tools for calculating network measures such as centrality and betweenness. OSMnx can be used to generate routing networks for a range of use cases, including transportation planning and urban design. 59 | + `Pandana `_ is a Python library for working with large-scale spatial networks, including road networks 60 | and public transit networks. It includes tools for network analysis and routing, as well as tools for generating spatial aggregates and 61 | conducting spatial queries. Pandana is designed to be fast and memory-efficient, making it well-suited for large-scale routing applications 62 | + `PyRoutelib `_ is a Python library for network routing that is built on top of NetworkX. 63 | It includes a range of routing algorithms, including Dijkstra's algorithm and A* search, as well as tools for working with routing 64 | profiles and generating directions. PyRoutelib is designed to be easy to use and can be integrated into other Python projects. 65 | 66 | Demo Applications 67 | ----------------- 68 | 69 | The demo applications use a Python back-end with wayfarer, and a JavaScript front-end build on `OpenLayers `_. 70 | The front-end code is stored in a separate repository at https://github.com/compassinformatics/wayfarer-demo/ 71 | 72 | To setup the code below you can run the following commands in a PowerShell terminal and using Windows. 73 | Alternatively you can use the `Dockerfile `_. 74 | 75 | .. code-block:: ps1 76 | 77 | # create a virtual environment and activate it 78 | virtualenv C:\VirtualEnvs\wayfarer 79 | C:\VirtualEnvs\wayfarer\Scripts\activate.ps1 80 | 81 | # check-out the latest version of the wayfarer project which include 82 | # the demo Python services and data 83 | git clone https://github.com/compassinformatics/wayfarer 84 | 85 | # install wayfarer and its requirements to a virtual environment 86 | cd C:\Temp\wayfarer 87 | pip install wayfarer 88 | pip install -r requirements.demo.txt 89 | 90 | # copy the data to the demo folder 91 | Copy-Item -Path demo -Destination C:\VirtualEnvs\wayfarer -Recurse 92 | Copy-Item -Path data -Destination C:\VirtualEnvs\wayfarer -Recurse 93 | 94 | # run the demo services as a Python web service 95 | cd C:\VirtualEnvs\wayfarer\demo 96 | uvicorn main:app --workers 4 --port 8001 97 | 98 | # should now be available at http://localhost:8001 99 | 100 | .. |Version| image:: https://img.shields.io/pypi/v/wayfarer.svg 101 | :target: https://pypi.python.org/pypi/wayfarer 102 | 103 | .. |Coveralls| image:: https://coveralls.io/repos/github/compassinformatics/wayfarer/badge.svg?branch=main 104 | :target: https://coveralls.io/github/compassinformatics/wayfarer?branch=main 105 | 106 | .. |Downloads| image:: http://pepy.tech/badge/wayfarer 107 | :target: http://pepy.tech/project/wayfarer -------------------------------------------------------------------------------- /build_docs.ps1: -------------------------------------------------------------------------------- 1 | $VENV_PATH="C:\VirtualEnvs\wayfarer" 2 | $PROJECT_PATH="D:\GitHub\wayfarer" 3 | cd $PROJECT_PATH 4 | ."$VENV_PATH\Scripts\activate.ps1" 5 | 6 | sphinx-build -w "$PROJECT_PATH\logs\sphinx.log" -b html "$PROJECT_PATH\docs" "$PROJECT_PATH\docs-build" 7 | 8 | # to run in a local browser 9 | $PROJECT_PATH="D:\GitHub\wayfarer" 10 | cd $PROJECT_PATH 11 | $VENV_PATH="C:\VirtualEnvs\wayfarer" 12 | ."$VENV_PATH\Scripts\activate.ps1" 13 | C:\Python310\python -m http.server --directory="$PROJECT_PATH\docs-build" 57920 14 | 15 | # http://localhost:57920 -------------------------------------------------------------------------------- /data/dublin.pickle: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/data/dublin.pickle -------------------------------------------------------------------------------- /data/gis_osm_roads_free_1.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/data/gis_osm_roads_free_1.zip -------------------------------------------------------------------------------- /data/rivers.pickle: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/data/rivers.pickle -------------------------------------------------------------------------------- /data/stbrice.osmnx.pickle: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/data/stbrice.osmnx.pickle -------------------------------------------------------------------------------- /data/stbrice.pickle: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/data/stbrice.pickle -------------------------------------------------------------------------------- /data/tipperary.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/data/tipperary.txt -------------------------------------------------------------------------------- /demo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/demo/__init__.py -------------------------------------------------------------------------------- /demo/edges.py: -------------------------------------------------------------------------------- 1 | """ 2 | pip install uvicorn[standard] 3 | pip install fastapi 4 | 5 | """ 6 | 7 | from fastapi import APIRouter 8 | from pydantic import BaseModel 9 | from wayfarer import routing, loader, io 10 | import logging 11 | from . import utils 12 | 13 | 14 | log = logging.getLogger("web") 15 | 16 | router = APIRouter() 17 | 18 | 19 | class ShortestPath(BaseModel): 20 | path: list[int | str] 21 | edits: str | None 22 | 23 | 24 | def get_network(fn): 25 | """ 26 | Can't cache this as any splits etc. modify the underlying dictionary 27 | """ 28 | log.debug(f"Loading network from {fn}") 29 | return loader.load_network_from_file(fn) 30 | 31 | 32 | @router.post("/solve_shortest_path_from_edges") 33 | async def solve_shortest_path_from_edges( 34 | shortest_path: ShortestPath, network_filename="../data/dublin.pickle" 35 | ): 36 | # net.copy() returns a shallow copy of the network, so attribute dicts are shared 37 | # need to use deepcopy here or the network is modified each time 38 | # net = deepcopy(get_network(network_filename)) 39 | 40 | log.info("Starting solve") 41 | net = get_network(network_filename) 42 | # add in the network modifications 43 | 44 | if shortest_path.edits: 45 | utils.add_edits_to_network(net, shortest_path.edits) 46 | 47 | # now run the routing - but what happens when the edges in the path are now split? 48 | edges = routing.solve_shortest_path_from_edges(net, shortest_path.path) 49 | 50 | for idx, e in enumerate(edges): 51 | e.attributes["index"] = idx 52 | 53 | # convert to GeoJSON 54 | return io.edges_to_featurecollection(edges) 55 | 56 | 57 | def test_solve_shortest_path_from_edges(): 58 | 59 | import asyncio 60 | 61 | edits = ( 62 | '{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"LineString",' 63 | '"coordinates":[[-699847.2947505378,7047362.468482355],[-699908.6370419887,7047120.352018594]]},' 64 | '"properties":{"startFeatureId":2401,"endFeatureId":2403},"id":-1}]}' 65 | ) 66 | 67 | edits = ( 68 | '{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"LineString",' 69 | '"coordinates":[[-699080.4219181875,7047263.789299513],[-698924.1756441435,7047311.084258694]]},' 70 | '"properties":{"startFeatureId":227,"endFeatureId":1254},"id":-1}]}' 71 | ) 72 | 73 | sp = ShortestPath(path=[384, 1575], edits=edits) 74 | print(sp.path) 75 | edges = asyncio.run( 76 | solve_shortest_path_from_edges(sp, network_filename="./data/dublin.pickle") 77 | ) 78 | print(edges) 79 | 80 | 81 | if __name__ == "__main__": 82 | test_solve_shortest_path_from_edges() 83 | print("Done!") 84 | -------------------------------------------------------------------------------- /demo/isochrones.py: -------------------------------------------------------------------------------- 1 | r""" 2 | A FastAPI router to handle solving 3 | upstream and downstream paths on a river network 4 | """ 5 | 6 | from fastapi import APIRouter 7 | import logging 8 | import osmnx as ox 9 | import networkx as nx 10 | from shapely.geometry import LineString, Point, Polygon 11 | from wayfarer import loader 12 | from pydantic import BaseModel 13 | from functools import lru_cache 14 | import geojson 15 | import geopandas as gpd 16 | 17 | 18 | router = APIRouter() 19 | log = logging.getLogger("web") 20 | 21 | 22 | class Coordinate(BaseModel): 23 | x: float 24 | y: float 25 | 26 | 27 | @lru_cache 28 | def get_readonly_network(fn): 29 | """ 30 | Can't cache this as any splits etc. modify the underlying dictionary 31 | """ 32 | log.debug(f"Loading network from {fn}") 33 | return loader.load_network_from_file(fn) 34 | 35 | 36 | def make_iso_polys( 37 | G, center_node, trip_times, edge_buff=25, node_buff=50, infill=False 38 | ): 39 | isochrone_polys = [] 40 | for trip_time in sorted(trip_times, reverse=True): 41 | subgraph = nx.ego_graph(G, center_node, radius=trip_time, distance="time") 42 | 43 | node_points = [ 44 | Point((data["x"], data["y"])) for node, data in subgraph.nodes(data=True) 45 | ] 46 | nodes_gdf = gpd.GeoDataFrame({"id": list(subgraph.nodes)}, geometry=node_points) 47 | nodes_gdf = nodes_gdf.set_index("id") 48 | 49 | edge_lines = [] 50 | 51 | for n_fr, n_to in subgraph.edges(): 52 | f = nodes_gdf.loc[n_fr].geometry 53 | t = nodes_gdf.loc[n_to].geometry 54 | 55 | # so get the first key for the edges between the nodes 56 | edges = G.get_edge_data(n_fr, n_to) 57 | edge_lookup = edges[next(iter(edges))].get("geometry", LineString([f, t])) 58 | 59 | edge_lines.append(edge_lookup) 60 | 61 | n = nodes_gdf.buffer(node_buff).geometry 62 | e = gpd.GeoSeries(edge_lines).buffer(edge_buff).geometry 63 | all_gs = list(n) + list(e) 64 | new_iso = gpd.GeoSeries(all_gs).unary_union 65 | 66 | # try to fill in surrounded areas so shapes will appear solid and 67 | # blocks without white space inside them 68 | if infill: 69 | new_iso = Polygon(new_iso.exterior) 70 | isochrone_polys.append(new_iso) 71 | 72 | return isochrone_polys 73 | 74 | 75 | @router.post("/isochrones") 76 | async def isochrones( 77 | isochrone_center: Coordinate, network_filename="../data/stbrice.osmnx.pickle" 78 | ): 79 | 80 | x = isochrone_center.x 81 | y = isochrone_center.y 82 | 83 | net = get_readonly_network(network_filename) 84 | # net = osmnx_compat.to_osmnx(net, geometry_field="geometry", crs="epsg:3857") 85 | 86 | # gdf_nodes = ox.graph_to_gdfs(net, edges=False) 87 | center_node = ox.distance.nearest_nodes(net, x, y) 88 | 89 | trip_times = [5, 10, 15, 20, 25, 30, 60] # in minutes 90 | travel_speed = 4.5 # walking speed in km/hour 91 | 92 | # add an edge attribute for time in minutes required to traverse each edge 93 | meters_per_minute = travel_speed * 1000 / 60 # km per hour to m per minute 94 | for _, _, _, data in net.edges(data=True, keys=True): 95 | data["time"] = data["length"] / meters_per_minute 96 | 97 | # get one color for each isochrone 98 | iso_colors = ox.plot.get_colors( 99 | n=len(trip_times), cmap="plasma", start=0, return_hex=True 100 | ) 101 | 102 | # make the isochrone polygons 103 | isochrone_polys = make_iso_polys( 104 | net, center_node, trip_times, edge_buff=25, node_buff=0, infill=True 105 | ) 106 | 107 | feats = [] 108 | for poly, time, color in zip(isochrone_polys, trip_times, iso_colors): 109 | f = geojson.Feature( 110 | id=time, 111 | geometry=poly.__geo_interface__, 112 | properties={"color": color, "time": time}, 113 | ) 114 | feats.append(f) 115 | 116 | # convert to GeoJSON 117 | return geojson.FeatureCollection(features=feats) 118 | 119 | 120 | def test_isochrones(): 121 | import asyncio 122 | 123 | # test_solve_shortest_path_from_points() 124 | iso = Coordinate(x=263316.62633553497, y=6275803.216269696) 125 | 126 | gj = asyncio.run(isochrones(iso, network_filename="./data/stbrice.osmnx.pickle")) 127 | 128 | print(gj) 129 | 130 | 131 | if __name__ == "__main__": 132 | test_isochrones() 133 | print("Done!") 134 | -------------------------------------------------------------------------------- /demo/main.py: -------------------------------------------------------------------------------- 1 | r""" 2 | pip install uvicorn[standard] 3 | pip install fastapi 4 | 5 | 6 | C:\VirtualEnvs\wayfarer\Scripts\activate.ps1 7 | cd D:\GitHub\wayfarer\demo 8 | uvicorn main:app --reload --port 8020 --log-level 'debug' 9 | 10 | http://127.0.0.1:8020/docs 11 | http://127.0.0.1:8020 12 | 13 | """ 14 | 15 | from fastapi import FastAPI 16 | 17 | # https://fastapi.tiangolo.com/tutorial/cors/ 18 | from fastapi.middleware.cors import CORSMiddleware 19 | import wayfarer 20 | import logging 21 | 22 | from demo import edges, isochrones, rivers, routes 23 | 24 | app = FastAPI() 25 | 26 | app.include_router(edges.router) 27 | app.include_router(isochrones.router) 28 | app.include_router(rivers.router) 29 | app.include_router(routes.router) 30 | 31 | 32 | origins = ["http://localhost:8020", "*"] # local development 33 | 34 | app.add_middleware( 35 | CORSMiddleware, 36 | allow_origins=origins, 37 | allow_credentials=True, # True, can only have True here with a specific set of origins 38 | allow_methods=["*"], 39 | allow_headers=["*"], 40 | ) 41 | 42 | # set the following to ensure there is output to the console 43 | # uvicorn sets the level to warning otherwise 44 | logging.basicConfig(level=logging.DEBUG) 45 | 46 | # logging.basicConfig( 47 | # filename=config.get_appconfig().log_file, 48 | # level=logging.DEBUG, 49 | # format="%(asctime)s:%(levelname)s:%(module)s:%(funcName)s: %(message)s", 50 | # datefmt="%Y-%m-%d %H:%M:%S", 51 | # ) 52 | 53 | # log = logging.getLogger("wayfarer") 54 | 55 | log = logging.getLogger("web") 56 | # ch = logging.StreamHandler() 57 | # ch.setLevel(logging.DEBUG) 58 | # log.addHandler(ch) 59 | 60 | 61 | @app.get("/") 62 | async def root(): 63 | return "Hello from the Wayfarer Web Example (wayfarer version {})!".format( 64 | wayfarer.__version__ 65 | ) 66 | -------------------------------------------------------------------------------- /demo/rivers.py: -------------------------------------------------------------------------------- 1 | r""" 2 | A FastAPI router to handle solving 3 | upstream and downstream paths on a river network 4 | """ 5 | 6 | from fastapi import APIRouter 7 | from networkx.algorithms.dag import descendants 8 | from networkx.algorithms.dag import ancestors 9 | from wayfarer import ( 10 | loader, 11 | functions, 12 | splitter, 13 | io, 14 | WITH_DIRECTION_FIELD, 15 | LENGTH_FIELD, 16 | GEOMETRY_FIELD, 17 | ) 18 | import wayfarer 19 | from wayfarer.splitter import SPLIT_KEY_SEPARATOR 20 | from functools import lru_cache 21 | import logging 22 | from pydantic import BaseModel 23 | from shapely.geometry import shape, Point 24 | 25 | router = APIRouter() 26 | log = logging.getLogger("web") 27 | 28 | 29 | # @lru_cache 30 | def get_network(fn): 31 | """ 32 | Can't cache this as any splits etc. modify the underlying dictionary 33 | """ 34 | log.debug(f"Loading network from {fn}") 35 | return loader.load_network_from_file(fn) 36 | 37 | 38 | @lru_cache 39 | def get_readonly_network(fn): 40 | """ 41 | Can't cache this as any splits etc. modify the underlying dictionary 42 | """ 43 | log.debug(f"Loading network from {fn}") 44 | return loader.load_network_from_file(fn) 45 | 46 | 47 | class Coordinate(BaseModel): 48 | x: float 49 | y: float 50 | edge_id: int 51 | 52 | 53 | def split_start_edge(net, edge_id, input_point): 54 | 55 | start_network_edge = functions.get_edge_by_key(net, edge_id) 56 | 57 | start_line = shape( 58 | start_network_edge.attributes[GEOMETRY_FIELD] 59 | ) # needs to be in the same projection 60 | 61 | start_measure = splitter.get_measure_for_point(start_line, input_point) 62 | start_node_id = splitter.get_split_node_for_measure( 63 | start_network_edge, start_line.length, start_measure 64 | ) 65 | 66 | if SPLIT_KEY_SEPARATOR in start_node_id: 67 | splitter.split_network_edge(net, edge_id, [start_measure]) 68 | 69 | return start_node_id 70 | 71 | 72 | def get_upstream_edges(net, node_id): 73 | 74 | upstream_nodes_ids = ancestors( 75 | net, node_id 76 | ) # use node_id_from as we will add the start edge separately 77 | 78 | upstream_nodes_ids.add( 79 | node_id 80 | ) # make sure we include the start node as well as the ancestors 81 | 82 | return net.in_edges(nbunch=upstream_nodes_ids, data=True, keys=True) 83 | 84 | 85 | def get_downstream_edges(net, node_id): 86 | """ 87 | Note this returns the edges in a random order 88 | """ 89 | downstream_nodes_ids = descendants( 90 | net, node_id 91 | ) # use node_id_to as we will add the start edge separately 92 | downstream_nodes_ids.add( 93 | node_id 94 | ) # make sure we include the start node as well as the ancestors 95 | return net.out_edges(nbunch=downstream_nodes_ids, data=True, keys=True) 96 | 97 | 98 | @router.post("/solve_upstream") 99 | async def solve_upstream(coords: Coordinate, network_filename="../data/rivers.pickle"): 100 | net = get_network(network_filename) 101 | 102 | edge_id = coords.edge_id 103 | start_point = Point(coords.x, coords.y) 104 | 105 | start_node_id = split_start_edge(net, edge_id, start_point) 106 | 107 | edges = get_upstream_edges(net, start_node_id) 108 | edges = wayfarer.to_edges(edges) 109 | 110 | for e in edges: 111 | e.attributes[WITH_DIRECTION_FIELD] = True 112 | e.attributes["FROM_M"] = 0 113 | e.attributes["TO_M"] = e.attributes[LENGTH_FIELD] 114 | 115 | # convert to GeoJSON 116 | return io.edges_to_featurecollection(edges) 117 | 118 | 119 | @router.post("/solve_downstream") 120 | async def solve_downstream( 121 | coords: Coordinate, network_filename="../data/rivers.pickle" 122 | ): 123 | net = get_network(network_filename) 124 | 125 | edge_id = coords.edge_id 126 | start_point = Point(coords.x, coords.y) 127 | 128 | start_node_id = split_start_edge(net, edge_id, start_point) 129 | 130 | edges = get_downstream_edges(net, start_node_id) 131 | edges = wayfarer.to_edges(edges) 132 | 133 | for e in edges: 134 | e.attributes[WITH_DIRECTION_FIELD] = True 135 | e.attributes["FROM_M"] = 0 136 | e.attributes["TO_M"] = e.attributes[LENGTH_FIELD] 137 | 138 | # convert to GeoJSON 139 | return io.edges_to_featurecollection(edges) 140 | 141 | 142 | def test_solve_downstream(): 143 | net = get_network("./data/rivers.pickle") 144 | print(functions.get_edge_by_key(net, 3)) 145 | print(functions.get_edge_by_key(net, 2287)) 146 | import asyncio 147 | 148 | coords = Coordinate(x=-1069103.5981220326, y=6866053.86210237, edge_id=129) 149 | gj = asyncio.run(solve_downstream(coords, network_filename="./data/rivers.pickle")) 150 | print(gj) 151 | 152 | 153 | if __name__ == "__main__": 154 | test_solve_downstream() 155 | print("Done!") 156 | -------------------------------------------------------------------------------- /demo/routes.py: -------------------------------------------------------------------------------- 1 | """ 2 | pip install uvicorn[standard] 3 | pip install fastapi 4 | 5 | """ 6 | 7 | from fastapi import APIRouter 8 | from pydantic import BaseModel 9 | from wayfarer import routing, loader, io, functions, splitter 10 | from wayfarer.splitter import SPLIT_KEY_SEPARATOR 11 | import logging 12 | import json 13 | from shapely.geometry import shape 14 | from collections import defaultdict 15 | from demo import utils 16 | 17 | log = logging.getLogger("web") 18 | 19 | router = APIRouter() 20 | 21 | 22 | def get_network(fn): 23 | """ 24 | Can't cache this as any splits etc. modify the underlying dictionary 25 | """ 26 | log.debug(f"Loading network from {fn}") 27 | return loader.load_network_from_file(fn) 28 | 29 | 30 | class ShortestPathPoints(BaseModel): 31 | points: str 32 | edits: str | None 33 | 34 | 35 | @router.post("/solve_shortest_path_from_points") 36 | async def solve_shortest_path_from_points( 37 | shortest_path_points: ShortestPathPoints, network_filename="../data/stbrice.pickle" 38 | ): 39 | net = get_network(network_filename) 40 | points_geojson = json.loads(shortest_path_points.points) 41 | 42 | if shortest_path_points.edits: 43 | split_edge_keys = utils.add_edits_to_network(net, shortest_path_points.edits) 44 | else: 45 | split_edge_keys = defaultdict(list) 46 | 47 | nodes = [] 48 | 49 | for feature in points_geojson["features"]: 50 | edge_id = feature["properties"]["edgeId"] 51 | index = feature["properties"]["index"] 52 | point = shape(feature["geometry"]) 53 | 54 | network_edge = functions.get_edge_by_key(net, edge_id) 55 | line = utils.get_geometry_for_edge(net, network_edge) 56 | measure = splitter.get_measure_for_point(line, point) 57 | node_id = splitter.get_split_node_for_measure( 58 | network_edge, line.length, measure 59 | ) 60 | if SPLIT_KEY_SEPARATOR in str(node_id): 61 | # the node_id indicates a split should take place - if at the start or end 62 | # then the node_id is simply a start or end node 63 | split_edge_keys[edge_id].append((index, node_id, measure)) 64 | nodes.append((index, node_id)) 65 | 66 | for edge_id, v in split_edge_keys.items(): 67 | measures = [n[2] for n in v] 68 | splitter.split_network_edge(net, edge_id, measures) 69 | 70 | # make sure nodes are sorted by the order they are entered in the UI 71 | sorted_nodes = [n[1] for n in sorted(nodes)] # type: list[int| str] 72 | route_nodes = routing.solve_shortest_path_from_nodes(net, sorted_nodes) 73 | 74 | edges = functions.get_edges_from_nodes(net, route_nodes, with_direction_flag=True) 75 | 76 | for e in edges: 77 | if e.attributes.get("IS_SPLIT", False) is True: 78 | e.attributes["FROM_M"] = int(e.attributes["OFFSET"]) 79 | e.attributes["TO_M"] = int(e.attributes["OFFSET"] + e.attributes["LEN_"]) 80 | else: 81 | e.attributes["FROM_M"] = 0 82 | e.attributes["TO_M"] = int(e.attributes["LEN_"]) 83 | 84 | # convert to GeoJSON 85 | return io.edges_to_featurecollection(edges) 86 | 87 | 88 | def test_solve_shortest_path_from_points(): 89 | import asyncio 90 | 91 | edits = ( 92 | '{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"LineString",' 93 | '"coordinates":[[262895.2264031371,6275790.488930107],[262901.97236427915,6275683.27458231]]},"properties":' 94 | '{"startFeatureId":460,"endFeatureId":462},"id":-1},{"type":"Feature","geometry":{"type":"LineString","coordinates":' 95 | '[[263326.24433953955,6275855.110074056],[263367.6774540128,6275880.598906959]]},"properties":{"startFeatureId":2281,"endFeatureId":691},"id":-2}]}' 96 | ) 97 | points = ( 98 | '{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point",' 99 | '"coordinates":[263316.62633553497,6275803.216269696]},"properties":{"edgeId":513,"index":0}},{"type":"Feature",' 100 | '"geometry":{"type":"Point","coordinates":[263175.7078335048,6276212.917601578]},"properties":{"edgeId":692,"index":1}}]}' 101 | ) 102 | 103 | sp = ShortestPathPoints(points=points, edits=edits) 104 | print(sp.points) 105 | edges = asyncio.run( 106 | solve_shortest_path_from_points(sp, network_filename="./data/stbrice.pickle") 107 | ) 108 | print(edges) 109 | 110 | 111 | if __name__ == "__main__": 112 | test_solve_shortest_path_from_points() 113 | print("Done!") 114 | -------------------------------------------------------------------------------- /demo/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | from shapely.geometry import shape, Point 3 | from collections import defaultdict 4 | from wayfarer import ( 5 | EDGE_ID_FIELD, 6 | LENGTH_FIELD, 7 | NODEID_FROM_FIELD, 8 | NODEID_TO_FIELD, 9 | GEOMETRY_FIELD, 10 | ) 11 | from wayfarer import functions, splitter 12 | from wayfarer.splitter import SPLIT_KEY_SEPARATOR 13 | 14 | 15 | def get_geometry_for_edge(net, network_edge): 16 | return shape( 17 | network_edge.attributes[GEOMETRY_FIELD] 18 | ) # needs to be in the same projection 19 | 20 | 21 | def add_edits_to_network(net, jsn: str): 22 | split_edge_keys = defaultdict(list) 23 | 24 | geojson_obj = json.loads(jsn) 25 | for idx, feature in enumerate(geojson_obj["features"]): 26 | feat_id = feature["id"] 27 | shapely_feature = shape(feature["geometry"]) 28 | 29 | start_edge_id = feature["properties"]["startFeatureId"] 30 | start_network_edge = functions.get_edge_by_key(net, start_edge_id) 31 | start_line = get_geometry_for_edge(net, start_network_edge) 32 | 33 | end_edge_id = feature["properties"]["endFeatureId"] 34 | end_network_edge = functions.get_edge_by_key(net, end_edge_id) 35 | end_line = get_geometry_for_edge(net, end_network_edge) 36 | 37 | sp = Point(shapely_feature.coords[0]) 38 | start_measure = splitter.get_measure_for_point(start_line, sp) 39 | start_node_id = splitter.get_split_node_for_measure( 40 | start_network_edge, start_line.length, start_measure 41 | ) 42 | 43 | if SPLIT_KEY_SEPARATOR in str(start_node_id): 44 | # a split should take place 45 | split_edge_keys[start_edge_id].append((idx, start_node_id, start_measure)) 46 | 47 | ep = Point(shapely_feature.coords[-1]) 48 | end_measure = splitter.get_measure_for_point(end_line, ep) 49 | end_node_id = splitter.get_split_node_for_measure( 50 | end_network_edge, end_line.length, end_measure 51 | ) 52 | 53 | if SPLIT_KEY_SEPARATOR in str(end_node_id): 54 | # a split should take place 55 | split_edge_keys[end_edge_id].append((idx, end_node_id, end_measure)) 56 | 57 | # now add the newly drawn line as a geometry 58 | attributes = { 59 | EDGE_ID_FIELD: feat_id, 60 | LENGTH_FIELD: shapely_feature.length, 61 | NODEID_FROM_FIELD: start_node_id, 62 | NODEID_TO_FIELD: end_node_id, 63 | GEOMETRY_FIELD: shapely_feature, 64 | } 65 | 66 | functions.add_edge( 67 | net, 68 | key=feat_id, 69 | start_node=start_node_id, 70 | end_node=end_node_id, 71 | attributes=attributes, 72 | ) 73 | 74 | return split_edge_keys 75 | -------------------------------------------------------------------------------- /dev_setup.ps1: -------------------------------------------------------------------------------- 1 | C:\Python310\python.exe -m pip install --upgrade pip 2 | C:\Python310\Scripts\virtualenv C:\VirtualEnvs\wayfarer 3 | 4 | $PROJECT_LOCATION = "D:\GitHub\wayfarer" 5 | cd $PROJECT_LOCATION 6 | 7 | C:\VirtualEnvs\wayfarer\Scripts\activate.ps1 8 | 9 | pip install -e . 10 | pip install -r requirements.txt 11 | pip install -r requirements.demo.txt 12 | -------------------------------------------------------------------------------- /docs/_static/placeholder.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/docs/_static/placeholder.txt -------------------------------------------------------------------------------- /docs/api/functions.rst: -------------------------------------------------------------------------------- 1 | Functions Module 2 | ================ 3 | 4 | .. automodule:: wayfarer.functions 5 | :members: -------------------------------------------------------------------------------- /docs/api/linearref.rst: -------------------------------------------------------------------------------- 1 | Linear Referencing Module 2 | ========================= 3 | 4 | .. automodule:: wayfarer.linearref 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/api/loader.rst: -------------------------------------------------------------------------------- 1 | Loader Module 2 | ============= 3 | 4 | .. automodule:: wayfarer.loader 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/api/routing.rst: -------------------------------------------------------------------------------- 1 | Routing Module 2 | ============== 3 | 4 | .. automodule:: wayfarer.routing 5 | :members: -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | import wayfarer # noqa: F401 10 | 11 | project = "Wayfarer" 12 | copyright = "2023, Compass Informatics" 13 | author = "Seth Girvin" 14 | release = "1.0" 15 | 16 | # -- General configuration --------------------------------------------------- 17 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 18 | 19 | extensions = [ 20 | "sphinx.ext.autodoc", 21 | "sphinx.ext.autosummary", 22 | "sphinx.ext.viewcode", 23 | "sphinx.ext.napoleon", 24 | "sphinx_autodoc_typehints", 25 | ] 26 | 27 | templates_path = ["_templates"] 28 | exclude_patterns = [] 29 | 30 | 31 | # -- Options for HTML output ------------------------------------------------- 32 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 33 | 34 | html_theme = "sphinx_material" 35 | 36 | html_show_sourcelink = False 37 | 38 | html_static_path = ["_static"] 39 | 40 | html_title = "Wayfarer" 41 | 42 | # These paths are either relative to html_static_path 43 | # or fully qualified paths (eg. https://...) 44 | html_css_files = [] 45 | 46 | # html_theme_options = { 47 | # "sidebar_secondary": {"remove": "true"} 48 | # } 49 | 50 | # https://pydata-sphinx-theme.readthedocs.io/en/stable/user_guide/configuring.html?highlight=html_sidebars#remove-the-sidebar-from-some-pages 51 | 52 | # html_sidebars = { 53 | # "**": [] 54 | # } 55 | 56 | html_js_files = [] 57 | 58 | napoleon_use_param = True 59 | -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ======== 3 | 4 | Casting results into a named tuple: 5 | 6 | .. sourcecode:: python 7 | 8 | from wayfarer import Edge 9 | edges = net.edges(nbunch=upstream_nodes_ids, data=True) 10 | edges = [Edge(*e) for e in edges] 11 | 12 | edge = edges[0] 13 | 14 | # get key 15 | edge_id = edge[2]["EDGE_ID"] 16 | 17 | edge_id = edge.key 18 | 19 | Now we can take advantage of using properties rather than indexes and strings. 20 | 21 | :-( above doesn't work as Edge expects a key.. 22 | 23 | ``Edge(start_node=105545731, end_node=104677819, key={'EDGE_ID': 103472683, 'LEN_': 1`` 24 | 25 | 26 | Would have to do.. 27 | 28 | .. sourcecode:: python 29 | 30 | edges = [Edge(e[0], e[1], attributes=e[2]) for e in edges] 31 | edge_ids = [e.attributes["EDGE_ID"] for e in edges] 32 | 33 | As keys aren't returned by https://networkx.org/documentation/stable/reference/classes/generated/networkx.Graph.edges.html -------------------------------------------------------------------------------- /docs/images/bottle_network.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/docs/images/bottle_network.png -------------------------------------------------------------------------------- /docs/images/circle_network.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/docs/images/circle_network.png -------------------------------------------------------------------------------- /docs/images/double_loop_network.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/docs/images/double_loop_network.png -------------------------------------------------------------------------------- /docs/images/dual_path_middle_network.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/docs/images/dual_path_middle_network.png -------------------------------------------------------------------------------- /docs/images/dual_path_network.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/docs/images/dual_path_network.png -------------------------------------------------------------------------------- /docs/images/dual_path_network_manual.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/docs/images/dual_path_network_manual.png -------------------------------------------------------------------------------- /docs/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/docs/images/logo.png -------------------------------------------------------------------------------- /docs/images/loop_middle_network.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/docs/images/loop_middle_network.png -------------------------------------------------------------------------------- /docs/images/p_network.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/docs/images/p_network.png -------------------------------------------------------------------------------- /docs/images/reverse_network.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/docs/images/reverse_network.png -------------------------------------------------------------------------------- /docs/images/reversed_loop_network.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/docs/images/reversed_loop_network.png -------------------------------------------------------------------------------- /docs/images/simple_network.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/docs/images/simple_network.png -------------------------------------------------------------------------------- /docs/images/single_edge_loop_network.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/docs/images/single_edge_loop_network.png -------------------------------------------------------------------------------- /docs/images/t_network.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/docs/images/t_network.png -------------------------------------------------------------------------------- /docs/images/triple_loop_network.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/docs/images/triple_loop_network.png -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | .. toctree:: 4 | :maxdepth: 1 5 | :caption: Contents: 6 | 7 | self 8 | examples.rst 9 | solver.rst 10 | api/functions.rst 11 | api/loader.rst 12 | api/linearref.rst 13 | api/routing.rst 14 | 15 | .. 16 | Indices and tables 17 | ================== 18 | 19 | * :ref:`genindex` 20 | * :ref:`modindex` 21 | * :ref:`search` 22 | -------------------------------------------------------------------------------- /docs/introduction.rst: -------------------------------------------------------------------------------- 1 | wayfarer 2 | ======== 3 | 4 | Features: 5 | 6 | + Uses reverse_lookup for faster access of edge by key 7 | + Supports line directions 8 | + Supports all networkx algorithms 9 | 10 | 11 | -------------------------------------------------------------------------------------------------------- benchmark: 2 tests -------------------------------------------------------------------------------------------------------- 12 | Name (time in ns) Min Max Mean StdDev Median IQR Outliers OPS (Kops/s) Rounds Iterations 13 | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ 14 | test_lookup_speed_with_reverse_lookup 300.0000 (1.0) 53,900.0000 (1.0) 447.2362 (1.0) 455.2966 (1.0) 400.0000 (1.0) 100.0000 (1.0) 824;3686 2,235.9549 (1.0) 192308 1 15 | test_lookup_speed_without_reverse_lookup 2,100.0000 (7.00) 60,300.0000 (1.12) 2,343.6191 (5.24) 1,012.9738 (2.22) 2,300.0000 (5.75) 100.0000 (1.0) 470;1928 426.6905 (0.19) 46083 1 16 | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ 17 | 18 | 19 | Additional "keys" dict associated with the graph 20 | 21 | { 22 | 12345: (0, 100), 23 | 12346: (100, 0) 24 | } 25 | 26 | Places in code to check for this and keep it updated: 27 | 28 | if "keys" in net.graph.keys(): 29 | 30 | Use Cases 31 | --------- 32 | 33 | EPA River Networks 34 | Prime2 transfer process 35 | PMS routing 36 | 37 | 38 | Edge Class 39 | ---------- 40 | 41 | .. sourcecode:: python 42 | 43 | >>> to_edge((0, 1, 1, {"LEN_": 10})) 44 | Edge(start_node=0, end_node=1, key=1, attributes={'LEN_': 10}) 45 | 46 | 47 | Requires Python 3.10 48 | Use of annotations: 49 | 50 | | was added in python 3.10, so not available in python 3.8. 51 | 52 | 53 | 54 | + Doc tests with examples 55 | + Test suite and networks -------------------------------------------------------------------------------- /docs/network_comparison.rst: -------------------------------------------------------------------------------- 1 | Comparing Networks 2 | ================== 3 | 4 | 5 | Used in the Pavement Management System to transfer data from one road network to another 6 | by comparing lines. 7 | 8 | Pass in a distance so the path that is closest to this distance is returned 9 | A key can also be included to ensure that a particular edge id is included. 10 | 11 | 12 | wayfarer.routing.solve_matching_path(net, start_node_id, end_node_id, distance=original_length, 13 | cutoff=cutoff, include_key=edge_to_include) 14 | -------------------------------------------------------------------------------- /docs/network_loading.rst: -------------------------------------------------------------------------------- 1 | Creating Networks 2 | ================= 3 | 4 | Each source and fields are different so load these outside project. 5 | 6 | Field names mapping as dict if not using standard? 7 | 8 | https://stackoverflow.com/questions/12496089/how-can-i-override-a-constant-in-an-imported-python-module 9 | 10 | Put in __init__.py 11 | 12 | 13 | Load with NODEID_FROM and TO when checking to see if with or against direction in a networkx.MultiGraph() 14 | 15 | If the line dataset does not contain attribute fields containing a nodeid from and a nodeid to, then the geometry 16 | can be used instead, by using objects that use the ``__geo_interface__``, see `here `_ 17 | for more details. 18 | 19 | If the ``fiona`` library is used then all records returned implement this interface. 20 | 21 | 22 | ``key_field`` must be an integer field, with no null values. 23 | 24 | MultiLineStrings 25 | ---------------- 26 | 27 | Note that only LineStrings are supported by the wayfarer library - any MultiLineStrings must be split into simple LineStrings 28 | before attempting to create a network. This is because each edge in the network can only have a single start and end node. 29 | 30 | 31 | Multipart to singleparts 32 | This algorithm takes a vector layer with multipart geometries and generates a new one in which all geometries 33 | contain a single part. Features with multipart geometries are divided in as many different features as parts the geometry 34 | contain, and the same attributes are used for each of them. 35 | 36 | 37 | 38 | File Geodatabase Example 39 | ------------------------ 40 | 41 | 42 | 43 | 44 | Python Code Examples 45 | -------------------- 46 | 47 | .. sourcecode:: python 48 | 49 | net = networkx.MultiGraph() 50 | net.add_edge(1, 2, key=1, **{LENGTH_FIELD: 10, EDGE_ID_FIELD: 1, NODEID_FROM_FIELD: 1, NODEID_TO_FIELD: 2}) 51 | net.add_edge(2, 3, key=2, **{LENGTH_FIELD: 10, EDGE_ID_FIELD: 2, NODEID_FROM_FIELD: 2, NODEID_TO_FIELD: 3}) 52 | net.add_edge(3, 4, key=3, **{LENGTH_FIELD: 10, EDGE_ID_FIELD: 3, NODEID_FROM_FIELD: 3, NODEID_TO_FIELD: 4}) 53 | net.add_edge(4, 5, key=4, **{LENGTH_FIELD: 10, EDGE_ID_FIELD: 4, NODEID_FROM_FIELD: 4, NODEID_TO_FIELD: 5}) 54 | 55 | return net 56 | 57 | Alternate: 58 | 59 | .. sourcecode:: python 60 | 61 | recs = [ 62 | { 63 | EDGE_ID_FIELD: 1, 64 | NODEID_FROM_FIELD: 1, 65 | NODEID_TO_FIELD: 2, 66 | LENGTH_FIELD: 10, 67 | }, 68 | { 69 | EDGE_ID_FIELD: 2, 70 | NODEID_FROM_FIELD: 2, 71 | NODEID_TO_FIELD: 3, 72 | LENGTH_FIELD: 10, 73 | }, 74 | { 75 | EDGE_ID_FIELD: 3, 76 | NODEID_FROM_FIELD: 3, 77 | NODEID_TO_FIELD: 4, 78 | LENGTH_FIELD: 10, 79 | }, 80 | { 81 | EDGE_ID_FIELD: 4, 82 | NODEID_FROM_FIELD: 4, 83 | NODEID_TO_FIELD: 5, 84 | LENGTH_FIELD: 10, 85 | }, 86 | ] 87 | return loader.load_network_from_records(recs) 88 | -------------------------------------------------------------------------------- /docs/osmnx.rst: -------------------------------------------------------------------------------- 1 | OSMNx Compatibility 2 | =================== 3 | 4 | OSMNx brings ability to download OSM data directly into networks, plotting, and isochrone analysis. 5 | 6 | To handle one-way OSMNX adds in all paths twice - once with reverse flag true and once with it set to false. 7 | 8 | An edge is in the following format: 9 | 10 | .. sourcecode:: python 11 | 12 | (53128729, 53149520, 0, {'osmid': 6400955, 'name': 'Hill Lane', 'highway': 'residential', 'oneway': False, 'reversed': False, 'length': 118.926}) 13 | 14 | See loader functions at https://github.com/gboeing/osmnx/blob/c034e2bf670bd8e9e46c5605bc989a7d916d58f3/osmnx/graph.py#L528 15 | 16 | .. sourcecode:: python 17 | 18 | [(53017091, 53064327, 0, {'osmid': 6345781, 'name': 'Rose Avenue', 'highway': 'residential', 'oneway': False, 'reversed': False, 'length': 229.891}), 19 | (53017091, 53075599, 0, {'osmid': 6345781, 'name': 'Rose Avenue', 'highway': 'residential', 'oneway': False, 'reversed': True, 'length': 121.11399999999999, 20 | 'geometry': }), 21 | 22 | Shapely LineStrings support pickling: https://shapely.readthedocs.io/en/stable/geometry.html#pickling 23 | Whereas ``save_graphml`` converts all attributes to strings, including attributes such as ``LEN_`` which wayfarer requires to be numeric. 24 | 25 | .. sourcecode:: python 26 | 27 | # ox.save_graphml(G, filepath) # this converts numeric attributes to strings 28 | # https://github.com/gboeing/osmnx/blob/c123c39e5c69159f87802cd6bf6dd79204019744/osmnx/io.py#L158 29 | # G = ox.load_graphml(filepath) # this sets the types based on field names 30 | 31 | # geometry is expected as shapely.geometry.linestring.LineString 32 | 33 | Nodes are in the format: 34 | 35 | .. sourcecode:: python 36 | 37 | (53022625, {'y': 37.8207744, 'x': -122.2323445, 'highway': 'turning_circle', 'street_count': 1}) -------------------------------------------------------------------------------- /docs/presentation.rst: -------------------------------------------------------------------------------- 1 | 2 | 3 | networkx 4 | 5 | what is it? 6 | examples? 7 | 8 | 9 | spatial networks - edges and nodes 10 | 11 | load example 12 | 13 | Other software - osmnx 14 | Load these networks into wayfarer? 15 | 16 | use cases? 17 | why not pgRouting? 18 | speed tests 19 | 20 | roadmap 21 | -------------------------------------------------------------------------------- /docs/self_loops.rst: -------------------------------------------------------------------------------- 1 | .. sourcecode:: python 2 | 3 | # add in any loops (this includes self-loops) 4 | # see https://networkx.org/documentation/stable/reference/algorithms/generated/networkx.algorithms.cycles.find_cycle.html 5 | # values are returned as a list of directed edges in the form [(u, v, k), (u, v, k),..] 6 | # the algorithm returns the first cycle found, traversing from the start_node - however 7 | # we want to then ensure that only cycles containing the node are included 8 | 9 | #if start_node == end_node: 10 | # try: 11 | # loop_edges = cycles.find_cycle(net, start_node) 12 | # loop_edges = (e for e in loop_edges if start_node in e) 13 | # # now remove the key from the tuple 14 | # nodes = ([e[:-1] for e in loop_edges]) 15 | # # now we can flatten into a list of nodes 16 | # nodes = itertools.chain(*nodes) 17 | # # now convert [(u, v), (u, v)] into a list of nodes without consecutive duplicates 18 | # # e.g. (1,2),(2,1) becomes [1,2,1] 19 | # nodes = [n[0] for n in itertools.groupby(nodes)] 20 | # print("loop", nodes) 21 | # all_shortest_paths.append(nodes) # = itertools.chain(all_shortest_paths, (nodes)) 22 | # except networkx.exception.NetworkXNoCycle: 23 | # pass -------------------------------------------------------------------------------- /docs/solver.rst: -------------------------------------------------------------------------------- 1 | Edge Solver 2 | =========== 3 | 4 | The edge solver allows users to select edges to create segments. Segments can only have a maximum of two 5 | unattached junctions to support linear referencing (measures / chainage). 6 | 7 | Loops and circular segments are allowed. The use cases below detail the currently supported network types in wayfarer. 8 | 9 | .. 10 | To note: 11 | 12 | + solver points are added wherever a user clicks - but the whole edge will be highlighted in yellow 13 | + points can be added to edges in any order and the routing will occur between all points - the path index 14 | will be preserved even in the cases where a user "back tracks" and clicks on previous edges 15 | + multiple points can be added to the same edge (however it will not affect the routing so has no purpose) 16 | 17 | .. image:: /images/edge_solver.png 18 | :align: center 19 | 20 | Supported Segments 21 | ------------------ 22 | 23 | The following network types are supported by the edge solver. 24 | 25 | .. image:: /images/simple_network.png 26 | :align: center 27 | :scale: 50% 28 | 29 | As above but with nodes in different orders: 30 | 31 | .. image:: /images/reverse_network.png 32 | :align: center 33 | :scale: 50% 34 | 35 | A loop at either end of the segment: 36 | 37 | .. image:: /images/p_network.png 38 | :align: center 39 | :scale: 50% 40 | 41 | A segment with loops at both ends: 42 | 43 | .. image:: /images/double_loop_network.png 44 | :align: center 45 | :scale: 50% 46 | 47 | A circular segment composed of several edges: 48 | 49 | .. image:: /images/circle_network.png 50 | :align: center 51 | :scale: 50% 52 | 53 | A segment with an edge at one end that loops back onto itself: 54 | 55 | .. image:: /images/single_edge_loop_network.png 56 | :align: center 57 | 58 | A two segments with two further segments in the middle both connected to the same start and end nodes: 59 | 60 | .. image:: /images/dual_path_middle_network.png 61 | :align: center 62 | :scale: 50% 63 | 64 | Valid but Unsupported Segments 65 | ------------------------------ 66 | 67 | It should be possible to implement the following types if required. The current edge solver however does not support them. 68 | 69 | Loops in the middle of the segment are unsupported. 70 | 71 | .. image:: /images/loop_middle_network.png 72 | :align: center 73 | :scale: 50% 74 | 75 | This also excludes loops in the middle with loops at the start and ends. 76 | 77 | .. image:: /images/triple_loop_network.png 78 | :align: center 79 | :scale: 50% 80 | 81 | The case below is where a road joins back to itself (a single edge loop). 82 | 83 | .. image:: /images/dual_path_network_manual.png 84 | :align: center 85 | 86 | .. 87 | .. image:: /images/dual_path_network.png 88 | :align: center 89 | :scale: 50% 90 | 91 | 92 | Invalid Segments 93 | ---------------- 94 | 95 | These segments have more than 2 ends. This means it is impossible to apply linear referencing to them, so they will never be 96 | supported by the edge solver. 97 | 98 | .. image:: /images/t_network.png 99 | :align: center 100 | :scale: 50% -------------------------------------------------------------------------------- /docs/talk.rst: -------------------------------------------------------------------------------- 1 | 2 | https://graph-tool.skewed.de/performance 3 | 4 | NetworkX is comparatively very inefficient, but it is trivial to install --- requiring no compilation at all, since it is pure python. 5 | Thus one can get started with very little to no effort. The speed may not be a problem if one is dealing with very small graphs, and does not care 6 | if an algorithm runs in, say, 1 or 30 seconds. However, if the graph size increases to hundreds of thousands, or millions of vertices/edges, this difference can scale up quickly. 7 | 8 | 9 | -------------------------------------------------------------------------------- /images/edges.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/images/edges.png -------------------------------------------------------------------------------- /images/isochrones.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/images/isochrones.png -------------------------------------------------------------------------------- /images/logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/images/logo-small.png -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/images/logo.png -------------------------------------------------------------------------------- /images/rivers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/images/rivers.png -------------------------------------------------------------------------------- /images/route.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/images/route.png -------------------------------------------------------------------------------- /requirements.demo.txt: -------------------------------------------------------------------------------- 1 | Jinja2 2 | matplotlib 3 | Fiona==1.10.1 4 | shapely==2.0.6 5 | osmnx==2.0.1 6 | # geopandas==1.0.1 # osmnx 1.9.4 depends on geopandas<0.15 and >=0.12 7 | uvicorn[standard]==0.34.0 8 | fastapi==0.115.6 9 | # following required for isochrones 10 | scipy==1.15.0 11 | odbcutils==0.2.1 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-benchmark 3 | pytest-cov 4 | flake8 5 | black 6 | coveralls 7 | mypy 8 | 9 | # for docs 10 | sphinx 11 | sphinx-material 12 | sphinx-autodoc-typehints -------------------------------------------------------------------------------- /run_local.ps1: -------------------------------------------------------------------------------- 1 | C:\VirtualEnvs\wayfarer\Scripts\activate.ps1 2 | cd D:\GitHub\wayfarer 3 | 4 | black . 5 | flake8 . 6 | 7 | mypy --install-types 8 | mypy wayfarer tests scripts demo 9 | 10 | pytest --doctest-modules 11 | 12 | 13 | # web app 14 | C:\VirtualEnvs\wayfarer\Scripts\activate.ps1 15 | cd D:\GitHub\wayfarer\demo 16 | uvicorn main:app --reload --port 8020 --log-level 'debug' -------------------------------------------------------------------------------- /scripts/benchmarking.py: -------------------------------------------------------------------------------- 1 | r""" 2 | Load a large file and check performance 3 | Uses pytest-benchmark 4 | First requires the test dat files to be created by unzipping gis_osm_roads_free_1.zip 5 | and running load_osm_network.py 6 | 7 | C:\VirtualEnvs\wayfarer\Scripts\activate.ps1 8 | cd D:/Github/wayfarer 9 | 10 | pytest -v scripts/benchmarking.py 11 | 12 | """ 13 | 14 | import pytest 15 | import copy 16 | import marshal 17 | import networkx 18 | from wayfarer import loader, functions 19 | 20 | 21 | @pytest.fixture 22 | def get_network(): 23 | net = loader.load_network_from_file("./data/osm_ireland.dat") 24 | return net 25 | 26 | 27 | def xtest_get_edge_by_key(get_network): 28 | net = get_network 29 | edge = functions.get_edge_by_key(net, 618090637) 30 | print(edge) 31 | 32 | 33 | def test_lookup_speed_with_reverse_lookup(benchmark): 34 | net = loader.load_network_from_file("./data/osm_ireland.dat") 35 | benchmark(functions.get_edge_by_key, net, 151364) 36 | 37 | 38 | def test_lookup_speed_without_reverse_lookup(benchmark): 39 | net = loader.load_network_from_file("./data/osm_ireland_no_lookup.dat") 40 | benchmark(functions.get_edge_by_key, net, 151364) 41 | 42 | 43 | def get_edge_by_key(): 44 | net = loader.load_network_from_file("./data/osm_ireland.dat") 45 | edge = functions.get_edge_by_key(net, 618090637, with_data=False) 46 | print(edge) # ((-9.4357979, 51.9035691), (-9.4359127, 51.9036818), 618090637) 47 | edge = functions.get_edge_by_key(net, 618090637, with_data=True) 48 | print(edge) # ((-9.4357979, 51.9035691), (-9.4359127, 51.9036818), 618090637) 49 | 50 | # following works but is slow 51 | # edges = networkx.get_edge_attributes(net, "EDGE_ID") 52 | # mapping = {y:x for x,y in edges.items()} 53 | # edge = mapping[618090637] 54 | print(edge) # ((-9.4357979, 51.9035691), (-9.4359127, 51.9036818), 618090637) 55 | 56 | 57 | def test_loading_network(benchmark): 58 | """ 59 | 125 ms 60 | """ 61 | benchmark(loader.load_network_from_file, "./data/dublin.pickle") 62 | 63 | 64 | def clone_network(net): 65 | copy.deepcopy(net) 66 | 67 | 68 | def marshal_network(net): 69 | marshal.loads(marshal.dumps(net)) 70 | 71 | 72 | def test_cloning_network(benchmark): 73 | """ 74 | 971 ms seconds to clone - much slower than simply re-reading from disk 75 | """ 76 | net = loader.load_network_from_file("./data/dublin.pickle") 77 | benchmark(clone_network, net) 78 | 79 | 80 | def xtest_marshal_network(benchmark): 81 | """ 82 | ValueError: unmarshallable object - it is not possible to 83 | use marshal.dumps on the networkx network 84 | """ 85 | net = loader.load_network_from_file("./data/dublin.pickle") 86 | benchmark(marshal_network, net) 87 | 88 | 89 | def test_get_edges_from_nodes_all_lengths(benchmark): 90 | """ 91 | pytest -v scripts/benchmarking.py -k "test_get_edges_from_nodes_all_lengths" 92 | 93 | with copy.deepcopy - min 7.4000 94 | with marshal - min 4.9000 95 | 96 | When running path solving with over 100,000 iterations: 97 | marshal: 588 seconds 98 | copy.deepcopy: 660 seconds 99 | 100 | See discussions at https://stackoverflow.com/a/55157627/179520 101 | """ 102 | net = networkx.MultiGraph() 103 | 104 | net.add_edge(1, 2, key="A", **{"EDGE_ID": "A", "LEN_": 50}) 105 | net.add_edge(1, 2, key="B", **{"EDGE_ID": "B", "LEN_": 100}) 106 | 107 | node_list = [1, 2] 108 | benchmark(functions.get_edges_from_nodes, net, node_list, shortest_path_only=False) 109 | -------------------------------------------------------------------------------- /scripts/convert_to_linestring.py: -------------------------------------------------------------------------------- 1 | """ 2 | wayfarer can only use LineStrings for its networks 3 | This helper script converts MultiLineStrings to LineStrings 4 | """ 5 | 6 | from shapely.geometry import shape 7 | from shapely.geometry import mapping 8 | import fiona 9 | from wayfarer import loader 10 | 11 | 12 | def load_network_with_conversion(fgdb_path, layer_name, key_field="LINK_ID"): 13 | 14 | linestring_recs = [] 15 | 16 | with fiona.open(fgdb_path, driver="FileGDB", layer=layer_name) as recs: 17 | 18 | # memory error in loop.. 19 | for r in recs: 20 | geom = r["geometry"] 21 | if geom["type"] == "MultiLineString": 22 | # convert to shapely geometry 23 | shapely_geom = shape(geom) 24 | if len(shapely_geom.geoms) == 1: 25 | r["geometry"] = mapping(shapely_geom.geoms[0]) 26 | linestring_recs.append(r) 27 | 28 | net = loader.load_network_from_geometries( 29 | linestring_recs, key_field=key_field, skip_errors=True 30 | ) 31 | return net 32 | 33 | 34 | if __name__ == "__main__": 35 | 36 | layer_name = "ExampleLayer" 37 | fgdb_path = r"./data/network.gdb" 38 | 39 | load_network_with_conversion(fgdb_path, layer_name) 40 | -------------------------------------------------------------------------------- /scripts/create_network_images.py: -------------------------------------------------------------------------------- 1 | r""" 2 | This script generates images of the network used in the unit tests 3 | 4 | $env:PYTHONPATH = 'D:\GitHub\wayfarer\tests;' + $env:PYTHONPATH 5 | python D:\GitHub\wayfarer\scripts\create_network_images.py 6 | 7 | """ 8 | 9 | import os 10 | from tests import networks 11 | import networkx as nx 12 | import matplotlib 13 | import logging 14 | import matplotlib.pyplot as plt 15 | import wayfarer 16 | 17 | 18 | def save_network_image_to_file(net, fn): 19 | 20 | matplotlib.use("Agg") 21 | 22 | f = plt.figure() 23 | ax = f.add_subplot(111) 24 | 25 | pos = nx.spring_layout(net) 26 | # pos = nx.bipartite_layout(net, net.nodes()) 27 | 28 | nx.draw_networkx(net, pos=pos, ax=ax, with_labels=True) 29 | 30 | edge_labels = wayfarer.to_edges( 31 | net.edges(data=True, keys=True) 32 | ) # nx.get_edge_attributes(net,'EDGE_ID') # key is edge, pls check for your case 33 | edge_labels_dict = {(e.start_node, e.end_node): e.key for e in edge_labels} 34 | 35 | nx.draw_networkx_edge_labels( 36 | net, pos, edge_labels=edge_labels_dict, font_color="black" 37 | ) 38 | 39 | logging.info(f"Saving {fn}...") 40 | plt.box(False) # removes border box 41 | plt.savefig(fn) 42 | 43 | 44 | def create_network_images(out_folder): 45 | 46 | network_functions = { 47 | "simple_network": networks.simple_network, 48 | "single_edge_loop_network": networks.single_edge_loop_network, 49 | "reverse_network": networks.reverse_network, 50 | "t_network": networks.t_network, 51 | "dual_path_network": networks.dual_path_network, 52 | "dual_path_middle_network": networks.dual_path_middle_network, 53 | "p_network": networks.p_network, 54 | "double_loop_network": networks.double_loop_network, 55 | "circle_network": networks.circle_network, 56 | "loop_middle_network": networks.loop_middle_network, 57 | "triple_loop_network": networks.triple_loop_network, 58 | "reversed_loop_network": networks.reversed_loop_network, 59 | "bottle_network": networks.bottle_network, 60 | } 61 | 62 | for name, func in network_functions.items(): 63 | fn = os.path.join(out_folder, name + ".png") 64 | net = func() 65 | logging.debug((net.edges(data=True, keys=True))) 66 | save_network_image_to_file(net, fn) 67 | 68 | 69 | if __name__ == "__main__": 70 | logging.basicConfig(level=logging.INFO) 71 | fld = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) 72 | out_folder = os.path.join(fld, "docs", "images") 73 | create_network_images(out_folder) 74 | print("Done!") 75 | -------------------------------------------------------------------------------- /scripts/load_from_shapefile.py: -------------------------------------------------------------------------------- 1 | """ 2 | An example of create a Wayfarer network using 3 | the pyshp library 4 | 5 | pip install pyshp 6 | """ 7 | 8 | from wayfarer import loader 9 | import shapefile 10 | 11 | shpfile = "./data/gis_osm_roads_free_1.shp" 12 | sf = shapefile.Reader(shpfile) 13 | 14 | records = [] 15 | # convert the shapefile records into the JSON-like __geo_interface__ records 16 | for shaperec in sf.shapeRecords(): 17 | records.append(shaperec.__geo_interface__) 18 | 19 | # print a sample record 20 | print(records[0]) 21 | 22 | # use the load_network_from_geometries function to create the network 23 | # using __geo_interface__ records 24 | net = loader.load_network_from_geometries(records, key_field="osm_id") 25 | 26 | print(f"Network contains {len(net.edges())} edges") 27 | 28 | print("Done!") 29 | -------------------------------------------------------------------------------- /scripts/load_osm_network.py: -------------------------------------------------------------------------------- 1 | """ 2 | pip install numpy 3 | pip install fiona --extra-index-url ... 4 | 5 | Run from root of project 6 | python scripts/load_osm_network.py 7 | """ 8 | 9 | import fiona 10 | from wayfarer import loader, functions 11 | 12 | 13 | def main(shp): 14 | """ 15 | 154 MB without reversed lookup 16 | 164 MB with lookup 17 | """ 18 | recs = fiona.open(shp, "r") 19 | net = loader.load_network_from_geometries(recs, key_field="osm_id") 20 | 21 | print(net.name) 22 | print(net.graph["keys"][618090637]) 23 | 24 | output_file = "./data/osm_ireland.dat" 25 | loader.save_network_to_file(net, output_file) 26 | 27 | print(net.name) 28 | 29 | test_key = 618090637 30 | edge1 = net.graph["keys"][test_key] 31 | edge2 = functions.get_edge_by_key(net, test_key) 32 | 33 | print(edge1, edge2) 34 | assert edge1[0] == edge2.start_node 35 | assert edge1[1] == edge2.end_node 36 | 37 | net = loader.load_network_from_geometries( 38 | recs, key_field="osm_id", use_reverse_lookup=False 39 | ) 40 | output_file = "./data/osm_ireland_no_lookup.dat" 41 | loader.save_network_to_file(net, output_file) 42 | net = loader.load_network_from_file(output_file) 43 | print(net.name) 44 | edge = functions.get_edge_by_key(net, test_key) 45 | print(edge) 46 | 47 | 48 | if __name__ == "__main__": 49 | shp = "./data/gis_osm_roads_free_1.shp" 50 | main(shp) 51 | print("Done!") 52 | -------------------------------------------------------------------------------- /scripts/ordered_path.py: -------------------------------------------------------------------------------- 1 | """ 2 | Script to get an ordered path between edges, providing an index and a direction 3 | """ 4 | 5 | from wayfarer import loader, routing, functions 6 | 7 | fn = r"./data/tipperary.txt" 8 | net = loader.load_network_from_file(fn) 9 | 10 | edges = [] 11 | # get edge_ids per ProjectId per Group (see SQL above) 12 | edge_ids = [1393622, 1414298, 1425617, 1457705, 1464289, 1548367, 1573235] 13 | 14 | for edge_id in edge_ids: 15 | edge = functions.get_edge_by_key(net, edge_id, with_data=True) 16 | edges.append(edge) 17 | 18 | ordered_edges = routing.find_ordered_path(edges) 19 | 20 | for idx, oe in enumerate(ordered_edges): 21 | print(idx, oe.attributes["EDGE_ID"], oe.attributes["WITH_DIRECTION"]) 22 | -------------------------------------------------------------------------------- /scripts/ptals.py: -------------------------------------------------------------------------------- 1 | r""" 2 | C:\VirtualEnvs\wayfarer\Scripts\activate 3 | 4 | pip install numpy 5 | pip install GDAL --extra-index-url https://pypi.compass.ie 6 | pip install fiona --extra-index-url https://pypi.compass.ie 7 | pip install shapely --extra-index-url https://pypi.compass.ie 8 | pip install git+https://github.com/compassinformatics/wayfarer@main 9 | 10 | C:\VirtualEnvs\wayfarer\Scripts\activate 11 | python C:\GitHub\wayfarer\docs\examples\ptals.py 12 | 13 | Preprocessing to convert MultiLineStrings to LineStrings 14 | """ 15 | 16 | import os 17 | import wayfarer 18 | from wayfarer import loader, routing, functions 19 | import fiona 20 | import networkx 21 | 22 | 23 | def get_network(fgdb_path, output_file, layer_name, key_field="OBJECTID"): 24 | """ 25 | Returns the network if already created, otherwise generates 26 | a new network file from the FileGeoDatabase 27 | """ 28 | if os.path.exists(output_file) is True: 29 | net = loader.load_network_from_file(output_file) 30 | else: 31 | with fiona.open(fgdb_path, driver="FileGDB", layer=layer_name) as recs: 32 | # strip_properties reduces the size of the network from over 1GB to 67MB 33 | net = loader.load_network_from_geometries( 34 | recs, key_field, strip_properties=True 35 | ) 36 | 37 | print( 38 | "Saving new network file {} containing {} edges".format( 39 | output_file, len(net.edges()) 40 | ) 41 | ) 42 | loader.save_network_to_file(net, output_file) 43 | 44 | return net 45 | 46 | 47 | def analyse_network(net): 48 | """ 49 | Check network for connectivity and subgraphs 50 | """ 51 | print( 52 | "Network is connected? {}".format( 53 | networkx.algorithms.components.is_connected(net) 54 | ) 55 | ) 56 | 57 | sub_graphs = networkx.connected_components(net) 58 | print(networkx.number_connected_components(net)) 59 | 60 | for i, nodes in enumerate(sub_graphs): 61 | print("subgraph {} has {} nodes".format(i, len(nodes))) 62 | 63 | if len(nodes) < 1000: 64 | edges = net.edges(nodes, keys=True, data=True) 65 | edges = wayfarer.to_edges(edges) 66 | edge_ids = [edge.key for edge in edges] 67 | print(edge_ids) 68 | 69 | 70 | def routing_example(net): 71 | """ 72 | Routes between 2 nodes on the network 73 | """ 74 | edge1 = functions.get_edge_by_key(net, 63684) 75 | edge2 = functions.get_edge_by_key(net, 238610) # 417508 76 | 77 | path_edges = routing.solve_shortest_path(net, edge1.start_node, edge2.end_node) 78 | 79 | path_ids = [edge.key for edge in path_edges] 80 | print(path_ids) 81 | 82 | 83 | if __name__ == "__main__": 84 | 85 | output_file = r"C:\Data\ptals.dat" 86 | fgdb_path = r"C:\Data\NTA_Network_Datasets\linestrings.fgb" 87 | layer_name = "linestrings" 88 | net = get_network(fgdb_path, output_file, layer_name) 89 | analyse_network(net) 90 | 91 | print("Done!") 92 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | from setuptools import setup 3 | from io import open 4 | 5 | (__version__,) = re.findall('__version__ = "(.*)"', open("wayfarer/__init__.py").read()) 6 | 7 | 8 | def readme(): 9 | with open("README.rst", "r", encoding="utf-8") as f: 10 | return f.read() 11 | 12 | 13 | setup( 14 | name="wayfarer", 15 | version=__version__, 16 | description="A library to add spatial functions to networkx", 17 | long_description=readme(), 18 | classifiers=[ 19 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers 20 | "Development Status :: 4 - Beta", 21 | "License :: OSI Approved :: MIT License", 22 | "Programming Language :: Python :: 3.10", # minimum required due to use of annotations 23 | "Intended Audience :: Developers", 24 | ], 25 | url="https://github.com/compassinformatics/wayfarer/", 26 | author="Seth Girvin", 27 | author_email="sgirvin@compass.ie", 28 | license="MIT", 29 | package_data={"wayfarer": ["py.typed"]}, 30 | packages=["wayfarer"], 31 | install_requires=["networkx>=3.0", "geojson"], 32 | zip_safe=False, 33 | ) 34 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/tests/__init__.py -------------------------------------------------------------------------------- /tests/helper.py: -------------------------------------------------------------------------------- 1 | from wayfarer import loader 2 | 3 | 4 | def simple_features(): 5 | """ 6 | Create a list of features representing a network 7 | There are 3 features in a flat line running from 0,0 to 300,0 8 | Each feature is 100 units in length 9 | """ 10 | return [ 11 | { 12 | "properties": {"EDGE_ID": 1}, 13 | "geometry": {"type": "LineString", "coordinates": [(0, 0), (100, 0)]}, 14 | }, 15 | { 16 | "properties": {"EDGE_ID": 2}, 17 | "geometry": {"type": "LineString", "coordinates": [(100, 0), (200, 0)]}, 18 | }, 19 | { 20 | "properties": {"EDGE_ID": 3}, 21 | "geometry": {"type": "LineString", "coordinates": [(200, 0), (300, 0)]}, 22 | }, 23 | ] 24 | 25 | 26 | if __name__ == "__main__": 27 | feats = simple_features() 28 | net = loader.load_network_from_geometries(feats, use_reverse_lookup=True) 29 | print("Done!") 30 | -------------------------------------------------------------------------------- /tests/networks.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file contains a collection of various network forms 3 | """ 4 | 5 | from wayfarer import ( 6 | EDGE_ID_FIELD, 7 | LENGTH_FIELD, 8 | NODEID_FROM_FIELD, 9 | NODEID_TO_FIELD, 10 | loader, 11 | ) 12 | 13 | 14 | def tuples_to_net(tuples): 15 | """ 16 | Takes in a list of tuples in the form (1, 1, 2, 10) and 17 | converts to {'EDGE_ID': 1, 'NODEID_FROM': 1, 'NODEID_TO': 2, 'LEN_': 10} 18 | before adding to a network 19 | """ 20 | 21 | fields = [EDGE_ID_FIELD, NODEID_FROM_FIELD, NODEID_TO_FIELD, LENGTH_FIELD] 22 | recs = [dict(zip(fields, t)) for t in tuples] 23 | # print(recs) 24 | return loader.load_network_from_records(recs) 25 | 26 | 27 | def simple_network(): 28 | """ 29 | A simple network of connected edges from 1 to 5 30 | """ 31 | 32 | data = [(1, 1, 2, 10), (2, 2, 3, 10), (3, 3, 4, 10), (4, 4, 5, 10)] 33 | return tuples_to_net(data) 34 | 35 | 36 | def reverse_network(): 37 | """ 38 | Start and ends in different directions 39 | """ 40 | 41 | data = [(1, 1, 2, 10), (2, 3, 1, 10), (3, 3, 5, 5), (4, 5, 4, 5)] 42 | return tuples_to_net(data) 43 | 44 | 45 | def t_network(): 46 | """ 47 | Create a T shaped network 48 | """ 49 | 50 | data = [(1, 1, 2, 10), (2, 2, 3, 10), (3, 3, 4, 10), (4, 3, 5, 10)] 51 | return tuples_to_net(data) 52 | 53 | 54 | def single_edge_loop_network(): 55 | """ 56 | Create a network with a single edge loop at one end 57 | """ 58 | 59 | data = [(1, 1, 2, 10), (2, 2, 3, 10), (3, 3, 3, 10)] 60 | return tuples_to_net(data) 61 | 62 | 63 | def dual_path_network(): 64 | """ 65 | Network has 3 edges, 2 of which are between the same nodes 66 | Cannot make DOT file 67 | http://stackoverflow.com/questions/35007046/how-to-draw-parallel-edges-in-networkx-graphviz 68 | and image doesn't show both edges 69 | """ 70 | 71 | data = [ 72 | (1, 1, 2, 10), 73 | (2, 2, 3, 10), 74 | (3, 2, 3, 20), 75 | ] # longer edge of 20 to create loop 76 | return tuples_to_net(data) 77 | 78 | 79 | def dual_path_middle_network(): 80 | """ 81 | Network has 2 edges in the middle which are between the same nodes 82 | """ 83 | 84 | data = [ 85 | (1, 1, 2, 10), 86 | (2, 2, 3, 10), 87 | (3, 2, 3, 20), # longer edge of 20 to create loop 88 | (4, 3, 4, 20), 89 | ] 90 | return tuples_to_net(data) 91 | 92 | 93 | def p_network(): 94 | """ 95 | Network has 4 edges, the last of which loops back on itself 96 | """ 97 | 98 | data = [(1, 1, 2, 10), (2, 2, 3, 10), (3, 3, 4, 5), (4, 4, 2, 5)] 99 | return tuples_to_net(data) 100 | 101 | 102 | def loop_middle_network(): 103 | """ 104 | Network has a loop in the middle of the network 105 | """ 106 | 107 | data = [(1, 1, 2, 10), (2, 2, 3, 10), (3, 3, 4, 5), (4, 4, 2, 5), (5, 2, 5, 5)] 108 | return tuples_to_net(data) 109 | 110 | 111 | def double_loop_network(): 112 | """ 113 | Network has a loop at the start and end of the path 114 | """ 115 | 116 | data = [ 117 | (1, 1, 2, 10), 118 | (2, 2, 3, 10), 119 | (3, 3, 1, 5), 120 | (4, 1, 4, 5), 121 | (5, 4, 5, 5), 122 | (6, 5, 6, 5), 123 | (7, 6, 4, 5), 124 | ] 125 | return tuples_to_net(data) 126 | 127 | 128 | def triple_loop_network(): 129 | """ 130 | Network has a loop in the middle of the network and at the start and end 131 | NOT SUPPORTED 132 | """ 133 | 134 | data = [ 135 | (1, 1, 2, 10), 136 | (2, 2, 3, 10), 137 | (3, 3, 1, 10), 138 | (4, 1, 4, 5), 139 | (5, 4, 5, 5), 140 | (6, 5, 6, 5), 141 | (7, 6, 4, 5), 142 | (8, 4, 7, 5), 143 | (9, 7, 8, 5), 144 | (10, 8, 9, 5), 145 | (11, 9, 7, 5), 146 | ] 147 | return tuples_to_net(data) 148 | 149 | 150 | def circle_network(): 151 | """ 152 | Network in a circle 153 | """ 154 | 155 | data = [ 156 | (1, 1, 2, 5), 157 | (2, 2, 3, 5), 158 | (3, 3, 4, 10), 159 | (4, 4, 1, 10), 160 | ] 161 | return tuples_to_net(data) 162 | 163 | 164 | def reversed_loop_network(): 165 | 166 | data = [ 167 | (1, 1, 2, 10), 168 | (2, 2, 5, 10), 169 | (3, 5, 4, 10), 170 | (4, 4, 99, 10), 171 | (5, 99, 2, 10), 172 | ] 173 | return tuples_to_net(data) 174 | 175 | 176 | def bottle_network(): 177 | """ 178 | Create a bottle-shaped network 179 | For testing creating a specific "loop" segment (lollipop route) 180 | Same as dual_network above 181 | """ 182 | 183 | data = [ 184 | (1, 1, 2, 10), 185 | (2, 2, 3, 10), 186 | (3, 3, 2, 10), 187 | ] 188 | return tuples_to_net(data) 189 | 190 | 191 | if __name__ == "__main__": 192 | simple_network() 193 | print("Done!") 194 | -------------------------------------------------------------------------------- /tests/test_linearref.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from wayfarer import linearref 3 | from shapely.geometry import LineString, Point, MultiLineString 4 | from shapely.wkt import loads 5 | from decimal import Decimal 6 | 7 | 8 | def test_invalid_lr(): 9 | ls = LineString([(0, 0), (0, 100)]) 10 | 11 | with pytest.raises(ValueError): 12 | linearref.create_line(ls, -1, 100) 13 | 14 | 15 | def test_invalid_lr2(): 16 | """ 17 | Cannot have a LR feature with identical TO and FROM values 18 | """ 19 | ls = LineString([(0, 0), (0, 100)]) 20 | 21 | with pytest.raises(linearref.IdenticalMeasuresError): 22 | linearref.create_line(ls, 50, 50) 23 | 24 | 25 | def test_invalid_lr3(): 26 | """ 27 | Cannot have a LR feature with a TO value greater than the line length 28 | """ 29 | ls = LineString([(0, 0), (0, 100)]) 30 | 31 | with pytest.raises(ValueError): 32 | linearref.create_line(ls, 0, 100.01) 33 | 34 | 35 | def test_invalid_lr4(): 36 | """ 37 | Check if the length is outside the tolerance 38 | """ 39 | ls = LineString([(0, 0), (0, 100)]) 40 | 41 | tolerance = 1 42 | # acceptable 43 | linearref.create_line(ls, 0, 100.999, tolerance) 44 | 45 | tolerance = 1 46 | # not acceptable - outside tolerance 47 | 48 | with pytest.raises(ValueError): 49 | linearref.create_line(ls, 0, 101.001, tolerance) 50 | 51 | 52 | def test_valid_lr(): 53 | ls = LineString([(0, 0), (0, 100)]) 54 | new_line = linearref.create_line(ls, 0, 50) 55 | assert type(new_line) is LineString 56 | assert new_line.is_valid 57 | assert new_line.length == 50 58 | expected = [(0.0, 0.0), (0.0, 50.0)] 59 | assert list(new_line.coords) == expected 60 | 61 | 62 | def test_valid_lr2(): 63 | ls = LineString([(0, 0), (0, 100)]) 64 | new_line = linearref.create_line(ls, 1, 50) 65 | assert type(new_line) is LineString 66 | assert new_line.is_valid 67 | assert new_line.length == 49 68 | expected = [(0.0, 1.0), (0.0, 50.0)] 69 | assert list(new_line.coords) == expected 70 | 71 | 72 | def test_ring_feature(): 73 | """ 74 | Test a ring feature 75 | """ 76 | ls = LineString([(0, 0), (0, 100), (100, 100), (100, 0), (0, 0)]) 77 | 78 | assert ls.is_ring 79 | assert ls.length == 400 80 | 81 | new_line = linearref.create_line(ls, 0, 400) 82 | assert type(new_line) is LineString 83 | assert new_line.is_valid 84 | 85 | assert new_line.length == 400 86 | expected = [(0.0, 0.0), (0.0, 100.0), (100.0, 100.0), (100.0, 0.0), (0.0, 0.0)] 87 | 88 | assert list(new_line.coords) == expected 89 | assert new_line.length == 400 90 | 91 | 92 | def test_find_common_vertex(): 93 | ls1 = LineString([(0, 0), (0, 100)]) 94 | ls2 = LineString([(0, 100), (0, 200)]) 95 | 96 | pts = linearref.find_common_vertices(ls1, ls2) 97 | assert len(pts) == 1 98 | assert list(pts[0].coords) == [(0, 100)] 99 | 100 | 101 | def test_find_common_vertex2(): 102 | """ 103 | Check that if two lines cross, but do not intersect NO common point is returned 104 | This is not a valid occurrence as the network should have been split. 105 | """ 106 | 107 | ls1 = LineString([(0, 0), (0, 100)]) 108 | ls2 = LineString([(50, -50), (50, 50)]) 109 | 110 | pts = linearref.find_common_vertices(ls1, ls2) 111 | assert len(pts) == 0 112 | 113 | 114 | def test_multiple_common_vertices(): 115 | """ 116 | Check that two lines that touch twice return all the touching 117 | vertices 118 | """ 119 | 120 | ls1 = LineString([(0, 0), (0, 100), (100, 100), (100, 0)]) 121 | ls2 = LineString([(0, 0), (100, 0)]) 122 | 123 | pts = linearref.find_common_vertices(ls1, ls2) 124 | assert len(pts) == 2 125 | assert list(pts[0].coords) == [(0, 0)] 126 | assert list(pts[1].coords) == [(100, 0)] 127 | 128 | 129 | def test_missing_common_vertex(): 130 | """ 131 | Check two line strings that don't join don't return a common point 132 | """ 133 | ls1 = LineString([(0, 0), (0, 100)]) 134 | ls2 = LineString([(0, 100.001), (0, 200)]) 135 | 136 | pts = linearref.find_common_vertices(ls1, ls2) 137 | assert len(pts) == 0 138 | 139 | 140 | def test_snap_to_ends(): 141 | ls = LineString([(0, 0), (0, 100)]) 142 | m_values = linearref.snap_to_ends(ls, 4, 96, tolerance=5) 143 | assert m_values == (0, 100) 144 | 145 | 146 | def test_snap_to_ends2(): 147 | ls = LineString([(0, 0), (0, 100)]) 148 | m_values = linearref.snap_to_ends(ls, 4, 96, tolerance=3) 149 | assert m_values == (4, 96) 150 | 151 | 152 | def test_snap_to_ends3(): 153 | ls = LineString([(0, 0), (0, 100)]) 154 | with pytest.raises(ValueError): 155 | linearref.snap_to_ends(ls, 96, 4, tolerance=5) 156 | 157 | 158 | def test_is_partial(): 159 | ls = LineString([(0, 0), (0, 100)]) 160 | assert linearref.is_partial(ls, 0, 100) is False 161 | 162 | 163 | def test_is_partial2(): 164 | ls = LineString([(0, 0), (0, 100)]) 165 | assert linearref.is_partial(ls, 0.1, 100) 166 | 167 | 168 | def test_is_partial3(): 169 | ls = LineString([(0, 0), (0, 100)]) 170 | assert linearref.is_partial(ls, 0, 99.999) 171 | 172 | 173 | def test_is_partial3b(): 174 | ls = LineString([(0, 0), (0, 100)]) 175 | assert linearref.is_partial(ls, 0, 99.999, tolerance=0.002) is False 176 | 177 | 178 | def test_is_partial4(): 179 | ls = LineString([(0, 0), (0, 100)]) 180 | assert linearref.is_partial(ls, 0, 100) is False 181 | 182 | 183 | def test_get_m_values(): 184 | pt1 = Point(5, 0) 185 | pt2 = Point(95, 0) 186 | ls = LineString([(0, 0), (100, 0)]) 187 | ms = linearref.get_measures(ls, pt1, pt2) 188 | assert ms == (5, 95) 189 | 190 | 191 | def test_get_m_values2(): 192 | pt1 = Point(4, 0) 193 | pt2 = Point(96, 0) 194 | 195 | ls = LineString([(0, 0), (100, 0)]) 196 | ms = linearref.get_measures(ls, pt1, pt2) 197 | 198 | assert ms == (4, 96) 199 | 200 | 201 | def test_get_m_values3(): 202 | """ 203 | Point order should not matter 204 | """ 205 | pt1 = Point(96, 0) 206 | pt2 = Point(4, 0) 207 | 208 | ls = LineString([(0, 0), (100, 0)]) 209 | ms = linearref.get_measures(ls, pt1, pt2) 210 | assert ms == (4, 96) 211 | 212 | 213 | def test_get_end_point(): 214 | ls = LineString([(0, 0), (100, 0)]) 215 | input_point = Point(40, 0) 216 | common_vertices = [Point(0, 0), Point(100, 0)] 217 | 218 | res = linearref.get_end_point(ls, input_point, common_vertices) 219 | assert list(res.coords) == [(0.0, 0.0)] 220 | 221 | res = linearref.get_end_point(ls, Point(80, 0), common_vertices) 222 | assert list(res.coords) == [(100.0, 0.0)] 223 | 224 | # this is a point equi-distant between the two end points, it snaps to the start 225 | res = linearref.get_end_point(ls, Point(50, 0), common_vertices) 226 | assert list(res.coords) == [(0.0, 0.0)] 227 | 228 | # shifting it slightly will snap to the end 229 | res = linearref.get_end_point(ls, Point(50.1, 0), common_vertices) 230 | assert list(res.coords) == [(100.0, 0.0)] 231 | 232 | 233 | def test_get_end_point2(): 234 | ls = LineString([(30, 0), (30, 100), (70, 100), (70, 0)]) 235 | input_point = Point(70, 100) 236 | common_vertices = [Point(30, 0), Point(70, 0)] 237 | 238 | res = linearref.get_end_point(ls, input_point, common_vertices) 239 | assert list(res.coords) == [(70.0, 0.0)] 240 | 241 | res = linearref.get_end_point(ls, Point(30, 50), common_vertices) 242 | assert list(res.coords) == [(30.0, 0.0)] 243 | 244 | 245 | def test_get_end_point_error(): 246 | 247 | ls = LineString([(30, 0), (30, 100), (70, 100), (70, 0)]) 248 | input_point = Point(70, 100) 249 | # too many common vertices for 2 connecting lines 250 | common_vertices = [Point(30, 0), Point(70, 0), Point(70, 0)] 251 | 252 | with pytest.raises(ValueError): 253 | linearref.get_end_point(ls, input_point, common_vertices) 254 | 255 | 256 | def test_ring(): 257 | line = LineString( 258 | [ 259 | (168764.57100460742, 68272.608483201067), 260 | (168832.93332556245, 68298.551410977932), 261 | (168883.42972378185, 68309.132574679476), 262 | (168886.35790014532, 68299.801526872063), 263 | (168887.16619741436, 68287.32007539396), 264 | (168853.44703749285, 68263.61740366694), 265 | (168852.87793392732, 68200.530325998014), 266 | (168789.0274291017, 68193.019515298161), 267 | (168780.06970638721, 68220.672574178898), 268 | (168764.57100460742, 68272.608483201067), 269 | ] 270 | ) 271 | 272 | assert line.is_ring 273 | 274 | m_values = [183.07291799999999, 366.56121400000001] 275 | new_line = linearref.create_line(line, m_values[0], m_values[1]) 276 | 277 | pytest.approx(new_line.length, m_values[1] - m_values[0], 3) 278 | # AssertionError: 161.59143850732218 != 183.48829600000002 279 | 280 | 281 | def test_with_decimals(): 282 | line = LineString( 283 | [ 284 | (84689.6348601028, 29919.330092903132), 285 | (84687.432594077953, 29909.819003758097), 286 | (84686.236535025368, 29902.688215733091), 287 | (84686.310773797828, 29887.686476198891), 288 | (84686.863399977301, 29874.065014830347), 289 | (84686.253012411442, 29863.203702854349), 290 | (84685.593192687331, 29849.902211625373), 291 | (84688.290414040355, 29829.089909696544), 292 | (84691.276261255116, 29810.737785154419), 293 | (84693.60228874578, 29793.925923847732), 294 | (84697.86667247866, 29779.714284997921), 295 | (84701.702191497869, 29768.413072214124), 296 | (84705.669710675953, 29756.381695013002), 297 | (84701.355804249732, 29746.720619865046), 298 | (84691.43297749311, 29738.199729009848), 299 | (84689.469905171762, 29734.669302664308), 300 | (84689.181188774921, 29721.997810781715), 301 | (84691.993932900717, 29712.616825774101), 302 | (84695.48288360161, 29704.745874264907), 303 | (84701.034133080713, 29695.134794451256), 304 | (84705.620187982611, 29687.93401295822), 305 | (84706.791440420973, 29672.222223408633), 306 | (84710.239197656672, 29637.008300359623), 307 | (84711.691018333935, 29633.047797870815), 308 | (84711.163108233581, 29624.45691354759), 309 | (84710.494868746246, 29620.186381851963), 310 | (84709.134035832816, 29604.794620576053), 311 | (84703.376637957466, 29584.472389191658), 312 | (84700.250461355696, 29581.371980855274), 313 | (84695.433541978447, 29576.06137180372), 314 | (84686.72316112541, 29568.280528164498), 315 | (84680.190511288252, 29560.409576655304), 316 | (84677.361289776381, 29552.428693544738), 317 | (84672.197892615892, 29545.777976997306), 318 | (84663.075214970377, 29535.136818894589), 319 | (84659.14074109956, 29528.186014073093), 320 | (84653.424627224507, 29518.044925581675), 321 | (84647.947707052954, 29511.384093699158), 322 | (84645.002962768311, 29505.323380230664), 323 | (84645.044337303698, 29496.232426296145), 324 | (84647.114603169684, 29484.53107643556), 325 | (84649.201527491925, 29478.070400292607), 326 | (84652.038897161765, 29470.579471458255), 327 | (84651.676032527539, 29461.918593667), 328 | (84652.129613320314, 29453.117612671464), 329 | (84651.453316210114, 29442.246301628606), 330 | (84650.727405871483, 29433.535428503041), 331 | (84647.023887011121, 29426.254480072785), 332 | (84642.965742209577, 29416.803501597136), 333 | (84636.960911937684, 29408.252497273137), 334 | (84626.452504229426, 29391.790719574827), 335 | (84615.878096441724, 29375.728904550975), 336 | (84606.780225410417, 29355.696587971455), 337 | (84597.2368312092, 29328.253509494807), 338 | (84593.690119121908, 29305.200893382011), 339 | (84583.635292206425, 29268.926778575129), 340 | (84575.271479671705, 29256.045422556668), 341 | (84560.432688151137, 29238.73337537047), 342 | (84541.47799044264, 29229.972390642379), 343 | (84532.693461352217, 29226.582125635225), 344 | (84527.241438329962, 29223.771690237823), 345 | (84510.142528863085, 29215.370788184966), 346 | (84488.333893563569, 29207.579887344771), 347 | (84474.023012143356, 29203.519510455568), 348 | (84466.401496798251, 29201.549171076862), 349 | (84459.307891553501, 29199.848980905746), 350 | (84444.271190329731, 29200.989223332617), 351 | (84429.853115364866, 29203.499512321843), 352 | (84403.69761886698, 29206.929773596443), 353 | (84385.872889596547, 29208.439981497191), 354 | (84383.019042540633, 29211.690375836501), 355 | (84377.476122289649, 29219.011204265986), 356 | (84359.271960463826, 29228.612285012772), 357 | (84342.181380225069, 29235.843121840502), 358 | (84329.066467322336, 29239.963609399092), 359 | (84320.859371025595, 29240.963690487573), 360 | ] 361 | ) 362 | 363 | m_values = [Decimal("0.000000"), Decimal("996.524166")] 364 | new_line = linearref.create_line(line, m_values[0], m_values[1]) 365 | 366 | pytest.approx(new_line.length, float(m_values[1]) - float(m_values[0]), 3) 367 | 368 | 369 | def test_get_measure_on_line(): 370 | pt = Point(50, 0) 371 | ls = LineString([(0, 0), (100, 0)]) 372 | 373 | m = linearref.get_measure_on_line(ls, pt) 374 | assert m == 50 375 | 376 | 377 | def test_get_measure_on_line2(): 378 | pt = Point(606009.852, 627525.514) 379 | 380 | wkt = ( 381 | "LINESTRING (606019.852 627522.087, 606016.046 627522.628, 606011.352 627524.538, 606006.532 627527.675, " 382 | "606003.574 627530.937, 606000.695 627535.076, 605998.74 627539.943, 605997.949 627545.735, 605998.684 627549.394)" 383 | ) 384 | ls = loads(wkt) 385 | 386 | # without a conversion to float this returns np.float64(10.702118085318782) in newer versions of Shapely 387 | m = linearref.get_measure_on_line(ls, pt) 388 | print(m) 389 | pytest.approx(m, 10.702118) 390 | 391 | 392 | def test_get_measure_not_on_line(): 393 | pt = Point(500, 0) 394 | ls = LineString([(0, 0), (100, 0)]) 395 | 396 | m = linearref.get_measure_on_line(ls, pt) 397 | assert m == 100 # the measure is set to the end of the line 398 | 399 | 400 | def test_duplicate_coords(): 401 | """ 402 | Originally the following will failed, as if two points have the same coordinates 403 | (as magnitude is 0) a ZeroDivisionError: float division error is thrown 404 | This is resolved by removing duplicate points in the function 405 | """ 406 | p = Point(1.5, 1.5) 407 | line = LineString([(0, 0), (1, 1), (1, 1), (2, 2)]) 408 | ip, dist = linearref.get_nearest_vertex(p, line) 409 | 410 | assert dist == 0 411 | 412 | 413 | def test_remove_duplicates_in_line(): 414 | polyline = LineString([(0, 0), (1, 1), (1, 1), (2, 2)]) 415 | polyline = linearref.remove_duplicates_in_line(polyline) 416 | assert list(polyline.coords) == [(0, 0), (1, 1), (2, 2)] 417 | 418 | 419 | def test_remove_duplicates_in_multipolyline(): 420 | polyline = MultiLineString( 421 | [[(0, 0), (1, 1), (1, 1), (2, 2)], [(0, 0), (1, 1), (1, 1), (2, 2)]] 422 | ) 423 | 424 | with pytest.raises(NotImplementedError): 425 | polyline = linearref.remove_duplicates_in_line(polyline) 426 | 427 | 428 | def test_get_closest_point_to_measure(): 429 | ls = LineString([(0, 0), (0, 50), (0, 100)]) 430 | points = [Point(0, 10), Point(0, 55)] 431 | res = linearref.get_closest_point_to_measure(ls, points, measure=20) 432 | assert res.x == 0 433 | assert res.y == 10 434 | 435 | 436 | def test_intersect_point_to_line(): 437 | 438 | point = Point(1, 50) 439 | start_point = Point(0, 0) 440 | end_point = Point(0, 100) 441 | pt = linearref.intersect_point_to_line(point, start_point, end_point) 442 | assert pt.x == 0 443 | assert pt.y == 50 444 | 445 | 446 | def test_get_nearest_vertex(): 447 | 448 | point = Point(2, 21) 449 | line = LineString([(0, 0), (0, 50), (0, 100)]) 450 | pt, distance = linearref.get_nearest_vertex(point, line) 451 | print(pt, distance) 452 | assert pt.x == 0 453 | assert pt.y == 21 454 | assert distance == 2.0 455 | 456 | 457 | def run_tests(): 458 | pytest.main(["tests/test_linearref.py"]) 459 | 460 | 461 | if __name__ == "__main__": 462 | # run_tests() 463 | # test_intersect_point_to_line() 464 | # test_get_end_point_error() 465 | test_get_measure_on_line2() 466 | print("Done!") 467 | -------------------------------------------------------------------------------- /tests/test_loader.py: -------------------------------------------------------------------------------- 1 | import wayfarer 2 | from wayfarer import loader, functions, Edge 3 | import networkx 4 | import pytest 5 | import logging 6 | import tempfile 7 | 8 | 9 | def test_add_edge(): 10 | 11 | net = networkx.MultiGraph() 12 | key = loader.add_edge(net, {"EDGE_ID": 1, "NODEID_FROM": 1, "NODEID_TO": 1}) 13 | assert key == 1 14 | 15 | 16 | def test_add_edge_missing_key(): 17 | 18 | net = networkx.MultiGraph() 19 | with pytest.raises(KeyError): 20 | loader.add_edge(net, {"XEDGE_ID": 1, "NODEID_FROM": 1, "NODEID_TO": 1}) 21 | 22 | 23 | def test_add_edge_with_reverse_lookup(): 24 | 25 | reverse_lookup = loader.UniqueDict() 26 | net = networkx.MultiGraph(keys=reverse_lookup) 27 | key = loader.add_edge(net, {"EDGE_ID": 1, "NODEID_FROM": 1, "NODEID_TO": 1}) 28 | assert key == 1 29 | assert reverse_lookup[1] == (1, 1) 30 | 31 | 32 | def test_create_graph(): 33 | 34 | net = loader.create_graph() 35 | assert type(net) is networkx.classes.multigraph.MultiGraph 36 | version = wayfarer.__version__ 37 | assert ( 38 | str(net) 39 | == f"MultiGraph named 'Wayfarer Generated MultiGraph (version {version})' with 0 nodes and 0 edges" 40 | ) 41 | assert net.graph["keys"] == {} 42 | 43 | 44 | def test_create_multidigraph(): 45 | 46 | net = loader.create_graph(graph_type=networkx.MultiDiGraph) 47 | assert type(net) is networkx.classes.multidigraph.MultiDiGraph 48 | version = wayfarer.__version__ 49 | assert ( 50 | str(net) 51 | == f"MultiDiGraph named 'Wayfarer Generated MultiDiGraph (version {version})' with 0 nodes and 0 edges" 52 | ) 53 | 54 | 55 | def test_create_graph_no_lookup(): 56 | 57 | net = loader.create_graph(use_reverse_lookup=False) 58 | assert "keys" not in net.graph 59 | 60 | 61 | def test_load_network_from_records(): 62 | 63 | rec1 = {"EDGE_ID": 1, "NODEID_FROM": 1, "NODEID_TO": 2, "LEN_": 5, "PROP1": "A"} 64 | rec2 = {"EDGE_ID": 2, "NODEID_FROM": 2, "NODEID_TO": 3, "LEN_": 15, "PROP1": "B"} 65 | recs = [rec1, rec2] 66 | 67 | net = loader.load_network_from_records(recs) 68 | assert net.graph["keys"] == {1: (1, 2), 2: (2, 3)} 69 | 70 | 71 | def test_load_network_from_records_missing_key(): 72 | 73 | rec1 = {"EDGE_ID": 1, "NODEID_FROM": 1, "NODEID_TO": 2, "LEN_": 5, "PROP1": "A"} 74 | rec2 = {"XEDGE_ID": 2, "NODEID_FROM": 2, "NODEID_TO": 3, "LEN_": 15, "PROP1": "B"} 75 | recs = [rec1, rec2] 76 | 77 | with pytest.raises(KeyError): 78 | loader.load_network_from_records(recs) 79 | 80 | 81 | def test_load_network_from_geometries(): 82 | 83 | rec1 = { 84 | "geometry": {"type": "LineString", "coordinates": [(0, 0), (0, 1)]}, 85 | "properties": {"EDGE_ID": 1, "LEN_": 1}, 86 | } 87 | rec2 = { 88 | "geometry": {"type": "LineString", "coordinates": [(0, 1), (0, 2)]}, 89 | "properties": {"EDGE_ID": 2, "LEN_": 1}, 90 | } 91 | recs = [rec1, rec2] 92 | 93 | net = loader.load_network_from_geometries(recs) 94 | assert net.graph["keys"] == {1: ("0|0", "0|1"), 2: ("0|1", "0|2")} 95 | 96 | edge = functions.get_edge_by_key(net, 2) 97 | assert edge == Edge( 98 | start_node="0|1", 99 | end_node="0|2", 100 | key=2, 101 | attributes={ 102 | "EDGE_ID": 2, 103 | "LEN_": 1, 104 | "NODEID_FROM": "0|1", 105 | "NODEID_TO": "0|2", 106 | }, 107 | ) 108 | 109 | 110 | def test_load_network_from_geometries_calculated_length(): 111 | 112 | rec1 = { 113 | "geometry": {"type": "LineString", "coordinates": [(0, 0), (0, 1)]}, 114 | "properties": {"EDGE_ID": 1}, 115 | } 116 | rec2 = { 117 | "geometry": {"type": "LineString", "coordinates": [(0, 1), (0, 2)]}, 118 | "properties": {"EDGE_ID": 2}, 119 | } 120 | recs = [rec1, rec2] 121 | 122 | net = loader.load_network_from_geometries(recs) 123 | assert net.graph["keys"] == {1: ("0|0", "0|1"), 2: ("0|1", "0|2")} 124 | 125 | edge = functions.get_edge_by_key(net, 2) 126 | assert edge == Edge( 127 | start_node="0|1", 128 | end_node="0|2", 129 | key=2, 130 | attributes={ 131 | "EDGE_ID": 2, 132 | "LEN_": 1, 133 | "NODEID_FROM": "0|1", 134 | "NODEID_TO": "0|2", 135 | }, 136 | ) 137 | 138 | 139 | def test_load_network_from_invalid_geometries(): 140 | 141 | rec1 = { 142 | "geometry": {"type": "LineString", "coordinates": [(0, 0), (0, 1)]}, 143 | "properties": {"EDGE_ID": 1}, 144 | } 145 | rec2 = { 146 | "geometry": {"type": "MultiLineString", "coordinates": [[(0, 1), (0, 2)]]}, 147 | "properties": {"EDGE_ID": 2}, 148 | } 149 | recs = [rec1, rec2] 150 | 151 | with pytest.raises(ValueError): 152 | loader.load_network_from_geometries(recs) 153 | 154 | 155 | def test_load_network_from_invalid_geometries_skip(): 156 | 157 | rec1 = { 158 | "geometry": {"type": "LineString", "coordinates": [(0, 0), (0, 1)]}, 159 | "properties": {"EDGE_ID": 1}, 160 | } 161 | rec2 = { 162 | "geometry": {"type": "MultiLineString", "coordinates": [[(0, 1), (0, 2)]]}, 163 | "properties": {"EDGE_ID": 2}, 164 | } 165 | recs = [rec1, rec2] 166 | 167 | net = loader.load_network_from_geometries(recs, skip_errors=True) 168 | assert len(net.edges()) == 1 169 | 170 | 171 | def test_load_network_from_invalid_geometries_missing_key(): 172 | 173 | rec1 = { 174 | "geometry": {"type": "LineString", "coordinates": [(0, 0), (0, 1)]}, 175 | "properties": {"MYKEY": 2}, 176 | } 177 | 178 | recs = [rec1] 179 | 180 | with pytest.raises(KeyError): 181 | loader.load_network_from_geometries(recs) 182 | 183 | 184 | def test_uniquedict(): 185 | 186 | ud = loader.UniqueDict() 187 | ud[1] = "foo" 188 | ud[2] = "bar" 189 | 190 | assert ud == {1: "foo", 2: "bar"} 191 | 192 | 193 | def test_uniquedict_error(): 194 | 195 | ud = loader.UniqueDict() 196 | ud[1] = "foo" 197 | 198 | with pytest.raises(KeyError): 199 | ud[1] = "bar" 200 | 201 | 202 | def test_pickling(): 203 | 204 | net = loader.create_graph() 205 | loader.add_edge(net, {"EDGE_ID": 1, "NODEID_FROM": 1, "NODEID_TO": 1}) 206 | assert net.graph["keys"][1] == (1, 1) 207 | assert len(net.edges()) == 1 208 | 209 | f = tempfile.NamedTemporaryFile(delete=False) 210 | fn = f.name 211 | 212 | loader.save_network_to_file(net, fn) 213 | 214 | net2 = loader.load_network_from_file(fn) 215 | assert net2.graph["keys"][1] == (1, 1) 216 | assert len(net2.edges()) == 1 217 | 218 | 219 | def test_load_network_with_valid_ints(): 220 | 221 | rec1 = { 222 | "geometry": {"type": "LineString", "coordinates": [(0, 0), (0, 1)]}, 223 | "properties": {"EDGE_ID": "1"}, 224 | } 225 | rec2 = { 226 | "geometry": {"type": "LineString", "coordinates": [(0, 1), (0, 2)]}, 227 | "properties": {"EDGE_ID": "21"}, 228 | } 229 | recs = [rec1, rec2] 230 | 231 | net = loader.load_network_from_geometries(recs, use_integer_keys=True) 232 | # print(list(net.graph["keys"].keys())) 233 | assert list(net.graph["keys"].keys()) == [1, 21] 234 | 235 | 236 | def test_load_network_with_invalid_ints(): 237 | 238 | rec1 = { 239 | "geometry": {"type": "LineString", "coordinates": [(0, 0), (0, 1)]}, 240 | "properties": {"EDGE_ID": "1"}, 241 | } 242 | rec2 = { 243 | "geometry": {"type": "LineString", "coordinates": [(0, 1), (0, 2)]}, 244 | "properties": {"EDGE_ID": "2_1"}, 245 | } 246 | recs = [rec1, rec2] 247 | 248 | with pytest.raises(ValueError): 249 | loader.load_network_from_geometries(recs, use_integer_keys=True) 250 | 251 | 252 | def test_doctest(): 253 | import doctest 254 | 255 | print(doctest.testmod(loader)) 256 | 257 | 258 | if __name__ == "__main__": 259 | logging.basicConfig(level=logging.DEBUG) 260 | test_doctest() 261 | test_add_edge() 262 | test_add_edge_missing_key() 263 | test_add_edge_with_reverse_lookup() 264 | test_create_graph() 265 | test_create_multidigraph() 266 | test_create_graph_no_lookup() 267 | test_load_network_from_records() 268 | test_load_network_from_records_missing_key() 269 | test_load_network_from_geometries() 270 | test_load_network_from_geometries_calculated_length() 271 | test_load_network_from_invalid_geometries() 272 | test_load_network_from_invalid_geometries_skip() 273 | test_load_network_from_invalid_geometries_missing_key() 274 | test_uniquedict() 275 | test_uniquedict_error() 276 | test_pickling() 277 | test_load_network_with_invalid_ints() 278 | test_load_network_with_valid_ints() 279 | print("Done!") 280 | -------------------------------------------------------------------------------- /tests/test_loops.py: -------------------------------------------------------------------------------- 1 | from wayfarer import loops, Edge 2 | import logging 3 | from tests import networks 4 | 5 | 6 | def test_get_root_node(): 7 | 8 | outdeg = [(0, 2), (1, 3), (2, 2)] 9 | root_node = loops.get_root_node(outdeg) 10 | assert root_node == 1 11 | 12 | outdeg = [(0, 2), (1, 2), (2, 2)] 13 | root_node = loops.get_root_node(outdeg) 14 | assert root_node == 0 15 | 16 | 17 | def test_get_unique_lists(): 18 | 19 | all_lists = [[1, 2, 3], [1, 3, 2]] 20 | unique_lists = loops.get_unique_lists(all_lists) 21 | assert unique_lists == [[1, 2, 3]] 22 | 23 | all_lists = [[1, 2, 3], [1, 3, 2, 4], [1, 3, 2]] 24 | unique_lists = loops.get_unique_lists(all_lists) 25 | assert unique_lists == [[1, 2, 3], [1, 3, 2, 4]] 26 | 27 | all_lists = [[1, 2], [2, 1], [1, 2]] 28 | unique_lists = loops.get_unique_lists(all_lists) 29 | assert unique_lists == [[1, 2]] 30 | 31 | 32 | def test_get_loop_nodes(): 33 | 34 | edges = [Edge(0, 1, "A", {}), Edge(1, 2, "B", {}), Edge(2, 0, "C", {})] 35 | loop_nodes = loops.get_loop_nodes(edges) 36 | assert loop_nodes == {0: [0, 1, 2]} 37 | 38 | 39 | def test_is_loop(): 40 | 41 | assert loops.is_loop(networks.circle_network()) is True 42 | 43 | 44 | def test_is_not_loop(): 45 | 46 | assert loops.is_loop(networks.triple_loop_network()) is False 47 | 48 | 49 | def test_doctest(): 50 | import doctest 51 | 52 | print(doctest.testmod(loops)) 53 | 54 | 55 | if __name__ == "__main__": 56 | logging.basicConfig(level=logging.DEBUG) 57 | # test_get_unique_lists() 58 | # test_get_root_node() 59 | # test_get_loop_nodes() 60 | # test_is_loop() 61 | test_is_not_loop() 62 | print("Done!") 63 | -------------------------------------------------------------------------------- /tests/test_osmnx_compat.py: -------------------------------------------------------------------------------- 1 | from wayfarer import osmnx_compat 2 | import wayfarer 3 | from tests import networks 4 | import logging 5 | from shapely import wkb 6 | from shapely.geometry import Point, LineString 7 | import pytest 8 | 9 | 10 | def test_to_osmnx_missing_geom(): 11 | 12 | net = networks.simple_network() 13 | 14 | with pytest.raises(KeyError): 15 | osmnx_compat.to_osmnx(net) 16 | 17 | 18 | def test_to_osmnx(): 19 | 20 | net = networks.simple_network() 21 | 22 | assert len(net.edges()) == 4 23 | assert len(net.nodes()) == 5 24 | 25 | for e in wayfarer.to_edges(net.edges(keys=True, data=True)): 26 | e.attributes["Geometry4326"] = wkb.dumps(LineString([Point(0, 0), Point(0, 1)])) 27 | 28 | G = osmnx_compat.to_osmnx(net) 29 | 30 | assert len(G.edges()) == 8 31 | assert len(G.nodes()) == 5 32 | 33 | edges = list(wayfarer.to_edges(G.edges(keys=True, data=True))) 34 | 35 | keys = list(sorted(edges[0].attributes.keys())) 36 | 37 | # check all the edge keys for both wayfarer and osmnx are present 38 | assert keys == [ 39 | "EDGE_ID", 40 | "Geometry4326", 41 | "LEN_", 42 | "NODEID_FROM", 43 | "NODEID_TO", 44 | "geometry", 45 | "highway", 46 | "length", 47 | "name", 48 | "oneway", 49 | "osmid", 50 | "reversed", 51 | ] 52 | 53 | 54 | def test_doctest(): 55 | import doctest 56 | 57 | print(doctest.testmod(osmnx_compat)) 58 | 59 | 60 | if __name__ == "__main__": 61 | logging.basicConfig(level=logging.DEBUG) 62 | test_to_osmnx() 63 | print("Done!") 64 | -------------------------------------------------------------------------------- /tests/test_routing.py: -------------------------------------------------------------------------------- 1 | """ 2 | pytest -v tests/test_splitting.py 3 | """ 4 | 5 | import logging 6 | import pytest 7 | from wayfarer import routing, splitter, Edge, WITH_DIRECTION_FIELD 8 | from wayfarer.splitter import SPLIT_KEY_SEPARATOR 9 | from tests import networks 10 | from networkx import NetworkXNoPath, NodeNotFound 11 | 12 | 13 | def test_solve_all_simple_paths(): 14 | net = networks.simple_network() 15 | simple_paths = routing.solve_all_simple_paths(net, 1, 5) 16 | assert list(simple_paths) == [[1, 2, 3, 4, 5]] 17 | 18 | 19 | def test_solve_all_simple_paths_no_node(): 20 | net = networks.simple_network() 21 | with pytest.raises(NodeNotFound): 22 | list(routing.solve_all_simple_paths(net, 1, 99)) 23 | 24 | 25 | def test_solve_all_simple_paths_cutoff(): 26 | net = networks.simple_network() 27 | simple_paths = routing.solve_all_simple_paths(net, 1, 5, cutoff=3) 28 | assert list(simple_paths) == [] 29 | 30 | net = networks.simple_network() 31 | simple_paths = routing.solve_all_simple_paths(net, 1, 3, cutoff=3) 32 | assert list(simple_paths) == [[1, 2, 3]] 33 | 34 | 35 | def test_solve_all_shortest_paths(): 36 | net = networks.circle_network() 37 | all_paths = routing.solve_all_shortest_paths(net, 1, 3) 38 | # print(list(all_paths)) 39 | assert list(all_paths) == [[1, 2, 3], [1, 4, 3]] 40 | 41 | 42 | def test_solve_all_shortest_paths_no_node(): 43 | net = networks.circle_network() 44 | # it seems NetworkXNoPath is raised rather than NodeNotFound 45 | # when using all_shortest_paths in networkx 46 | # and it is only raised when the generator is turned into a list 47 | with pytest.raises(NetworkXNoPath): 48 | print(list(routing.solve_all_shortest_paths(net, 1, 99))) 49 | 50 | 51 | def test_get_path_ends(): 52 | edges = [Edge(0, 1, 1, {}), Edge(1, 2, 1, {})] 53 | ends = routing.get_path_ends(edges) 54 | # print(ends) 55 | assert ends == (0, 2) 56 | 57 | 58 | def test_get_path_ends_single_edge(): 59 | edges = [Edge(0, 1, 1, {})] 60 | ends = routing.get_path_ends(edges) 61 | # print(ends) 62 | assert ends == (0, 1) 63 | 64 | 65 | def test_solve_shortest_path(): 66 | net = networks.simple_network() 67 | edges = routing.solve_shortest_path(net, start_node=1, end_node=5) 68 | edge_ids = [edge.key for edge in edges] 69 | assert edge_ids == [1, 2, 3, 4] 70 | 71 | 72 | def test_solve_shortest_path_no_weight(): 73 | net = networks.simple_network() 74 | edges = routing.solve_shortest_path(net, start_node=1, end_node=5, weight=None) 75 | edge_ids = [edge.key for edge in edges] 76 | assert edge_ids == [1, 2, 3, 4] 77 | 78 | 79 | def test_solve_shortest_path_directions(): 80 | net = networks.simple_network() 81 | edges = routing.solve_shortest_path(net, start_node=1, end_node=5) 82 | 83 | edge_directions = [edge.attributes[WITH_DIRECTION_FIELD] for edge in edges] 84 | assert edge_directions == [True, True, True, True] 85 | 86 | edges = routing.solve_shortest_path(net, start_node=5, end_node=1) 87 | 88 | edge_directions = [edge.attributes[WITH_DIRECTION_FIELD] for edge in edges] 89 | assert edge_directions == [False, False, False, False] 90 | 91 | 92 | def test_solve_shortest_path_split_network(): 93 | net = networks.simple_network() 94 | 95 | splitter.split_network_edge(net, 3, [2, 8]) 96 | edges = routing.solve_shortest_path(net, start_node=1, end_node=5) 97 | 98 | # for edge in edges: 99 | # print(edge) 100 | 101 | edge_ids = [edge.key for edge in edges] 102 | # print(edge_ids) 103 | assert edge_ids == [ 104 | 1, 105 | 2, 106 | f"3{SPLIT_KEY_SEPARATOR}2", 107 | f"3{SPLIT_KEY_SEPARATOR}8", 108 | f"3{SPLIT_KEY_SEPARATOR}10", 109 | 4, 110 | ] 111 | 112 | 113 | def test_doctest(): 114 | import doctest 115 | 116 | print(doctest.testmod(routing)) 117 | 118 | 119 | if __name__ == "__main__": 120 | logging.basicConfig(level=logging.DEBUG) 121 | # test_doctest() 122 | # test_solve_all_simple_paths_no_node() 123 | # test_solve_all_simple_paths_cutoff() 124 | # test_solve_all_shortest_paths() 125 | # test_solve_all_shortest_paths_no_node() 126 | # test_get_path_ends() 127 | # test_get_path_ends_single_edge() 128 | # test_solve_shortest_path_directions() 129 | # test_solve_shortest_path_split_network() 130 | test_solve_shortest_path_no_weight() 131 | print("Done!") 132 | -------------------------------------------------------------------------------- /tests/test_routing_ordered_path.py: -------------------------------------------------------------------------------- 1 | """ 2 | pytest -v tests/test_routing_ordered_path.py 3 | """ 4 | 5 | import logging 6 | import pytest 7 | from wayfarer import Edge, routing, loops, functions 8 | from tests import networks 9 | import wayfarer 10 | from networkx.exception import NetworkXError 11 | 12 | 13 | def test_get_path_ends(): 14 | 15 | edges = [ 16 | Edge(0, 1, key="A", attributes={"EDGE_ID": "A", "LEN_": 100}), 17 | Edge(1, 2, key="B", attributes={"EDGE_ID": "B", "LEN_": 100}), 18 | Edge(2, 3, key="C", attributes={"EDGE_ID": "C", "LEN_": 100}), 19 | ] 20 | 21 | ordered_edges = routing.find_ordered_path(edges, with_direction_flag=False) 22 | start_node, end_node = routing.get_path_ends(ordered_edges) 23 | assert start_node == 0 24 | assert end_node == 3 25 | 26 | # assert 2, 0 == (2,4) # WARNING will always return True! As interpreted as assert (2, 0) # True 27 | 28 | 29 | def test_get_path_ends2(): 30 | """ 31 | A P shape 32 | """ 33 | 34 | edges = [ 35 | Edge(0, 1, key="A", attributes={"EDGE_ID": "A", "LEN_": 100}), 36 | Edge(1, 2, key="B", attributes={"EDGE_ID": "B", "LEN_": 100}), 37 | Edge(2, 3, key="C", attributes={"EDGE_ID": "C", "LEN_": 100}), 38 | Edge(3, 1, key="D", attributes={"EDGE_ID": "D", "LEN_": 100}), 39 | ] 40 | 41 | ordered_edges = routing.find_ordered_path(edges, with_direction_flag=False) 42 | start_node, end_node = routing.get_path_ends(ordered_edges) 43 | 44 | assert start_node == 0 45 | assert end_node == 3 46 | 47 | 48 | def test_get_path_ends3(): 49 | """ 50 | A P shape - inverse loop 51 | """ 52 | 53 | edges = [ 54 | Edge(0, 1, key="A", attributes={"EDGE_ID": "A", "LEN_": 100}), 55 | Edge(2, 1, key="B", attributes={"EDGE_ID": "B", "LEN_": 100}), 56 | Edge(3, 2, key="C", attributes={"EDGE_ID": "C", "LEN_": 100}), 57 | Edge(3, 1, key="D", attributes={"EDGE_ID": "D", "LEN_": 100}), 58 | ] 59 | 60 | ordered_edges = routing.find_ordered_path(edges, with_direction_flag=False) 61 | start_node, end_node = routing.get_path_ends(ordered_edges) 62 | 63 | assert start_node == 0 64 | assert end_node == 3 65 | 66 | 67 | def test_simple_network(): 68 | 69 | # simple case 70 | net = networks.simple_network() 71 | edges = wayfarer.to_edges(net.edges(keys=True, data=True)) 72 | 73 | loops.has_loop(net) is False 74 | loop_nodes = loops.get_loop_nodes(edges) 75 | # no loops 76 | assert len(loop_nodes) == 0 77 | 78 | end_nodes = functions.get_end_nodes(edges) 79 | assert end_nodes == [1, 5] 80 | 81 | edges = routing.find_ordered_path(edges) 82 | edge_ids = [edge.key for edge in edges] 83 | assert edge_ids == [1, 2, 3, 4] 84 | 85 | edges = routing.solve_shortest_path(net, start_node=1, end_node=5) 86 | edge_ids = [edge.key for edge in edges] 87 | assert edge_ids == [1, 2, 3, 4] 88 | 89 | edge_id_list = [1, 4] 90 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 91 | assert [e.key for e in edges] == [1, 2, 3, 4] 92 | 93 | edge_id_list = [4, 1] 94 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 95 | assert [e.key for e in edges] == [4, 3, 2, 1] 96 | 97 | edge_id_list = [3, 4, 1] 98 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 99 | assert [e.key for e in edges] == [4, 3, 2, 1] 100 | 101 | 102 | def test_single_edge_loop_network(): 103 | 104 | net = networks.single_edge_loop_network() 105 | edges = wayfarer.to_edges(net.edges(keys=True, data=True)) 106 | 107 | loop_nodes = loops.get_loop_nodes(edges) 108 | # no loops (self-loops on one node are not included) 109 | assert len(loop_nodes) == 0 110 | 111 | end_nodes = functions.get_end_nodes(edges) 112 | assert end_nodes == [1] 113 | 114 | edges = routing.find_ordered_path(edges) 115 | assert [edge.key for edge in edges] == [1, 2, 3] 116 | 117 | edges = routing.solve_shortest_path(net, start_node=1, end_node=3) 118 | assert [edge.key for edge in edges] == [1, 2] 119 | 120 | edges = routing.solve_shortest_path(net, start_node=3, end_node=1) 121 | assert [edge.key for edge in edges] == [2, 1] 122 | 123 | edge_id_list = [3, 2] 124 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 125 | assert [e.key for e in edges] == [3, 2] 126 | 127 | edge_id_list = [2] 128 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 129 | assert [e.key for e in edges] == [2] 130 | 131 | # TOD this throw an error - Graph has no Eulerian path 132 | edge_id_list = [1, 3] 133 | # edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 134 | # assert [e.key for e in edges] == [1, 2, 3] 135 | 136 | 137 | def test_reverse_network(): 138 | 139 | net = networks.reverse_network() 140 | edges = wayfarer.to_edges(net.edges(keys=True, data=True)) 141 | 142 | loop_nodes = loops.get_loop_nodes(edges) 143 | # no loops 144 | assert len(loop_nodes) == 0 145 | 146 | end_nodes = functions.get_end_nodes(edges) 147 | assert end_nodes == [2, 4] 148 | 149 | edges = routing.find_ordered_path(edges) 150 | assert [edge.key for edge in edges] == [1, 2, 3, 4] 151 | 152 | edges = routing.solve_shortest_path(net, start_node=4, end_node=2) 153 | assert [edge.key for edge in edges] == [4, 3, 2, 1] 154 | 155 | edge_id_list = [1, 4] 156 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 157 | assert [e.key for e in edges] == [1, 2, 3, 4] 158 | 159 | edge_id_list = [4, 1] 160 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 161 | assert [e.key for e in edges] == [4, 3, 2, 1] 162 | 163 | edge_id_list = [3, 1, 4] 164 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 165 | assert [e.key for e in edges] == [1, 2, 3, 4] 166 | 167 | 168 | def test_t_network(): 169 | 170 | # simple case 171 | net = networks.t_network() 172 | edges = wayfarer.to_edges(net.edges(keys=True, data=True)) 173 | 174 | loop_nodes = loops.get_loop_nodes(edges) 175 | # no loops 176 | assert len(loop_nodes) == 0 177 | 178 | end_nodes = functions.get_end_nodes(edges) 179 | assert end_nodes == [1, 4, 5] 180 | 181 | with pytest.raises(NetworkXError): 182 | routing.find_ordered_path(edges) 183 | 184 | edges = routing.solve_shortest_path(net, start_node=1, end_node=4) 185 | assert [edge.key for edge in edges] == [1, 2, 3] 186 | 187 | # throw error 188 | with pytest.raises(NetworkXError): 189 | edge_id_list = [3, 1, 4] 190 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 191 | 192 | 193 | def test_p_network(): 194 | 195 | # simple case 196 | net = networks.p_network() 197 | edges = wayfarer.to_edges(net.edges(keys=True, data=True)) 198 | 199 | loop_nodes = loops.get_loop_nodes(edges) 200 | assert loop_nodes[2] == [2, 3, 4] 201 | 202 | end_nodes = functions.get_end_nodes(edges) 203 | assert end_nodes == [1] 204 | 205 | edges = routing.find_ordered_path(edges) 206 | assert [edge.key for edge in edges] == [1, 2, 3, 4] 207 | 208 | edges = routing.solve_shortest_path(net, start_node=1, end_node=3) 209 | assert [edge.key for edge in edges] == [1, 2] 210 | 211 | edge_id_list = [1, 2, 3, 4] 212 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 213 | assert [e.key for e in edges] == [1, 2, 3, 4] 214 | 215 | edge_id_list = [4, 3, 2, 1] 216 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 217 | assert [e.key for e in edges] == [4, 3, 2, 1] 218 | 219 | edge_id_list = [3, 1, 2, 4] 220 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 221 | # print([e.key for e in edges]) 222 | assert [e.key for e in edges] == [2, 3, 4, 1] 223 | 224 | 225 | def test_double_loop_network(): 226 | 227 | net = networks.double_loop_network() 228 | edges = wayfarer.to_edges(net.edges(keys=True, data=True)) 229 | 230 | loop_nodes = loops.get_loop_nodes(edges) 231 | # loops 232 | assert loop_nodes[1] == [1, 2, 3] 233 | assert loop_nodes[4] == [4, 5, 6] 234 | 235 | end_nodes = functions.get_end_nodes(edges) 236 | assert end_nodes == [] 237 | 238 | edges = routing.find_ordered_path(edges) 239 | assert [edge.key for edge in edges] == [1, 2, 3, 4, 5, 6, 7] 240 | 241 | edges = routing.solve_shortest_path(net, start_node=1, end_node=6) 242 | assert [edge.key for edge in edges] == [4, 7] 243 | 244 | edge_id_list = [1, 2, 3, 4, 5, 6, 7] 245 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 246 | assert [e.key for e in edges] == [1, 2, 3, 4, 5, 6, 7] 247 | 248 | edge_id_list = [4, 3, 2, 1, 5, 7, 6] 249 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 250 | assert [e.key for e in edges] == [3, 2, 1, 4, 5, 6, 7] 251 | 252 | edge_id_list = [3, 1, 6, 2, 4, 5, 7] 253 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 254 | # print([e.key for e in edges]) 255 | assert [e.key for e in edges] == [3, 2, 1, 4, 5, 6, 7] 256 | 257 | 258 | def test_circle_network(): 259 | 260 | net = networks.circle_network() 261 | edges = wayfarer.to_edges(net.edges(keys=True, data=True)) 262 | 263 | loop_nodes = loops.get_loop_nodes(edges) 264 | # loops 265 | assert loop_nodes[1] == [1, 2, 3, 4] 266 | 267 | end_nodes = functions.get_end_nodes(edges) 268 | assert end_nodes == [] 269 | 270 | edges = routing.find_ordered_path(edges) 271 | assert [edge.key for edge in edges] == [1, 2, 3, 4] 272 | 273 | edges = routing.solve_shortest_path(net, start_node=1, end_node=4) 274 | assert [edge.key for edge in edges] == [4] 275 | 276 | edges = routing.solve_shortest_path(net, start_node=1, end_node=3) 277 | assert [edge.key for edge in edges] == [1, 2] 278 | 279 | edge_id_list = [1, 2, 3, 4] 280 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 281 | assert [e.key for e in edges] == [1, 2, 3, 4] 282 | 283 | edge_id_list = [4, 3, 2, 1] 284 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 285 | # print([e.key for e in edges]) 286 | # 4, 3, 2, 1 is equally valid, but the eulerian_path returns them in this order 287 | assert [e.key for e in edges] == [4, 1, 2, 3] 288 | 289 | edge_id_list = [2, 1, 4, 3] 290 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 291 | # print([e.key for e in edges]) 292 | # 2, 1, 4, 3 is equally valid, but the eulerian_path returns them in this order 293 | assert [e.key for e in edges] == [2, 3, 4, 1] 294 | 295 | 296 | def test_dual_path(): 297 | 298 | net = networks.dual_path_network() 299 | edges = wayfarer.to_edges(net.edges(keys=True, data=True)) 300 | 301 | loop_nodes = loops.get_loop_nodes(edges) 302 | # no loops (self-loops on one node are not included) 303 | assert len(loop_nodes) == 0 304 | 305 | end_nodes = functions.get_end_nodes(edges) 306 | assert end_nodes == [1] 307 | 308 | edges = routing.find_ordered_path(edges) 309 | # print([e.key for e in edges]) 310 | assert [edge.key for edge in edges] == [1, 2, 3] 311 | 312 | edge_id_list = [1, 2] 313 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 314 | assert [e.key for e in edges] == [1, 2] 315 | 316 | edge_id_list = [1, 3] 317 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 318 | assert [e.key for e in edges] == [1, 3] 319 | 320 | edge_id_list = [1, 2] 321 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 322 | assert [e.key for e in edges] == [1, 2] 323 | 324 | edge_id_list = [1, 2, 3] 325 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 326 | assert [e.key for e in edges] == [1, 2, 3] 327 | 328 | 329 | def test_dual_path_middle_network(): 330 | 331 | net = networks.dual_path_middle_network() 332 | edges = wayfarer.to_edges(net.edges(keys=True, data=True)) 333 | 334 | loop_nodes = loops.get_loop_nodes(edges) 335 | # no loops (self-loops on one node are not included) 336 | assert len(loop_nodes) == 0 337 | 338 | # throw error 339 | # networkx.exception.NetworkXError: Graph has no Eulerian paths 340 | with pytest.raises(NetworkXError): 341 | edges = routing.find_ordered_path(edges) 342 | 343 | edge_id_list = [4, 2, 1] 344 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 345 | assert [e.key for e in edges] == [4, 2, 1] 346 | 347 | edge_id_list = [1, 2, 4] 348 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 349 | assert [e.key for e in edges] == [1, 2, 4] 350 | 351 | edge_id_list = [4, 3, 1] 352 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 353 | assert [e.key for e in edges] == [4, 3, 1] 354 | 355 | edge_id_list = [1, 3, 4] 356 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 357 | assert [e.key for e in edges] == [1, 3, 4] 358 | 359 | # throw error 360 | # networkx.exception.NetworkXError: Graph has no Eulerian paths 361 | edge_id_list = [1, 2, 3, 4] 362 | with pytest.raises(NetworkXError): 363 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 364 | 365 | 366 | def test_loop_middle_network(): 367 | 368 | net = networks.loop_middle_network() 369 | edges = wayfarer.to_edges(net.edges(keys=True, data=True)) 370 | 371 | loop_nodes = loops.get_loop_nodes(edges) 372 | assert loop_nodes[2] == [2, 3, 4] 373 | 374 | end_nodes = functions.get_end_nodes(edges) 375 | assert end_nodes == [1, 5] 376 | 377 | edges = routing.find_ordered_path(edges) 378 | assert [edge.key for edge in edges] == [1, 2, 3, 4, 5] 379 | 380 | edges = routing.solve_shortest_path(net, start_node=1, end_node=5) 381 | assert [edge.key for edge in edges] == [1, 5] 382 | 383 | edges = routing.solve_shortest_path(net, start_node=1, end_node=3) 384 | assert [edge.key for edge in edges] == [1, 2] 385 | 386 | edge_id_list = [1, 2, 3, 4, 5] 387 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 388 | print([e.key for e in edges]) 389 | assert [e.key for e in edges] == [1, 2, 3, 4, 5] 390 | 391 | 392 | def test_triple_loop_network(): 393 | 394 | net = networks.triple_loop_network() 395 | edges = wayfarer.to_edges(net.edges(keys=True, data=True)) 396 | 397 | loop_nodes = loops.get_loop_nodes(edges) 398 | assert loop_nodes[1] == [1, 2, 3] 399 | assert loop_nodes[4] == [4, 5, 6] 400 | assert loop_nodes[7] == [7, 8, 9] 401 | 402 | end_nodes = functions.get_end_nodes(edges) 403 | assert end_nodes == [] 404 | 405 | edges = routing.find_ordered_path(edges) 406 | assert [edge.key for edge in edges] == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] 407 | 408 | edges = routing.solve_shortest_path(net, start_node=5, end_node=8) 409 | assert [edge.key for edge in edges] == [5, 8, 9] 410 | 411 | edge_id_list = [2, 8, 10] 412 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 413 | assert [e.key for e in edges] == [2, 3, 4, 8, 9, 10] 414 | 415 | 416 | def test_bottle_network(): 417 | 418 | net = networks.bottle_network() 419 | edges = wayfarer.to_edges(net.edges(keys=True, data=True)) 420 | 421 | loop_nodes = loops.get_loop_nodes(edges) 422 | # no loops (self-loops on one node are not included) 423 | assert len(loop_nodes) == 0 424 | 425 | end_nodes = functions.get_end_nodes(edges) 426 | assert end_nodes == [1] 427 | 428 | edges = routing.find_ordered_path(edges) 429 | # print([e.key for e in edges]) 430 | assert [edge.key for edge in edges] == [1, 2, 3] 431 | 432 | edge_id_list = [1, 3] 433 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 434 | assert [e.key for e in edges] == [1, 3] 435 | 436 | edge_id_list = [1, 3] 437 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 438 | assert [e.key for e in edges] == [1, 3] 439 | 440 | edge_id_list = [1, 2] 441 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 442 | assert [e.key for e in edges] == [1, 2] 443 | 444 | edge_id_list = [1, 2, 3] 445 | edges = routing.solve_shortest_path_from_edges(net, edge_id_list) 446 | assert [e.key for e in edges] == [1, 2, 3] 447 | 448 | 449 | if __name__ == "__main__": 450 | logging.basicConfig(level=logging.DEBUG) 451 | # test_get_path_ends() 452 | # test_get_path_ends2() 453 | # test_get_path_ends3() 454 | # test_simple_network() 455 | # test_single_edge_loop_network() 456 | # test_reverse_network() 457 | # test_t_network() 458 | # test_p_network() 459 | # test_double_loop_network() 460 | test_dual_path_middle_network() 461 | # test_circle_network() 462 | # test_dual_path() 463 | # test_loop_middle_network() 464 | # test_triple_loop_network() 465 | # test_bottle_network() 466 | print("Done!") 467 | -------------------------------------------------------------------------------- /tests/test_splitting.py: -------------------------------------------------------------------------------- 1 | """ 2 | pytest -v tests/test_splitting.py 3 | """ 4 | 5 | import logging 6 | import pytest 7 | from wayfarer import splitter, functions, loader, routing 8 | from tests.helper import simple_features 9 | 10 | 11 | @pytest.mark.parametrize("use_reverse_lookup", [(True), (False)]) 12 | def test_split_network_edge(use_reverse_lookup): 13 | feats = simple_features() 14 | net = loader.load_network_from_geometries( 15 | feats, use_reverse_lookup=use_reverse_lookup 16 | ) 17 | 18 | assert len(net.nodes()) == 4 19 | assert len(net.edges()) == 3 20 | 21 | # split the second edge 22 | edge_id_to_split = 2 23 | measure = 50 24 | splitter.split_network_edge(net, edge_id_to_split, [measure]) 25 | 26 | split_node_id = splitter.create_split_key(edge_id_to_split, measure) 27 | assert split_node_id in net.nodes(), "The split node has been added to the network" 28 | 29 | # after the split operation there should be one extra edge 30 | # and one extra node 31 | assert len(net.nodes()) == 5 32 | assert len(net.edges()) == 4 33 | 34 | # the split edges no longer have the EdgeId as a key 35 | split_edges = functions.get_edges_by_attribute(net, "EDGE_ID", edge_id_to_split) 36 | split_edges = list(split_edges) 37 | assert len(split_edges) == 2 38 | 39 | # if use_reverse_lookup: print(net.graph["keys"].keys()) 40 | 41 | split_edge_id = splitter.create_split_key(edge_id_to_split, measure) 42 | 43 | split_edge = functions.get_edge_by_key(net, split_edge_id, with_data=True) 44 | assert split_edge.attributes["LEN_"] == 50 45 | assert split_edge.attributes["OFFSET"] == 0 46 | 47 | split_edge_id = splitter.create_split_key(edge_id_to_split, 100) 48 | split_edge = functions.get_edge_by_key(net, split_edge_id, with_data=True) 49 | assert split_edge.attributes["LEN_"] == 50 50 | assert split_edge.attributes["OFFSET"] == 50 51 | 52 | 53 | @pytest.mark.parametrize("use_reverse_lookup", [(True), (False)]) 54 | def test_multiple_split_network_edge(use_reverse_lookup): 55 | feats = simple_features() 56 | net = loader.load_network_from_geometries( 57 | feats, use_reverse_lookup=use_reverse_lookup 58 | ) 59 | 60 | # split the second edge multiple times 61 | edge_id_to_split = 2 62 | splitter.split_network_edge(net, edge_id_to_split, [20, 40, 60]) 63 | 64 | if use_reverse_lookup: 65 | print(net.graph["keys"].keys()) 66 | 67 | # after the split operation there should be one extra edge 68 | # and one extra node 69 | assert len(net.nodes()) == 7 70 | assert len(net.edges()) == 6 71 | 72 | 73 | @pytest.mark.parametrize("use_reverse_lookup", [(True), (False)]) 74 | def test_double_split_network_edge(use_reverse_lookup): 75 | feats = simple_features() 76 | net = loader.load_network_from_geometries( 77 | feats, use_reverse_lookup=use_reverse_lookup 78 | ) 79 | 80 | # split the second edge multiple times 81 | edge_id_to_split = 2 82 | splitter.split_network_edge(net, edge_id_to_split, [20, 40, 60]) 83 | 84 | split_edge_id = splitter.create_split_key(edge_id_to_split, 40) 85 | 86 | if use_reverse_lookup: 87 | print(net.graph["keys"].keys()) 88 | 89 | splitter.split_network_edge(net, split_edge_id, [10]) 90 | 91 | if use_reverse_lookup: 92 | print(net.graph["keys"].keys()) 93 | 94 | assert len(net.nodes()) == 8 95 | assert len(net.edges()) == 7 96 | 97 | 98 | @pytest.mark.parametrize("use_reverse_lookup", [(True), (False)]) 99 | def test_split_invalid_measure(use_reverse_lookup): 100 | feats = simple_features() 101 | net = loader.load_network_from_geometries( 102 | feats, use_reverse_lookup=use_reverse_lookup 103 | ) 104 | 105 | edge_count = len(net.edges()) 106 | key = 2 107 | original_edge = functions.get_edge_by_key(net, key, with_data=True) 108 | 109 | # 150 is greater than edge length 110 | split_edges = splitter.split_network_edge(net, key, [150]) 111 | # no split should happen and the original edge should be returned 112 | assert original_edge == split_edges[0] 113 | assert edge_count == len(net.edges()) 114 | 115 | 116 | @pytest.mark.parametrize("use_reverse_lookup", [(True), (False)]) 117 | def test_split_invalid_measure2(use_reverse_lookup): 118 | feats = simple_features() 119 | net = loader.load_network_from_geometries( 120 | feats, use_reverse_lookup=use_reverse_lookup 121 | ) 122 | 123 | edge_count = len(net.edges()) 124 | key = 2 125 | original_edge = functions.get_edge_by_key(net, key, with_data=True) 126 | 127 | # 100 is equal to edge length 128 | split_edges = splitter.split_network_edge(net, key, [100]) 129 | # no split should happen and the original edge should be returned 130 | assert original_edge == split_edges[0] 131 | assert edge_count == len(net.edges()) 132 | 133 | 134 | @pytest.mark.parametrize("use_reverse_lookup", [(True), (False)]) 135 | def test_split_invalid_measure3(use_reverse_lookup): 136 | feats = simple_features() 137 | net = loader.load_network_from_geometries( 138 | feats, use_reverse_lookup=use_reverse_lookup 139 | ) 140 | 141 | edge_count = len(net.edges()) 142 | key = 2 143 | original_edge = functions.get_edge_by_key(net, key, with_data=True) 144 | 145 | # 0 values should be ignored 146 | split_edges = splitter.split_network_edge(net, key, [0]) 147 | # no split should happen and the original edge should be returned 148 | assert original_edge == split_edges[0] 149 | assert edge_count == len(net.edges()) 150 | 151 | 152 | @pytest.mark.parametrize("use_reverse_lookup", [(True), (False)]) 153 | def test_split_invalid_measure4(use_reverse_lookup): 154 | feats = simple_features() 155 | net = loader.load_network_from_geometries( 156 | feats, use_reverse_lookup=use_reverse_lookup 157 | ) 158 | 159 | edge_count = len(net.edges()) 160 | key = 2 161 | original_edge = functions.get_edge_by_key(net, key, with_data=True) 162 | 163 | # negative values should be ignored 164 | split_edges = splitter.split_network_edge(net, key, [-10]) 165 | # no split should happen and the original edge should be returned 166 | assert original_edge == split_edges[0] 167 | assert edge_count == len(net.edges()) 168 | 169 | 170 | @pytest.mark.parametrize("use_reverse_lookup", [(True), (False)]) 171 | def test_split_invalid_measure5(use_reverse_lookup): 172 | feats = simple_features() 173 | net = loader.load_network_from_geometries( 174 | feats, use_reverse_lookup=use_reverse_lookup 175 | ) 176 | 177 | edge_count = len(net.edges()) 178 | key = 2 179 | original_edge = functions.get_edge_by_key(net, key, with_data=True) 180 | 181 | # all invalid measures should be ignored 182 | split_edges = splitter.split_network_edge(net, key, [-10, -5, 0, 100, 150, 200]) 183 | # no split should happen and the original edge should be returned 184 | assert original_edge == split_edges[0] 185 | assert edge_count == len(net.edges()) 186 | 187 | 188 | @pytest.mark.parametrize("use_reverse_lookup", [(True), (False)]) 189 | def test_split_with_points(use_reverse_lookup): 190 | feats = simple_features() 191 | net = loader.load_network_from_geometries( 192 | feats, use_reverse_lookup=use_reverse_lookup 193 | ) 194 | 195 | recs = [ 196 | {"EDGE_ID": 1, "POINT_ID": "A", "MEASURE": 50, "LEN_": 12}, 197 | {"EDGE_ID": 2, "POINT_ID": "B", "MEASURE": 20, "LEN_": 14}, 198 | {"EDGE_ID": 2, "POINT_ID": "C", "MEASURE": 40, "LEN_": 16}, 199 | ] 200 | 201 | splitter.split_with_points(net, recs) 202 | 203 | # run a test solve to the newly added point 204 | point_id = "B" 205 | edges = routing.solve_shortest_path(net, "0|0", point_id, with_direction_flag=True) 206 | # print(edges) 207 | assert len(edges) == 4 208 | 209 | # run a solve from start to finish across the original (now split) edges 210 | edges = routing.solve_shortest_path(net, "0|0", "300|0", with_direction_flag=True) 211 | # print(edges) 212 | assert len(edges) == 6 213 | 214 | 215 | def test_unsplit_network_edges(): 216 | """ 217 | Test splitting a network edge and then joining it back 218 | together again 219 | """ 220 | feats = simple_features() 221 | net = loader.load_network_from_geometries(feats, use_reverse_lookup=True) 222 | 223 | edge_id_to_split = 2 224 | 225 | assert len((net.graph["keys"])) == 3 226 | assert len(net.nodes()) == 4 227 | 228 | original_edge = functions.get_edge_by_key(net, edge_id_to_split) 229 | # custom fields should be persisted 230 | original_edge.attributes["CUSTOM_FIELD"] = "CUSTOM_VALUE" 231 | # print(original_edge) 232 | 233 | # split the second edge multiple times 234 | 235 | new_edges = splitter.split_network_edge(net, edge_id_to_split, [20, 40, 60]) 236 | 237 | assert len((net.graph["keys"])) == 6 238 | 239 | for ne in new_edges: 240 | print(ne) 241 | 242 | network_edges = list( 243 | functions.get_edges_by_attribute(net, "EDGE_ID", edge_id_to_split) 244 | ) 245 | # for ne in network_edges: print(ne) 246 | 247 | unsplit_edge = splitter.unsplit_network_edges(net, network_edges) 248 | 249 | assert len((net.graph["keys"])) == 3 250 | 251 | assert len(net.nodes()) == 4 252 | assert unsplit_edge == original_edge 253 | 254 | 255 | def test_doctest(): 256 | import doctest 257 | 258 | print(doctest.testmod(splitter)) 259 | 260 | 261 | if __name__ == "__main__": 262 | logging.basicConfig(level=logging.DEBUG) 263 | # test_multiple_split_network_edge(True) 264 | test_split_invalid_measure(True) 265 | test_split_invalid_measure2(True) 266 | test_split_invalid_measure3(True) 267 | # test_multiple_split_network_edge(True) 268 | # test_multiple_split_network_edge(True) 269 | # test_double_split_network_edge(True) 270 | # test_split_with_points(True) 271 | # test_split_network_edge(True) 272 | # test_unsplit_network_edges() 273 | print("Done!") 274 | -------------------------------------------------------------------------------- /tests/test_validator.py: -------------------------------------------------------------------------------- 1 | """ 2 | pytest -v tests/test_validator.py 3 | """ 4 | 5 | from wayfarer import loader, validator 6 | import networkx 7 | 8 | 9 | def create_net_with_duplicates(): 10 | net = networkx.MultiGraph() 11 | 12 | networkx.add_path(net, [0, 1, 2]) 13 | net.add_edge(2, 3, key=1, **{"EDGE_ID": 1, "LEN_": 100}) 14 | net.add_edge(3, 4, key="C", **{"EDGE_ID": 1, "LEN_": 100}) 15 | net.add_edge(4, 5, key="C", **{"EDGE_ID": 1, "LEN_": 100}) 16 | 17 | return net 18 | 19 | 20 | def create_net_without_duplicates(): 21 | net = networkx.MultiGraph() 22 | 23 | net.add_edge(0, 1, key=0) 24 | net.add_edge(1, 2, key=1) 25 | net.add_edge(2, 3, key=3) 26 | 27 | return net 28 | 29 | 30 | def test_duplicate_keys(): 31 | net_with_duplicates = create_net_with_duplicates() 32 | net_without_duplicates = create_net_without_duplicates() 33 | 34 | duplicates = validator.duplicate_keys(net_with_duplicates) 35 | no_duplicates = validator.duplicate_keys(net_without_duplicates) 36 | 37 | assert duplicates == [0, "C"] and no_duplicates == [] 38 | 39 | 40 | def test_valid_reverse_lookup(): 41 | recs = [ 42 | {"EDGE_ID": "A", "LEN_": 100, "NODEID_FROM": 0, "NODEID_TO": 100}, 43 | {"EDGE_ID": "B", "LEN_": 100, "NODEID_FROM": 100, "NODEID_TO": 200}, 44 | ] 45 | net = loader.load_network_from_records(recs, use_reverse_lookup=True) 46 | 47 | assert validator.valid_reverse_lookup(net) 48 | 49 | 50 | def test_recalculate_keys(): 51 | recs1 = [ 52 | {"EDGE_ID": "A", "LEN_": 100, "NODEID_FROM": 0, "NODEID_TO": 100}, 53 | {"EDGE_ID": 1, "LEN_": 100, "NODEID_FROM": 100, "NODEID_TO": 200}, 54 | ] 55 | net1 = loader.load_network_from_records(recs1, use_reverse_lookup=True) 56 | 57 | recs2 = [ 58 | {"EDGE_ID": "B", "LEN_": 100, "NODEID_FROM": 100, "NODEID_TO": 200}, 59 | {"EDGE_ID": 2, "LEN_": 100, "NODEID_FROM": 200, "NODEID_TO": 300}, 60 | ] 61 | net2 = loader.load_network_from_records(recs2, use_reverse_lookup=True) 62 | 63 | net = networkx.compose(net1, net2) 64 | 65 | validator.recalculate_keys(net) 66 | 67 | assert net.graph["keys"] == { 68 | "A": (0, 100), 69 | 1: (100, 200), 70 | "B": (100, 200), 71 | 2: (200, 300), 72 | } 73 | 74 | 75 | def test_edge_attributes(): 76 | recs = [{"EDGE_ID": 0, "LEN_": 10, "NODEID_FROM": 0, "NODEID_TO": 10, "PROP1": "A"}] 77 | net = loader.load_network_from_records(recs) 78 | 79 | attributes = validator.edge_attributes(net) 80 | 81 | assert attributes == [["EDGE_ID", "LEN_", "NODEID_FROM", "NODEID_TO", "PROP1"]] 82 | -------------------------------------------------------------------------------- /wayfarer.pyproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Debug 4 | 2.0 5 | f8ac0630-98af-462b-b7ee-5688d6183c85 6 | . 7 | tests\test_routing_ordered_path.py 8 | 9 | 10 | . 11 | . 12 | wayfarer 13 | wayfarer 14 | SAK 15 | SAK 16 | SAK 17 | SAK 18 | MSBuild|wayfarer|$(MSBuildProjectFullPath) 19 | Standard Python launcher 20 | False 21 | PATH=./data/mod_spatialite-4.3.0a-win-amd64;%PATH% 22 | true 23 | 24 | 25 | true 26 | false 27 | 28 | 29 | true 30 | false 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | Code 46 | 47 | 48 | 49 | 50 | 51 | 52 | Code 53 | 54 | 55 | Code 56 | 57 | 58 | 59 | 60 | Code 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | Code 69 | 70 | 71 | 72 | 73 | Code 74 | 75 | 76 | Code 77 | 78 | 79 | Code 80 | 81 | 82 | 83 | 84 | 85 | Code 86 | 87 | 88 | 89 | 90 | Code 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | Code 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | Code 165 | 166 | 167 | 168 | 169 | 170 | wayfarer 171 | 3.10 172 | wayfarer (Python 3.10 (64-bit)) 173 | Scripts\python.exe 174 | Scripts\pythonw.exe 175 | PYTHONPATH 176 | X64 177 | 178 | 179 | 180 | 183 | 184 | 185 | 186 | 187 | 188 | -------------------------------------------------------------------------------- /wayfarer/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import NamedTuple 3 | 4 | 5 | __version__ = "0.13.1" 6 | 7 | LENGTH_FIELD = "LEN_" 8 | EDGE_ID_FIELD = "EDGE_ID" 9 | OFFSET_FIELD = "OFFSET" 10 | 11 | NODEID_FROM_FIELD = "NODEID_FROM" 12 | NODEID_TO_FIELD = "NODEID_TO" 13 | 14 | WITH_DIRECTION_FIELD = "WITH_DIRECTION" 15 | 16 | GEOMETRY_FIELD = "geometry" 17 | 18 | SPLIT_POINT_ID_FIELD = "POINT_ID" 19 | SPLIT_MEASURE_FIELD = "MEASURE" 20 | 21 | SPLIT_FLAG = "IS_SPLIT" 22 | 23 | # the Edge class 24 | 25 | # https://mypy.readthedocs.io/en/stable/kinds_of_types.html#named-tuples 26 | 27 | 28 | class Edge(NamedTuple): 29 | """ 30 | A class representing a network edge 31 | """ 32 | 33 | start_node: int | str 34 | end_node: int | str 35 | key: int | str 36 | attributes: dict 37 | 38 | 39 | def to_edge(edge: tuple) -> Edge: 40 | """ 41 | Convert a tuple into an Edge namedtuple 42 | 43 | >>> to_edge((0, 1, 1, {"LEN_": 10})) 44 | Edge(start_node=0, end_node=1, key=1, attributes={'LEN_': 10}) 45 | 46 | >>> to_edge((0, 1, 1)) 47 | Edge(start_node=0, end_node=1, key=1, attributes={}) 48 | """ 49 | assert len(edge) >= 3 # edges must have at least a key 50 | 51 | # attributes will be None if tuple is 3 in length 52 | if len(edge) == 3: 53 | if type(edge[2]) is dict: 54 | # ensure we don't allow edges which have data but are missing a key 55 | # which can happen when selecting edges using ``edges(data=True, keys=False)`` 56 | raise ValueError(f"The edge tuple {edge} is missing a key value") 57 | attributes = {} 58 | else: 59 | attributes = edge[3] 60 | 61 | return Edge( 62 | start_node=edge[0], 63 | end_node=edge[1], 64 | key=edge[2], 65 | attributes=attributes, 66 | ) 67 | 68 | 69 | def to_edges(edges: tuple) -> list[Edge]: 70 | """ 71 | Convert a list of tuples to a list of Edges 72 | 73 | >>> tuples = [(0, 1, 1, {"LEN_": 10}), (1, 2, 2, {"LEN_": 20})] 74 | >>> to_edges(tuples) 75 | [Edge(start_node=0, end_node=1, key=1, attributes={'LEN_': 10}), \ 76 | Edge(start_node=1, end_node=2, key=2, attributes={'LEN_': 20})] 77 | """ 78 | return [to_edge(edge) for edge in edges] 79 | 80 | 81 | # if __name__ == "__main__": 82 | # import doctest 83 | 84 | # doctest.testmod() 85 | # print("Done!") 86 | -------------------------------------------------------------------------------- /wayfarer/io.py: -------------------------------------------------------------------------------- 1 | import geojson 2 | import math 3 | from wayfarer import Edge, GEOMETRY_FIELD 4 | import logging 5 | 6 | log = logging.getLogger("wayfarer") 7 | 8 | try: 9 | import fiona 10 | from fiona.model import Geometry 11 | except ImportError: 12 | log.info("fiona is not available") 13 | 14 | try: 15 | from shapely.geometry import shape 16 | from shapely.geometry import mapping 17 | except ImportError: 18 | log.info("shapely is not available") 19 | 20 | 21 | def edge_to_feature(edge: Edge): 22 | """ 23 | Convert a wayfarer Edge to a GeoJSON feature 24 | """ 25 | 26 | try: 27 | line = edge.attributes[GEOMETRY_FIELD] 28 | except KeyError: 29 | log.error(f"Edge: {edge}") 30 | log.error("Available properties: {}".format(",".join(edge.attributes.keys()))) 31 | raise 32 | 33 | edge.attributes.pop(GEOMETRY_FIELD) 34 | 35 | # remove any Nan values that can't be converted to JSON 36 | for k, v in edge.attributes.items(): 37 | if type(v) is float and math.isnan(v): 38 | edge.attributes[k] = None 39 | 40 | return geojson.Feature( 41 | id=edge.key, geometry=line.__geo_interface__, properties=edge.attributes 42 | ) 43 | 44 | 45 | def edges_to_featurecollection(edges: list[Edge]): 46 | """ 47 | Convert a list of wayfarer Edges to a GeoJSON feature collection 48 | """ 49 | 50 | features = [edge_to_feature(e) for e in edges] 51 | return geojson.FeatureCollection(features=features) 52 | 53 | 54 | def convert_to_geojson( 55 | input_file: str, 56 | output_file: str, 57 | layer_name: str, 58 | driver_name: str = "GPKG", 59 | convert_multilinestring: bool = True, 60 | ) -> None: 61 | """ 62 | Converts an input dataset to GeoJSON. 63 | Requires fiona and shapely 64 | Ensures each output feature has a unique id field, and that any MultiLineString features which consist 65 | of a single linestring are converted to linestrings. 66 | """ 67 | 68 | with fiona.open( 69 | input_file, "r", driver=driver_name, layer=layer_name 70 | ) as datasource: 71 | geojson_schema = datasource.schema.copy() 72 | 73 | # set the geometry schema or we will get the following errors 74 | # Record's geometry type does not match collection schema's geometry type: 'LineString' != '3D MultiLineString' 75 | 76 | if convert_multilinestring: 77 | geojson_schema["geometry"] = "LineString" 78 | 79 | with fiona.open( 80 | output_file, 81 | "w", 82 | driver="GeoJSON", 83 | schema=geojson_schema, 84 | crs=datasource.crs, 85 | ID_GENERATE="YES", 86 | ) as geojson_datasource: 87 | # Loop through each feature in the datasource and write it to the GeoJSON file 88 | for feature in datasource: 89 | if convert_multilinestring: 90 | geom = feature["geometry"] 91 | if geom.type == "MultiLineString": 92 | # convert to shapely geometry 93 | shapely_geom = shape(geom) 94 | if len(shapely_geom.geoms) == 1: 95 | feature["geometry"] = Geometry.from_dict( 96 | **mapping(shapely_geom.geoms[0]) 97 | ) 98 | 99 | geojson_datasource.write(feature) 100 | -------------------------------------------------------------------------------- /wayfarer/loader.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import logging 3 | import wayfarer 4 | from math import hypot 5 | from wayfarer import ( 6 | EDGE_ID_FIELD, 7 | LENGTH_FIELD, 8 | NODEID_FROM_FIELD, 9 | NODEID_TO_FIELD, 10 | GEOMETRY_FIELD, 11 | ) 12 | from wayfarer import functions 13 | import pickle 14 | from typing import Iterable 15 | from networkx import MultiGraph, MultiDiGraph 16 | 17 | 18 | log = logging.getLogger("wayfarer") 19 | 20 | 21 | class UniqueDict(dict): 22 | """ 23 | A subclass of dict to ensure all keys are unique 24 | """ 25 | 26 | def __setitem__(self, key, value): 27 | if key not in self: 28 | dict.__setitem__(self, key, value) 29 | else: 30 | raise KeyError("The key {} already exists. Keys must be unique".format(key)) 31 | 32 | 33 | def distance( 34 | p1: tuple[(int | float), (int | float)], p2: tuple[(int | float), (int | float)] 35 | ) -> float: 36 | """ 37 | Return the Euclidean distance between two points. 38 | Any z-values associated with the points are ignored. 39 | 40 | Args: 41 | p1: The first point 42 | p2: The second point 43 | Returns: 44 | The distance between the two points 45 | 46 | >>> distance((0, 0), (10, 10)) 47 | 14.142135623730951 48 | """ 49 | 50 | x1, y1 = p1[0], p1[1] 51 | x2, y2 = p2[0], p2[1] 52 | return hypot(x2 - x1, y2 - y1) 53 | 54 | 55 | def save_network_to_file(net: MultiGraph | MultiDiGraph, filename: str) -> None: 56 | """ 57 | Save a network to a Python pickle file 58 | Note these cannot be shared between different versions of Python 59 | 60 | Args: 61 | net: The network 62 | filename: The filename to save the network as a pickle file 63 | 64 | """ 65 | 66 | with open(filename, "wb") as f: 67 | pickle.dump(net, f, protocol=pickle.HIGHEST_PROTOCOL) 68 | 69 | 70 | def load_network_from_file( 71 | filename: str, 72 | ) -> MultiGraph | MultiDiGraph: 73 | """ 74 | Return a network previously saved to a pickle file 75 | 76 | Args: 77 | filename: The filename to the pickle file containing the network 78 | Returns: 79 | The network 80 | 81 | """ 82 | with open(filename, "rb") as f: 83 | return pickle.load(f) 84 | 85 | 86 | def add_edge(net: MultiGraph | MultiDiGraph, properties: dict) -> str | int: 87 | """ 88 | Add a new edge to a network based on a dict containing 89 | the required wayfarer fields 90 | 91 | Args: 92 | net: A network 93 | properties: A dictionary containing values for the edge 94 | Returns: 95 | The key of the edge 96 | 97 | >>> net = MultiGraph() 98 | >>> add_edge(net, {"EDGE_ID": 1, "NODEID_FROM": 1, "NODEID_TO": 1}) 99 | 1 100 | """ 101 | 102 | key = properties[EDGE_ID_FIELD] 103 | start_node = properties[NODEID_FROM_FIELD] 104 | end_node = properties[NODEID_TO_FIELD] 105 | 106 | # key is used to identify different edges between nodes 107 | net.add_edge(start_node, end_node, key=key, **properties) 108 | 109 | # update the reverse lookup dictionary attached to the graph 110 | # this is used for fast edge_id lookups 111 | # the key is the dict key and the values are the nodes 112 | 113 | if "keys" in net.graph.keys(): 114 | net.graph["keys"][key] = (start_node, end_node) 115 | 116 | return key 117 | 118 | 119 | def create_graph( 120 | use_reverse_lookup: bool = True, 121 | graph_type: MultiGraph | MultiDiGraph = MultiGraph, 122 | ) -> MultiGraph | MultiDiGraph: 123 | """ 124 | Create a new networkx graph, with an optional dictionary to store unique keys 125 | for fast edge lookups 126 | 127 | >>> net = create_graph() 128 | 129 | Args: 130 | use_reverse_lookup: Create a dictionary for fast key lookup 131 | graph_type: The type of network 132 | Returns: 133 | The new network 134 | """ 135 | if graph_type not in (MultiDiGraph, MultiGraph): 136 | raise ValueError( 137 | "The graph type {} unsupported. Only MultiGraph and MultiDiGraph are supported".format( 138 | graph_type.__name__ 139 | ) 140 | ) 141 | 142 | graph_name = "Wayfarer Generated {} (version {})".format( 143 | graph_type.__name__, wayfarer.__version__ 144 | ) 145 | 146 | if use_reverse_lookup: 147 | reverse_lookup = UniqueDict() 148 | net = graph_type(name=graph_name, keys=reverse_lookup) 149 | else: 150 | net = graph_type(name=graph_name) 151 | return net 152 | 153 | 154 | def load_network_from_records( 155 | recs: Iterable, 156 | use_reverse_lookup: bool = True, 157 | graph_type: MultiGraph | MultiDiGraph = MultiGraph, 158 | key_field: str = EDGE_ID_FIELD, 159 | length_field: str = LENGTH_FIELD, 160 | from_field: str = NODEID_FROM_FIELD, 161 | to_field: str = NODEID_TO_FIELD, 162 | ) -> MultiGraph | MultiDiGraph: 163 | """ 164 | Create a new networkX graph based on a list of dictionary objects 165 | containing the required wayfarer properties 166 | 167 | >>> recs = [{"EDGE_ID": 1, "NODEID_FROM": 1, "NODEID_TO": 2, "LEN_": 5, "PROP1": "A"}] 168 | >>> net = load_network_from_records(recs) 169 | >>> str(net) # doctest: +ELLIPSIS 170 | "MultiGraph named 'Wayfarer Generated MultiGraph (version ...)' with 2 nodes and 1 edges" 171 | 172 | Args: 173 | recs: An iterable of dictionary objects The filename to the pickle file containing the network 174 | use_reverse_lookup: Create a dictionary as part of the graph that stores unique edge keys for 175 | fast lookups 176 | graph_type: The type of network to create 177 | Returns: 178 | A new network 179 | 180 | """ 181 | 182 | net = create_graph(use_reverse_lookup, graph_type) 183 | 184 | for r in recs: 185 | 186 | try: 187 | key = r[key_field] 188 | start_node = r[from_field] 189 | end_node = r[to_field] 190 | len_ = r[length_field] 191 | except KeyError: 192 | log.error("Available properties: {}".format(",".join(r.keys()))) 193 | raise 194 | 195 | network_attributes = { 196 | EDGE_ID_FIELD: key, 197 | LENGTH_FIELD: len_, 198 | NODEID_FROM_FIELD: start_node, 199 | NODEID_TO_FIELD: end_node, 200 | } 201 | 202 | field_names = [key_field, length_field, from_field, to_field] 203 | all(map(r.pop, field_names)) # remove all keys already added 204 | network_attributes.update(r) # add in any remaining keys 205 | 206 | add_edge(net, network_attributes) 207 | 208 | return net 209 | 210 | 211 | def load_network_from_geometries( 212 | recs: Iterable, 213 | use_reverse_lookup: bool = True, 214 | graph_type: MultiGraph | MultiDiGraph = MultiGraph, 215 | skip_errors: bool = False, 216 | strip_properties: bool = False, 217 | keep_geometry: bool = False, 218 | key_field: str = EDGE_ID_FIELD, 219 | length_field: str = LENGTH_FIELD, 220 | rounding: int = 5, 221 | use_integer_keys: bool = True, 222 | ) -> MultiGraph | MultiDiGraph: 223 | """ 224 | Create a new networkX graph using a list of recs of type ``__geo_interface__`` 225 | This allows networks to be created using libraries such as Fiona. 226 | Any ``MultiLineString`` geometries in the network will be ignored, geometries should 227 | be converted to ``LineString`` prior to creating the network. 228 | 229 | >>> rec = {"geometry": {"type": "LineString", "coordinates": [(0, 0), (0, 1)]}, "properties": {"EDGE_ID": 1}} 230 | >>> net = load_network_from_geometries([rec]) 231 | >>> str(net) # doctest: +ELLIPSIS 232 | "MultiGraph named 'Wayfarer Generated MultiGraph (version ...)' with 2 nodes and 1 edges" 233 | 234 | Args: 235 | recs: An iterable of dictionary objects The filename to the pickle file containing the network 236 | use_reverse_lookup: Create a dictionary as part of the graph that stores unique edge keys for 237 | fast lookups 238 | graph_type: The type of network to create 239 | skip_errors: Ignore any geometries that are not of type ``LineString`` 240 | strip_properties: Reduce the size of the network file by only retaining the 241 | properties required to run routing. Any other properties in the records 242 | will be ignored 243 | key_field: A key field containing a unique Id for each feature must be provided 244 | length_field: The field in the geometry properties that contains a length. If not provided 245 | then the length will be calculated from the geometry. It is assumed the geometry 246 | is projected, as a simple planar distance will be calculated. 247 | rounding: If no node fields are used then nodes will be created based on the start and end points of 248 | the input geometries. As node values must match exactly for edges to be connected rounding 249 | to a fixed number of decimal places avoids unconnected edges to to tiny differences in floats 250 | e.g. (-9.564484483347517, 52.421103202488965) and (-9.552925853749544, 52.41969110706263) 251 | use_integer_keys: Using Integer keys makes using wayfarer faster. By default keys will be attempted to be 252 | converted to integers. Set this to ``False`` to leave keys unconverted (for example when using 253 | String keys) 254 | Returns: 255 | A new network 256 | """ 257 | 258 | net = create_graph(use_reverse_lookup, graph_type) 259 | error_count = 0 260 | 261 | for r in recs: 262 | 263 | # __geo__interface 264 | geom = r["geometry"] 265 | coords = geom["coordinates"] 266 | 267 | if geom["type"] != "LineString": 268 | if skip_errors is True: 269 | error_count += 1 270 | continue 271 | else: 272 | raise ValueError( 273 | "The geometry type {} is not supported - only LineString can be used".format( 274 | geom["type"] 275 | ) 276 | ) 277 | 278 | properties = r["properties"] 279 | 280 | if length_field in properties: 281 | length = properties[length_field] 282 | else: 283 | length = sum([distance(*combo) for combo in functions.pairwise(coords)]) 284 | 285 | if key_field == "id" and "id" in r: 286 | key = r["id"] 287 | else: 288 | try: 289 | key = properties[key_field] 290 | except KeyError: 291 | log.error( 292 | "Available properties: {}".format(",".join(properties.keys())) 293 | ) 294 | raise 295 | 296 | # keys as integers are faster than strings, so convert if possible 297 | if use_integer_keys and isinstance(key, str): 298 | if key.isdigit(): 299 | key = int(key) 300 | else: 301 | raise ValueError(f"Input string '{key}' is not a valid integer") 302 | 303 | # if we simply take the coordinates then often these differ due to rounding issues as they 304 | # are floats e.g. (-9.564484483347517, 52.421103202488965) and (-9.552925853749544, 52.41969110706263) 305 | # instead convert the coordinates to unique strings, apply rounding, and strip any z-values 306 | 307 | start_node = "{}|{}".format( 308 | round(coords[0][0], rounding), round(coords[0][1], rounding) 309 | ) 310 | end_node = "{}|{}".format( 311 | round(coords[-1][0], rounding), round(coords[-1][1], rounding) 312 | ) 313 | 314 | network_attributes = { 315 | EDGE_ID_FIELD: key, 316 | LENGTH_FIELD: length, 317 | NODEID_FROM_FIELD: start_node, 318 | NODEID_TO_FIELD: end_node, 319 | } 320 | 321 | # only use fields required for routing 322 | if strip_properties is True: 323 | properties = network_attributes 324 | else: 325 | properties.update(network_attributes) 326 | 327 | if keep_geometry: 328 | properties[GEOMETRY_FIELD] = geom 329 | 330 | add_edge(net, properties) 331 | 332 | if error_count > 0: 333 | log.warning("{} MultiLineString features were ignored".format(error_count)) 334 | 335 | return net 336 | -------------------------------------------------------------------------------- /wayfarer/loops.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module containing functions relating to loops 3 | """ 4 | 5 | from __future__ import annotations 6 | import networkx 7 | from networkx import cycles 8 | from wayfarer import Edge, functions 9 | import logging 10 | from typing import Iterable 11 | 12 | 13 | log = logging.getLogger("wayfarer") 14 | 15 | 16 | def is_loop(net: networkx.MultiGraph | networkx.MultiDiGraph) -> bool: 17 | """ 18 | Check if the entire network is a loop 19 | """ 20 | 21 | try: 22 | edges = networkx.algorithms.cycles.find_cycle(net) 23 | if len(edges) == len(net.edges()): 24 | return True 25 | else: 26 | return False 27 | except networkx.exception.NetworkXNoCycle: 28 | return False 29 | 30 | 31 | def has_loop(net: networkx.MultiGraph | networkx.MultiDiGraph) -> bool: 32 | """ 33 | Check if the network has a loop 34 | """ 35 | 36 | try: 37 | networkx.algorithms.cycles.find_cycle(net) 38 | return True 39 | except networkx.exception.NetworkXNoCycle: 40 | return False 41 | 42 | 43 | def get_unique_lists(all_lists: Iterable) -> list: 44 | """ 45 | Returns a unique list of lists, and preserves order 46 | 47 | >>> get_unique_lists([[1,2],[2,1],[1,2]]) 48 | [[1, 2]] 49 | """ 50 | unique_sorted_lists = [] 51 | unique_lists = [] 52 | 53 | for sublist in all_lists: 54 | if sorted(sublist) not in unique_sorted_lists: 55 | unique_sorted_lists.append(sorted(sublist)) 56 | unique_lists.append(sublist) 57 | 58 | return unique_lists 59 | 60 | 61 | def get_root_node(outdeg: list[tuple]) -> int | str: 62 | """ 63 | Find the most connected node - this will be where a loop starts and ends 64 | If degrees are equal the first node will always be returned 65 | 66 | >>> outdeg = [(0, 2), (1, 3), (2, 2)] 67 | >>> get_root_node(outdeg) 68 | 1 69 | """ 70 | root_node = max(outdeg, key=lambda n: n[1])[ 71 | 0 72 | ] # get the id of the node with the highest degree 73 | return root_node 74 | 75 | 76 | def get_loop_nodes(edges: list[Edge]) -> dict: 77 | """ 78 | Return a dict of nodes in a loop, with the key as the start node of the loop 79 | The order of the nodes in the list is sorted by node key, otherwise this is nondeterministic 80 | If an edge starts and ends at the same node it is not included as a loop here 81 | 82 | >>> edges = [Edge(0, 1, "A", {}), Edge(1, 2, "B", {}), Edge(2, 0, "C", {})] 83 | >>> get_loop_nodes(edges) 84 | {0: [0, 1, 2]} 85 | """ 86 | 87 | net = functions.edges_to_graph(edges) 88 | 89 | # to_directed adds two directed edges between each node 90 | loops = ( 91 | loop for loop in cycles.simple_cycles(net.to_directed()) if len(loop) > 2 92 | ) # filter out any loops between 2 connected edges 93 | 94 | unique_loops = get_unique_lists(loops) 95 | if len(unique_loops) > 0: 96 | log.debug(("Unique loops", unique_loops)) 97 | 98 | loop_nodes = {} 99 | 100 | for node_list in unique_loops: 101 | outdeg = net.degree(node_list) 102 | root_node = get_root_node(outdeg) 103 | loop_nodes[root_node] = sorted(node_list) 104 | 105 | return loop_nodes 106 | 107 | 108 | def find_self_loop( 109 | net: networkx.MultiGraph | networkx.MultiDiGraph, node_id: str | int 110 | ): 111 | """ 112 | For a node_id check to see if any edges start 113 | and end at this node 114 | """ 115 | return functions.get_edges_from_nodes( 116 | net, [node_id, node_id], with_direction_flag=True 117 | ) 118 | -------------------------------------------------------------------------------- /wayfarer/merger.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/wayfarer/merger.py -------------------------------------------------------------------------------- /wayfarer/osmnx_compat.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper module to convert between wayfarer and osmnx networks 3 | """ 4 | 5 | from __future__ import annotations 6 | import wayfarer 7 | import networkx as nx 8 | from shapely import wkb, LineString 9 | import osmnx as ox 10 | import logging 11 | import datetime as dt 12 | 13 | 14 | from wayfarer import ( 15 | LENGTH_FIELD, 16 | NODEID_FROM_FIELD, 17 | NODEID_TO_FIELD, 18 | ) 19 | 20 | 21 | log = logging.getLogger("wayfarer") 22 | 23 | 24 | def to_osmnx( 25 | net: nx.MultiGraph | nx.MultiDiGraph, 26 | geometry_field: str = "Geometry4326", 27 | crs: str = "epsg:4326", 28 | ) -> nx.MultiDiGraph: 29 | """ 30 | Convert a wayfarer network into a osmnx network 31 | default_crs in osmnx is in settings.default_crs and is epsg:4326 32 | """ 33 | 34 | # for timestamp see code from https://github.com/gboeing/osmnx/blob/3822ed659f1cc9f426a990c02dd8ca6b3f4d56d7/osmnx/utils.py#L50 35 | template = "{:%Y-%m-%d %H:%M:%S}" 36 | ts = template.format(dt.datetime.now()) 37 | 38 | metadata = { 39 | "created_date": ts, 40 | "created_with": f"OSMnx {ox.__version__}", 41 | "crs": crs, 42 | } 43 | 44 | G = nx.MultiDiGraph(**metadata) # wayfarer typically uses MultiGraph networks 45 | 46 | for tpl in net.edges(data=True, keys=True): 47 | 48 | edge = wayfarer.to_edge(tpl) 49 | 50 | try: 51 | edge_geometry = edge.attributes[geometry_field] 52 | except KeyError: 53 | keys = list(edge.attributes.keys()) 54 | log.error( 55 | f"The attribute '{geometry_field}' was not found in the edge attributes: {keys}" 56 | ) 57 | raise 58 | 59 | if not type(edge_geometry) is LineString: 60 | edge_geometry = wkb.loads(edge_geometry) 61 | 62 | G.add_node( 63 | edge.start_node, x=edge_geometry.coords[0][0], y=edge_geometry.coords[0][1] 64 | ) 65 | G.add_node( 66 | edge.end_node, x=edge_geometry.coords[-1][0], y=edge_geometry.coords[-1][1] 67 | ) 68 | 69 | properties = { 70 | "osmid": edge.key, 71 | "name": "test", 72 | "highway": "highway", 73 | "oneway": False, 74 | "reversed": False, 75 | "length": edge.attributes[LENGTH_FIELD], 76 | "geometry": edge_geometry, 77 | } 78 | 79 | # merge with wayfarer fields 80 | edge.attributes.update(properties) 81 | G.add_edge(edge.start_node, edge.end_node, key=edge.key, **edge.attributes) 82 | 83 | # now add the reversed edge 84 | reversed_properties = edge.attributes.copy() 85 | reversed_properties.update( 86 | { 87 | "reversed": True, 88 | NODEID_FROM_FIELD: edge.end_node, 89 | NODEID_TO_FIELD: edge.start_node, 90 | } 91 | ) 92 | key = edge.key * -1 93 | 94 | G.add_edge(edge.end_node, edge.start_node, key=key, **reversed_properties) 95 | 96 | return G 97 | -------------------------------------------------------------------------------- /wayfarer/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compassinformatics/wayfarer/2773ad2debfc1618d94e961e085ee35811b1e1e6/wayfarer/py.typed -------------------------------------------------------------------------------- /wayfarer/routing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import logging 3 | import itertools 4 | import networkx 5 | from wayfarer import functions, LENGTH_FIELD, Edge 6 | from networkx.algorithms import eulerian_path 7 | from networkx import NetworkXNoPath, NodeNotFound 8 | 9 | 10 | class MultipleEnds(Exception): 11 | pass 12 | 13 | 14 | log = logging.getLogger("wayfarer") 15 | 16 | 17 | def solve_shortest_path( 18 | net: networkx.MultiGraph | networkx.MultiDiGraph, 19 | start_node: str | int, 20 | end_node: str | int, 21 | with_direction_flag: bool = True, 22 | weight: str | None = LENGTH_FIELD, 23 | ): 24 | """ 25 | Solve the shortest path between two nodes, returning a list of Edge objects. 26 | Set weight to the attribute name for deciding the shortest path, or to None 27 | to ignore any weightings (faster). 28 | """ 29 | nodes = solve_shortest_path_from_nodes(net, [start_node, end_node], weight) 30 | return functions.get_edges_from_nodes( 31 | net, nodes, with_direction_flag=with_direction_flag, length_field=weight 32 | ) 33 | 34 | 35 | def solve_shortest_path_from_nodes( 36 | net: networkx.MultiGraph | networkx.MultiDiGraph, 37 | node_list: list[int | str], 38 | weight: str | None = LENGTH_FIELD, 39 | ) -> list[int | str]: 40 | """ 41 | Return a list of nodes found by solving from each node in node_list to 42 | the next. Set weight to the attribute name for deciding the shortest path, or to None 43 | to ignore any weightings (faster). 44 | """ 45 | nodes_in_path = [] 46 | 47 | for start_node, end_node in functions.pairwise(node_list): 48 | log.debug("Solving from %s to %s", start_node, end_node) 49 | 50 | if start_node == end_node: 51 | log.debug("Same start and end node used for path: {}".format(start_node)) 52 | else: 53 | try: 54 | nodes_in_path += networkx.shortest_path( 55 | net, source=start_node, target=end_node, weight=weight 56 | ) 57 | except KeyError: 58 | raise 59 | 60 | return nodes_in_path 61 | 62 | 63 | def solve_shortest_path_from_edges( 64 | net: networkx.MultiGraph | networkx.MultiDiGraph, edge_id_list: list[int | str] 65 | ): 66 | """ 67 | Return a path routing from edge to edge, rather than 68 | from node to node 69 | """ 70 | 71 | log.debug(f"Edge ids used for path solve: {edge_id_list}") 72 | 73 | # remove any duplicates 74 | edge_id_list = functions.get_unique_ordered_list(edge_id_list) 75 | 76 | edges = [] 77 | removed_edges = [] 78 | previous_edge_nodes = [] # type: list[int | str] 79 | 80 | for edge_id in edge_id_list: 81 | edge = functions.get_edge_by_key(net, edge_id) 82 | 83 | end_nodes = [edge.start_node, edge.end_node] 84 | 85 | # as a first-step remove any dual edges between points if they aren't in the input edge_id_list 86 | # this is to handle loops in the middle of a path (with two edges connecting the same two nodes) 87 | # simply setting the length to 0 for one pass isn't enough, as in the next 88 | # solve in this loop the shorter edge is returned. If we remove the shorter edge we can 89 | # ensure only the required edge is returned - see test_dual_path_middle_network 90 | 91 | edges_between_nodes = functions.get_edges_from_nodes( 92 | net, end_nodes, shortest_path_only=False 93 | ) 94 | for path_edge in edges_between_nodes: 95 | if path_edge.key not in edge_id_list: 96 | removed_edges.append(path_edge) 97 | functions.remove_edge(net, path_edge) 98 | 99 | if previous_edge_nodes: 100 | node_list = previous_edge_nodes + end_nodes 101 | else: 102 | node_list = end_nodes 103 | 104 | # set the edge_id in the list to have a length of 0 to ensure it is 105 | # part of the route if there are alternative paths 106 | 107 | original_length = float(edge.attributes[LENGTH_FIELD]) 108 | edge.attributes[LENGTH_FIELD] = 0 109 | log.debug( 110 | f"Updated edge id {edge_id} to have length 0 from {original_length:.2f}" 111 | ) 112 | 113 | if edge.start_node == edge.end_node: 114 | # self-loop 115 | edges.extend(functions.get_edges_from_nodes(net, end_nodes)) 116 | else: 117 | try: 118 | nodes = solve_shortest_path_from_nodes(net, node_list) 119 | except NetworkXNoPath as ex: 120 | log.warning(f"No path found using node_list: {node_list}") 121 | log.warning(ex) 122 | raise 123 | 124 | edges.extend(functions.get_edges_from_nodes(net, nodes)) 125 | 126 | # reset original length - note not in original implementation 127 | # ensure this is only reset after get_edges_from_nodes has been called 128 | edge.attributes[LENGTH_FIELD] = original_length 129 | 130 | if sorted(previous_edge_nodes) == sorted(end_nodes): 131 | # a loop of two edges - get all edges between the nodes 132 | loop_edges = functions.get_edges_from_nodes( 133 | net, [edge.start_node, edge.end_node] 134 | ) 135 | if loop_edges: 136 | edges.extend(loop_edges) 137 | 138 | previous_edge_nodes = end_nodes 139 | 140 | # reset network by adding back removed edges 141 | for removed_edge in removed_edges: 142 | functions.add_single_edge(net, removed_edge) 143 | 144 | # edge ids are not unique at this step - due to the case with doubling-back see test_simple_reversed 145 | unique_edge_ids = functions.get_unique_ordered_list((e.key for e in edges)) 146 | assert len(unique_edge_ids) <= len(edges) 147 | 148 | # the solve should have <= all edges clicked on 149 | assert len(edge_id_list) <= len(unique_edge_ids) 150 | # all edges the user clicked on should be returned in the solve 151 | try: 152 | assert set(edge_id_list).issubset(unique_edge_ids) 153 | except AssertionError: 154 | # this can occur where there are 2 edges looping on to the same node 155 | # see test_dual_path 156 | log.debug(f"edge_id_list: {edge_id_list} unique_edge_ids: {unique_edge_ids}") 157 | raise 158 | 159 | solved_edges = [] 160 | 161 | for edge_id in unique_edge_ids: 162 | edge = [e for e in edges if e.key == edge_id][ 163 | 0 164 | ] # get the first edge from full list of edges 165 | solved_edges.append(edge) 166 | 167 | # start_key = edge_id_list[0] 168 | # start_edge = functions.get_edge_by_key(net, start_key) 169 | # start_node = start_edge.start_node 170 | 171 | return find_ordered_path(solved_edges, start_node=None) 172 | 173 | 174 | def solve_matching_path( 175 | net: networkx.MultiGraph | networkx.MultiDiGraph, 176 | start_node: str | int, 177 | end_node: str | int, 178 | distance: int = 0, 179 | cutoff: int = 10, 180 | include_key: str | int | None = None, 181 | ) -> list[Edge] | None: 182 | """ 183 | Return the path between the nodes that best matches the 184 | distance 185 | If no distance is supplied the shortest path is returned 186 | 187 | Cut-off is the maximum number of edges to search for 188 | If long solves are not as expected then increase this value 189 | 190 | include_key can be used to only include paths that contain this edge key 191 | """ 192 | 193 | paths = [] 194 | 195 | all_shortest_paths = list(solve_all_simple_paths(net, start_node, end_node, cutoff)) 196 | 197 | # check for self-loops if start and end nodes are the same 198 | if start_node == end_node: 199 | all_shortest_paths.append([start_node]) 200 | 201 | for path_nodes in all_shortest_paths: 202 | all_paths = functions.get_all_paths_from_nodes( 203 | net, path_nodes, with_direction_flag=True 204 | ) 205 | 206 | for path_edges in all_paths: 207 | if include_key and include_key not in [e.key for e in path_edges]: 208 | continue 209 | path_length = functions.get_path_length(path_edges) 210 | length_difference = abs(path_length - distance) 211 | log.debug([p.key for p in path_edges]) 212 | log.debug( 213 | "Desired length: {} Solved path length: {} Difference: {}".format( 214 | distance, path_length, length_difference 215 | ) 216 | ) 217 | paths.append((length_difference, path_edges, path_length)) 218 | 219 | # take the closest matching path to the original length based on absolute difference 220 | 221 | if paths: 222 | edges = min(paths)[ 223 | 1 224 | ] # min in list based on first tuple value (length_difference) 225 | log.debug("Desired and solved length difference: {}".format(min(paths)[0])) 226 | else: 227 | log.warning("No paths found") 228 | edges = None 229 | 230 | return edges 231 | 232 | 233 | def solve_matching_path_from_nodes( 234 | net: networkx.MultiGraph | networkx.MultiDiGraph, 235 | node_list: list[int | str], 236 | distance: int, 237 | ) -> list[Edge] | None: 238 | """ 239 | From a list of unordered nodes find the longest path that connects all nodes 240 | Then rerun the solve from the start to the end of the path getting a path 241 | closest to the desired distance 242 | """ 243 | 244 | if not node_list: 245 | return None 246 | 247 | # solve all paths from each node to all other nodes in the list 248 | all_edges = [] 249 | 250 | # get unique combinations of node pairs 251 | 252 | # l = [1,2,3] 253 | # list(itertools.combinations(l, 2)) 254 | # [(1, 2), (1, 3), (2, 3)] 255 | 256 | all_node_combinations = itertools.combinations(node_list, 2) 257 | 258 | for n1, n2 in all_node_combinations: 259 | path_nodes = solve_shortest_path(net, start_node=n1, end_node=n2) 260 | path_edges = functions.get_edges_from_nodes(net, path_nodes) 261 | if path_edges: 262 | all_edges.append(path_edges) 263 | 264 | all_edges_generator = itertools.chain(*all_edges) 265 | # get a unique list of edges based on key 266 | unique_edges = list({v.key: v for v in all_edges_generator}.values()) 267 | 268 | # TODO following will not work if there is a closed loop 269 | full_path = find_ordered_path(unique_edges) 270 | start_node, end_node = get_path_ends(full_path) 271 | 272 | return solve_matching_path(net, start_node, end_node, distance) 273 | 274 | 275 | def get_path_ends(edges: list[Edge]) -> tuple[(int | str), (int | str)]: 276 | """ 277 | For a list of connected edges find the (unattached) end nodes 278 | TODO Check if the edges form a loop 279 | 280 | >>> edges = [Edge(0, 1, 1, {}), Edge(1, 2, 1, {})] 281 | >>> get_path_ends(edges) 282 | (0, 2) 283 | """ 284 | 285 | # first check the case where there is only a single edge 286 | if len(edges) == 1: 287 | single_edge = edges[0] 288 | return single_edge.start_node, single_edge.end_node 289 | 290 | subnet = functions.edges_to_graph(edges) 291 | 292 | start_edge = edges[0] 293 | end_edge = edges[-1] 294 | 295 | start_node = functions.get_unattached_node(subnet, start_edge) 296 | end_node = functions.get_unattached_node(subnet, end_edge) 297 | 298 | return start_node, end_node 299 | 300 | 301 | def solve_all_simple_paths( 302 | net: networkx.MultiGraph | networkx.MultiDiGraph, 303 | start_node: int | str, 304 | end_node: int | str, 305 | cutoff: int = 10, 306 | ) -> list: 307 | """ 308 | Find all simple paths between the two nodes on the network. 309 | A simple path does not have any repeated nodes 310 | A wrapper function for 311 | `all_simple_paths `_ 312 | TODO add ``has_path`` check prior to running 313 | 314 | Args: 315 | net: The network 316 | start_node: The node Id of the start node 317 | end_node: The node Id of the end node 318 | cutoff: The maximum number of edges to search for before stopping the search 319 | Returns: 320 | An iterator of a list of list of nodes 321 | 322 | >>> edges = [Edge(0, 1, "A", {}), Edge(1, 2, "B", {"LEN_": 10}), Edge(2, 1, "C", {"LEN_": 10})] 323 | >>> net = functions.edges_to_graph(edges) 324 | >>> pths = solve_all_simple_paths(net, 0, 2) 325 | >>> print(list(pths)) 326 | [[0, 1, 2], [0, 1, 2]] 327 | """ 328 | 329 | all_shortest_paths = [] 330 | 331 | log.debug("Solving from %s to %s", start_node, end_node) 332 | try: 333 | all_shortest_paths = networkx.all_simple_paths( 334 | net, source=start_node, target=end_node, cutoff=cutoff 335 | ) 336 | except NodeNotFound as ex: 337 | log.error(ex) # target node 99 not in graph 338 | raise 339 | 340 | return all_shortest_paths 341 | 342 | 343 | def solve_all_shortest_paths( 344 | net: networkx.MultiGraph | networkx.MultiDiGraph, 345 | start_node: int | str, 346 | end_node: int | str, 347 | ): 348 | """ 349 | Find all shortest paths between the two nodes on the network. 350 | This includes loops and repeated nodes. 351 | A wrapper function for 352 | `all_shortest_paths `_ 353 | Unsure why less results are returned in the example below than when using all_simple_paths 354 | 355 | Args: 356 | net: The network 357 | start_node: The node Id of the start node 358 | end_node: The node Id of the end node 359 | Returns: 360 | An iterator of a list of list of nodes 361 | 362 | >>> edges = [Edge(0, 1, "A", {}), Edge(1, 2, "B", {"LEN_": 10}), Edge(2, 1, "C", {"LEN_": 10})] 363 | >>> net = functions.edges_to_graph(edges) 364 | >>> pths = solve_all_shortest_paths(net, 0, 2) 365 | >>> print(list(pths)) 366 | [[0, 1, 2]] 367 | 368 | # noqa: E501 369 | """ 370 | 371 | all_shortest_paths = [] 372 | 373 | log.debug("Solving from %s to %s", start_node, end_node) 374 | try: 375 | all_shortest_paths = networkx.all_shortest_paths( 376 | net, source=start_node, target=end_node 377 | ) 378 | except Exception as ex: 379 | log.error(ex) # errors only seem to be raised when the iterator is used 380 | raise 381 | 382 | return all_shortest_paths 383 | 384 | 385 | def find_ordered_path( 386 | edges: list[Edge], 387 | start_node: int | str | None = None, 388 | with_direction_flag: bool = True, 389 | ) -> list[Edge]: 390 | """ 391 | Given a collection of randomly ordered connected edges, find the full 392 | path, covering all edges, from one end to the other. 393 | A start_node can be provided to return the edges in a specific direction. 394 | Uses the 395 | `eulerian_path `_ 396 | function from networkx 397 | """ 398 | 399 | subnet = functions.edges_to_graph(edges) 400 | 401 | try: 402 | ordered_path = list(eulerian_path(subnet, source=None, keys=True)) 403 | except networkx.exception.NetworkXError as ex: 404 | log.exception(ex) 405 | raise 406 | 407 | if len(ordered_path) == 0: 408 | raise (ValueError("No path found for the edges. Are they connected?")) 409 | 410 | ordered_edges = [] 411 | 412 | # ensure the returned path follows the direction based on the start_node to end_node 413 | # if a start_node is provided 414 | 415 | if start_node: 416 | end_edge = ordered_path[-1] 417 | end_nodes = [end_edge[0], end_edge[1]] 418 | if start_node in end_nodes: 419 | ordered_path = list(reversed(ordered_path)) 420 | else: 421 | start_edge = ordered_path[0] 422 | start_nodes = [start_edge[0], start_edge[1]] 423 | if start_node not in start_nodes: 424 | raise ValueError( 425 | "The provided start_node {} was not found at the start or end of the path".format( 426 | start_node 427 | ) 428 | ) 429 | 430 | # now get the edge objects back from the edge list 431 | 432 | for p in ordered_path: 433 | edge_key = p[2] 434 | edge = next(e for e in edges if e.key == edge_key) 435 | if with_direction_flag: 436 | functions.add_direction_flag(p[0], p[1], edge.attributes) 437 | ordered_edges.append(edge) 438 | 439 | return ordered_edges 440 | 441 | 442 | # if __name__ == "__main__": 443 | # import doctest 444 | # doctest.testmod() 445 | -------------------------------------------------------------------------------- /wayfarer/splitter.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module handles splitting existing edges, and creating new nodes 3 | to join the split edges 4 | """ 5 | 6 | from __future__ import annotations 7 | import logging 8 | import uuid 9 | from collections import defaultdict, OrderedDict 10 | from wayfarer import functions, linearref 11 | import networkx 12 | from wayfarer import ( 13 | LENGTH_FIELD, 14 | OFFSET_FIELD, 15 | EDGE_ID_FIELD, 16 | SPLIT_FLAG, 17 | SPLIT_POINT_ID_FIELD, 18 | SPLIT_MEASURE_FIELD, 19 | NODEID_FROM_FIELD, 20 | NODEID_TO_FIELD, 21 | GEOMETRY_FIELD, 22 | Edge, 23 | ) 24 | 25 | log = logging.getLogger("wayfarer") 26 | 27 | SPLIT_KEY_SEPARATOR = "::" 28 | 29 | 30 | def create_node_id( 31 | net: networkx.MultiGraph | networkx.MultiDiGraph, point_id: str | int 32 | ): 33 | # if point_id in net.nodes(): 34 | # raise ValueError("The point ID {} already exists as a Node Id in the network".format(point_id)) 35 | return point_id 36 | 37 | 38 | def create_unique_join_node() -> str: 39 | """ 40 | Create a unique GUID for the join node 41 | """ 42 | 43 | return str(uuid.uuid4()) 44 | 45 | 46 | def group_measures_by_edge(input): 47 | res = OrderedDict() 48 | 49 | for edge_id, measure in input: 50 | if edge_id in res: 51 | res[edge_id].append(measure) 52 | else: 53 | res[edge_id] = [measure] 54 | return res 55 | 56 | 57 | def split_with_points( 58 | net: networkx.MultiGraph | networkx.MultiDiGraph, 59 | recs: list[dict], 60 | edge_id_field: str = EDGE_ID_FIELD, 61 | point_id_field: str = SPLIT_POINT_ID_FIELD, 62 | measure_field: str = SPLIT_MEASURE_FIELD, 63 | ): 64 | """ 65 | Join a list of point records to a network, creating a new "join" 66 | edge 67 | 68 | recs is a list of dict objects that should have at a minimum the following 69 | fields [{"EDGE_ID": 1, "POINT_ID": 1, "MEASURE": 10.0}] 70 | Any additional fields will be added as properties to the new line 71 | """ 72 | 73 | # first we'll group all points for each edge 74 | 75 | edge_splits = defaultdict(list) 76 | 77 | for r in recs: 78 | edge_id, point_id, measure = ( 79 | r[edge_id_field], 80 | r[point_id_field], 81 | r[measure_field], 82 | ) 83 | split_node = create_split_key(edge_id, measure) 84 | unique_edge_key = create_unique_join_node() 85 | from_node = create_node_id(net, point_id) 86 | 87 | # set the nodes as attributes so we can calculate the direction if required 88 | r[NODEID_FROM_FIELD] = from_node 89 | r[NODEID_TO_FIELD] = split_node 90 | 91 | # here we create a new edge joining the point to attach to the network 92 | # to a new node on the split line represented by the line id and measure along 93 | edge = Edge( 94 | start_node=from_node, end_node=split_node, key=unique_edge_key, attributes=r 95 | ) 96 | 97 | functions.add_edge(net, **edge._asdict()) # unpack namedtuple to **kwargs 98 | edge_splits[edge_id].append(measure) 99 | 100 | for edge_id, measures in edge_splits.items(): 101 | split_network_edge(net, edge_id, measures) 102 | 103 | 104 | def get_split_attributes( 105 | original_attributes: dict, 106 | from_m: float | int, 107 | to_m: float | int, 108 | start_node: str | int | None = None, 109 | end_node: str | int | None = None, 110 | ) -> dict: 111 | """ 112 | For a part of an edge that has been split 113 | update its attributes so the length is correct 114 | and any start and end nodes stored in the attributes table 115 | are correct 116 | 117 | >>> atts = {"OFFSET": 10, "LEN_": 100} 118 | >>> get_split_attributes(atts, 50, 70) 119 | {'OFFSET': 60, 'LEN_': 20, 'IS_SPLIT': True} 120 | """ 121 | atts = original_attributes.copy() 122 | atts[LENGTH_FIELD] = to_m - from_m 123 | 124 | if OFFSET_FIELD in original_attributes: 125 | offset = original_attributes[ 126 | OFFSET_FIELD 127 | ] # when using a cumulative measure along a feature 128 | else: 129 | offset = 0 130 | 131 | atts[OFFSET_FIELD] = from_m + offset 132 | 133 | # update the nodes if provided and the fields are part of the list of attributes 134 | if start_node and end_node: 135 | if all(p in atts for p in [NODEID_FROM_FIELD, NODEID_TO_FIELD]): 136 | atts[NODEID_FROM_FIELD] = start_node 137 | atts[NODEID_TO_FIELD] = end_node 138 | 139 | # now add a flag to indicate this edge has been split 140 | atts[SPLIT_FLAG] = True 141 | 142 | # if geometry is being stored in the edge then create a new line 143 | # based on the offset 144 | if GEOMETRY_FIELD in atts: 145 | edge_id = atts[EDGE_ID_FIELD] 146 | ls = atts[GEOMETRY_FIELD] 147 | log.debug(f"Creating split geometry from {from_m} to {to_m} on {edge_id}") 148 | cut_line = linearref.create_line(ls, from_m, to_m) 149 | atts[GEOMETRY_FIELD] = cut_line 150 | 151 | return atts 152 | 153 | 154 | def create_split_key(key: str | int, m: float | int, precision=3) -> str: 155 | """ 156 | Function to ensure a standard format for split keys 157 | using the key of an edge and a measure 158 | E.g. "123829::154.2" 159 | The ``SPLIT_KEY_SEPARATOR`` can be used to find split keys 160 | in the network 161 | 162 | >>> create_split_key(123829, 154.22384972623, precision=3) 163 | '123829::154.224' 164 | >>> create_split_key(123829, 154.2) 165 | '123829::154.2' 166 | >>> create_split_key(123829, 154, precision=3) 167 | '123829::154' 168 | >>> SPLIT_KEY_SEPARATOR in "123829::154.2" 169 | True 170 | """ 171 | 172 | # display with 3 decimal places, but remove any trailing zeros 173 | # and decimal place if a round number 174 | measure = f"{m:.{precision}f}".rstrip("0").rstrip(".") 175 | return f"{key}{SPLIT_KEY_SEPARATOR}{measure}" 176 | 177 | 178 | def check_split_edges(split_edges: list[Edge]): 179 | # all edges should have the split flag 180 | is_split = [e.attributes[SPLIT_FLAG] for e in split_edges] 181 | assert set(is_split) == {True} 182 | 183 | # all edges should have the same original EdgeId 184 | edge_ids = [e.attributes[EDGE_ID_FIELD] for e in split_edges] 185 | assert len(set(edge_ids)) == 1 186 | 187 | return True 188 | 189 | 190 | def get_split_nodes(split_edges: list[Edge]) -> list[int | str]: 191 | """ 192 | Get the unique list of split nodes associated 193 | with the collection of edges 194 | 195 | Args: 196 | split_edges: a list of split edges that make up a single full edge 197 | 198 | Returns: 199 | A unique list of split nodes 200 | """ 201 | nodes = [] 202 | split_nodes = [] 203 | 204 | for split_edge in split_edges: 205 | nodes.extend([split_edge.start_node, split_edge.end_node]) 206 | 207 | for n in set(nodes): 208 | if SPLIT_KEY_SEPARATOR in str(n): 209 | split_nodes.append(n) 210 | 211 | return split_nodes 212 | 213 | 214 | def get_measures_from_split_edges(split_edges: list[Edge]): 215 | """ 216 | From a collection of split edges, get all the measures 217 | that the original edge was split 218 | Split nodes are in the form '2::60' (using the ``SPLIT_KEY_SEPARATOR``) 219 | 220 | Args: 221 | split_edges: a list of split edges that make up a single full edge 222 | 223 | Returns: 224 | The measures used to split the original edge 225 | """ 226 | 227 | check_split_edges(split_edges) 228 | measures = [] 229 | split_nodes = get_split_nodes(split_edges) 230 | 231 | for n in split_nodes: 232 | m_val = float(str(n).split(SPLIT_KEY_SEPARATOR)[1]) 233 | measures.append(m_val) 234 | 235 | return measures 236 | 237 | 238 | def unsplit_network_edges( 239 | net: networkx.MultiGraph | networkx.MultiDiGraph, split_edges: list[Edge] 240 | ) -> Edge: 241 | """ 242 | Take a list of split network edges and join them back together 243 | to form the original edge. Split keys and nodes are removed from the network. 244 | This function can be used to then resplit the edge in different locations 245 | or add additional splits 246 | 247 | Args: 248 | net (object): a networkx network 249 | split_edges: a list of split edges that make up a single full edge 250 | 251 | Returns: 252 | The unsplit edge 253 | 254 | """ 255 | 256 | check_split_edges(split_edges) 257 | 258 | # get all lengths 259 | original_length = sum([e.attributes[LENGTH_FIELD] for e in split_edges]) 260 | 261 | # get first edge 262 | first_edge = min(split_edges, key=lambda e: e.attributes[OFFSET_FIELD]) 263 | start_node = first_edge.attributes[NODEID_FROM_FIELD] 264 | edge_id = first_edge.attributes[EDGE_ID_FIELD] 265 | 266 | # get last edge 267 | last_edge = max(split_edges, key=lambda e: e.attributes[OFFSET_FIELD]) 268 | end_node = last_edge.attributes[NODEID_TO_FIELD] 269 | 270 | # remove the split edges from the network 271 | 272 | for split_edge in split_edges: 273 | functions.remove_edge_by_key(net, split_edge.key) 274 | 275 | split_nodes = get_split_nodes(split_edges) 276 | 277 | # clean-up the split nodes - removing a node however removes 278 | # any connecting edges which will lead to errors, so check they are orphaned 279 | for n in split_nodes: 280 | if net.degree(n) == 0: 281 | net.remove_node(n) 282 | 283 | # copy across any attributes from the original edge 284 | atts = first_edge.attributes.copy() 285 | 286 | atts.update( 287 | { 288 | EDGE_ID_FIELD: edge_id, 289 | LENGTH_FIELD: original_length, 290 | NODEID_FROM_FIELD: start_node, 291 | NODEID_TO_FIELD: end_node, 292 | } 293 | ) 294 | 295 | atts.pop(SPLIT_FLAG) 296 | atts.pop(OFFSET_FIELD) 297 | 298 | # the add_edge function takes care of adding the edge_id back to the keys 299 | new_edge = functions.add_edge(net, start_node, end_node, edge_id, atts) 300 | 301 | return new_edge 302 | 303 | 304 | def split_network_edge( 305 | net: networkx.MultiGraph | networkx.MultiDiGraph, 306 | key: str | int, 307 | measures: list[int | float], 308 | ) -> list[Edge]: 309 | """ 310 | Split a network edge based on a list of measures. 311 | We can also split a split edge but this relies on knowing the key of the 312 | split edge and measures would need to reflect the length of the new edge 313 | rather than the original edge. 314 | 315 | Args: 316 | net (object): a networkx network 317 | key: the key of the edge to be split 318 | measures: a list of measures where the line should be split 319 | 320 | Returns: 321 | A list of the new edges created by splitting 322 | """ 323 | 324 | new_edges = [] 325 | 326 | original_edge = functions.get_edge_by_key(net, key, with_data=True) 327 | 328 | original_length = original_edge.attributes[LENGTH_FIELD] 329 | 330 | validated_measures = [] 331 | 332 | for m in measures: 333 | if m >= original_length: 334 | log.debug( 335 | f"Split measure {m} is greater or equal to the length {original_length} of edge {key}" 336 | ) 337 | elif m <= 0: 338 | log.debug("Split measure is 0 or less - no need to split!") 339 | else: 340 | validated_measures.append(m) 341 | 342 | if len(validated_measures) == 0: 343 | log.debug("No measures along line - returning original edges") 344 | return [original_edge] 345 | 346 | log.debug(f"Splitting {original_edge.key} with {len(measures)} points") 347 | 348 | functions.remove_edge_by_key(net, original_edge.key) 349 | prev_node = original_edge.start_node 350 | from_m = 0 # type: (int | float) 351 | 352 | for to_m in sorted(set(validated_measures)): 353 | split_key = create_split_key(key, to_m) 354 | atts = get_split_attributes( 355 | original_edge.attributes, from_m, to_m, prev_node, split_key 356 | ) 357 | new_edge = functions.add_edge(net, prev_node, split_key, split_key, atts) 358 | new_edges.append(new_edge) 359 | from_m, prev_node = to_m, split_key 360 | 361 | # add final part 362 | atts = get_split_attributes( 363 | original_edge.attributes, 364 | from_m, 365 | original_length, 366 | prev_node, 367 | original_edge.end_node, 368 | ) 369 | 370 | split_key = create_split_key(key, original_length) 371 | new_edge = functions.add_edge( 372 | net, prev_node, original_edge.end_node, split_key, atts 373 | ) 374 | new_edges.append(new_edge) 375 | 376 | return new_edges 377 | 378 | 379 | def get_measure_for_point(line, pt): 380 | snapped_input_point, dist = linearref.get_nearest_vertex(pt, line) 381 | log.debug(f"Input point {dist:.5f} from line") 382 | measure = linearref.get_measure_on_line(line, snapped_input_point) 383 | return measure 384 | 385 | 386 | def get_split_node_for_measure(network_edge, length, measure): 387 | if abs(length - measure) < 0.001: 388 | # don't split at the end of the line 389 | node_id = network_edge.end_node 390 | elif abs(measure - 0) < 0.001: 391 | # don't split at the start of the line 392 | node_id = network_edge.start_node 393 | else: 394 | # a new split node is created 395 | node_id = create_split_key(network_edge.key, measure) 396 | 397 | return node_id 398 | -------------------------------------------------------------------------------- /wayfarer/validator.py: -------------------------------------------------------------------------------- 1 | """ 2 | A collection of functions to help validate a network 3 | """ 4 | 5 | from __future__ import annotations 6 | from collections import Counter 7 | import networkx 8 | 9 | 10 | def duplicate_keys( 11 | net: networkx.MultiGraph | networkx.MultiDiGraph, 12 | ) -> list[int | str]: 13 | """ 14 | Find any duplicate keys in the network 15 | Keys must be unique for routing to function correctly 16 | 17 | """ 18 | 19 | keys = [] 20 | 21 | for u, v, k in net.edges(keys=True): 22 | keys.append(k) 23 | 24 | duplicates = [item for item, count in Counter(keys).items() if count > 1] 25 | return duplicates 26 | 27 | 28 | def valid_reverse_lookup(net: networkx.MultiGraph | networkx.MultiDiGraph): 29 | """ 30 | Check if the reverse lookup dictionary 31 | has the same count as edges in the network 32 | """ 33 | return len(net.graph["keys"]) == len(net.edges()) 34 | 35 | 36 | def recalculate_keys(net: networkx.MultiGraph | networkx.MultiDiGraph): 37 | """ 38 | Recalculate keys in the reverse lookup dictionary 39 | These can become out-of-sync following a merge of networks e.g. with 40 | the compose_all function 41 | """ 42 | 43 | net.graph["keys"] = {} 44 | for start_node, end_node, key in net.edges(keys=True): 45 | net.graph["keys"][key] = (start_node, end_node) 46 | 47 | 48 | def edge_attributes( 49 | net: networkx.MultiGraph | networkx.MultiDiGraph, with_sample_data: bool = False 50 | ) -> list: 51 | """ 52 | Return the list of attributes for the edges 53 | in the network 54 | """ 55 | 56 | attributes_with_data = [] 57 | attributes = [] 58 | 59 | for u, v, d in net.edges(data=True): 60 | keys = sorted(d.keys()) 61 | if keys not in attributes: 62 | attributes.append(keys) 63 | if with_sample_data: 64 | attributes_with_data.append(d) 65 | 66 | if with_sample_data: 67 | return attributes_with_data 68 | else: 69 | return attributes 70 | --------------------------------------------------------------------------------