├── .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 |
--------------------------------------------------------------------------------