├── .coveragerc ├── .github └── workflows │ ├── codecov.yml │ ├── publish.yml │ └── python-test.yml ├── .gitignore ├── LICENSE ├── README.md ├── datasets └── readme.md ├── examples ├── __init__.py ├── basic graph theory.ipynb ├── comparing graphs.ipynb ├── generating and visualising graphs.ipynb ├── graphs as finite state machines.ipynb ├── graphs.py ├── images │ ├── 2500pxLondon_Underground_Overground.png │ ├── 3-3-cart-traffic-jam.png │ ├── 3-3-cart-traffic-jam_initial.png │ ├── 3-3-traffic-jam-road-map.png │ ├── 3-3-tree-of-states.png │ ├── 6nodes.png │ ├── Torniqueterevolution.jpg │ ├── cpm_w_artificial_dependency.png │ ├── cpm_wo_artificial_dependency.png │ ├── easy_sudoku.png │ ├── easy_sudoku3.png │ ├── movement_graph.png │ ├── search_tree.png │ ├── sudoku-wave-collapse-1.png │ ├── sudoku_solver1.png │ ├── tjs-map-loads.png │ ├── tjs-map.png │ ├── tjs_problem_w_distance_restrictions.png │ ├── traffic_bi_directional.gif │ └── turnstile.png ├── readme.md ├── solving assignment problems.ipynb ├── solving flow problems.ipynb ├── solving search problems.ipynb ├── solving transport problems.ipynb └── statistics on graphs.ipynb ├── graph ├── __init__.py ├── adjacency_matrix.py ├── all_pairs_shortest_path.py ├── all_paths.py ├── all_simple_paths.py ├── assignment_problem.py ├── base.py ├── bfs.py ├── core.py ├── critical_path.py ├── cycle.py ├── dag.py ├── degree_of_separation.py ├── dfs.py ├── distance_map.py ├── finite_state_machine.py ├── hash_methods.py ├── max_flow.py ├── max_flow_min_cut.py ├── maximum_flow_min_cut.py ├── min_cost_flow.py ├── minmax.py ├── minsum.py ├── partite.py ├── random.py ├── shortest_path.py ├── shortest_tree_all_pairs.py ├── topological_sort.py ├── traffic_scheduling_problem.py ├── transshipment_problem.py ├── tsp.py ├── version.py └── visuals.py ├── requirements.txt ├── setup.py ├── test-requirements.txt └── tests ├── __init__.py ├── test_assignment_problem.py ├── test_basics.py ├── test_facility_location_problem.py ├── test_finite_state_machine.py ├── test_flow_problem.py ├── test_graph.py ├── test_hashgraph.py ├── test_random.py ├── test_search.py ├── test_spatial_graph.py ├── test_topology.py ├── test_traffic_scheduling_problem.py ├── test_transform.py ├── test_transshipment_problem.py ├── test_tsp.py ├── test_visuals.py └── test_wtap.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | concurrency = multiprocessing 4 | omit = setup.py, package.py, examples.py 5 | -------------------------------------------------------------------------------- /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | name: CodeCov 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | jobs: 8 | run: 9 | runs-on: ubuntu-latest 10 | env: 11 | OS: ubuntu-latest 12 | PYTHON: '3.9' 13 | steps: 14 | - uses: actions/checkout@master 15 | - name: Setup Python 16 | uses: actions/setup-python@master 17 | with: 18 | python-version: 3.9 19 | - name: 'generate report' 20 | run: | 21 | python -m pip install --upgrade pip 22 | python -m pip install flake8 pytest 23 | python -m pip install coverage 24 | python -m pip install -r test-requirements.txt 25 | coverage run --source='.' --parallel -m pytest 26 | - name: Upload coverage to Codecov 27 | uses: codecov/codecov-action@v1 28 | with: 29 | flags: pytest 30 | fail_ci_if_error: true 31 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI.org 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | pypi: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v3 11 | with: 12 | fetch-depth: 0 13 | - name: install python 14 | run: | 15 | python3 -m pip install --upgrade build 16 | - name: build wheel 17 | run: | 18 | python3 -m build --wheel 19 | - name: Publish package 20 | uses: pypa/gh-action-pypi-publish@release/v1.5 21 | with: 22 | password: ${{ secrets.PYPI_API_TOKEN }} 23 | 24 | 25 | -------------------------------------------------------------------------------- /.github/workflows/python-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ${{ matrix.os}} 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | os: [ubuntu-latest, macos-latest, windows-latest] 20 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | python -m pip install -r test-requirements.txt 32 | - name: Test with pytest 33 | run: | 34 | python -m pytest tests -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # byte compiled files 2 | __pycache__/** 3 | *.py[cod] 4 | 5 | # Distribution / packaging 6 | dist/ 7 | *.egg-info 8 | 9 | # Testing 10 | .pytest_cache/ 11 | 12 | # Tutorial checkpoints 13 | examples/.ipynb_checkpoints/ 14 | 15 | # IDE / Workspace 16 | .vscode/ 17 | *scratch*.py 18 | 19 | # private datasets 20 | /datasets 21 | 22 | # virtual environments 23 | .env/ 24 | .venv/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 root-11 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 | -------------------------------------------------------------------------------- /datasets/readme.md: -------------------------------------------------------------------------------- 1 | # README 2 | This folder is listed in .gitignore so that developers can have confidential datasets withouth checking them in. 3 | 4 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/root-11/graph-theory/235c7e06b420bc2235ea7e8fbb13cedbcd5e9d82/examples/__init__.py -------------------------------------------------------------------------------- /examples/comparing graphs.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [] 9 | } 10 | ], 11 | "metadata": { 12 | "kernelspec": { 13 | "display_name": "Python 3", 14 | "language": "python", 15 | "name": "python3" 16 | }, 17 | "language_info": { 18 | "codemirror_mode": { 19 | "name": "ipython", 20 | "version": 3 21 | }, 22 | "file_extension": ".py", 23 | "mimetype": "text/x-python", 24 | "name": "python", 25 | "nbconvert_exporter": "python", 26 | "pygments_lexer": "ipython3", 27 | "version": "3.8.5" 28 | } 29 | }, 30 | "nbformat": 4, 31 | "nbformat_minor": 4 32 | } 33 | -------------------------------------------------------------------------------- /examples/images/2500pxLondon_Underground_Overground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/root-11/graph-theory/235c7e06b420bc2235ea7e8fbb13cedbcd5e9d82/examples/images/2500pxLondon_Underground_Overground.png -------------------------------------------------------------------------------- /examples/images/3-3-cart-traffic-jam.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/root-11/graph-theory/235c7e06b420bc2235ea7e8fbb13cedbcd5e9d82/examples/images/3-3-cart-traffic-jam.png -------------------------------------------------------------------------------- /examples/images/3-3-cart-traffic-jam_initial.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/root-11/graph-theory/235c7e06b420bc2235ea7e8fbb13cedbcd5e9d82/examples/images/3-3-cart-traffic-jam_initial.png -------------------------------------------------------------------------------- /examples/images/3-3-traffic-jam-road-map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/root-11/graph-theory/235c7e06b420bc2235ea7e8fbb13cedbcd5e9d82/examples/images/3-3-traffic-jam-road-map.png -------------------------------------------------------------------------------- /examples/images/3-3-tree-of-states.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/root-11/graph-theory/235c7e06b420bc2235ea7e8fbb13cedbcd5e9d82/examples/images/3-3-tree-of-states.png -------------------------------------------------------------------------------- /examples/images/6nodes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/root-11/graph-theory/235c7e06b420bc2235ea7e8fbb13cedbcd5e9d82/examples/images/6nodes.png -------------------------------------------------------------------------------- /examples/images/Torniqueterevolution.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/root-11/graph-theory/235c7e06b420bc2235ea7e8fbb13cedbcd5e9d82/examples/images/Torniqueterevolution.jpg -------------------------------------------------------------------------------- /examples/images/cpm_w_artificial_dependency.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/root-11/graph-theory/235c7e06b420bc2235ea7e8fbb13cedbcd5e9d82/examples/images/cpm_w_artificial_dependency.png -------------------------------------------------------------------------------- /examples/images/cpm_wo_artificial_dependency.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/root-11/graph-theory/235c7e06b420bc2235ea7e8fbb13cedbcd5e9d82/examples/images/cpm_wo_artificial_dependency.png -------------------------------------------------------------------------------- /examples/images/easy_sudoku.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/root-11/graph-theory/235c7e06b420bc2235ea7e8fbb13cedbcd5e9d82/examples/images/easy_sudoku.png -------------------------------------------------------------------------------- /examples/images/easy_sudoku3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/root-11/graph-theory/235c7e06b420bc2235ea7e8fbb13cedbcd5e9d82/examples/images/easy_sudoku3.png -------------------------------------------------------------------------------- /examples/images/movement_graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/root-11/graph-theory/235c7e06b420bc2235ea7e8fbb13cedbcd5e9d82/examples/images/movement_graph.png -------------------------------------------------------------------------------- /examples/images/search_tree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/root-11/graph-theory/235c7e06b420bc2235ea7e8fbb13cedbcd5e9d82/examples/images/search_tree.png -------------------------------------------------------------------------------- /examples/images/sudoku-wave-collapse-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/root-11/graph-theory/235c7e06b420bc2235ea7e8fbb13cedbcd5e9d82/examples/images/sudoku-wave-collapse-1.png -------------------------------------------------------------------------------- /examples/images/sudoku_solver1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/root-11/graph-theory/235c7e06b420bc2235ea7e8fbb13cedbcd5e9d82/examples/images/sudoku_solver1.png -------------------------------------------------------------------------------- /examples/images/tjs-map-loads.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/root-11/graph-theory/235c7e06b420bc2235ea7e8fbb13cedbcd5e9d82/examples/images/tjs-map-loads.png -------------------------------------------------------------------------------- /examples/images/tjs-map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/root-11/graph-theory/235c7e06b420bc2235ea7e8fbb13cedbcd5e9d82/examples/images/tjs-map.png -------------------------------------------------------------------------------- /examples/images/tjs_problem_w_distance_restrictions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/root-11/graph-theory/235c7e06b420bc2235ea7e8fbb13cedbcd5e9d82/examples/images/tjs_problem_w_distance_restrictions.png -------------------------------------------------------------------------------- /examples/images/traffic_bi_directional.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/root-11/graph-theory/235c7e06b420bc2235ea7e8fbb13cedbcd5e9d82/examples/images/traffic_bi_directional.gif -------------------------------------------------------------------------------- /examples/images/turnstile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/root-11/graph-theory/235c7e06b420bc2235ea7e8fbb13cedbcd5e9d82/examples/images/turnstile.png -------------------------------------------------------------------------------- /examples/readme.md: -------------------------------------------------------------------------------- 1 | # Graph-theory examples 2 | 3 | This folder contains jupyter notebooks, with examples from 4 | graph-theory. 5 | 6 | If you're new to the field I would recommend to study (loosely) 7 | in the order below: 8 | 9 | ***[basic graph theory](basic%20graph%20theory.ipynb)***: An introduction to the fundamental 10 | terminology of graph-theory. Topics are: 11 | 12 | nodes 13 | edges 14 | indegree 15 | outdegree 16 | has_path 17 | distance path 18 | is a subgraph 19 | examples of existing graphs available for testing. 20 | 21 | ***[generating and visualising graphs](generating%20and%20visualising%20graphs.ipynb)***: An introduction 22 | to making random xy graphs, grids and visualise them. 23 | 24 | ***[comparing graphs](comparing%20graphs.ipynb)***: An overview of methods for comparing 25 | graphs, such as: 26 | 27 | topological sort 28 | phase lines 29 | graph-hash 30 | flow_graph_hash 31 | merkle-tree 32 | 33 | 34 | ***[solving search problems](solving%20search%20problems.ipynb)***: An introduction to different 35 | methods for findings paths, including: 36 | 37 | adjacency matrix 38 | BFS 39 | DFS 40 | DFScan 41 | bidi-BFS 42 | TSP 43 | [critical path method] 44 | find loops 45 | 46 | ***[calculating statistics about graphs](statistics%20on%20graphs.ipynb)*** provides an overview 47 | of common analysis of graphs, such as: 48 | 49 | components 50 | has cycles 51 | network size 52 | is partite 53 | degree of separation 54 | 55 | 56 | ***[solving transport problems](solving%20search%20problems.ipynb)*** provides tools for a wide range 57 | of problems where discrete transport is essential. 58 | 59 | minmax 60 | minsum 61 | shortest_tree all pairs. 62 | scheduling problem 63 | traffic scheduling problem 64 | jam solver 65 | trans shipment problem (needs rewrite) 66 | 67 | 68 | ***[solving flow problems](solving%20flow%20problems.ipynb)*** provides tools for solving a 69 | wide range of problems where continuous flow are central. 70 | 71 | max flow 72 | max flow min cut 73 | min cost flow 74 | all_simple_paths 75 | all_paths 76 | 77 | 78 | ***[solving assignment problems](solving%20assignment%20problems.ipynb)*** provides tools for solving 79 | any kind of assignment problem. 80 | 81 | assignment problem 82 | wtap 83 | 84 | 85 | ***[representing systems as graphs](graphs%20as%20finite%20state%20machines.ipynb)*** provides a use case 86 | for using `graph as finite state machine`. 87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /examples/solving assignment problems.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": { 7 | "collapsed": true 8 | }, 9 | "outputs": [], 10 | "source": [ 11 | "" 12 | ] 13 | } 14 | ], 15 | "metadata": { 16 | "kernelspec": { 17 | "display_name": "Python 3", 18 | "language": "python", 19 | "name": "python3" 20 | }, 21 | "language_info": { 22 | "codemirror_mode": { 23 | "name": "ipython", 24 | "version": 2 25 | }, 26 | "file_extension": ".py", 27 | "mimetype": "text/x-python", 28 | "name": "python", 29 | "nbconvert_exporter": "python", 30 | "pygments_lexer": "ipython2", 31 | "version": "2.7.6" 32 | } 33 | }, 34 | "nbformat": 4, 35 | "nbformat_minor": 0 36 | } -------------------------------------------------------------------------------- /examples/solving flow problems.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": { 7 | "collapsed": true 8 | }, 9 | "outputs": [], 10 | "source": [ 11 | "" 12 | ] 13 | } 14 | ], 15 | "metadata": { 16 | "kernelspec": { 17 | "display_name": "Python 3", 18 | "language": "python", 19 | "name": "python3" 20 | }, 21 | "language_info": { 22 | "codemirror_mode": { 23 | "name": "ipython", 24 | "version": 2 25 | }, 26 | "file_extension": ".py", 27 | "mimetype": "text/x-python", 28 | "name": "python", 29 | "nbconvert_exporter": "python", 30 | "pygments_lexer": "ipython2", 31 | "version": "2.7.6" 32 | } 33 | }, 34 | "nbformat": 4, 35 | "nbformat_minor": 0 36 | } -------------------------------------------------------------------------------- /examples/solving transport problems.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": { 7 | "collapsed": true 8 | }, 9 | "outputs": [], 10 | "source": [ 11 | "" 12 | ] 13 | } 14 | ], 15 | "metadata": { 16 | "kernelspec": { 17 | "display_name": "Python 3", 18 | "language": "python", 19 | "name": "python3" 20 | }, 21 | "language_info": { 22 | "codemirror_mode": { 23 | "name": "ipython", 24 | "version": 2 25 | }, 26 | "file_extension": ".py", 27 | "mimetype": "text/x-python", 28 | "name": "python", 29 | "nbconvert_exporter": "python", 30 | "pygments_lexer": "ipython2", 31 | "version": "2.7.6" 32 | } 33 | }, 34 | "nbformat": 4, 35 | "nbformat_minor": 0 36 | } -------------------------------------------------------------------------------- /examples/statistics on graphs.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": { 7 | "collapsed": true 8 | }, 9 | "outputs": [], 10 | "source": [ 11 | "" 12 | ] 13 | } 14 | ], 15 | "metadata": { 16 | "kernelspec": { 17 | "display_name": "Python 3", 18 | "language": "python", 19 | "name": "python3" 20 | }, 21 | "language_info": { 22 | "codemirror_mode": { 23 | "name": "ipython", 24 | "version": 2 25 | }, 26 | "file_extension": ".py", 27 | "mimetype": "text/x-python", 28 | "name": "python", 29 | "nbconvert_exporter": "python", 30 | "pygments_lexer": "ipython2", 31 | "version": "2.7.6" 32 | } 33 | }, 34 | "nbformat": 4, 35 | "nbformat_minor": 0 36 | } -------------------------------------------------------------------------------- /graph/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import Graph, Graph3D 2 | from .base import same_path 3 | -------------------------------------------------------------------------------- /graph/adjacency_matrix.py: -------------------------------------------------------------------------------- 1 | from .base import BasicGraph 2 | 3 | 4 | def adjacency_matrix(graph): 5 | """Converts directed graph to an adjacency matrix. 6 | :param graph: 7 | :return: dictionary 8 | The distance from a node to itself is 0 and distance from a node to 9 | an unconnected node is defined to be infinite. This does not mean that there 10 | is no path from a node to another via other nodes. 11 | Example: 12 | g = Graph(from_dict= 13 | {1: {2: 3, 3: 8, 5: -4}, 14 | 2: {4: 1, 5: 7}, 15 | 3: {2: 4}, 16 | 4: {1: 2, 3: -5}, 17 | 5: {4: 6}}) 18 | adjacency_matrix(g) 19 | {1: {1: 0, 2: 3, 3: 8, 4: inf, 5: -4}, 20 | 2: {1: inf, 2: 0, 3: inf, 4: 1, 5: 7}, 21 | 3: {1: inf, 2: 4, 3: 0, 4: inf, 5: inf}, 22 | 4: {1: 2, 2: inf, 3: -5, 4: 0, 5: inf}, 23 | 5: {1: inf, 2: inf, 3: inf, 4: 6, 5: 0}} 24 | """ 25 | if not isinstance(graph, BasicGraph): 26 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 27 | 28 | return { 29 | v1: {v2: 0 if v1 == v2 else graph.edge(v1, v2, default=float("inf")) for v2 in graph.nodes()} 30 | for v1 in graph.nodes() 31 | } 32 | -------------------------------------------------------------------------------- /graph/all_pairs_shortest_path.py: -------------------------------------------------------------------------------- 1 | from .base import BasicGraph 2 | 3 | 4 | def all_pairs_shortest_paths(graph): 5 | """Find the cost of the shortest path between every pair of vertices in a 6 | weighted graph. Uses the Floyd-Warshall algorithm. 7 | Example: 8 | inf = float('inf') 9 | g = Graph(from_dict=( 10 | {0: {0: 0, 1: 1, 2: 4}, 11 | 1: {0: inf, 1: 0, 2: 2}, 12 | 2: {0: inf, 1: inf, 2: 0}}) 13 | fw(g) 14 | {0: {0: 0, 1: 1, 2: 3}, 15 | 1: {0: inf, 1: 0, 2: 2}, 16 | 2: {0: inf, 1: inf, 2: 0}} 17 | h = {1: {2: 3, 3: 8, 5: -4}, 18 | 2: {4: 1, 5: 7}, 19 | 3: {2: 4}, 20 | 4: {1: 2, 3: -5}, 21 | 5: {4: 6}} 22 | fw(adj(h)) # 23 | {1: {1: 0, 2: 1, 3: -3, 4: 2, 5: -4}, 24 | 2: {1: 3, 2: 0, 3: -4, 4: 1, 5: -1}, 25 | 3: {1: 7, 2: 4, 3: 0, 4: 5, 5: 3}, 26 | 4: {1: 2, 2: -1, 3: -5, 4: 0, 5: -2}, 27 | 5: {1: 8, 2: 5, 3: 1, 4: 6, 5: 0}} 28 | """ 29 | if not isinstance(graph, BasicGraph): 30 | raise TypeError(f"Expected BasicGraph, Graph or Graph3D, not {type(graph)}") 31 | 32 | g = graph.adjacency_matrix() 33 | assert isinstance(g, dict), "previous function should have returned a dict." 34 | vertices = g.keys() 35 | 36 | for v2 in vertices: 37 | g = {v1: {v3: min(g[v1][v3], g[v1][v2] + g[v2][v3]) for v3 in vertices} for v1 in vertices} 38 | return g 39 | -------------------------------------------------------------------------------- /graph/all_paths.py: -------------------------------------------------------------------------------- 1 | from .base import BasicGraph 2 | 3 | 4 | from collections import deque 5 | 6 | 7 | def all_paths(graph, start, end): 8 | """finds all paths from start to end by traversing each fork once only. 9 | :param graph: instance of Graph 10 | :param start: node 11 | :param end: node 12 | :return: list of paths unique from start to end. 13 | """ 14 | if not isinstance(graph, BasicGraph): 15 | raise TypeError(f"Expected BasicGraph, Graph or Graph3D, not {type(graph)}") 16 | if start not in graph: 17 | raise ValueError("start not in graph.") 18 | if end not in graph: 19 | raise ValueError("end not in graph.") 20 | if start == end: 21 | raise ValueError("start is end") 22 | 23 | cache = {} 24 | if not graph.is_connected(start, end): 25 | return [] 26 | paths = [(start,)] 27 | q = deque([start]) 28 | skip_list = set() 29 | while q: 30 | n1 = q.popleft() 31 | if n1 == end: 32 | continue 33 | 34 | n2s = graph.nodes(from_node=n1) 35 | new_paths = [p for p in paths if p[-1] == n1] 36 | for n2 in n2s: 37 | if n2 in skip_list: 38 | continue 39 | n3s = graph.nodes(from_node=n2) 40 | 41 | con = cache.get((n2, n1)) 42 | if con is None: 43 | con = graph.is_connected(n2, n1) 44 | cache[(n2, n1)] = con 45 | 46 | if len(n3s) > 1 and con: 47 | # it's a fork and it's a part of a loop! 48 | # is the sequence n2,n3 already in the path? 49 | for n3 in n3s: 50 | for path in new_paths: 51 | a = [n2, n3] 52 | if any(all(path[i + j] == a[j] for j in range(len(a))) for i in range(len(path))): 53 | skip_list.add(n3) 54 | 55 | for path in new_paths: 56 | if path in paths: 57 | paths.remove(path) 58 | 59 | new_path = path + (n2,) 60 | if new_path not in paths: 61 | paths.append(new_path) 62 | 63 | if n2 not in q: 64 | q.append(n2) 65 | 66 | paths = [list(p) for p in paths if p[-1] == end] 67 | return paths -------------------------------------------------------------------------------- /graph/all_simple_paths.py: -------------------------------------------------------------------------------- 1 | from .base import BasicGraph 2 | 3 | 4 | from collections import deque 5 | 6 | 7 | def all_simple_paths(graph, start, end): 8 | """ 9 | finds all simple (non-looping) paths from start to end 10 | :param start: node 11 | :param end: node 12 | :return: list of paths 13 | """ 14 | if not isinstance(graph, BasicGraph): 15 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 16 | if start not in graph: 17 | raise ValueError("start not in graph.") 18 | if end not in graph: 19 | raise ValueError("end not in graph.") 20 | if start == end: 21 | raise ValueError("start is end") 22 | 23 | if not graph.is_connected(start, end): 24 | return [] 25 | 26 | paths = [] 27 | q = deque([(start,)]) 28 | while q: 29 | path = q.popleft() 30 | for s, e, d in graph.edges(from_node=path[0]): 31 | if e in path: 32 | continue 33 | new_path = (e,) + path 34 | if e == end: 35 | paths.append(new_path) 36 | else: 37 | q.append(new_path) 38 | return [list(reversed(p)) for p in paths] -------------------------------------------------------------------------------- /graph/assignment_problem.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from .base import BasicGraph 4 | 5 | __all__ = ['ap_solver', 'wtap_solver'] 6 | 7 | 8 | def ap_solver(graph): 9 | """ 10 | 11 | ASSIGNMENT PROBLEM 12 | 13 | Definition: 14 | 15 | The problem instance has a number of agents and a number of tasks. 16 | Any agent can be assigned to perform any task, incurring some cost that may 17 | vary depending on the agent-task assignment. It is required to perform all 18 | tasks by assigning exactly one agent to each task and exactly one task to each 19 | agent in such a way that the total cost of the assignment is minimized.[1] 20 | 21 | Variations: 22 | 23 | - If there are more agents than tasks the problem can be solved by creating a 24 | "do nothing tasks" with a cost of zero. The assignment problem solver does this 25 | automatically. 26 | 27 | - If there are more tasks than agents then the problem is a knapsack problem. 28 | The assignment problem solver handles this case gracefully too. 29 | 30 | Solution methods: 31 | 32 | 1. Using maximum flow method. 33 | 2. Using alternating iterative auction. 34 | 35 | [1] https://en.wikipedia.org/wiki/Assignment_problem 36 | 37 | ---------------------------------------------------- 38 | 39 | The assignment problem solver expects a bi-partite graph 40 | with agents, tasks and the value/cost of each task, as links, 41 | so that the relationship is explicit as: 42 | 43 | value = g.edge(agent 1, task 1) 44 | 45 | The optimal assignment is determined as an alternating auction 46 | (see Dmitri Bertsekas, MIT) which maximises the value. 47 | Once all agents are assigned the alternating auction halts. 48 | 49 | :param graph: Graph 50 | :return: optimal assignment as list of edges (agent, task, value) 51 | """ 52 | assert isinstance(graph, BasicGraph) 53 | agents = [n for n in graph.nodes(in_degree=0)] 54 | tasks = [n for n in graph.nodes(out_degree=0)] 55 | 56 | unassigned_agents = agents 57 | v_null = min(v for a, t, v in graph.edges()) - 1 58 | 59 | dummy_tasks = set() 60 | if len(agents) > len(tasks): # make dummy tasks. 61 | dummy_tasks_needed = len(agents) - len(tasks) 62 | 63 | for i in range(dummy_tasks_needed): 64 | task = uuid4().hex 65 | dummy_tasks.add(task) 66 | tasks.append(task) 67 | for agent in agents: 68 | graph.add_edge(agent, task, v_null) 69 | v_null -= 1 70 | 71 | unassigned_tasks = set(tasks) 72 | cls = type(graph) 73 | assignments = cls() 74 | 75 | while unassigned_agents: 76 | n = unassigned_agents.pop(0) # select phase: 77 | value_and_task_for_n = [(v, t) for a, t, v in graph.edges(from_node=n)] 78 | value_and_task_for_n.sort(reverse=True) 79 | for v, t in value_and_task_for_n: # for each opportunity (in ranked order) 80 | d = v_null 81 | for s, e, d in assignments.edges(from_node=t): # if connected, get whoever it is connected to. 82 | break 83 | 84 | if v > d: # if the opportunity is better. 85 | if t in assignments: # and if there is a previous relationship. 86 | unassigned_agents.append(e) # add the removed node to unassigned. 87 | assignments.del_edge(t, e) # erase any previous relationship. 88 | else: 89 | unassigned_tasks.remove(t) 90 | assignments.add_edge(t, n, v) # record the new relationship. 91 | break 92 | 93 | return [(a, t, v) for t, a, v in assignments.edges() if t not in dummy_tasks] 94 | 95 | 96 | def wtap_solver(probabilities, weapons, target_values): 97 | """ 98 | Definition: 99 | 100 | Weapons target assignment problem (WTAP) 101 | 102 | The problem instance has a number weapons which can be assigned 103 | to engage targets, with a success rate of P(x). Targets have values V. 104 | If a weapon is engaged against a target, and is successful, the value is 105 | reduced to zero. Expected outcome of an engagement (D,T) is thereby 106 | 107 | O = V * (1-P(x)) 108 | 109 | The optimal assignment is minimises the value of the targets. 110 | 111 | min E( O ) 112 | 113 | for more see: https://en.wikipedia.org/wiki/Weapon_target_assignment_problem 114 | 115 | Variations: 116 | 117 | - if V is unknown, use v = 1. This maximises the exploitation of the available 118 | probabilities. 119 | 120 | Method used: 121 | 122 | 1. initial assignment using greedy algorithm; 123 | 2. followed by search for improvements. 124 | 125 | ---------------- 126 | 127 | :param probabilities: instance of Graph, where the relationship 128 | between weapons and targets is given as the probability to a 129 | successful engagement of the device. 130 | :param weapons: list of devices. 131 | :param target_values: dict , where the d[target] = value of target. 132 | :return: tuple: value of target after attack, optimal assignment 133 | 134 | """ 135 | assert isinstance(probabilities, BasicGraph) 136 | assert isinstance(weapons, list) 137 | assert isinstance(target_values, dict) 138 | # first: verify internal integrity of inputs. 139 | weaponset = set(weapons) 140 | targetset = set(target_values) 141 | id_overlap = weaponset.intersection(targetset) 142 | if id_overlap: 143 | raise ValueError(f"weapon ids in target_values for {id_overlap}") 144 | target_prob_set = {e for s, e, d in probabilities.edges()} 145 | if targetset > target_prob_set: 146 | raise ValueError(f"targets have no probabilities: {targetset-target_prob_set}") 147 | 148 | # second: clear the memory from the validations. 149 | weaponset.clear() 150 | targetset.clear() 151 | target_prob_set.clear() 152 | 153 | # then: Calculate the solution. 154 | cls = type(probabilities) 155 | assignments = cls() 156 | current_target_values = sum(target_values.values()) + 1 157 | 158 | improvements = {} 159 | while True: 160 | for w in weapons: 161 | # calculate the effect of engaging in all targets. 162 | effect_of_assignment = {} 163 | for _, t, p in probabilities.edges(from_node=w): 164 | current_engagement = _get_current_engagement(w, assignments) 165 | if current_engagement != t: 166 | if w in assignments and current_engagement is not None: 167 | assignments.del_edge(w, current_engagement) 168 | assignments.add_edge(w, t, value=probabilities.edge(w, t)) 169 | effect_of_assignment[t] = _damages(probabilities=probabilities, 170 | assignment=assignments, 171 | target_values=target_values) 172 | 173 | damage_and_targets = [(v, t) for t, v in effect_of_assignment.items()] 174 | damage_and_targets.sort() 175 | best_alt_damage, best_alt_target = damage_and_targets[0] 176 | nett_effect = current_target_values - best_alt_damage 177 | improvements[w] = max(0, nett_effect) 178 | 179 | current_engagement = _get_current_engagement(w, assignments) 180 | if current_engagement != best_alt_target: 181 | if w in assignments and current_engagement is not None: 182 | assignments.del_edge(w, current_engagement) 183 | assignments.add_edge(w, best_alt_target, probabilities.edge(w, best_alt_target)) 184 | current_target_values = effect_of_assignment[best_alt_target] 185 | if sum(improvements.values()) == 0: 186 | break 187 | return current_target_values, assignments 188 | 189 | 190 | def _get_current_engagement(d, assignment): 191 | """ helper for WTAP solver 192 | Calculates the current engagement 193 | :param d: device 194 | :param assignment: class Graph. 195 | :return: 196 | """ 197 | if d in assignment: 198 | for d, t, v in assignment.edges(from_node=d): 199 | return t 200 | return None 201 | 202 | 203 | def _damages(probabilities, assignment, target_values): 204 | """ helper for WTAP solver 205 | :param probabilities: graph with probability of device effect on target 206 | :param assignment: graph with links between device and targets. 207 | :param target_values: dict with [target]=value. 208 | :return: total survival value. 209 | """ 210 | assert isinstance(probabilities, BasicGraph) 211 | assert isinstance(assignment, BasicGraph) 212 | assert isinstance(target_values, dict) 213 | 214 | survival_value = {target: [] for target in target_values} 215 | for edge in assignment.edges(): 216 | weapon, target, damage = edge 217 | 218 | p = probabilities.edge(weapon, target) 219 | survival_value[target].append(p) 220 | 221 | total_survival_value = 0 222 | for target, assigned_probabilities in survival_value.items(): 223 | p = 1 224 | for p_ in assigned_probabilities: 225 | p *= (1 - p_) 226 | total_survival_value += p * target_values[target] 227 | 228 | return total_survival_value -------------------------------------------------------------------------------- /graph/bfs.py: -------------------------------------------------------------------------------- 1 | from .base import BasicGraph 2 | 3 | 4 | from collections import deque 5 | 6 | 7 | def breadth_first_search(graph, start, end): 8 | """Determines the path from start to end with fewest nodes. 9 | :param graph: class Graph 10 | :param start: start node 11 | :param end: end node 12 | :return: path 13 | """ 14 | if not isinstance(graph, BasicGraph): 15 | raise TypeError(f"Expected BasicGraph, Graph or Graph3D, not {type(graph)}") 16 | if start not in graph: 17 | raise ValueError(f"{start} not in graph") 18 | if end not in graph: 19 | raise ValueError(f"{end} not in graph") 20 | 21 | visited = {start: None} 22 | q = deque([start]) 23 | while q: 24 | node = q.popleft() 25 | if node == end: 26 | path = deque() 27 | while node is not None: 28 | path.appendleft(node) 29 | node = visited[node] 30 | return list(path) 31 | for next_node in graph.nodes(from_node=node): 32 | if next_node not in visited: 33 | visited[next_node] = node 34 | q.append(next_node) 35 | return [] 36 | 37 | 38 | def breadth_first_walk(graph, start, end=None, reversed_walk=False): 39 | """ 40 | :param graph: Graph 41 | :param start: start node 42 | :param end: end node. 43 | :param reversed_walk: if True, the BFS traverse the graph backwards. 44 | :return: generator for walk. 45 | To walk all nodes use: `[n for n in g.breadth_first_walk(start)]` 46 | """ 47 | if not isinstance(graph, BasicGraph): 48 | raise TypeError(f"Expected BasicGraph, Graph or Graph3D, not {type(graph)}") 49 | if start not in graph: 50 | raise ValueError(f"{start} not in graph") 51 | if end is not None and end not in graph: 52 | raise ValueError(f"{end} not in graph. Use `end=None` if you want exhaustive search.") 53 | if not isinstance(reversed_walk, bool): 54 | raise TypeError(f"reversed_walk should be boolean, not {type(reversed_walk)}: {reversed_walk}") 55 | 56 | visited = {start: None} 57 | q = deque([start]) 58 | while q: 59 | node = q.popleft() 60 | yield node 61 | if node == end: 62 | break 63 | L = graph.nodes(from_node=node) if not reversed_walk else graph.nodes(to_node=node) 64 | for next_node in L: 65 | if next_node not in visited: 66 | visited[next_node] = node 67 | q.append(next_node) -------------------------------------------------------------------------------- /graph/core.py: -------------------------------------------------------------------------------- 1 | from .base import BasicGraph, subgraph, is_subgraph, same_path, has_path, network_size, components 2 | from .adjacency_matrix import adjacency_matrix 3 | from .all_pairs_shortest_path import all_pairs_shortest_paths 4 | from .all_paths import all_paths 5 | from .all_simple_paths import all_simple_paths 6 | from .bfs import breadth_first_search, breadth_first_walk 7 | from .critical_path import critical_path_minimize_for_slack, critical_path 8 | from .cycle import cycle, has_cycles 9 | from .dag import phase_lines, sources 10 | from .degree_of_separation import degree_of_separation 11 | from .dfs import depth_first_search, depth_scan 12 | from .distance_map import distance_map 13 | from .max_flow import maximum_flow 14 | from .max_flow_min_cut import maximum_flow_min_cut 15 | from .min_cost_flow import minimum_cost_flow_using_successive_shortest_path 16 | from .minmax import minmax 17 | from .minsum import minsum 18 | from .partite import is_partite 19 | from .shortest_path import shortest_path, shortest_path_bidirectional, ShortestPathCache, distance_from_path 20 | from .shortest_tree_all_pairs import shortest_tree_all_pairs 21 | from .topological_sort import topological_sort 22 | from .tsp import tsp_branch_and_bound, tsp_greedy, tsp_2023 23 | 24 | 25 | __description__ = """ 26 | The graph-theory library is organised in the following way for clarity of structure: 27 | 28 | 1. BasicGraph (class) - with general methods for all subclasses. 29 | 2. All methods for class Graph in same order as on Graph. 30 | 3. Graph (class) 31 | 4. Graph3D (class) 32 | """ 33 | 34 | 35 | class Graph(BasicGraph): 36 | """ 37 | Graph is the base graph that all methods use. 38 | 39 | For methods, please see the documentation on the 40 | individual functions, by importing them separately. 41 | 42 | """ 43 | 44 | def __init__(self, from_dict=None, from_list=None): 45 | super().__init__(from_dict=from_dict, from_list=from_list) 46 | self._cache = None 47 | 48 | def shortest_path(self, start, end, memoize=False, avoids=None): 49 | """ 50 | :param start: start node 51 | :param end: end node 52 | :param memoize: boolean (stores paths in a cache for faster repeated lookup) 53 | :param avoids: optional. A frozen set of nodes that cannot be on the path. 54 | :return: distance, path as list 55 | """ 56 | if not memoize: 57 | return shortest_path(graph=self, start=start, end=end, avoids=avoids) 58 | 59 | if self._cache is None: 60 | self._cache = ShortestPathCache(graph=self) 61 | return self._cache.shortest_path(start, end, avoids=avoids) 62 | 63 | def shortest_path_bidirectional(self, start, end): 64 | """ 65 | :param start: start node 66 | :param end: end node 67 | :return: distance, path as list 68 | """ 69 | return shortest_path_bidirectional(self, start, end) 70 | 71 | def breadth_first_search(self, start, end): 72 | """Determines the path with fewest nodes. 73 | :param start: start node 74 | :param end: end nodes 75 | :return: nodes, path as list 76 | """ 77 | return breadth_first_search(graph=self, start=start, end=end) 78 | 79 | def breadth_first_walk(self, start, end=None, reversed_walk=False): 80 | """ 81 | :param start: start node 82 | :param end: end node 83 | :param reversed_walk: if True, the BFS walk is backwards. 84 | :return: generator for breadth-first walk 85 | """ 86 | return breadth_first_walk(graph=self, start=start, end=end, reversed_walk=reversed_walk) 87 | 88 | def distance_map(self, starts=None, ends=None, reverse=False): 89 | """Maps the shortest path distance from any start to any end. 90 | :param graph: instance of Graph 91 | :param starts: node or (set,list,tuple) of nodes 92 | :param ends: None (exhaustive map), node or (set,list,tuple) of nodes 93 | :param reverse: boolean, if True follows edges backwards. 94 | :return: dictionary with {node: distance from start} 95 | """ 96 | return distance_map(self, starts, ends, reverse) 97 | 98 | def depth_first_search(self, start, end): 99 | """ 100 | Finds a path from start to end using DFS. 101 | :param start: start node 102 | :param end: end node 103 | :return: path 104 | """ 105 | return depth_first_search(graph=self, start=start, end=end) 106 | 107 | def depth_scan(self, start, criteria): 108 | """ 109 | traverses the descendants of node `start` using callable `criteria` to determine 110 | whether to terminate search along each branch in `graph`. 111 | 112 | :param start: start node 113 | :param criteria: function to terminate scan along a branch must return bool 114 | :return: set of nodes 115 | """ 116 | return depth_scan(graph=self, start=start, criteria=criteria) 117 | 118 | def distance_from_path(self, path): 119 | """ 120 | :param path: list of nodes 121 | :return: distance along the path. 122 | """ 123 | return distance_from_path(graph=self, path=path) 124 | 125 | def maximum_flow(self, start, end): 126 | """Determines the maximum flow of the graph between 127 | start and end. 128 | :param start: node (source) 129 | :param end: node (sink) 130 | :return: flow, graph of flow. 131 | """ 132 | return maximum_flow(self, start, end) 133 | 134 | def maximum_flow_min_cut(self, start, end): 135 | """ 136 | Finds the edges in the maximum flow min cut. 137 | :param start: start 138 | :param end: end 139 | :return: list of edges 140 | """ 141 | return maximum_flow_min_cut(self, start, end) 142 | 143 | def minimum_cost_flow(self, inventory, capacity=None): 144 | """ 145 | :param self: Graph with `cost per unit` as edge 146 | :param inventory: dict {node: stock, ...} 147 | stock < 0 is demand 148 | stock > 0 is supply 149 | :param capacity: None or Graph with `capacity` as edge. 150 | :return: total costs, graph of flows in solution. 151 | """ 152 | return minimum_cost_flow_using_successive_shortest_path(self, inventory, capacity) 153 | 154 | def solve_tsp(self, method="2023"): 155 | """solves the traveling salesman problem for the graph 156 | (finds the shortest path through all nodes) 157 | 158 | :param method: str: 'greedy' 159 | 160 | options: 161 | 'greedy' see tsp_greedy 162 | 'bnb' see tsp_branch_and_bound 163 | 164 | :return: tour length (path+return to starting point), 165 | path travelled. 166 | """ 167 | methods = {"greedy": tsp_greedy, "bnb": tsp_branch_and_bound, "2023": tsp_2023} 168 | solver = methods.get(method) 169 | return solver(self) 170 | 171 | def subgraph_from_nodes(self, nodes): 172 | """ 173 | constructs a copy of the graph containing only the 174 | listed nodes (and their links) 175 | :param nodes: list of nodes 176 | :return: class Graph 177 | """ 178 | return subgraph(graph=self, nodes=nodes) 179 | 180 | def is_subgraph(self, other): 181 | """Checks if self is a subgraph in other. 182 | :param other: instance of Graph 183 | :return: boolean 184 | """ 185 | return is_subgraph(self, other) 186 | 187 | def is_partite(self, n=2): 188 | """Checks if self is n-partite 189 | :param n: int the number of partitions. 190 | :return: tuple: boolean, partitions as dict 191 | (or None if graph isn't n-partite) 192 | """ 193 | return is_partite(self, n) 194 | 195 | def has_cycles(self): 196 | """Checks if the graph has a cycle 197 | :return: bool 198 | """ 199 | return has_cycles(graph=self) 200 | 201 | def components(self): 202 | """Determines the number of components 203 | :return: list of sets of nodes. Each set is a component. 204 | """ 205 | return components(graph=self) 206 | 207 | def network_size(self, n1, degrees_of_separation=None): 208 | """Determines the nodes within the range given by 209 | a degree of separation 210 | :param n1: start node 211 | :param degrees_of_separation: integer 212 | :return: set of nodes within given range 213 | """ 214 | return network_size(self, n1, degrees_of_separation) 215 | 216 | def phase_lines(self): 217 | """Determines the phase lines (cuts) of the graph 218 | :returns: dictionary with phase: nodes in phase 219 | """ 220 | return phase_lines(self) 221 | 222 | def sources(self, n): 223 | """Determines the DAG sources of node n""" 224 | return sources(graph=self, n=n) 225 | 226 | def topological_sort(self, key=None): 227 | """Returns a generator for the topological order""" 228 | return topological_sort(self, key=key) 229 | 230 | def critical_path(self): 231 | f"""{critical_path.__doc__}""" 232 | return critical_path(self) 233 | 234 | def critical_path_minimize_for_slack(self): 235 | f"""{critical_path_minimize_for_slack.__doc__}""" 236 | return critical_path_minimize_for_slack(self) 237 | 238 | @staticmethod 239 | def same_path(p1, p2): 240 | """compares two paths to determine if they're the same, despite 241 | being in different order. 242 | 243 | :param p1: list of nodes 244 | :param p2: list of nodes 245 | :return: boolean 246 | """ 247 | return same_path(p1, p2) 248 | 249 | def adjacency_matrix(self): 250 | """ 251 | Converts directed graph to an adjacency matrix. 252 | Note: The distance from a node to itself is 0 and distance from a node to 253 | an unconnected node is defined to be infinite. This does not mean that there 254 | is no path from a node to another via other nodes. 255 | :return: dict 256 | """ 257 | return adjacency_matrix(graph=self) 258 | 259 | def minsum(self): 260 | """Finds the mode(s) that have the smallest sum of distance to all other nodes. 261 | :return: list of nodes 262 | """ 263 | return minsum(self) 264 | 265 | def minmax(self): 266 | """Finds the node(s) with shortest distance to all other nodes. 267 | :return: list of nodes 268 | """ 269 | return minmax(self) 270 | 271 | def all_pairs_shortest_paths(self): 272 | """ 273 | Find the cost of the shortest path between every pair of vertices in a 274 | weighted graph. Uses the Floyd-Warshall algorithm. 275 | :return: dict {node 1: {node 2: distance}, ...} 276 | """ 277 | return all_pairs_shortest_paths(graph=self) 278 | 279 | def shortest_tree_all_pairs(self): 280 | """ 281 | :return: 282 | """ 283 | return shortest_tree_all_pairs(graph=self) 284 | 285 | def has_path(self, path): 286 | """ 287 | :param path: list of nodes 288 | :return: boolean, if the path is in G. 289 | """ 290 | return has_path(graph=self, path=path) 291 | 292 | def all_simple_paths(self, start, end): 293 | """ 294 | finds all simple (non-looping) paths from start to end 295 | :param start: node 296 | :param end: node 297 | :return: list of paths 298 | """ 299 | return all_simple_paths(self, start, end) 300 | 301 | def all_paths(self, start, end): 302 | """finds all paths from start to end by traversing each fork once only. 303 | :param start: node 304 | :param end: node 305 | :return: list of paths 306 | """ 307 | return all_paths(graph=self, start=start, end=end) 308 | 309 | def degree_of_separation(self, n1, n2): 310 | """determines the degree of separation between 2 nodes 311 | :param n1: node 312 | :param n2: node 313 | :return: degree 314 | """ 315 | return degree_of_separation(self, n1, n2) 316 | 317 | def loop(self, start, mid, end=None): 318 | """finds a looped path via a mid-point 319 | :param start: node 320 | :param mid: node, midpoint for loop. 321 | :param end: node 322 | :return: path as list 323 | """ 324 | return cycle(self, start, mid, end) 325 | 326 | 327 | class Graph3D(Graph): 328 | """a graph where all (x,y)-positions are unique.""" 329 | 330 | def __init__(self, from_dict=None, from_list=None): 331 | super().__init__(from_dict=from_dict, from_list=from_list) 332 | 333 | def copy(self): 334 | g = Graph3D(from_dict=self.to_dict()) 335 | return g 336 | 337 | # spatial only function 338 | # --------------------- 339 | @staticmethod 340 | def _check_tuples(n1): 341 | if not isinstance(n1, tuple): 342 | raise TypeError(f"expected tuple, not {type(n1)}") 343 | if len(n1) != 3: 344 | raise ValueError(f"expected tuple in the form as (x,y,z), got {n1}") 345 | if not all(isinstance(i, (float, int)) for i in n1): 346 | raise TypeError(f"expected all values to be integer or float, but got {n1}") 347 | 348 | @staticmethod 349 | def distance(n1, n2): 350 | """returns the distance between to xyz tuples coordinates 351 | :param n1: (x,y,z) 352 | :param n2: (x,y,z) 353 | :return: float 354 | """ 355 | Graph3D._check_tuples(n1) 356 | Graph3D._check_tuples(n2) 357 | (x1, y1, z1), (x2, y2, z2) = n1, n2 358 | a = abs(x2 - x1) 359 | b = abs(y2 - y1) 360 | c = abs(z2 - z1) 361 | return (a * a + b * b + c * c) ** (1 / 2) 362 | 363 | def add_edge(self, n1, n2, value=None, bidirectional=False): 364 | self._check_tuples(n1) 365 | self._check_tuples(n2) 366 | assert value is not None 367 | super().add_edge(n1, n2, value, bidirectional) 368 | 369 | def add_node(self, node_id, obj=None): 370 | self._check_tuples(node_id) 371 | super().add_node(node_id, obj) 372 | """ 373 | :param node_id: any hashable node. 374 | :param obj: any object that the node should refer to. 375 | 376 | PRO TIP: To retrieve the node obj use g.node(node_id) 377 | """ 378 | self._nodes[node_id] = obj 379 | 380 | def n_nearest_neighbours(self, node_id, n=1): 381 | """returns the node id of the `n` nearest neighbours.""" 382 | self._check_tuples(node_id) 383 | if not isinstance(n, int): 384 | raise TypeError(f"expected n to be integer, not {type(n)}") 385 | if n < 1: 386 | raise ValueError(f"expected n >= 1, not {n}") 387 | 388 | d = [(self.distance(n1=node_id, n2=n), n) for n in self.nodes() if n != node_id] 389 | d.sort() 390 | if d: 391 | return [b for a, b in d][:n] 392 | return None 393 | 394 | def plot(self, nodes=True, edges=True, rotation="xyz", maintain_aspect_ratio=False): 395 | """plots nodes and links using matplotlib3 396 | :param nodes: bool: plots nodes 397 | :param edges: bool: plots edges 398 | :param rotation: str: set view point as one of [xyz,xzy,yxz,yzx,zxy,zyx] 399 | :param maintain_aspect_ratio: bool: rescales the chart to maintain aspect ratio. 400 | :return: None. Plots figure. 401 | """ 402 | from graph.visuals import plot_3d # noqa 403 | 404 | return plot_3d(self, nodes, edges, rotation, maintain_aspect_ratio) 405 | -------------------------------------------------------------------------------- /graph/critical_path.py: -------------------------------------------------------------------------------- 1 | from .base import BasicGraph 2 | from .dag import phase_lines 3 | from .topological_sort import topological_sort 4 | 5 | 6 | class Task(object): 7 | """Helper for critical path method""" 8 | 9 | __slots__ = ["task_id", "duration", "earliest_start", "earliest_finish", "latest_start", "latest_finish"] 10 | 11 | def __init__( 12 | self, 13 | task_id, 14 | duration, 15 | earliest_start=0, 16 | latest_start=0, 17 | earliest_finish=float("inf"), 18 | latest_finish=float("inf"), 19 | ): 20 | self.task_id = task_id 21 | self.duration = duration 22 | self.earliest_start = earliest_start 23 | self.latest_start = latest_start 24 | self.earliest_finish = earliest_finish 25 | self.latest_finish = latest_finish 26 | 27 | def __eq__(self, other): 28 | if not isinstance(other, Task): 29 | raise TypeError(f"can't compare {type(other)} with {type(self)}") 30 | if any( 31 | ( 32 | self.task_id != other.task_id, 33 | self.duration != other.duration, 34 | self.earliest_start != other.earliest_start, 35 | self.latest_start != other.latest_start, 36 | self.earliest_finish != other.earliest_finish, 37 | self.latest_finish != other.latest_finish, 38 | ) 39 | ): 40 | return False 41 | return True 42 | 43 | @property 44 | def slack(self): 45 | return self.latest_finish - self.earliest_finish 46 | 47 | @property 48 | def args(self): 49 | return ( 50 | self.task_id, 51 | self.duration, 52 | self.earliest_start, 53 | self.latest_start, 54 | self.earliest_finish, 55 | self.latest_finish, 56 | ) 57 | 58 | def __repr__(self): 59 | return f"{self.__class__.__name__}{self.args}" 60 | 61 | def __str__(self): 62 | return self.__repr__() 63 | 64 | 65 | def critical_path(graph): 66 | """ 67 | The critical path method determines the 68 | schedule of a set of project activities that results in 69 | the shortest overall path. 70 | :param graph: acyclic graph where: 71 | nodes are task_id and node_obj is a value 72 | edges determines dependencies 73 | (see example below) 74 | :return: critical path length, schedule. 75 | schedule is list of Tasks 76 | Recipes: 77 | (1) Setting up the graph: 78 | tasks = {'A': 10, 'B': 20, 'C': 5, 'D': 10, 'E': 20, 'F': 15, 'G': 5, 'H': 15} 79 | dependencies = [ 80 | ('A', 'B'), 81 | ('B', 'C'), 82 | ('C', 'D'), 83 | ('D', 'E'), 84 | ('A', 'F'), 85 | ('F', 'G'), 86 | ('G', 'E'), 87 | ('A', 'H'), 88 | ('H', 'E'), 89 | ] 90 | g = Graph() 91 | for task, duration in tasks.items(): 92 | g.add_node(task, obj=duration) 93 | for n1, n2 in dependencies: 94 | g.add_edge(n1, n2, 0) 95 | """ 96 | if not isinstance(graph, BasicGraph): 97 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 98 | 99 | # 1. A topologically sorted list of nodes is prepared (topo.order). 100 | order = list(topological_sort(graph)) # this will raise if there are loops. 101 | 102 | d = {} 103 | critical_path_length = 0 104 | 105 | for task_id in order: # 2. forward pass: 106 | predecessors = [d[t] for t in graph.nodes(to_node=task_id)] 107 | duration = graph.node(task_id) 108 | if not isinstance(duration, (float, int)): 109 | raise ValueError(f"Expected task {task_id} to have a numeric duration, but got {type(duration)}") 110 | t = Task(task_id=task_id, duration=duration) 111 | # 1. the earliest start and earliest finish is determined in topological order. 112 | t.earliest_start = max(t.earliest_finish for t in predecessors) if predecessors else 0 113 | t.earliest_finish = t.earliest_start + t.duration 114 | d[task_id] = t 115 | # 2. the path length is recorded. 116 | critical_path_length = max(t.earliest_finish, critical_path_length) 117 | 118 | for task_id in reversed(order): # 3. backward pass: 119 | successors = [d[t] for t in graph.nodes(from_node=task_id)] 120 | t = d[task_id] 121 | # 1. the latest start and finish is determined in reverse topological order 122 | t.latest_finish = min(t.latest_start for t in successors) if successors else critical_path_length 123 | t.latest_start = t.latest_finish - t.duration 124 | 125 | return critical_path_length, d 126 | 127 | 128 | def critical_path_minimize_for_slack(graph): 129 | """ 130 | Determines the critical path schedule and attempts to minimise 131 | the concurrent resource requirements by inserting the minimal 132 | number of artificial dependencies. 133 | """ 134 | if not isinstance(graph, BasicGraph): 135 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 136 | 137 | cpl, schedule = critical_path(graph) 138 | phases = phase_lines(graph) 139 | 140 | new_graph = graph.copy() 141 | slack_nodes = [t for k, t in schedule.items() if t.slack > 0] # slack == 0 is on the critical path. 142 | slack_nodes.sort(reverse=True, key=lambda t: t.slack) # most slack on top as it has more freedom to move. 143 | slack_node_ids = [t.task_id for t in slack_nodes] 144 | slack = sum(t.slack for t in schedule.values()) 145 | 146 | # Part 1. Use a greedy algorithm to determine an initial solution. 147 | new_edges = [] 148 | stop = False 149 | for node in sorted(slack_nodes, reverse=True, key=lambda t: t.duration): 150 | if stop: 151 | break 152 | # identify any option for insertion: 153 | n1 = node.task_id 154 | for n2 in ( 155 | n2 156 | for n2 in slack_node_ids 157 | if n2 != n1 # ...not on critical path 158 | and phases[n2] >= phases[n1] # ...not pointing to the source 159 | and new_graph.edge(n2, n1) is None # ...downstream 160 | and new_graph.edge(n1, n2) is None # ... not creating a cycle. 161 | ): # ... not already a dependency. 162 | new_graph.add_edge(n1, n2) 163 | new_edges.append((n1, n2)) 164 | 165 | cpl2, schedule2 = critical_path(new_graph) 166 | if cpl2 != cpl: # the critical path is not allowed to be longer. Abort. 167 | new_graph.del_edge(n1, n2) 168 | new_edges.remove((n1, n2)) 169 | continue 170 | 171 | slack2 = sum(t.slack for t in schedule2.values()) 172 | 173 | if slack2 < slack and cpl == cpl2: # retain solution! 174 | slack = slack2 175 | if slack == 0: # an optimal solution has been found! 176 | stop = True 177 | break 178 | else: 179 | new_graph.del_edge(n1, n2) 180 | new_edges.remove((n1, n2)) 181 | 182 | # Part 2. Purge non-effective edges. 183 | for edge in new_graph.edges(): 184 | n1, n2, _ = edge 185 | if graph.edge(n1, n2) is not None: 186 | continue # it's an original edge. 187 | # else: it's an artificial edge: 188 | new_graph.del_edge(n1, n2) 189 | cpl2, schedule2 = critical_path(new_graph) 190 | slack2 = sum(t.slack for t in schedule2.values()) 191 | 192 | if cpl2 == cpl and slack2 == slack: 193 | continue # the removed edge had no effect and just made the graph more complicated. 194 | else: 195 | new_graph.add_edge(n1, n2) 196 | 197 | return new_graph -------------------------------------------------------------------------------- /graph/cycle.py: -------------------------------------------------------------------------------- 1 | from .base import BasicGraph 2 | from .topological_sort import topological_sort 3 | 4 | 5 | def cycle(graph, start, mid, end=None): 6 | """Returns a loop passing through a defined mid-point and returning via a different set of nodes to the outward 7 | journey. If end is None we return to the start position.""" 8 | if not isinstance(graph, BasicGraph): 9 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 10 | if start not in graph: 11 | raise ValueError("start not in graph.") 12 | if mid not in graph: 13 | raise ValueError("mid not in graph.") 14 | if end is not None and end not in graph: 15 | raise ValueError("end not in graph.") 16 | 17 | _, p = graph.shortest_path(start, mid) 18 | g2 = graph.copy() 19 | if end is not None: 20 | for n in p[:-1]: 21 | g2.del_node(n) 22 | _, p2 = g2.shortest_path(mid, end) 23 | else: 24 | for n in p[1:-1]: 25 | g2.del_node(n) 26 | _, p2 = g2.shortest_path(mid, start) 27 | lp = p + p2[1:] 28 | return lp 29 | 30 | 31 | def has_cycles(graph): 32 | """Checks if graph has a cycle 33 | :param graph: instance of class Graph. 34 | :return: bool 35 | """ 36 | if not isinstance(graph, BasicGraph): 37 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 38 | 39 | for n1, n2, _ in graph.edges(): 40 | if n1 == n2: # detect nodes that point to themselves 41 | return True 42 | try: 43 | _ = list(topological_sort(graph)) # tries to create a DAG. 44 | return False 45 | except AttributeError: 46 | return True -------------------------------------------------------------------------------- /graph/dag.py: -------------------------------------------------------------------------------- 1 | from collections import deque 2 | from .base import BasicGraph 3 | 4 | 5 | def phase_lines(graph): 6 | """Determines the phase lines of a directed graph. 7 | This is useful for determining which tasks can be performed in 8 | parallel. Each phase in the phaselines must be completed to assure 9 | that the tasks in the next phase can be performed with complete input. 10 | This is in contrast to Topological sort that only generates 11 | a queue of tasks, may be fine for a single processor, but has no 12 | mechanism for coordination that all inputs for a task have been completed 13 | so that multiple processors can work on them. 14 | Example: DAG with tasks: 15 | u1 u4 u2 u3 16 | | | |_______| 17 | csg cs3 append 18 | | | | 19 | op1 | op3 20 | | | | 21 | op2 | cs2 22 | | |___________| 23 | cs1 join 24 | | | 25 | map1 map2 26 | |___________| 27 | save 28 | phaselines = { 29 | "u1": 0, "u4": 0, "u2": 0, "u3": 0, 30 | "csg": 1, "cs3": 1, "append": 1, 31 | "op1": 2, "op3": 2, "op2": 3, "cs2": 3, 32 | "cs1": 4, "join": 4, 33 | "map1": 5, "map2": 5, 34 | "save": 6, 35 | } 36 | From this example it is visible that processing the 4 'uN' (uploads) is 37 | the highest degree of concurrency. This can be determined as follows: 38 | d = defaultdict(int) 39 | for _, pl in graph.phaselines(): 40 | d[pl] += 1 41 | max_processors = max(d, key=d.get) 42 | :param graph: Graph 43 | :return: dictionary with node id : phase in cut. 44 | Note: To transform the phaselines into a task sequence use 45 | the following recipe: 46 | tasks = defaultdict(set) 47 | for node, phase in phaselines(graph): 48 | tasks[phase].add(node) 49 | To obtain a sort stable tasks sequence use: 50 | for phase in sorted(tasks): 51 | print(phase, list(sorted(tasks[phase])) 52 | """ 53 | if not isinstance(graph, BasicGraph): 54 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 55 | 56 | nmax = len(graph.nodes()) 57 | phases = {n: nmax + 1 for n in graph.nodes()} 58 | phase_counter = 0 59 | 60 | g2 = graph.copy() 61 | q = list(g2.nodes(in_degree=0)) # Current iterations work queue 62 | if not q: 63 | raise AttributeError("The graph does not have any sources.") 64 | 65 | q2 = set() # Next iterations work queue 66 | while q: 67 | for n1 in q: 68 | if g2.in_degree(n1)!=0: 69 | q2.add(n1) # n1 has an in coming edge, so it has to wait. 70 | continue 71 | phases[n1] = phase_counter # update the phaseline number 72 | for n2 in g2.nodes(from_node=n1): 73 | q2.add(n2) # add node for next iteration 74 | 75 | # at this point the nodes with no incoming edges have been accounted 76 | # for, so now they may be removed for the working graph. 77 | for n1 in q: 78 | if n1 not in q2: 79 | g2.del_node(n1) # remove nodes that have no incoming edges 80 | 81 | if set(q) == q2: 82 | raise AttributeError("Loop found: The graph is not acyclic!") 83 | 84 | # Finally turn the next iterations workqueue into current. 85 | # and increment the phaseline counter. 86 | q = [n for n in q2] 87 | q2.clear() 88 | phase_counter += 1 89 | return phases 90 | 91 | 92 | def sources(graph, n): 93 | """Determines the set of all upstream sources of node 'n' in a DAG. 94 | :param graph: Graph 95 | :param n: node for which the sources are sought. 96 | :return: set of nodes 97 | """ 98 | if not isinstance(graph, BasicGraph): 99 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 100 | if n not in graph: 101 | raise ValueError(f"{n} not in graph") 102 | 103 | nodes = {n} 104 | q = deque([n]) 105 | while q: 106 | new = q.popleft() 107 | for src in graph.nodes(to_node=new): 108 | if src not in nodes: 109 | nodes.add(src) 110 | if src not in q: 111 | q.append(src) 112 | nodes.remove(n) 113 | return nodes 114 | -------------------------------------------------------------------------------- /graph/degree_of_separation.py: -------------------------------------------------------------------------------- 1 | from .base import BasicGraph 2 | from .bfs import breadth_first_search 3 | 4 | 5 | def degree_of_separation(graph, n1, n2): 6 | """Calculates the degree of separation between 2 nodes.""" 7 | if not isinstance(graph, BasicGraph): 8 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 9 | if n1 not in graph: 10 | raise ValueError("n1 not in graph.") 11 | if n2 not in graph: 12 | raise ValueError("n2 not in graph.") 13 | 14 | assert n1 in graph.nodes() 15 | p = breadth_first_search(graph, n1, n2) 16 | return len(p) - 1 -------------------------------------------------------------------------------- /graph/dfs.py: -------------------------------------------------------------------------------- 1 | from .base import BasicGraph 2 | 3 | 4 | from collections import deque 5 | 6 | 7 | def depth_first_search(graph, start, end): 8 | """ 9 | Determines path from start to end using 10 | 'depth first search' with backtracking. 11 | :param graph: class Graph 12 | :param start: start node 13 | :param end: end node 14 | :return: path as list of nodes. 15 | """ 16 | if not isinstance(graph, BasicGraph): 17 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 18 | if start not in graph: 19 | raise ValueError(f"{start} not in graph") 20 | if end not in graph: 21 | raise ValueError(f"{end} not in graph") 22 | 23 | q = deque([start]) # q = [start] 24 | # using deque as popleft and appendleft is faster than using lists. For details see 25 | # https://stackoverflow.com/questions/23487307/python-deque-vs-list-performance-comparison 26 | path = [] 27 | visited = set() 28 | while q: 29 | n1 = q.popleft() # n1 = q.pop() 30 | visited.add(n1) 31 | path.append(n1) 32 | if n1 == end: 33 | return path # <-- exit if end is found. 34 | for n2 in graph.nodes(from_node=n1): 35 | if n2 in visited: 36 | continue 37 | q.appendleft(n2) # q.append(n2) 38 | break 39 | else: 40 | path.remove(n1) 41 | while not q and path: 42 | for n2 in graph.nodes(from_node=path[-1]): 43 | if n2 in visited: 44 | continue 45 | q.appendleft(n2) # q.append(n2) 46 | break 47 | else: 48 | path = path[:-1] 49 | return None # <-- exit if not path was found. 50 | 51 | 52 | def depth_scan(graph, start, criteria): 53 | """traverses the descendants of node `start` using callable `criteria` to determine 54 | whether to terminate search along each branch in `graph`. 55 | :param graph: class Graph 56 | :param start: start node 57 | :param criteria: function to terminate scan along a branch must return bool 58 | :return: set of nodes 59 | """ 60 | if not isinstance(graph, BasicGraph): 61 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 62 | if start not in graph: 63 | raise ValueError(f"{start} not in graph") 64 | if not callable(criteria): 65 | raise TypeError(f"Expected {criteria} to be callable") 66 | if not criteria(start): 67 | return set() 68 | 69 | q = [start] 70 | path = [] 71 | visited = set() 72 | while q: 73 | n1 = q.pop() 74 | visited.add(n1) 75 | path.append(n1) 76 | for n2 in graph.nodes(from_node=n1): 77 | if n2 in visited: 78 | continue 79 | if not criteria(n2): 80 | visited.add(n2) 81 | continue 82 | q.append(n2) 83 | break 84 | else: 85 | path.remove(n1) 86 | while not q and path: 87 | for n2 in graph.nodes(from_node=path[-1]): 88 | if n2 in visited: 89 | continue 90 | if not criteria(n2): 91 | visited.add(n2) 92 | continue 93 | q.append(n2) 94 | break 95 | else: 96 | path = path[:-1] 97 | return visited -------------------------------------------------------------------------------- /graph/distance_map.py: -------------------------------------------------------------------------------- 1 | from .base import BasicGraph 2 | 3 | 4 | from collections import deque 5 | from collections.abc import Iterable 6 | 7 | 8 | def distance_map(graph, starts=None, ends=None, reverse=False): 9 | """Maps the shortest path distance from any start to any end. 10 | :param graph: instance of Graph 11 | :param starts: None, node or (set,list,tuple) of nodes 12 | :param ends: None (exhaustive map), node or (set,list,tuple) of nodes 13 | that terminate the search when all are found 14 | :param reverse: bool: walks the map from the ends towards the starts using reversed edges. 15 | :return: dictionary with {node: distance from start} 16 | """ 17 | if not isinstance(graph, BasicGraph): 18 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 19 | 20 | if not isinstance(reverse, bool): 21 | raise TypeError("keyword reverse was not boolean") 22 | 23 | if all((starts is None, ends is None)): 24 | raise ValueError("starts and ends cannot both be None") 25 | if all((starts is None, reverse is False)): 26 | raise ValueError("walking forward from end doesn't make sense.") 27 | if all((ends is None, reverse is True)): 28 | raise ValueError("walking from reverse from start doesn't make sense.") 29 | 30 | if isinstance(starts, Iterable): 31 | starts = set(starts) 32 | else: 33 | starts = {starts} 34 | if any(start not in graph for start in starts if start is not None): 35 | missing = [start not in graph for start in starts] 36 | raise ValueError(f"starts: ({missing}) not in graph") 37 | 38 | if isinstance(ends, Iterable): 39 | ends = set(ends) 40 | else: 41 | ends = {ends} 42 | if any(end not in graph for end in ends if end is not None): 43 | missing = [end not in graph for end in ends if end is not None] 44 | raise ValueError(f"{missing} not in graph. Use `end=None` if you want exhaustive search.") 45 | 46 | if not reverse: 47 | ends_found = set() 48 | visited = {start: 0 for start in starts} 49 | q = deque(starts) 50 | while q: 51 | if ends_found == ends: 52 | break 53 | n1 = q.popleft() 54 | if n1 in ends: 55 | ends_found.add(n1) 56 | d1 = visited[n1] 57 | for _, n2, d in graph.edges(from_node=n1): 58 | if n2 not in visited: 59 | q.append(n2) 60 | visited[n2] = min(d1 + d, visited.get(n2, float("inf"))) 61 | 62 | else: 63 | starts_found = set() 64 | visited = {end: 0 for end in ends} 65 | q = deque(ends) 66 | while q: 67 | if starts_found == starts: 68 | break 69 | n2 = q.popleft() 70 | if n2 in starts: 71 | starts_found.add(n2) 72 | d2 = visited[n2] 73 | for n1, _, d in graph.edges(to_node=n2): 74 | if n1 not in visited: 75 | q.append(n1) 76 | visited[n1] = min(d + d2, visited.get(n1, float("inf"))) 77 | 78 | return visited -------------------------------------------------------------------------------- /graph/finite_state_machine.py: -------------------------------------------------------------------------------- 1 | from .base import BasicGraph 2 | from itertools import count 3 | 4 | 5 | class FiniteStateMachine(object): 6 | def __init__(self): 7 | self.states = BasicGraph() 8 | self.current_state = None 9 | self._action_id = count() 10 | self.actions = {} 11 | self._initial_state_was_set = False 12 | 13 | def set_initial_state(self, state): 14 | """ method for setting initial state of the FSM 15 | 16 | :param state: available in options 17 | :return: None 18 | """ 19 | if self._initial_state_was_set: 20 | raise ValueError("initial state has already been set.") 21 | if state not in self.states.nodes(): 22 | raise ValueError(f"{state} is not a state.") 23 | self.current_state = state 24 | self._initial_state_was_set = True 25 | 26 | def add_transition(self, state_1, action, state_2): 27 | """ Adds a state transition from state 1 to state 2 if action is performed. 28 | 29 | :param state_1: any hashable value. 30 | :param action: any hashable value. 31 | :param state_2: any hashable value. 32 | :return: None 33 | """ 34 | action_id = next(self._action_id) 35 | self.states.add_edge(node1=state_1, node2=action_id) 36 | self.states.add_edge(node1=action_id, node2=state_2) 37 | self.actions[action_id] = action 38 | 39 | def options(self): 40 | """ returns list of options for the FSMs current state. """ 41 | return [self.actions[i] for i in self.states.nodes(from_node=self.current_state)] 42 | 43 | def next(self, action): 44 | """ transitions the FSM from it's current state as a reaction to input `action`. 45 | 46 | :param action: any action available in fsm.options() 47 | :return: None 48 | """ 49 | if self._initial_state_was_set is False: 50 | raise ValueError("initial state has not been set.") 51 | actions = self.states.nodes(from_node=self.current_state) 52 | if not actions: 53 | raise StopIteration("terminal node reached.") 54 | 55 | for action_id in actions: 56 | if self.actions[action_id] == action: 57 | self.current_state = self.states.nodes(from_node=action_id)[0] 58 | return 59 | raise ValueError(f"{self.current_state} does not permit {action}.") 60 | 61 | -------------------------------------------------------------------------------- /graph/hash_methods.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | 3 | from .base import BasicGraph 4 | 5 | 6 | def graph_hash(graph): 7 | """ Generates the top hash of the graph using sha3_256. 8 | :param graph: instance of class Graph. 9 | :return: graph hash (int) and graph (Graph) with hash values 10 | """ 11 | assert isinstance(graph, BasicGraph) 12 | hash_func = hashlib.sha3_256() 13 | nodes = bytes("|".join(str(n) for n in sorted(graph.nodes())), 'utf-8') 14 | edges = bytes("|".join(str(e) for e in sorted(graph.edges())), 'utf-8') 15 | hash_func.update(nodes + edges) 16 | return int(hash_func.hexdigest(), 16) 17 | 18 | 19 | def flow_graph_hash(graph): 20 | """ 21 | Calculates the hash of a flow graph, where the properties of the nodes 22 | and the supplying nodes are included in the hash. 23 | 24 | Any upstream change in hash will thereby propagate downstream. 25 | """ 26 | assert isinstance(graph, BasicGraph) 27 | sources = graph.nodes(in_degree=0) 28 | 29 | original_hash = 'original hash' 30 | new_hash = 'new_hash' 31 | cls = type(graph) 32 | hash_graph = cls() # new graph with hashes. 33 | visited = set() 34 | 35 | while sources: 36 | source = sources[0] 37 | sources = sources[1:] 38 | 39 | suppliers = graph.nodes(to_node=source) 40 | 41 | hash_func = hashlib.sha3_256() 42 | hash_func.update(bytes(str(source), 'utf-8')) 43 | for supplier in suppliers: 44 | if graph.depth_first_search(start=source, end=supplier): 45 | continue # it's a cycle. 46 | d = hash_graph.node(supplier) 47 | hash_func.update(bytes(d[new_hash], 'utf-8')) 48 | source_hash = hash_func.hexdigest() 49 | 50 | if source not in hash_graph: 51 | obj = {original_hash: source, new_hash: source_hash} 52 | hash_graph.add_node(source, obj=obj) 53 | else: 54 | n = hash_graph.node(source) 55 | n[new_hash] = source_hash 56 | 57 | receivers = graph.nodes(from_node=source) 58 | for receiver in receivers: 59 | if receiver in visited: 60 | continue 61 | visited.add(receiver) 62 | 63 | if receiver not in hash_graph: 64 | obj = {original_hash: receiver, new_hash: None} 65 | hash_graph.add_node(node_id=receiver, obj=obj) 66 | hash_graph.add_edge(source, receiver) 67 | if receiver not in sources: 68 | sources.append(receiver) 69 | 70 | for sink in graph.nodes(out_degree=0): 71 | n = hash_graph.node(sink) 72 | assert n[new_hash] is not None, n 73 | 74 | return hash_graph 75 | 76 | 77 | def merkle_tree(data_blocks): 78 | """ 79 | A hash tree or Merkle tree is a tree in which every leaf node is labelled with 80 | the hash of a data block, and every non-leaf node is labelled with the 81 | cryptographic hash of the labels of its child nodes. Hash trees allow efficient 82 | and secure verification of the contents of large data structures. Hash trees 83 | are a generalization of hash lists and hash chains. 84 | 85 | Top Hash 86 | hash ( 0 + 1 ) 87 | ^ ^ 88 | | | 89 | +-------> +---------+ 90 | ^ ^ 91 | | | 92 | + + 93 | Hash 0 Hash 1 94 | hash ( 0-0 + 0-1 ) hash ( 1-0 + 1-1 ) 95 | ^ ^ ^ ^ 96 | | | | | 97 | + + + + 98 | Hash 0-0 Hash 0-1 Hash 1-0 Hash 1-1 99 | hash(L1) hash(L2) hash(L3) hash(L4) 100 | ^ ^ ^ ^ 101 | | | | | 102 | +----------------------------------------------+ 103 | | L1 L2 L3 L4 | Data blocks 104 | +----------------------------------------------+ 105 | 106 | """ 107 | g = BasicGraph() 108 | 109 | # initial hash: 110 | leaves = [] 111 | for block in data_blocks: 112 | assert isinstance(block, bytes) 113 | hash_func = hashlib.sha3_256() 114 | hash_func.update(block) 115 | uuid = hash_func.hexdigest() 116 | leaves.append(uuid) 117 | g.add_node(node_id=uuid) 118 | 119 | # populate graph 120 | while leaves: 121 | if len(leaves) == 1: 122 | return g # <--- point of return. 123 | 124 | c1, c2 = leaves[:2] 125 | leaves = leaves[2:] 126 | 127 | hash_func = hashlib.sha3_256() 128 | hash_func.update(bytes(c1, 'utf-8') + bytes(c2, 'utf-8')) 129 | uuid = hash_func.hexdigest() 130 | leaves.append(uuid) 131 | g.add_node(node_id=uuid) 132 | g.add_edge(c1, uuid) 133 | g.add_edge(c2, uuid) -------------------------------------------------------------------------------- /graph/max_flow.py: -------------------------------------------------------------------------------- 1 | from .base import BasicGraph 2 | from .shortest_path import shortest_path 3 | 4 | 5 | def maximum_flow(graph, start, end): 6 | """ 7 | Returns the maximum flow graph 8 | :param graph: instance of Graph 9 | :param start: node 10 | :param end: node 11 | :return: flow, graph 12 | """ 13 | if not isinstance(graph, BasicGraph): 14 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 15 | Graph = type(graph) 16 | 17 | if start not in graph: 18 | raise ValueError(f"{start} not in graph") 19 | if end not in graph: 20 | raise ValueError(f"{end} not in graph") 21 | 22 | inflow = sum(d for s, e, d in graph.edges(from_node=start)) 23 | outflow = sum(d for s, e, d in graph.edges(to_node=end)) 24 | unassigned_flow = min(inflow, outflow) # search in excess of this 'flow' is a waste of time. 25 | total_flow = 0 26 | # ----------------------------------------------------------------------- 27 | # The algorithm 28 | # I reviewed a number of algorithms, such as Ford-fulkerson algorithm, 29 | # Edmonson-Karp and Dinic, but I didn't like them due to their naive usage 30 | # of DFS, which leads to a lot of node visits. 31 | # 32 | # I therefore choose to invert the capacities of the graph so that the 33 | # capacity any G[u][v] = c becomes 1/c in G_inverted. 34 | # This allows me to use the shortest path method to find the path with 35 | # most capacity in the first attempt, resulting in a significant reduction 36 | # of unassigned flow. 37 | # 38 | # By updating G_inverted, with the residual capacity, I can keep using the 39 | # shortest path, until the capacity is zero, whereby I remove the links 40 | # When the shortest path method returns 'No path' or when unassigned flow 41 | # is zero, I exit the algorithm. 42 | # 43 | # Even on small graphs, this method is very efficient, despite the overhead 44 | # of using shortest path. For very large graphs, this method outperforms 45 | # all other algorithms by orders of magnitude. 46 | # ----------------------------------------------------------------------- 47 | 48 | edges = [(n1, n2, 1 / d) for n1, n2, d in graph.edges() if d > 0] 49 | inverted_graph = Graph(from_list=edges) # create G_inverted. 50 | capacity_graph = Graph() # Create structure to record capacity left. 51 | flow_graph = Graph() # Create structure to record flows. 52 | 53 | while unassigned_flow: 54 | # 1. find the best path 55 | d, path = shortest_path(inverted_graph, start, end) 56 | if d == float("inf"): # then there is no path, and we must exit. 57 | return total_flow, flow_graph 58 | # else: use the path and lookup the actual flow from the capacity graph. 59 | 60 | path_flow = min([min(d, capacity_graph.edge(s, e, default=float("inf"))) for s, e, d in graph.edges(path=path)]) 61 | 62 | # 2. update the unassigned flow. 63 | unassigned_flow -= path_flow 64 | total_flow += path_flow 65 | 66 | # 3. record the flows and update the inverted graph, so that it is 67 | # ready for the next iteration. 68 | edges = graph.edges(path) 69 | for n1, n2, d in edges: 70 | # 3.a. recording: 71 | v = flow_graph.edge(n1, n2, default=None) 72 | if v is None: 73 | flow_graph.add_edge(n1, n2, path_flow) 74 | c = graph.edge(n1, n2) - path_flow 75 | else: 76 | flow_graph.add_edge(n1, n2, value=v + path_flow) 77 | c = graph.edge(n1, n2) - (v + path_flow) 78 | capacity_graph.add_edge(n1, n2, c) 79 | 80 | # 3.b. updating: 81 | # if there is capacity left: update with new 1/capacity 82 | # else: remove node, as we can't do 1/zero. 83 | if c > 0: 84 | inverted_graph.add_edge(n1, n2, 1 / c) 85 | else: 86 | inverted_graph.del_edge(n1, n2) 87 | return total_flow, flow_graph -------------------------------------------------------------------------------- /graph/max_flow_min_cut.py: -------------------------------------------------------------------------------- 1 | from .base import BasicGraph 2 | from .max_flow import maximum_flow 3 | 4 | 5 | def maximum_flow_min_cut(graph, start, end): 6 | """ 7 | Finds the edges in the maximum flow min cut. 8 | :param graph: Graph 9 | :param start: start 10 | :param end: end 11 | :return: list of edges 12 | """ 13 | if not isinstance(graph, BasicGraph): 14 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 15 | if start not in graph: 16 | raise ValueError(f"{start} not in graph") 17 | if end not in graph: 18 | raise ValueError(f"{end} not in graph") 19 | 20 | flow, mfg = maximum_flow(graph, start, end) 21 | if flow == 0: 22 | return [] 23 | 24 | cls = type(graph) 25 | working_graph = cls(from_list=mfg.to_list()) 26 | 27 | min_cut = [] 28 | for n1 in mfg.breadth_first_walk(start, end): 29 | n2s = mfg.nodes(from_node=n1) 30 | for n2 in n2s: 31 | if graph.edge(n1, n2) - mfg.edge(n1, n2) == 0: 32 | working_graph.del_edge(n1, n2) 33 | min_cut.append((n1, n2)) 34 | 35 | min_cut_nodes = set(working_graph.nodes(out_degree=0)) 36 | min_cut_nodes.remove(end) 37 | min_cut = [(n1, n2) for (n1, n2) in min_cut if n1 in min_cut_nodes] 38 | return min_cut -------------------------------------------------------------------------------- /graph/maximum_flow_min_cut.py: -------------------------------------------------------------------------------- 1 | from .base import BasicGraph 2 | from .max_flow import maximum_flow 3 | 4 | 5 | def maximum_flow_min_cut(graph, start, end): 6 | """ 7 | Finds the edges in the maximum flow min cut. 8 | :param graph: Graph 9 | :param start: start 10 | :param end: end 11 | :return: list of edges 12 | """ 13 | if not isinstance(graph, BasicGraph): 14 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 15 | Graph = type(graph) 16 | if start not in graph: 17 | raise ValueError(f"{start} not in graph") 18 | if end not in graph: 19 | raise ValueError(f"{end} not in graph") 20 | 21 | flow, mfg = maximum_flow(graph, start, end) 22 | if flow == 0: 23 | return [] 24 | 25 | working_graph = Graph(from_list=mfg.to_list()) 26 | 27 | min_cut = [] 28 | for n1 in mfg.breadth_first_walk(start, end): 29 | n2s = mfg.nodes(from_node=n1) 30 | for n2 in n2s: 31 | if graph.edge(n1, n2) - mfg.edge(n1, n2) == 0: 32 | working_graph.del_edge(n1, n2) 33 | min_cut.append((n1, n2)) 34 | 35 | min_cut_nodes = set(working_graph.nodes(out_degree=0)) 36 | min_cut_nodes.remove(end) 37 | min_cut = [(n1, n2) for (n1, n2) in min_cut if n1 in min_cut_nodes] 38 | return min_cut -------------------------------------------------------------------------------- /graph/min_cost_flow.py: -------------------------------------------------------------------------------- 1 | from .base import BasicGraph 2 | 3 | 4 | from bisect import insort 5 | 6 | 7 | def minimum_cost_flow_using_successive_shortest_path(costs, inventory, capacity=None): 8 | """ 9 | Calculates the minimum cost flow solution using successive shortest path. 10 | :param costs: Graph with `cost per unit` as edge 11 | :param inventory: dict {node: stock, ...} 12 | stock < 0 is demand 13 | stock > 0 is supply 14 | :param capacity: None or Graph with `capacity` as edge. 15 | if capacity is None, capacity is assumed to be float('inf') 16 | :return: total costs, flow graph 17 | """ 18 | if not isinstance(costs, BasicGraph): 19 | raise TypeError(f"expected costs as Graph, not {type(costs)}") 20 | Graph = type(costs) 21 | 22 | if not isinstance(inventory, dict): 23 | raise TypeError(f"expected inventory as dict, not {type(inventory)}") 24 | 25 | if not all(d >= 0 for s, e, d in costs.edges()): 26 | raise ValueError("The costs graph has negative edges. That won't work.") 27 | 28 | if not all(isinstance(v, (float, int)) for v in inventory.values()): 29 | raise TypeError("not all stock is numeric.") 30 | 31 | if capacity is None: 32 | capacity = Graph(from_list=[(s, e, float("inf")) for s, e, d in costs.edges()]) 33 | else: 34 | if not isinstance(capacity, Graph): 35 | raise TypeError("Expected capacity as a Graph") 36 | if any(d < 0 for s, e, d in capacity.edges()): 37 | nn = [(s, e) for s, e, d in capacity.edges() if d < 0] 38 | raise ValueError(f"negative capacity on edges: {nn}") 39 | if {(s, e) for s, e, d in costs.edges()} != {(s, e) for s, e, d in capacity.edges()}: 40 | raise ValueError("cost and capacity have different links") 41 | 42 | # successive shortest path algorithm begins ... 43 | # ------------------------------------------------ 44 | paths = costs.copy() # initialise a copy of the cost graph so edges that 45 | # have exhausted capacities can be removed. 46 | flows = Graph() # initialise F as copy with zero flow 47 | capacities = Graph() # initialise C as a copy of capacity, so used capacity 48 | # can be removed. 49 | balance = [(v, k) for k, v in inventory.items() if v != 0] # list with excess/demand, node id 50 | balance.sort() 51 | 52 | distances = paths.all_pairs_shortest_paths() 53 | 54 | while balance: # while determine excess / imbalances: 55 | D, Dn = balance[0] # pick node Dn where the demand D is greatest 56 | if D > 0: 57 | break # only supplies left. 58 | balance = balance[1:] # remove selection. 59 | 60 | supply_sites = [(distances[En][Dn], E, En) for E, En in balance if E > 0] 61 | if not supply_sites: 62 | break # supply exhausted. 63 | supply_sites.sort() 64 | dist, E, En = supply_sites[0] # pick nearest node En with excess E. 65 | balance.remove((E, En)) # maintain balance by removing the node. 66 | 67 | if E < 0: 68 | break # no supplies left. 69 | if dist == float("inf"): 70 | raise Exception("bad logic: Case not checked for.") 71 | 72 | cost, path = paths.shortest_path(En, Dn) # compute shortest path P from E to a node in demand D. 73 | 74 | # determine the capacity limit C on P: 75 | capacity_limit = min(capacities.edge(s, e, default=capacity.edge(s, e)) for s, e in zip(path[:-1], path[1:])) 76 | 77 | # determine L units to be transferred as min(demand @ D and the limit C) 78 | L = min(E, abs(D), capacity_limit) 79 | for s, e in zip(path[:-1], path[1:]): 80 | flows.add_edge(s, e, L + flows.edge(s, e, default=0)) # update F. 81 | new_capacity = capacities.edge(s, e, default=capacity.edge(s, e)) - L 82 | capacities.add_edge(s, e, new_capacity) # update C 83 | 84 | if new_capacity == 0: # remove the edge from potential solutions. 85 | paths.del_edge(s, e) 86 | distances = paths.all_pairs_shortest_paths() 87 | 88 | # maintain balance, in case there is excess or demand left. 89 | if E - L > 0: 90 | insort(balance, (E - L, En)) 91 | if D + L < 0: 92 | insort(balance, (D + L, Dn)) 93 | 94 | total_cost = sum(d * costs.edge(s, e) for s, e, d in flows.edges()) 95 | return total_cost, flows -------------------------------------------------------------------------------- /graph/minmax.py: -------------------------------------------------------------------------------- 1 | from .base import BasicGraph 2 | 3 | 4 | def minmax(graph): 5 | """finds the node(s) with shortest distance to all other nodes.""" 6 | if not isinstance(graph, BasicGraph): 7 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 8 | adj_mat = graph.all_pairs_shortest_paths() 9 | for n in adj_mat: 10 | adj_mat[n] = max(adj_mat[n].values()) 11 | smallest = min(adj_mat.values()) 12 | return [k for k, v in adj_mat.items() if v == smallest] -------------------------------------------------------------------------------- /graph/minsum.py: -------------------------------------------------------------------------------- 1 | from .base import BasicGraph 2 | 3 | 4 | def minsum(graph): 5 | """finds the mode(s) that have the smallest sum of distance to all other nodes.""" 6 | if not isinstance(graph, BasicGraph): 7 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 8 | adj_mat = graph.all_pairs_shortest_paths() 9 | for n in adj_mat: 10 | adj_mat[n] = sum(adj_mat[n].values()) 11 | smallest = min(adj_mat.values()) 12 | return [k for k, v in adj_mat.items() if v == smallest] -------------------------------------------------------------------------------- /graph/partite.py: -------------------------------------------------------------------------------- 1 | from .base import BasicGraph 2 | 3 | 4 | def is_partite(graph, n): 5 | """Checks if graph is n-partite 6 | :param graph: class Graph 7 | :param n: int, number of partitions. 8 | :return: boolean and partitions as dict[colour] = set(nodes) or None. 9 | """ 10 | if not isinstance(graph, BasicGraph): 11 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 12 | 13 | if not isinstance(n, int): 14 | raise TypeError(f"Expected n as integer > 0, not {type(n)}") 15 | colours_and_nodes = {i: set() for i in range(n)} 16 | nodes_and_colours = {} 17 | n1 = set(graph.nodes()).pop() 18 | q = [n1] 19 | visited = set() 20 | colour = 0 21 | while q: 22 | n1 = q.pop() 23 | visited.add(n1) 24 | 25 | if n1 in nodes_and_colours: 26 | colour = nodes_and_colours[n1] 27 | else: 28 | colours_and_nodes[colour].add(n1) 29 | nodes_and_colours[n1] = colour 30 | 31 | next_colour = (colour + 1) % n 32 | neighbours = graph.nodes(from_node=n1) + graph.nodes(to_node=n1) 33 | for n2 in neighbours: 34 | if n2 in nodes_and_colours: 35 | if nodes_and_colours[n2] == colour: 36 | return False, None 37 | # else: pass # it already has a colour and there is no conflict. 38 | else: # if n2 not in nodes_and_colours: 39 | colours_and_nodes[next_colour].add(n2) 40 | nodes_and_colours[n2] = next_colour 41 | continue 42 | if n2 not in visited: 43 | q.append(n2) 44 | 45 | return True, colours_and_nodes -------------------------------------------------------------------------------- /graph/random.py: -------------------------------------------------------------------------------- 1 | import random 2 | import itertools 3 | 4 | from .core import Graph 5 | 6 | 7 | def xy_distance(n1, n2): 8 | """ calculates the xy_distance between to (x,y)-nodes""" 9 | x1, y1 = n1 10 | x2, y2 = n2 11 | dy, dx = (y2 - y1) * (y2 - y1), (x2 - x1) * (x2 - x1) 12 | return (dy + dx) ** (1 / 2) 13 | 14 | 15 | def random_xy_graph(nodes, x_max, y_max, edges=None, seed=42): 16 | """ Generates a graph with N nodes, M links, where all nodes have x,y in 17 | range [1,1] to [x_max, y_max] 18 | :param nodes: integer 19 | :param x_max: integer (800 pixels for example) 20 | :param y_max: integer (400 pixels for example) 21 | :param edges: integer or None, if None the graph will be fully connected. 22 | :param seed: seed for random number generator 23 | :return: Graph 24 | """ 25 | if x_max * y_max < nodes: 26 | raise ValueError("frame (x:{},y:{}) is too small for {} nodes".format(x_max,y_max,nodes)) 27 | 28 | max_edges = nodes * nodes 29 | if edges is None: 30 | edges = max_edges 31 | if max_edges < edges: 32 | raise ValueError( 33 | "A fully connected graph with {} nodes, would at most have {} edges: {}".format( 34 | nodes, max_edges, edges 35 | )) 36 | 37 | random.seed(seed) 38 | g = Graph() 39 | xy_space = set() 40 | 41 | # Step 1: random search mode 42 | node_count = 0 43 | while node_count < nodes: 44 | xy = (random.randint(1, x_max), random.randint(1, y_max)) 45 | if xy not in xy_space: 46 | g.add_node(xy) 47 | xy_space.add(xy) 48 | node_count += 1 49 | continue 50 | 51 | if len(xy_space) > (x_max * y_max) / 2: 52 | # use of random is inefficient --> proceed with step 2. 53 | break 54 | 55 | # Step 2: structured search mode. 56 | if len(g.nodes()) < nodes: 57 | x_range = list(range(1, x_max+1)) 58 | random.shuffle(x_range) 59 | y_range = list(range(1, y_max+1)) 60 | random.shuffle(y_range) 61 | quit = False 62 | for x in x_range: 63 | if quit: 64 | break 65 | for y in y_range: 66 | xy = (x, y) 67 | if xy in xy_space: 68 | continue 69 | else: 70 | g.add_node(xy) 71 | xy_space.add(xy) 72 | node_count += 1 73 | 74 | if len(xy_space) == nodes: 75 | quit = True 76 | break 77 | 78 | n1s = g.nodes() 79 | random.shuffle(n1s) 80 | n2s = n1s[:] 81 | random.shuffle(n2s) 82 | 83 | edge_count = 0 84 | for n1, n2 in itertools.product(*[n1s, n2s]): 85 | if edge_count == edges: 86 | break 87 | edge_count += 1 88 | 89 | d = xy_distance(n1, n2) 90 | g.add_edge(n1, n2, d) 91 | 92 | return g -------------------------------------------------------------------------------- /graph/shortest_path.py: -------------------------------------------------------------------------------- 1 | # Graph functions 2 | # ----------------------------- 3 | from bisect import insort 4 | from .base import BasicGraph 5 | from collections import defaultdict 6 | 7 | 8 | from heapq import heappop, heappush 9 | 10 | 11 | def shortest_path(graph, start, end, avoids=None): 12 | """single source shortest path algorithm. 13 | :param graph: class Graph 14 | :param start: start node 15 | :param end: end node 16 | :param avoids: optional set,frozenset or list of nodes that cannot be a part of the path. 17 | :return distance, path (as list), 18 | returns float('inf'), [] if no path exists. 19 | """ 20 | if not isinstance(graph, BasicGraph): 21 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 22 | if start not in graph: 23 | raise ValueError(f"{start} not in graph") 24 | if end not in graph: 25 | raise ValueError(f"{end} not in graph") 26 | if avoids is None: 27 | visited = set() 28 | elif not isinstance(avoids, (frozenset, set, list)): 29 | raise TypeError(f"Expect obstacles as set or frozenset, not {type(avoids)}") 30 | else: 31 | visited = set(avoids) 32 | 33 | q, minimums = [(0, 0, start, ())], {start: 0} 34 | i = 1 35 | while q: 36 | (cost, _, v1, path) = heappop(q) 37 | if v1 not in visited: 38 | visited.add(v1) 39 | path = (v1, path) 40 | 41 | if v1 == end: # exit criteria. 42 | L = [] 43 | while path: 44 | v, path = path[0], path[1] 45 | L.append(v) 46 | L.reverse() 47 | return cost, L 48 | 49 | for _, v2, dist in graph.edges(from_node=v1): 50 | if v2 in visited: 51 | continue 52 | prev = minimums.get(v2, None) 53 | next_node = cost + dist 54 | if prev is None or next_node < prev: 55 | minimums[v2] = next_node 56 | heappush(q, (next_node, i, v2, path)) 57 | i += 1 58 | return float("inf"), [] 59 | 60 | 61 | class ScanThread(object): 62 | __slots__ = ["cost", "n1", "path"] 63 | 64 | """ search thread for bidirectional search """ 65 | 66 | def __init__(self, cost, n1, path=()): 67 | if not isinstance(path, tuple): 68 | raise TypeError(f"Expected a tuple, not {type(path)}") 69 | self.cost = cost 70 | self.n1 = n1 71 | self.path = path 72 | 73 | def __lt__(self, other): 74 | return self.cost < other.cost 75 | 76 | def __str__(self): 77 | return f"{self.cost}:{self.path}" 78 | 79 | 80 | class BiDirectionalSearch(object): 81 | """data structure for organizing bidirectional search""" 82 | 83 | forward = True 84 | backward = False 85 | 86 | def __str__(self): 87 | if self.forward == self.direction: 88 | return "forward scan" 89 | return "backward scan" 90 | 91 | def __init__(self, graph, start, direction=True, avoids=None): 92 | """ 93 | :param graph: class Graph. 94 | :param start: first node in the search. 95 | :param direction: bool 96 | :param avoids: nodes that cannot be a part of the solution. 97 | """ 98 | if not isinstance(graph, BasicGraph): 99 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 100 | if start not in graph: 101 | raise ValueError("start not in graph.") 102 | if not isinstance(direction, bool): 103 | raise TypeError(f"Expected boolean, not {type(direction)}") 104 | if avoids is None: 105 | self.avoids = frozenset() 106 | elif not isinstance(avoids, (frozenset, set, list)): 107 | raise TypeError(f"Expect obstacles as set or frozenset, not {type(avoids)}") 108 | else: 109 | self.avoids = frozenset(avoids) 110 | 111 | self.q = [] 112 | self.q.append(ScanThread(cost=0, n1=start)) 113 | self.graph = graph 114 | self.boundary = set() # visited. 115 | self.mins = {start: 0} 116 | self.paths = {start: ()} 117 | self.direction = direction 118 | self.sp = () 119 | self.sp_length = float("inf") 120 | 121 | def update(self, sp, sp_length): 122 | if sp_length > self.sp_length: 123 | raise ValueError("Bad logic!") 124 | self.sp = sp 125 | self.sp_length = sp_length 126 | 127 | def search(self, other): 128 | assert isinstance(other, BiDirectionalSearch) 129 | if not self.q: 130 | return 131 | 132 | sp, sp_length = self.sp, self.sp_length 133 | 134 | st = self.q.pop(0) 135 | assert isinstance(st, ScanThread) 136 | if st.cost > self.sp_length: 137 | return 138 | 139 | self.boundary.add(st.n1) 140 | 141 | if st.n1 in other.boundary: # if there's an intercept between the two searches ... 142 | if st.cost + other.mins[st.n1] < self.sp_length: 143 | sp_length = st.cost + other.mins[st.n1] 144 | if self.direction == self.forward: 145 | sp = tuple(reversed(st.path)) + (st.n1,) + other.paths[st.n1] 146 | else: # direction == backward: 147 | sp = tuple(reversed(other.paths[st.n1])) + (st.n1,) + st.path 148 | 149 | self.q = [a for a in self.q if a.cost < sp_length] 150 | 151 | if self.direction == self.forward: 152 | edges = sorted((d, e) for s, e, d in self.graph.edges(from_node=st.n1) if e not in self.avoids) 153 | else: 154 | edges = sorted((d, s) for s, e, d in self.graph.edges(to_node=st.n1) if s not in self.avoids) 155 | 156 | for dist, n2 in edges: 157 | n2_dist = st.cost + dist 158 | if n2_dist > self.sp_length: # no point pursuing as the solution is worse. 159 | continue 160 | if n2 in other.mins and n2_dist + other.mins[n2] > self.sp_length: # already longer than lower bound. 161 | continue 162 | 163 | # at this point we can't dismiss that n2 will lead to a better solution, so we retain it. 164 | prev = self.mins.get(n2, None) 165 | if prev is None or n2_dist < prev: 166 | self.mins[n2] = n2_dist 167 | path = (st.n1,) + st.path 168 | self.paths[n2] = path 169 | insort(self.q, ScanThread(n2_dist, n2, path)) 170 | 171 | self.update(sp, sp_length) 172 | other.update(sp, sp_length) 173 | 174 | 175 | def shortest_path_bidirectional(graph, start, end, avoids=None): 176 | """Bidirectional search using lower bound. 177 | :param graph: Graph 178 | :param start: start node 179 | :param end: end node 180 | :param avoids: nodes that cannot be a part of the shortest path. 181 | :return: shortest path 182 | In Section 3.4.6 of Artificial Intelligence: A Modern Approach, Russel and 183 | Norvig write: 184 | Bidirectional search is implemented by replacing the goal test with a check 185 | to see whether the frontiers of the two searches intersect; if they do, 186 | a solution has been found. It is important to realize that the first solution 187 | found may not be optimal, even if the two searches are both breadth-first; 188 | some additional search is required to make sure there isn't a shortcut 189 | across the gap. 190 | To overcome this limit for weighted graphs, I've added a lower bound, so 191 | that when the two searches intersect, the lower bound is updated and the 192 | lower bound path is stored. In subsequent searches any path shorter than 193 | the lower bound, leads to an update of the lower bound and shortest path. 194 | The algorithm stops when all nodes on the frontier exceed the lower bound. 195 | ---------------- 196 | The algorithms works as follows: 197 | Lower bound = float('infinite') 198 | shortest path = None 199 | Two queues (forward scan and backward scan) are initiated with respectively 200 | the start and end node as starting point for each scan. 201 | while there are nodes in the forward- and backward-scan queues: 202 | 1. select direction from (forward, backward) in alternations. 203 | 2. pop the top item from the queue of the direction. 204 | (The top item contains the node N that is nearest the starting point for the scan) 205 | 3. Add the node N to the scan-directions frontier. 206 | 4. If the node N is in the other directions frontier: 207 | the path distance from the directions _start_ to the _end_ via 208 | the point of intersection (N), is compared with the lower bound. 209 | If the path distance is less than the lower bound: 210 | *lower bound* is updated with path distance, and, 211 | the *shortest path* is recorded. 212 | 5. for each node N2 (to N if backward, from N if forward): 213 | the distance D is accumulated 214 | if D > lower bound, the node N2 is ignored. 215 | if N2 is within the other directions frontier and D + D.other > lower bound, the node N2 is ignored. 216 | otherwise: 217 | the path P recorded 218 | and N2, D and P are added to the directions scan queue 219 | The algorithm terminates when the scan queues are exhausted. 220 | ---------------- 221 | Given that: 222 | - `c` is the connectivity of the nodes in the graph, 223 | - `R` is the length of the path, 224 | the explored solution landscape can be estimated as: 225 | A = c * (R**2), for single source shortest path 226 | A = c * 2 * (1/2 * R) **2, for bidirectional shortest path 227 | """ 228 | if not isinstance(graph, BasicGraph): 229 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 230 | if start not in graph: 231 | raise ValueError("start not in graph.") 232 | if end not in graph: 233 | raise ValueError("end not in graph.") 234 | 235 | forward = BiDirectionalSearch(graph, start=start, direction=BiDirectionalSearch.forward, avoids=avoids) 236 | backward = BiDirectionalSearch(graph, start=end, direction=BiDirectionalSearch.backward, avoids=avoids) 237 | 238 | while any((forward.q, backward.q)): 239 | forward.search(other=backward) 240 | backward.search(other=forward) 241 | 242 | return forward.sp_length, list(forward.sp) 243 | 244 | 245 | class ShortestPathCache(object): 246 | """ 247 | Data structure optimised for repeated calls to shortest path. 248 | Used by shortest path when using keyword `memoize=True` 249 | """ 250 | 251 | def __init__(self, graph): 252 | if not isinstance(graph, BasicGraph): 253 | raise TypeError(f"Expected type Graph, not {type(graph)}") 254 | self.graph = graph 255 | self.cache = {} 256 | self.repeated_cache = {} 257 | 258 | def _update_cache(self, path): 259 | """private method for updating the cache for future lookups. 260 | :param path: tuple of nodes 261 | Given a shortest path, all steps along the shortest path, 262 | also constitute the shortest path between each pair of steps. 263 | """ 264 | if not isinstance(path, (list, tuple)): 265 | raise TypeError 266 | b = len(path) 267 | if b <= 2: 268 | dist = self.graph.distance_from_path(path) 269 | self.cache[(path[0], path[-1])] = (dist, tuple(path)) 270 | 271 | for a, _ in enumerate(path): 272 | section = tuple(path[a : b - a]) 273 | if len(section) < 3: 274 | break 275 | dist = self.graph.distance_from_path(section) 276 | self.cache[(section[0], section[-1])] = (dist, section) 277 | 278 | for ix, start in enumerate(path[1:-1]): 279 | section = tuple(path[ix:]) 280 | dist = self.graph.distance_from_path(section) 281 | self.cache[(section[0], section[-1])] = (dist, section) 282 | 283 | def shortest_path(self, start, end, avoids=None): 284 | """Shortest path method that utilizes caching and bidirectional search""" 285 | if start not in self.graph: 286 | raise ValueError("start not in graph.") 287 | if end not in self.graph: 288 | raise ValueError("end not in graph.") 289 | if avoids is None: 290 | pass 291 | elif not isinstance(avoids, (frozenset, set, list)): 292 | raise TypeError(f"Expect obstacles as None, set or frozenset, not {type(avoids)}") 293 | else: 294 | avoids = frozenset(avoids) 295 | 296 | if isinstance(avoids, frozenset): 297 | # as avoids can be volatile, it is not possible to benefit from the caching 298 | # methodology. This does however not mean that we need to forfeit the benefit 299 | # of bidirectional search. 300 | hash_key = hash(avoids) 301 | d, p = self.repeated_cache.get((start, end, hash_key), (None, None)) 302 | if d is None: 303 | d, p = shortest_path_bidirectional(self.graph, start, end, avoids=avoids) 304 | self.repeated_cache[(start, end, hash_key)] = (d, p) 305 | else: 306 | d, p = self.cache.get((start, end), (None, None)) 307 | 308 | if d is None: # search for it. 309 | _, p = shortest_path_bidirectional(self.graph, start, end) 310 | if not p: 311 | self.cache[(start, end)] = (float("inf"), []) 312 | else: 313 | self._update_cache(p) 314 | d, p = self.cache[(start, end)] 315 | 316 | return d, list(p) 317 | 318 | 319 | class SPLength(object): 320 | def __init__(self): 321 | self.value = float("inf") 322 | 323 | 324 | def distance_from_path(graph, path): 325 | """Calculates the distance for the path in graph 326 | :param graph: class Graph 327 | :param path: list of nodes 328 | :return: distance 329 | """ 330 | if not isinstance(graph, BasicGraph): 331 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 332 | if not isinstance(path, (tuple, list)): 333 | raise TypeError(f"expected tuple or list, not {type(path)}") 334 | 335 | cache = defaultdict(dict) 336 | path_length = 0 337 | for idx in range(len(path) - 1): 338 | n1, n2 = path[idx], path[idx + 1] 339 | 340 | # if the edge exists... 341 | d = graph.edge(n1, n2, default=None) 342 | if d: 343 | path_length += d 344 | continue 345 | 346 | # if we've seen the edge before... 347 | d = cache.get((n1, n2), None) 348 | if d: 349 | path_length += d 350 | continue 351 | 352 | # if there no alternative ... (search) 353 | d, _ = shortest_path(graph, n1, n2) 354 | if d == float("inf"): 355 | return float("inf") # <-- Exit if there's no path. 356 | else: 357 | cache[(n1, n2)] = d 358 | path_length += d 359 | return path_length 360 | -------------------------------------------------------------------------------- /graph/shortest_tree_all_pairs.py: -------------------------------------------------------------------------------- 1 | from .base import BasicGraph 2 | from .all_pairs_shortest_path import all_pairs_shortest_paths 3 | 4 | 5 | def shortest_tree_all_pairs(graph): 6 | """ 7 | 'minimize the longest distance between any pair' 8 | Note: This algorithm is not shortest path as it jumps 9 | to a new branch when it has exhausted a branch in the tree. 10 | :return: path 11 | """ 12 | if not isinstance(graph, BasicGraph): 13 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 14 | g = all_pairs_shortest_paths(graph) 15 | assert isinstance(g, dict) 16 | 17 | distance = float("inf") 18 | best_starting_point = -1 19 | # create shortest path gantt diagram. 20 | for start_node in g.keys(): 21 | if start_node in g: 22 | dist = sum(v for k, v in g[start_node].items()) 23 | if dist < distance: 24 | best_starting_point = start_node 25 | # else: skip the node as it's isolated. 26 | g2 = g[best_starting_point] # {1: 0, 2: 1, 3: 2, 4: 3} 27 | 28 | inv_g2 = {} 29 | for k, v in g2.items(): 30 | if v not in inv_g2: 31 | inv_g2[v] = set() 32 | inv_g2[v].add(k) 33 | 34 | all_nodes = set(g.keys()) 35 | del g 36 | path = [] 37 | while all_nodes and inv_g2.keys(): 38 | v_nearest = min(inv_g2.keys()) 39 | for v in inv_g2[v_nearest]: 40 | all_nodes.remove(v) 41 | path.append(v) 42 | del inv_g2[v_nearest] 43 | return path -------------------------------------------------------------------------------- /graph/topological_sort.py: -------------------------------------------------------------------------------- 1 | from .base import BasicGraph 2 | 3 | 4 | def topological_sort(graph, key=None): 5 | """Return a generator of nodes in topologically sorted order. 6 | :param graph: Graph 7 | :param key: optional function for sortation. 8 | :return: Generator 9 | Topological sort (ordering) is a linear ordering of vertices. 10 | https://en.wikipedia.org/wiki/Topological_sorting 11 | Note: The algorithm does not check for loops before initiating 12 | the sortation, but raise AttributeError at the first conflict. 13 | This saves O(m+n) runtime. 14 | """ 15 | if not isinstance(graph, BasicGraph): 16 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 17 | 18 | if key is None: 19 | 20 | def key(x): 21 | return x 22 | 23 | g2 = graph.copy() 24 | 25 | zero_in_degree = sorted(g2.nodes(in_degree=0), key=key) 26 | 27 | while zero_in_degree: 28 | for task in zero_in_degree: 29 | yield task # <--- do something. 30 | 31 | g2.del_node(task) 32 | 33 | zero_in_degree = sorted(g2.nodes(in_degree=0), key=key) 34 | 35 | if g2.nodes(): 36 | raise AttributeError(f"Graph is not acyclic: Loop found: {g2.nodes()}") -------------------------------------------------------------------------------- /graph/transshipment_problem.py: -------------------------------------------------------------------------------- 1 | from graph import Graph 2 | import itertools 3 | 4 | __description__ = """ 5 | Transshipment problems form a subgroup of transportation problems, where 6 | transshipment is allowed. In transshipment, transportation may or must go 7 | through intermediate nodes, possibly changing modes of transport. 8 | Transshipment or Transhipment is the shipment of goods or containers to an 9 | intermediate destination, and then from there to yet another destination. One 10 | possible reason is to change the means of transport during the journey (for 11 | example from ship transport to road transport), known as transloading. Another 12 | reason is to combine small shipments into a large shipment (consolidation), 13 | dividing the large shipment at the other end (deconsolidation). Transshipment 14 | usually takes place in transport hubs. Much international transshipment also 15 | takes place in designated customs areas, thus avoiding the need for customs 16 | checks or duties, otherwise a major hindrance for efficient transport. 17 | [1](https://en.wikipedia.org/wiki/Transshipment_problem) 18 | """ 19 | 20 | 21 | def clondike_transshipment_problem(): 22 | """ 23 | A deep gold mining operation is running at full speed. 24 | 25 | Problem: 26 | What schedule guarantees an optimal throughput given unpredictable output 27 | from the mines and unpredictable needs for mining equipment? 28 | 29 | Constraints: 30 | The gold mine has a surface depot which connects the 4 mining levels using 31 | an elevator running in the main vertical mine shaft. 32 | At each level there is a narrow gage electric train which can pull rail cars 33 | between the lift and side tracks where excavation occurs. 34 | Loading minerals and unloading equipment takes time, so the best way to 35 | avoid obstructing the busy lift is by moving the rail cars directly into 36 | the lift. However as mining equipment and valuable minerals are very heavy, 37 | the lift can only move one load at the time. 38 | Similarly the best way to avoid obstructing the railway at each level is by 39 | by moving the narrow gage rail cars onto side tracks that are available at 40 | each horizontal mine entry. 41 | Schematic of the mine: 42 | Mining shaft lift to surface depot. 43 | ^ 44 | | 45 | +- level 1 <-------------------> 46 | | 1 2 3 ... N (horizontal mine shafts) 47 | | 48 | +- level 2 <-------------------> 49 | | 1 2 3 ... N 50 | | 51 | +- level 3 <-------------------> 52 | | 1 2 3 ... N 53 | | 54 | +- level 4 <-------------------> 55 | | 1 2 3 ... N 56 | | 57 | +- level N 58 | In the intersection between the mining shaft lift and the electric 59 | locomotive there is limited space to perform any exchange, and because of 60 | constraints of the equipment, the order of delivery is strict: 61 | ^ towards surface. 62 | | 63 | | (railway switch) 64 | | | 65 | [ from lift ] ---->[S]<-->[ electric locomotive ]<---> into the mine. 66 | [ onto lift ] <-----| 67 | | 68 | | 69 | v into the mine. 70 | """ 71 | paths = [ 72 | ("Surface", "L-1", 1), 73 | ("L-1", "L-2", 1), 74 | ("L-2", "L-3", 1), 75 | ("L-3", "L-4", 1), 76 | ("L-1", "L-1-1", 1), 77 | ("L-2", "L-2-1", 1), 78 | ("L-3", "L-3-1", 1), 79 | ("L-4", "L-4-1", 1), 80 | ] 81 | 82 | for level in [1, 2, 3, 4]: # adding stops for the narrow gage trains in the levels. 83 | paths.append(("L-{}".format(level), "L-{}-1".format(level), 1), ) 84 | for dig in [1, 2, 3, 4, 5, 6]: 85 | paths.append(("L-{}-{}".format(level, dig), "L-{}-{}".format(level, dig + 1), 1)) 86 | 87 | paths.extend([(n2, n1, d) for n1, n2, d in paths]) # adding the reverse path. 88 | g = Graph(from_list=paths) 89 | return g 90 | 91 | 92 | class Train(object): 93 | def __init__(self, rail_network, start_location, access): 94 | """ 95 | :param rail_network: the while rail network as a Graph. 96 | :param start_location: a node in the network. 97 | :param access: Set of nodes to which this train has access. 98 | """ 99 | assert isinstance(rail_network, Graph) 100 | self._rail_network = rail_network 101 | assert start_location in self._rail_network.nodes() 102 | self._current_location = start_location 103 | assert isinstance(access, set) 104 | assert all([n in self._rail_network for n in access]) 105 | self._access_nodes = access 106 | 107 | self._schedule = [] 108 | 109 | def schedule(self, jobs=None): 110 | """ 111 | Initialise the solution using shortest jobs first. 112 | Then improve the solution using combinatorics until improvement is zero 113 | param: jobs, (optional) list of jobs 114 | returns: list of jobs in scheduled order. 115 | """ 116 | if jobs is None: 117 | return self._schedule 118 | assert isinstance(jobs, list) 119 | new_jobs = find(rail_network=self._rail_network, stops=self._access_nodes, jobs=jobs) 120 | self._schedule = schedule(graph=self._rail_network, start=self._current_location, jobs=new_jobs) 121 | return self._schedule 122 | 123 | 124 | def schedule_rail_system(rail_network, trains, jobs): 125 | """ 126 | Iterative over the trains until a feasible schedule is found. 127 | 1. Each device loop through the jobs: 128 | select all relevant jobs. 129 | create itinerary items on the jobs. 130 | when all itineraries are complete, return the schedule. 131 | """ 132 | assert isinstance(rail_network, Graph) 133 | assert isinstance(trains, list) 134 | assert all(isinstance(t, Train) for t in trains) 135 | assert isinstance(jobs, list) 136 | 137 | for train in trains: 138 | assert isinstance(train, Train) 139 | train.schedule(jobs) 140 | 141 | 142 | def find(rail_network, stops, jobs): 143 | """ 144 | Finds the route sections that the jobs need to travel from/to (stops) 145 | in the rail network. 146 | :param rail_network: class Graph 147 | :param stops: set of train stops 148 | :param jobs: list of jobs. 149 | :return: sub jobs. 150 | """ 151 | sub_jobs = [] 152 | for A, B in jobs: 153 | if A not in rail_network: 154 | raise ValueError("{} not in rail network".format(A)) 155 | if B not in rail_network: 156 | raise ValueError("{} not in rail network".format(B)) 157 | 158 | _, path = rail_network.shortest_path(A, B) 159 | part_route = [p for p in path if p in stops] 160 | 161 | if not rail_network.has_path(part_route): 162 | raise ValueError("Can't find path for {}".format(part_route)) 163 | 164 | new_a, new_b = part_route[0], part_route[-1] 165 | sub_jobs.append((new_a, new_b)) 166 | return sub_jobs 167 | 168 | 169 | def schedule(graph, start, jobs): 170 | """ 171 | The best possible path is a circuit. 172 | First we'll find all circuits and attempt to remove them from 173 | the equation. 174 | Once no more circuits can be found, the best solution is to look for 175 | alternative combinations that provide improvement. 176 | :return: 177 | """ 178 | new_schedule = [] 179 | jobs_to_plan = jobs[:] 180 | while jobs_to_plan: 181 | circuit_path = find_perfect_circuit(graph=graph, start=start, jobs=jobs_to_plan) 182 | if circuit_path: 183 | job_sequence = jobs_from_path(circuit_path) 184 | else: # circuit not possible. 185 | shortest_path = [] 186 | shortest_distance = float('inf') 187 | for perm in itertools.permutations(jobs_to_plan, len(jobs_to_plan)): 188 | path = path_from_schedule(jobs=perm, start=start) 189 | distance = graph.distance_from_path(path) 190 | if distance < shortest_distance: 191 | shortest_distance = distance 192 | shortest_path = path 193 | job_sequence = jobs_from_path(shortest_path) 194 | # remove planned jobs from options: 195 | for job in job_sequence: 196 | if job in jobs_to_plan: 197 | jobs_to_plan.remove(job) 198 | new_schedule.append(job) 199 | return new_schedule 200 | 201 | 202 | def jobs_from_path(path): 203 | """ helper for finding jobs from path""" 204 | return [(path[i], path[i + 1]) for i in range(len(path) - 1)] 205 | 206 | 207 | def path_from_schedule(jobs, start): 208 | """ The evaluation is based on building the travel path. 209 | For example in the network A,B,C with 4 trips as: 210 | 1 (A,B), 2 (A,C), 3 (B,A), 4 (C,A) 211 | which have the travel path: [A,B,A,C,B,A,C,A] 212 | The shortest path for these jobs is: [A,C,A,B,A] which uses the order: 213 | 2 (A,C), 4 (C,A), 1 (A,B), 3(B,A) 214 | """ 215 | path = [start] 216 | for A, B in jobs: 217 | if A != path[-1]: 218 | path.append(A) 219 | path.append(B) 220 | return path 221 | 222 | 223 | def find_perfect_circuit(graph, start, jobs): 224 | """ A perfect circuit is a path that starts and ends at the same place 225 | and where every movement includes a job. 226 | :param: start: starting location. 227 | :param: jobs: list of movements [(A1,B1), (A2,B2,) ....] 228 | :return path [A1,B1, ..., A1] 229 | """ 230 | G = type(graph) 231 | g = G() 232 | for A, B in jobs: 233 | try: 234 | g.edge(A, B) 235 | except KeyError: 236 | d, p = graph.shortest_path(A, B) 237 | g.add_edge(A, B, d) 238 | 239 | new_starts = [B for A, B in jobs if A == start] 240 | for A in new_starts: 241 | if A in g: 242 | p = g.breadth_first_search(A, start) # path back to start. 243 | if p: 244 | return [start] + p 245 | return [] 246 | 247 | -------------------------------------------------------------------------------- /graph/tsp.py: -------------------------------------------------------------------------------- 1 | from sys import maxsize 2 | from itertools import combinations, permutations 3 | from collections import Counter 4 | from .base import BasicGraph 5 | from bisect import insort 6 | from random import shuffle 7 | 8 | 9 | def tsp_branch_and_bound(graph): 10 | """ 11 | Solve the traveling salesman's problem for the graph. 12 | :param graph: instance of class Graph 13 | :return: tour_length, path 14 | solution quality 100% 15 | """ 16 | if not isinstance(graph, BasicGraph): 17 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 18 | 19 | def lower_bound(graph, nodes): 20 | """Calculates the lower bound of distances for given nodes.""" 21 | L = [] 22 | edges = set() 23 | for n in nodes: 24 | L2 = [(d, e) for s, e, d in graph.edges(from_node=n) if e in nodes - {n}] 25 | if not L2: 26 | continue 27 | L2.sort() 28 | 29 | for d, n2 in L2: 30 | if (n2, n) in edges: # Solution is not valid as it creates a loop. 31 | continue 32 | else: 33 | edges.add((n, n2)) # remember! 34 | L.append((n, n2, d)) 35 | break 36 | 37 | return L 38 | 39 | global_lower_bound = sum(d for n, n2, d in lower_bound(graph, set(graph.nodes()))) 40 | 41 | q = [] 42 | all_nodes = set(graph.nodes()) 43 | 44 | # create initial tree. 45 | start = graph.nodes()[0] 46 | for start, end, distance in graph.edges(from_node=start): 47 | lb = lower_bound(graph, all_nodes - {start}) 48 | dist = sum(d for s, e, d in lb) 49 | insort( 50 | q, 51 | ( 52 | distance + dist, # lower bound of distance. 53 | -2, # number of nodes in tour. 54 | (start, end), 55 | ), # locations visited. 56 | ) 57 | 58 | hit, switch, q2 = 0, True, [] 59 | while q: # walk the tree. 60 | d, _, tour = q.pop(0) 61 | tour_set = set(tour) 62 | 63 | if tour_set == all_nodes: 64 | if hit < len(all_nodes): # to overcome premature exit. 65 | hit += 1 66 | insort(q2, (d, tour)) 67 | continue 68 | else: 69 | d, tour = q2.pop(0) 70 | assert d >= global_lower_bound, "Solution not possible." 71 | return d, list(tour[:-1]) 72 | 73 | remaining_nodes = all_nodes - tour_set 74 | 75 | for n2 in remaining_nodes: 76 | new_tour = tour + (n2,) 77 | 78 | lb_set = remaining_nodes - {n2} 79 | if len(lb_set) > 1: 80 | lb_dists = lower_bound(graph, lb_set) 81 | lb = sum(d for n, n2, d in lb_dists) 82 | new_lb = graph.distance_from_path(new_tour) + lb 83 | elif len(lb_set) == 1: 84 | last_node = lb_set.pop() 85 | new_tour = new_tour + (last_node, tour[0]) 86 | new_lb = graph.distance_from_path(new_tour) 87 | else: 88 | raise Exception("bad logic!") 89 | 90 | insort(q, (new_lb, -len(new_tour), new_tour)) 91 | 92 | return float("inf"), [] # <-- exit path if not solvable. 93 | 94 | 95 | def tsp_greedy(graph): 96 | """ 97 | Solves the traveling salesman's problem for the graph. 98 | Runtime approximation: seconds = 10**(-5) * (nodes)**2.31 99 | Solution quality: Range 98.1% - 100% optimal. 100 | :param graph: instance of class Graph 101 | :return: tour_length, path 102 | """ 103 | if not isinstance(graph, BasicGraph): 104 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 105 | 106 | t1 = _greedy(graph) 107 | d1 = graph.distance(t1) 108 | t2 = _opt2(graph, t1) 109 | d2 = graph.distance(t2) 110 | 111 | if d1 >= d2: # shortest path wins. 112 | return d2, t2 113 | else: 114 | return d1, t1 115 | 116 | 117 | # TSP 2023 ---- 118 | 119 | 120 | def _join_endpoints(endpoints, a, b): 121 | """Join segments [...,a] + [b,...] into one segment. Maintain `endpoints`. 122 | :param endpoints: 123 | :param a: node 124 | :param b: node 125 | :return: 126 | """ 127 | a_seg, b_seg = endpoints[a], endpoints[b] 128 | if a_seg[-1] is not a: 129 | a_seg.reverse() 130 | if b_seg[0] is not b: 131 | b_seg.reverse() 132 | a_seg += b_seg 133 | del endpoints[a] 134 | del endpoints[b] 135 | endpoints[a_seg[0]] = endpoints[a_seg[-1]] = a_seg 136 | return a_seg 137 | 138 | 139 | def _greedy(graph): 140 | c = combinations(graph.nodes(), 2) 141 | distances = [(graph.edge(a, b), a, b) for a, b in c if graph.edge(a, b)] 142 | distances.sort() 143 | 144 | new_segment = [] 145 | endpoints = {n: [n] for n in graph.nodes()} 146 | for _, a, b in distances: 147 | if a in endpoints and b in endpoints and endpoints[a] != endpoints[b]: 148 | new_segment = _join_endpoints(endpoints, a, b) 149 | if len(new_segment) == len(graph.nodes()): 150 | break # return new_segment 151 | 152 | if len(new_segment) != len(graph.nodes()): 153 | raise ValueError("there's an unconnected component in the graph.") 154 | return tuple(new_segment) 155 | 156 | 157 | def _opt1(graph, tour): 158 | """Iterative improvement based on relocation.""" 159 | 160 | d_best = graph.distance(tour, return_to_start=True) 161 | p_best = tour 162 | L = tuple(tour) 163 | for i in range(len(tour)): 164 | tmp = L[:i] + L[i + 1 :] 165 | for j in range(len(tour)): 166 | L2 = tmp[:j] + (L[i],) + tmp[j:] 167 | d = graph.distance(L2, return_to_start=True) 168 | if d < d_best: 169 | d_best = d 170 | p_best = tuple(L2) 171 | 172 | tour = p_best 173 | 174 | # TODO: link swop 175 | # distances = [(graph.edge(tour[i], tour[i + 1]), i, i + 1) for i in range(len(tour)-1)] 176 | # distances.sort(reverse=True) # longest link first. 177 | 178 | # for d1, i in distances: 179 | # for j in range(len(tour)): 180 | # if i == j: 181 | # continue 182 | # a, b = tour[i], tour[j] 183 | 184 | # options = sorted([(d2, e) for _, e, d2 in graph.edges(from_node=a) if d2 < d1 and e != b]) 185 | # for d2, e in options: 186 | # ix_e = tour.index(e) 187 | # tmp = tour[32345678] 188 | 189 | # for i in range(len(tour)): 190 | # tmp = p_best[:] 191 | # for j in range(len(tour)): 192 | # if i == j: 193 | # continue 194 | 195 | return tuple(p_best) 196 | 197 | 198 | def _opt2(graph, tour): 199 | """Iterative improvement based on 2 exchange.""" 200 | 201 | def reverse_segment_if_improvement(graph, tour, i, j): 202 | """If reversing tour[i:j] would make the tour shorter, then do it.""" 203 | # Given tour [...a,b...c,d...], consider reversing b...c to get [...a,c...b,d...] 204 | a, b, c, d = tour[i - 1], tour[i], tour[j - 1], tour[j % len(tour)] 205 | # are old links (ab + cd) longer than new ones (ac + bd)? if so, reverse segment. 206 | ab, cd, ac, bd = graph.edge(a, b), graph.edge(c, d), graph.edge(a, c), graph.edge(b, d) 207 | # if all are not None and improvement is shorter than previous ... 208 | if all((ab, cd, ac, bd)) and ab + cd > ac + bd: 209 | tour[i:j] = reversed(tour[i:j]) # ..retain the solution. 210 | return True 211 | 212 | def _zipwalk(tour): 213 | return [(tour[i - 1], tour[i]) for i in range(len(tour))] 214 | 215 | tour = list(tour) 216 | n = len(tour) 217 | g = tuple((i, i + length) for length in reversed(range(2, n)) for i in reversed(range(n - length + 1))) 218 | 219 | counter, c2, inc = Counter(), {}, 0 220 | p0, d0 = tuple(tour), sum(graph.edge(a, b) for a, b in _zipwalk(tour)) 221 | counter[p0] += 1 222 | 223 | while True: 224 | improvements = {reverse_segment_if_improvement(graph, tour, i, j) for (i, j) in g} 225 | 226 | d1 = sum(graph.edge(a, b) for a, b in _zipwalk(tour)) 227 | if d1 < d0: 228 | d0 = d1 229 | p0 = tour[:] 230 | 231 | if improvements == {None} or len(improvements) == 0: 232 | break 233 | 234 | counter[tuple(tour)] += 1 235 | inc += 1 236 | 237 | if inc % 100 == 0: 238 | if any(v > 2 for v in counter.values()): 239 | break 240 | return tuple(p0) 241 | 242 | 243 | def _opt3(graph, tour): 244 | """Iterative improvement based on 3 exchange.""" 245 | 246 | def distance(a, b, graph=graph): 247 | return graph.edge(a, b, default=maxsize) 248 | 249 | def _zipwalk(tour): 250 | return [(tour[i - 1], tour[i]) for i in range(len(tour))] 251 | 252 | def reverse_segment_if_better(graph, tour, i, j, k): 253 | """If reversing tour[i:j] would make the tour shorter, then do it.""" 254 | distance = lambda a, b: graph.edge(a, b, default=maxsize) 255 | 256 | # Given tour [...A-B...C-D...E-F...] 257 | A, B, C, D, E, F = tour[i - 1], tour[i], tour[j - 1], tour[j], tour[k - 1], tour[k % len(tour)] 258 | dmin, mindex = maxsize, "" 259 | d0 = distance(A, B) + distance(C, D) + distance(E, F) 260 | dmin, mindex = d0, "d0" 261 | d1 = distance(A, C) + distance(B, D) + distance(E, F) 262 | if d1 < dmin: 263 | dmin, mindex = d1, "d1" 264 | d2 = distance(A, B) + distance(C, E) + distance(D, F) 265 | if d2 < dmin: 266 | dmin, mindex = d2, "d2" 267 | d3 = distance(A, D) + distance(E, B) + distance(C, F) 268 | if d3 < dmin: 269 | dmin, mindex = d3, "d3" 270 | d4 = distance(F, B) + distance(C, D) + distance(E, A) 271 | if d4 < dmin: 272 | dmin, mindex = d4, "d4" 273 | 274 | if mindex == "d0": 275 | return 0 276 | if mindex == "d1": 277 | tour[i:j] = reversed(tour[i:j]) 278 | return -d0 + d1 279 | elif mindex == "d2": 280 | tour[j:k] = reversed(tour[j:k]) 281 | return -d0 + d2 282 | elif mindex == "d3": 283 | tmp = tour[j:k] + tour[i:j] 284 | tour[i:k] = tmp 285 | return -d0 + d3 286 | else: # elif mindex == "d4": 287 | tour[i:k] = reversed(tour[i:k]) 288 | return -d0 + d4 289 | 290 | def all_segments(n: int): 291 | """Generate all segments combinations""" 292 | return ((i, j, k) for i in range(n) for j in range(i + 2, n) for k in range(j + 2, n + (i > 0))) 293 | 294 | tour = list(tour) 295 | p0, d0 = tour[:], sum(graph.edge(a, b) for a, b in _zipwalk(tour)) 296 | counter, inc = Counter(), 0 297 | while True: 298 | delta = 0 299 | for a, b, c in all_segments(len(tour)): 300 | delta += reverse_segment_if_better(graph, tour, a, b, c) 301 | 302 | d1 = sum(graph.edge(a, b) for a, b in _zipwalk(tour)) 303 | if d1 < d0: 304 | d0 = d1 305 | p0 = tour[:] 306 | 307 | if delta >= 0: 308 | break 309 | 310 | inc += 1 311 | if inc % 100 == 0: 312 | if any(v > 2 for v in counter.values()): 313 | break 314 | return tuple(p0) 315 | 316 | 317 | def brute_force(graph): 318 | d2 = maxsize 319 | nodes = graph.nodes() 320 | for route in permutations(nodes, len(nodes)): 321 | if route[0] != nodes[0]: # all iterations after this point are rotations. 322 | break 323 | route += (route[0],) 324 | d = graph.distance_from_path(route) 325 | if d < d2: 326 | d2 = d 327 | p2 = route[:-1] 328 | return d2, tuple(p2) 329 | 330 | 331 | def tsp_2023(graph): 332 | """ 333 | TSP-2023 is the authors implementation of best practices discussed in Frontiers in Robotics and AI 334 | read more: https://www.frontiersin.org/articles/10.3389/frobt.2021.689908/full 335 | 336 | Args: 337 | graph (BasicGraph): fully connected subclass of BasicGraph 338 | """ 339 | if not isinstance(graph, BasicGraph): 340 | raise TypeError(f"Expected subclass of BasicGraph, not {type(graph)}") 341 | 342 | if len(graph.nodes()) < 7: 343 | return brute_force(graph) 344 | 345 | t = _greedy(graph) 346 | d = graph.distance(t) 347 | 348 | t1 = _opt1(graph, t) 349 | d1 = graph.distance(t1) 350 | 351 | t2 = _opt2(graph, t1) 352 | d2 = graph.distance(t2) 353 | 354 | t3 = _opt3(graph, t2) 355 | d3 = graph.distance(t3) 356 | 357 | L = [(d, t), (d1, t1), (d2, t2), (d3, t3)] 358 | L.sort() 359 | return L[0] 360 | -------------------------------------------------------------------------------- /graph/version.py: -------------------------------------------------------------------------------- 1 | major, minor, patch = 2023, 7, 8 2 | __version_info__ = (major, minor, patch) 3 | __version__ = ".".join(str(i) for i in __version_info__) 4 | -------------------------------------------------------------------------------- /graph/visuals.py: -------------------------------------------------------------------------------- 1 | try: 2 | from matplotlib import pyplot as plt 3 | from mpl_toolkits.mplot3d import Axes3D # import required by matplotlib. 4 | 5 | visuals_enabled = True 6 | except ImportError: 7 | visuals_enabled = False 8 | import warnings 9 | warnings.warn("matplotlib is not installed, visuals are disabled. Please install matplotlib>=3.1.0") 10 | 11 | 12 | def visualise(func): 13 | def wrapper(*args, **kwargs): 14 | if not visuals_enabled: 15 | raise ImportError("visualise is not available unless matplotlib is installed") 16 | return func(*args, **kwargs) 17 | 18 | return wrapper 19 | 20 | 21 | @visualise 22 | def plot_2d(graph, nodes=True, edges=True): 23 | """ 24 | :param graph: instance of Graph with nodes as (x,y) 25 | :param nodes: bool: plots nodes 26 | :param edges: bool: plots edges 27 | :return: matlibplot.pyplot 28 | 29 | PRO-TIP: If your graph does not have nodes as (x,y) use random_xy_graph to 30 | create it with this recipe: 31 | 32 | Step 1: Get the imports. 33 | >>> from graph.random import random_xy_graph 34 | >>> from graph.hash import graph_hash 35 | 36 | Step 2: Determine the initialisation values. 37 | >>> node_count = len(graph.nodes()) 38 | >>> seed = graph_hash(graph) 39 | >>> x_max = node_count * 8 40 | >>> y_max = node_count * 4 41 | >>> xygraph = random_xy_graph(node_count, x_max, y_max, edges=0, seed=seed) 42 | 43 | Step 3: create a mapping between the xy graph and the original graph. 44 | >>> mapping = {a:b for a,b in zip(xygraph.nodes(), graph.nodes())} 45 | 46 | Step 4: add the edges: 47 | >>> for edge in graph.edges(): 48 | >>> start, end, distance = edge 49 | >>> xygraph.add_edge(mapping[start], mapping[end], distance) 50 | >>> plt = xygraph.plot_2d() 51 | >>> plt.show() 52 | 53 | """ 54 | assert isinstance(nodes, bool) 55 | assert isinstance(edges, bool) 56 | 57 | for node in graph.nodes(): 58 | if not isinstance(node, tuple): 59 | raise ValueError(f"expected graph.nodes() to be tuple(x,y), but found {node}") 60 | if not len(node) == 2: 61 | raise ValueError(f"expected tuples have 2 values, but found {node} (len={len(node)})") 62 | x, y = node 63 | if not isinstance(x, (float, int)): 64 | raise ValueError(f"expected node in graph.nodes() to have (x,y) as float or int, but got {type(x)}") 65 | if not isinstance(y, (float, int)): 66 | raise ValueError(f"expected node in graph.nodes() to have (x,y) as float or int, but got {type(y)}") 67 | 68 | plt.figure() 69 | if nodes: 70 | xs, ys = [a[0] for a in graph.nodes()], [a[1] for a in graph.nodes()] 71 | plt.plot(xs, ys) 72 | 73 | if edges: 74 | for edge in graph.edges(): 75 | s, e, d = edge # s: (x1,y1), e: (x2,y2), d: distance 76 | plt.plot([s[0], e[0]], [s[1], e[1]], "bo-", clip_on=False) 77 | 78 | plt.axis("scaled") 79 | plt.axis("off") 80 | return plt 81 | 82 | 83 | @visualise 84 | def plot_3d(graph, nodes=True, edges=True, rotation="xyz", maintain_aspect_ratio=False): 85 | """plots nodes and links using matplotlib3 86 | :param nodes: bool: plots nodes 87 | :param edges: bool: plots edges 88 | :param rotation: str: set view point as one of [xyz,xzy,yxz,yzx,zxy,zyx] 89 | :param maintain_aspect_ratio: bool: rescales the chart to maintain aspect ratio. 90 | :return: matlibplot.pyplot 91 | """ 92 | fig = plt.figure() 93 | ax = fig.add_subplot(111, projection="3d") 94 | if not len(rotation) == 3: 95 | raise ValueError(f"expected viewpoint as 'xyz' but got: {rotation}") 96 | for c in "xyz": 97 | if c not in rotation: 98 | raise ValueError(f"rotation was missing {c}.") 99 | x, y, z = rotation 100 | 101 | # Data for a three-dimensional line 102 | if edges: 103 | for edge in graph.edges(): 104 | n1, n2, v = edge 105 | xyz = dict() 106 | xyz[x] = [n1[0], n2[0]] 107 | xyz[y] = [n1[1], n2[1]] 108 | xyz[z] = [n1[2], n2[2]] 109 | ax.plot3D(xyz["x"], xyz["y"], xyz["z"], "gray") 110 | 111 | # Data for three-dimensional scattered points 112 | if nodes: 113 | xyz = {x: [], y: [], z: []} 114 | ix = [] 115 | for idx, node in enumerate(graph.nodes()): 116 | vx, vy, vz = node # value of ... 117 | xyz[x].append(vx) 118 | xyz[y].append(vy) 119 | xyz[z].append(vz) 120 | ix.append(idx) 121 | ax.scatter3D(xyz["x"], xyz["y"], xyz["z"], c=ix, cmap="Greens") 122 | 123 | if (nodes or edges) and maintain_aspect_ratio: 124 | nodes = [n for n in graph.nodes()] 125 | xyz_dir = {"x": 0, "y": 1, "z": 2} 126 | 127 | xdim = xyz_dir[x] # select the x dimension in the projection. 128 | # as the rotation will change the xdimension index. 129 | xs = [n[xdim] for n in nodes] # use the xdim index to obtain the values. 130 | xmin, xmax = min(xs), max(xs) 131 | dx = (xmax + xmin) / 2 # determine the midpoint for the dimension. 132 | 133 | ydim = xyz_dir[y] 134 | ys = [n[ydim] for n in nodes] 135 | ymin, ymax = min(ys), max(ys) 136 | dy = (ymax + ymin) / 2 137 | 138 | zdim = xyz_dir[z] 139 | zs = [n[zdim] for n in nodes] 140 | zmin, zmax = min(zs), max(zs) 141 | dz = (zmax + zmin) / 2 142 | 143 | # calculate the radius for the aspect ratio. 144 | max_dim = max([xmax - xmin, ymax - ymin, zmax - zmin]) / 2 145 | 146 | xa, xb = dx - max_dim, dx + max_dim # lower, uppper 147 | ax.set_xlim(xa, xb) # apply the lower and upper to the axis. 148 | ya, yb = dy - max_dim, dy + max_dim 149 | ax.set_ylim(ya, yb) 150 | za, zb = dz - max_dim, dz + max_dim 151 | ax.set_zlim(za, zb) 152 | 153 | ax.set_xlabel(f"{x} Label") 154 | ax.set_ylabel(f"{y} Label") 155 | ax.set_zlabel(f"{z} Label") 156 | return plt 157 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/root-11/graph-theory/235c7e06b420bc2235ea7e8fbb13cedbcd5e9d82/requirements.txt -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | graph-theory 3 | """ 4 | from setuptools import setup 5 | from pathlib import Path 6 | 7 | 8 | root = Path(__file__).parent 9 | 10 | __version__ = None 11 | version_file = root / "graph" / "version.py" 12 | exec(version_file.read_text()) 13 | assert isinstance(__version__, str) # noqa 14 | 15 | with open(root / "README.md", encoding="utf-8") as f: 16 | long_description = f.read() 17 | 18 | with open(root / "requirements.txt", "r", encoding="utf-8") as fi: 19 | requirements = [v.rstrip("\n") for v in fi.readlines()] 20 | 21 | keywords = list( 22 | { 23 | "complex-networks", 24 | "discrete mathematics", 25 | "graph", 26 | "Graph Theory", 27 | "graph-algorithms", 28 | "graph-analysis", 29 | "analysis", 30 | "algorithms", 31 | "graph-generation", 32 | "graph-theory", 33 | "graph-visualization", 34 | "graphs", 35 | "math", 36 | "Mathematics", 37 | "maths", 38 | "generation", 39 | "generate", 40 | "theory", 41 | "minimum-spanning-trees", 42 | "network", 43 | "Networks", 44 | "optimization", 45 | "python", 46 | "shortest-path", 47 | "tsp", 48 | "tsp-solver", 49 | "minimum", 50 | "spanning", 51 | "tree", 52 | "assignment problem", 53 | "flow-problem", 54 | "hash", 55 | "graph-hash", 56 | "random graph", 57 | "search", 58 | "cycle", 59 | "path", 60 | "flow", 61 | "path", 62 | "shortest", 63 | "component", 64 | "components", 65 | "adjacency", 66 | "matrix", 67 | "all pairs shortest path", 68 | "finite state machine", 69 | "fsm", 70 | "adjacent", 71 | "pairs", 72 | "finite", 73 | "state", 74 | "machine", 75 | "traffic-jam", 76 | "traffic-jam-solver", 77 | "solver", 78 | "hill-climbing", 79 | "simple", 80 | "simple-path", 81 | "critical", 82 | "path", 83 | "congestions", 84 | "jam", 85 | "traffic", 86 | "optimisation", 87 | "method", 88 | "critical-path", 89 | "minimize", 90 | "minimise", 91 | "optimize", 92 | "optimise", 93 | "merkle", 94 | "tree", 95 | "merkle-tree", 96 | "hash-tree", 97 | } 98 | ) 99 | 100 | keywords.sort(key=lambda x: x.lower()) 101 | 102 | 103 | setup( 104 | name="graph-theory", 105 | version=__version__, 106 | url="https://github.com/root-11/graph-theory", 107 | license="MIT", 108 | author="https://github.com/root-11", 109 | description="A graph library", 110 | long_description=long_description, 111 | long_description_content_type="text/markdown", 112 | keywords=keywords, 113 | packages=["graph"], 114 | python_requires=">=3.8", 115 | include_package_data=True, 116 | data_files=[(".", ["LICENSE", "README.md", "requirements.txt"])], 117 | platforms="any", 118 | install_requires=requirements, 119 | classifiers=[ 120 | "Development Status :: 5 - Production/Stable", 121 | "Intended Audience :: Science/Research", 122 | "Natural Language :: English", 123 | "License :: OSI Approved :: MIT License", 124 | "Programming Language :: Python :: 3.8", 125 | "Programming Language :: Python :: 3.9", 126 | "Programming Language :: Python :: 3.10", 127 | "Programming Language :: Python :: 3.11", 128 | "Programming Language :: Python :: 3.12", 129 | ], 130 | ) 131 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | pytest >= 7 2 | matplotlib >= 3.1 -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import cProfile 2 | import io 3 | import pstats 4 | 5 | 6 | def profileit(func): 7 | """ decorator for profiling function. 8 | usage: 9 | >>> def this(var1, var2): 10 | # do something 11 | 12 | >>> new_this = profileit(this) 13 | >>> calls, cprofile_text = new_this(var1=1,var2=2) 14 | """ 15 | def wrapper(*args, **kwargs): 16 | prof = cProfile.Profile() 17 | retval = prof.runcall(func, *args, **kwargs) 18 | s = io.StringIO() 19 | sortby = 'cumulative' 20 | ps = pstats.Stats(prof, stream=s).sort_stats(sortby) 21 | ps.print_stats() 22 | text = s.getvalue() 23 | calls = int(text.split('\n')[0].lstrip().split(" ")[0]) 24 | return calls, text 25 | return wrapper 26 | -------------------------------------------------------------------------------- /tests/test_assignment_problem.py: -------------------------------------------------------------------------------- 1 | from itertools import permutations 2 | 3 | from graph import Graph 4 | from graph.assignment_problem import ap_solver 5 | 6 | 7 | def test_01_taxis_and_customers(): 8 | """ Test where taxis are assigned to customers, so that the 9 | time to travel is minimised. 10 | 11 | Note that the travel time is negative, whereby the optimal 12 | assignment will have the minimal reponse time for all taxis 13 | to collect all customers. 14 | 15 | """ 16 | taxis = [1, 2, 3] 17 | customers = [4, 5, 6] 18 | # relationships with distance as minutes apart. 19 | L = [ 20 | (1, 4, -11), # taxi 1, customer 4, 11 minutes apart. 21 | (1, 5, -23), 22 | (1, 6, -33), 23 | (2, 4, -14), 24 | (2, 5, -17), 25 | (2, 6, -34), 26 | (3, 4, -22), 27 | (3, 5, -19), 28 | (3, 6, -13) 29 | ] 30 | relationships = Graph(from_list=L) 31 | 32 | permutations_checked = 0 33 | for taxis_ in permutations(taxis, len(taxis)): 34 | for customers_ in permutations(customers, len(customers)): 35 | permutations_checked += 1 36 | print(permutations_checked, taxis_, customers_) 37 | assignment = ap_solver(graph=relationships) 38 | assert set(assignment) == {(1, 4, -11), (2, 5, -17), (3, 6, -13)} 39 | assert sum(v for a, t, v in assignment) == sum([-11, -17, -13]) 40 | print("The assignment problem solver is insensitive to initial conditions.") 41 | 42 | 43 | def test_02_taxis_and_more_customers(): 44 | """ 45 | Like test_01, but with an additional customer who is attractive 46 | (value = -10) 47 | 48 | The same conditions exist as in test_01, but the least attractice 49 | customer (5) with value -17 is expected to be dropped. 50 | """ 51 | L = [ 52 | (1, 4, -11), # taxi 1, customer 4, 11 minutes apart. 53 | (1, 5, -23), 54 | (1, 6, -33), 55 | (1, 7, -10), 56 | (2, 4, -14), 57 | (2, 5, -17), 58 | (2, 6, -34), 59 | (2, 7, -10), 60 | (3, 4, -22), 61 | (3, 5, -19), 62 | (3, 6, -13), 63 | (3, 7, -10) 64 | ] 65 | relationships = Graph(from_list=L) 66 | assignment = ap_solver(graph=relationships) 67 | assert set(assignment) == {(1, 7, -10), (2, 4, -14), (3, 6, -13)} 68 | assert sum(v for a, t, v in assignment) > sum([-11, -17, -13]) 69 | 70 | 71 | def test_03_taxis_but_fewer_customers(): 72 | """ 73 | Like test_01, but with an additional taxi who is more attractive. 74 | 75 | We hereby expect that the new taxi (7) will steal the customer 76 | from 2. 77 | """ 78 | L = [ 79 | (1, 4, -11), # taxi 1, customer 4, 11 minutes apart. 80 | (1, 5, -23), 81 | (1, 6, -33), 82 | (2, 4, -14), 83 | (2, 5, -17), 84 | (2, 6, -34), 85 | (3, 4, -22), 86 | (3, 5, -19), 87 | (3, 6, -13), 88 | (7, 4, -11), 89 | (7, 5, -11), 90 | (7, 6, -11), 91 | ] 92 | relationships = Graph(from_list=L) 93 | assignment = ap_solver(graph=relationships) 94 | assert set(assignment) == {(1, 4, -11), (2, 5, -17), (7, 6, -11)} 95 | assert sum(v for a, t, v in assignment) > sum([-11, -17, -13]) -------------------------------------------------------------------------------- /tests/test_basics.py: -------------------------------------------------------------------------------- 1 | from graph import Graph 2 | from tests.test_graph import graph3x3, graph01, graph05, graph_cycle_6, graph_cycle_5 3 | 4 | 5 | def test_to_from_dict(): 6 | d = {1: {2: 10, 3: 5}, 7 | 2: {4: 1, 3: 2}, 8 | 3: {2: 3, 4: 9, 5: 2}, 9 | 4: {5: 4}, 10 | 5: {1: 7, 4: 6}, 11 | 6: {}} 12 | g = Graph() 13 | g.from_dict(d) 14 | d2 = g.to_dict() 15 | assert d == d2 16 | 17 | 18 | def test_setitem(): 19 | g = Graph() 20 | try: 21 | g[1][2] = 3 22 | raise ValueError("Assignment is not permitted use g.add_edge instead.") 23 | except ValueError: 24 | pass 25 | g.add_node(1) 26 | try: 27 | g[1][2] = 3 28 | raise ValueError 29 | except ValueError: 30 | pass 31 | g.add_edge(1, 2, 3) 32 | assert g.edges() == [(1, 2, 3)] 33 | link_1 = g.edge(1, 2) 34 | assert link_1 == 3 35 | link_1 = g.edge(1, 2) 36 | assert link_1 == 3 37 | link_1 = 4 # attempt setattr. 38 | assert g.edge(1, 2) != 4 # the edge is not an object. 39 | g.add_edge(1, 2, 4) 40 | assert g.edges() == [(1, 2, 4)] 41 | 42 | g = Graph() 43 | try: 44 | g[1] = {2: 3} 45 | raise ValueError 46 | except ValueError: 47 | pass 48 | 49 | 50 | def test_add_node_attr(): 51 | g = graph3x3() 52 | g.add_node(1, "this") 53 | assert set(g.nodes()) == set(range(1, 10)) 54 | node_1 = g.node(1) 55 | assert node_1 == "this" 56 | 57 | d = {"This": 1, "That": 2} 58 | g.add_node(1, obj=d) 59 | assert g.node(1) == d 60 | 61 | rm = 5 62 | g.del_node(rm) 63 | for n1, n2, d in g.edges(): 64 | assert n1 != rm and n2 != rm 65 | g.del_node(rm) # try again for a node that doesn't exist. 66 | 67 | 68 | def test_add_edge_attr(): 69 | g = Graph() 70 | try: 71 | g.add_edge(1, 2, {'a': 1, 'b': 2}) 72 | raise Exception("Assignment of non-values is not supported.") 73 | except ValueError: 74 | pass 75 | 76 | 77 | class MyCustomHashableNode(): 78 | def __init__(self, name): 79 | self.name = name 80 | 81 | def __hash__(self): 82 | return hash(self.name) 83 | 84 | def __eq__(self, other): 85 | """ note that without __eq__ this wont work. 86 | https://stackoverflow.com/questions/9010222/why-can-a-python-dict-have-multiple-keys-with-the-same-hash?noredirect=1&lq=1 87 | """ 88 | return hash(self) == hash(other) 89 | 90 | 91 | def test_node_types(): 92 | for test in [ 93 | [1, 2, 1, 3], 94 | ['A', 'B', 'A', 'C'], 95 | [MyCustomHashableNode(i) for i in ['A', 'B', 'A', 'C']], 96 | ]: 97 | a,b,c,d = test 98 | g = Graph() 99 | g.add_edge(a,b,10) 100 | g.add_edge(c,d,10) 101 | assert len(g.nodes()) == 3 102 | 103 | 104 | def test_to_list(): 105 | g1 = graph01() 106 | g1.add_node(44) 107 | g2 = Graph(from_list=g1.to_list()) 108 | assert g1.edges() == g2.edges() 109 | assert g1.nodes() == g2.nodes() 110 | 111 | 112 | def test_bidirectional_link(): 113 | g = Graph() 114 | g.add_edge(node1=1, node2=2, value=4, bidirectional=True) 115 | assert g.edge(1, 2) == g.edge(2, 1) 116 | 117 | 118 | def test_edges_with_node(): 119 | g = graph3x3() 120 | edges = g.edges(from_node=5) 121 | assert set(edges) == {(5, 6, 1), (5, 8, 1)} 122 | assert g.edge(5, 6) == 1 123 | assert g.edge(5, 600) is None # 600 doesn't exist. 124 | 125 | 126 | def test_nodes_from_node(): 127 | g = graph3x3() 128 | nodes = g.nodes(from_node=1) 129 | assert set(nodes) == {2, 4} 130 | nodes = g.nodes(to_node=9) 131 | assert set(nodes) == {6, 8} 132 | nodes = g.nodes() 133 | assert set(nodes) == set(range(1, 10)) 134 | 135 | try: 136 | _ = g.nodes(in_degree=-1) 137 | assert False 138 | except ValueError: 139 | assert True 140 | 141 | nodes = g.nodes(in_degree=0) 142 | assert set(nodes) == {1} 143 | nodes = g.nodes(in_degree=1) 144 | assert set(nodes) == {2, 3, 4, 7} 145 | nodes = g.nodes(in_degree=2) 146 | assert set(nodes) == {5, 6, 8, 9} 147 | nodes = g.nodes(in_degree=3) 148 | assert nodes == [] 149 | 150 | try: 151 | _ = g.nodes(out_degree=-1) 152 | assert False 153 | except ValueError: 154 | assert True 155 | 156 | nodes = g.nodes(out_degree=0) 157 | assert set(nodes) == {9} 158 | 159 | g.add_node(44) 160 | assert set(g.nodes(out_degree=0)) == {9, 44} 161 | 162 | nodes = g.nodes(out_degree=1) 163 | assert set(nodes) == {3, 6, 7, 8} 164 | nodes = g.nodes(out_degree=2) 165 | assert set(nodes) == {1, 2, 4, 5} 166 | nodes = g.nodes(out_degree=3) 167 | assert nodes == [] 168 | 169 | try: 170 | _ = g.nodes(in_degree=1, out_degree=1) 171 | assert False 172 | except ValueError: 173 | assert True 174 | 175 | 176 | def test01(): 177 | """ 178 | Asserts that the shortest_path is correct 179 | """ 180 | g = graph01() 181 | dist, path = g.shortest_path(1, 4) 182 | assert [1, 3, 2, 4] == path, path 183 | assert 9 == dist, dist 184 | 185 | 186 | def test02(): 187 | """ 188 | Assert that the dict loader works. 189 | """ 190 | d = {1: {2: 10, 3: 5}, 191 | 2: {4: 1, 3: 2}, 192 | 3: {2: 3, 4: 9, 5: 2}, 193 | 4: {5: 4}, 194 | 5: {1: 7, 4: 6}} 195 | g = Graph(from_dict=d) 196 | assert 3 in g 197 | assert d[3][4] == g.edge(3, 4) 198 | 199 | 200 | def test03(): 201 | g = graph3x3() 202 | all_edges = g.edges() 203 | edges = g.edges(path=[1, 2, 3, 6, 9]) 204 | for edge in edges: 205 | assert edge in all_edges, edge 206 | 207 | 208 | def test_subgraph(): 209 | g = graph3x3() 210 | g2 = g.subgraph_from_nodes([1, 2, 3, 4]) 211 | d = {1: {2: 1, 4: 1}, 212 | 2: {3: 1}, 213 | } 214 | assert g2.is_subgraph(g) 215 | for k, v in d.items(): 216 | for k2, d2 in v.items(): 217 | assert g.edge(k, k2) == g2.edge(k, k2) 218 | 219 | g3 = graph3x3() 220 | g3.add_edge(3, 100, 7) 221 | assert not g3.is_subgraph(g2) 222 | 223 | 224 | def test_in_and_out_degree(): 225 | g = graph3x3() 226 | 227 | in_degree = {1: 0, 2: 1, 3: 1, 4: 1, 5: 2, 6: 2, 7: 1, 8: 2, 9: 2} 228 | out_degree = {1: 2, 2: 2, 3: 1, 4: 2, 5: 2, 6: 1, 7: 1, 8: 1, 9: 0} 229 | for node in g.nodes(): 230 | assert g.in_degree(node) == in_degree[node] 231 | assert g.out_degree(node) == out_degree[node] 232 | 233 | def test_equals(): 234 | g1 = Graph(from_list=[(1,2),(2,3)]) 235 | g2 = g1.copy() 236 | assert g1 == g2 237 | 238 | g1.add_edge(3,1) 239 | g1.del_edge(3,1) 240 | assert g1 == g2 241 | 242 | assert g1.edges() == g2.edges() 243 | assert g1.nodes() == g2.nodes() 244 | 245 | def test_equals_2(): 246 | g1 = Graph(from_list=[(1,2),(2,3)]) 247 | g2 = Graph(from_list=[(1,2),(2,3)]) 248 | _ = g1.edge(3,1) # simple getter. 249 | assert g1._edges == g2._edges 250 | assert g1 == g2 251 | 252 | 253 | def test_copy_equals(): 254 | g1 = Graph(from_list=[(1,2),(2,3)]) 255 | g1.add_edge(3,1) 256 | g1.del_edge(3,1) 257 | g2 = g1.copy() 258 | assert g1 == g2 259 | 260 | 261 | def test_copy_equals_2(): 262 | g1 = Graph(from_list=[(1,2),(2,3)]) 263 | g1.edge(3,1) 264 | g2 = g1.copy() 265 | assert g1 == g2 266 | assert g1._edges == g2._edges 267 | assert g1._edges is not g2._edges, "the two graphs must be independent" 268 | assert g1._edge_count == g2._edge_count 269 | 270 | 271 | def test_copy(): 272 | g = graph05() 273 | g2 = g.copy() 274 | assert set(g.edges()) == set(g2.edges()) 275 | assert g == g2, "testing == operator failed" 276 | g2.add_node(1, "this") 277 | 278 | g3 = Graph(from_list=g.to_list()) 279 | assert g2 != g3 280 | g2.add_node(1) 281 | assert g2 == g3 282 | 283 | 284 | def test_errors(): 285 | g = graph05() 286 | try: 287 | len(g) 288 | raise AssertionError 289 | except ValueError: 290 | assert True 291 | 292 | try: 293 | g.edges(from_node=1, to_node=1) 294 | raise AssertionError 295 | except ValueError: 296 | assert True 297 | 298 | try: 299 | g.edges(path=[1]) 300 | raise AssertionError 301 | except ValueError: 302 | assert True 303 | 304 | try: 305 | g.edges(path="this") 306 | raise AssertionError 307 | except ValueError: 308 | assert True 309 | 310 | e = g.edges(from_node=77) 311 | assert e == [] 312 | 313 | 314 | def test_delitem(): 315 | g = graph05() 316 | 317 | try: 318 | g.__delitem__(key=1) 319 | assert False 320 | except ValueError: 321 | assert True 322 | 323 | g.del_edge(node1=0, node2=1) 324 | g.del_edge(0, 1) # idempotent. 325 | 326 | v = g.edge(0, 1) 327 | if v is not None: 328 | raise ValueError 329 | v = g.edge(0, 1, default=44) 330 | if v != 44: 331 | raise ValueError 332 | 333 | 334 | def test_is_partite(): 335 | g = graph_cycle_6() 336 | bol, partitions = g.is_partite(n=2) 337 | assert bol is True 338 | 339 | g = graph_cycle_5() 340 | bol, part = g.is_partite(n=2) 341 | assert bol is False 342 | bol, part = g.is_partite(n=5) 343 | assert bol is True 344 | assert len(part) == 5 345 | 346 | 347 | def test_is_cyclic(): 348 | g = graph_cycle_5() 349 | assert g.has_cycles() 350 | 351 | 352 | def test_is_not_cyclic(): 353 | g = graph3x3() 354 | assert not g.has_cycles() 355 | 356 | 357 | def test_is_really_cyclic(): 358 | g = Graph(from_list=[(1, 1, 1), (2, 2, 1)]) # two loops onto themselves. 359 | assert g.has_cycles() 360 | 361 | 362 | def test_no_edge_connected(): 363 | g = Graph() 364 | g.add_node(4) 365 | g.add_node(5) 366 | assert g.is_connected(4, 5) is False 367 | 368 | 369 | def test_edge_connected(): 370 | g = Graph() 371 | g.add_edge(4, 5) 372 | assert g.is_connected(4, 5) is True 373 | 374 | 375 | def test_edge_not_connected(): 376 | g = Graph() 377 | g.add_edge(3, 4) 378 | g.add_edge(5, 4) 379 | assert g.is_connected(3, 5) is False 380 | 381 | 382 | def test_del_edge(): 383 | the_sort = [] 384 | g = Graph() 385 | g.add_edge(1, 2) 386 | g.add_edge(2, 3) 387 | zero_in = g.nodes(in_degree=0) 388 | while zero_in: 389 | for n in zero_in: 390 | the_sort.append(n) 391 | g.del_node(n) 392 | zero_in = g.nodes(in_degree=0) 393 | assert len(g.nodes()) == 0 394 | assert len(g._nodes) == 0 395 | assert len(g._edges) == 0 396 | assert len(g._reverse_edges) == 0 397 | assert len(g._in_degree) == 0 398 | assert len(g._out_degree) == 0 399 | 400 | -------------------------------------------------------------------------------- /tests/test_facility_location_problem.py: -------------------------------------------------------------------------------- 1 | from examples.graphs import london_underground 2 | 3 | """ 4 | The problem of deciding the exact place in a community where a school or a fire station should be located, 5 | is classified as the facility location problem. 6 | 7 | If the facility is a school, it is desirable to locate it so that the sum 8 | of distances travelled by all members of the communty is as short as possible. 9 | This is the minimum of sum - or in short `minsum` of the graph. 10 | 11 | If the facility is a firestation, it is desirable to locate it so that the distance from the firestation 12 | to the farthest point in the community is minimized. 13 | This is the minimum of max distances - or in short `minmax` of the graph. 14 | """ 15 | 16 | 17 | def test_minsum(): 18 | g = london_underground() 19 | stations = g.minsum() 20 | assert len(stations) == 1 21 | station_list = [g.node(s) for s in stations] 22 | assert station_list == [(51.515, -0.1415, 'Oxford Circus')] 23 | 24 | 25 | def test_minmax(): 26 | g = london_underground() 27 | stations = g.minmax() 28 | assert len(stations) == 3 29 | station_list = [g.node(s) for s in stations] 30 | assert station_list == [(51.5226, -0.1571, 'Baker Street'), 31 | (51.5142, -0.1494, 'Bond Street'), 32 | (51.5234, -0.1466, "Regent's Park")] 33 | -------------------------------------------------------------------------------- /tests/test_finite_state_machine.py: -------------------------------------------------------------------------------- 1 | from graph.finite_state_machine import FiniteStateMachine 2 | from itertools import cycle 3 | 4 | 5 | def test_traffic_light(): 6 | green, yellow, red = 'Green', 'Yellow', 'Red' 7 | seq = cycle([green, yellow, red]) 8 | _ = next(seq) 9 | fsm = FiniteStateMachine() 10 | fsm.add_transition(green, 'switch', yellow) 11 | fsm.add_transition(yellow, 'switch', red) 12 | fsm.add_transition(red, 'switch', green) 13 | fsm.set_initial_state(green) 14 | for _ in range(20): 15 | current_state = fsm.current_state 16 | new_state = next(seq) 17 | fsm.next('switch') 18 | assert fsm.current_state == new_state, (fsm.current_state, new_state) 19 | assert new_state != current_state, (new_state, current_state) 20 | 21 | 22 | def test_turnstile(): 23 | locked, unlocked = 'locked', 'unlocked' # states 24 | push, coin = 'push', 'coin' # actions 25 | fsm = FiniteStateMachine() 26 | fsm.add_transition(locked, coin, unlocked) 27 | fsm.add_transition(unlocked, push, locked) 28 | fsm.add_transition(locked, push, locked) 29 | fsm.add_transition(unlocked, coin, unlocked) 30 | try: 31 | assert fsm._initial_state_was_set is False 32 | fsm.next(coin) 33 | raise AssertionError 34 | except ValueError: 35 | pass 36 | 37 | try: 38 | assert fsm._initial_state_was_set is False 39 | fsm.set_initial_state('fish') 40 | raise AssertionError 41 | except ValueError: 42 | pass 43 | 44 | fsm.set_initial_state(locked) 45 | 46 | try: 47 | assert fsm._initial_state_was_set is True 48 | fsm.set_initial_state(locked) 49 | raise AssertionError 50 | except ValueError: 51 | assert fsm._initial_state_was_set is True 52 | pass 53 | 54 | # pay and go: 55 | display_state = set(fsm.options()) 56 | assert display_state == {coin, push}, display_state 57 | assert fsm.current_state == locked 58 | fsm.next(action=coin) 59 | display_state = set(fsm.options()) 60 | assert display_state == {coin, push}, display_state 61 | assert fsm.current_state == unlocked 62 | fsm.next(action=push) 63 | assert fsm.current_state == locked 64 | 65 | # try to cheat 66 | fsm.next(action=push) 67 | assert fsm.current_state == locked 68 | fsm.next(action=push) 69 | assert fsm.current_state == locked 70 | 71 | # pay and go: 72 | fsm.next(action=coin) 73 | assert fsm.current_state == unlocked 74 | fsm.next(action=push) 75 | assert fsm.current_state == locked 76 | 77 | try: 78 | assert fsm._initial_state_was_set is True 79 | fsm.next(action='fish') 80 | raise AssertionError 81 | except ValueError: 82 | pass 83 | 84 | fsm.add_transition(locked, 'fire', 'fire escape mode') 85 | fsm.next('fire') 86 | try: 87 | fsm.next(push) 88 | raise AssertionError 89 | except StopIteration: 90 | pass 91 | 92 | -------------------------------------------------------------------------------- /tests/test_flow_problem.py: -------------------------------------------------------------------------------- 1 | from graph import Graph 2 | from graph.min_cost_flow import minimum_cost_flow_using_successive_shortest_path 3 | 4 | 5 | def test_maximum_flow(): 6 | """ [2] ----- [5] 7 | / + / | + 8 | [1] [4] | [7] 9 | + / + | / 10 | [3] ----- [6] 11 | """ 12 | edges = [ 13 | (1, 2, 18), 14 | (1, 3, 10), 15 | (2, 4, 7), 16 | (2, 5, 6), 17 | (3, 4, 2), 18 | (3, 6, 8), 19 | (4, 5, 10), 20 | (4, 6, 10), 21 | (5, 6, 16), 22 | (5, 7, 9), 23 | (6, 7, 18) 24 | ] 25 | g = Graph(from_list=edges) 26 | 27 | flow, g2 = g.maximum_flow(1, 7) 28 | assert flow == 23, flow 29 | 30 | 31 | def test_min_cut(): 32 | """ [2] ----- [5] 33 | / + / | + 34 | [1] [4] | [7] 35 | + / + | / 36 | [3] ----- [6] 37 | """ 38 | edges = [ 39 | (1, 2, 18), 40 | (1, 3, 18), # different from test_maximum_flow 41 | (2, 4, 7), 42 | (2, 5, 6), 43 | (3, 4, 2), 44 | (3, 6, 8), 45 | (4, 5, 10), 46 | (4, 6, 10), 47 | (5, 6, 16), 48 | (5, 7, 9), 49 | (6, 7, 18) 50 | ] 51 | g = Graph(from_list=edges) 52 | 53 | max_flow_min_cut = g.maximum_flow_min_cut(1,7) 54 | assert set(max_flow_min_cut) == {(2, 5), (2, 4), (3, 4), (3, 6)} 55 | 56 | 57 | def test_maximum_flow01(): 58 | edges = [ 59 | (1, 2, 1) 60 | ] 61 | g = Graph(from_list=edges) 62 | flow, g2 = g.maximum_flow(start=1, end=2) 63 | assert flow == 1, flow 64 | 65 | 66 | def test_maximum_flow02(): 67 | edges = [ 68 | (1, 2, 10), 69 | (2, 3, 1), # bottleneck. 70 | (3, 4, 10) 71 | ] 72 | g = Graph(from_list=edges) 73 | flow, g2 = g.maximum_flow(start=1, end=4) 74 | assert flow == 1, flow 75 | 76 | 77 | def test_maximum_flow03(): 78 | edges = [ 79 | (1, 2, 10), 80 | (1, 3, 10), 81 | (2, 4, 1), # bottleneck 1 82 | (3, 5, 1), # bottleneck 2 83 | (4, 6, 10), 84 | (5, 6, 10) 85 | ] 86 | g = Graph(from_list=edges) 87 | flow, g2 = g.maximum_flow(start=1, end=6) 88 | assert flow == 2, flow 89 | 90 | 91 | def test_maximum_flow04(): 92 | edges = [ 93 | (1, 2, 10), 94 | (1, 3, 10), 95 | (2, 4, 1), # bottleneck 1 96 | (2, 5, 1), # bottleneck 2 97 | (3, 5, 1), # bottleneck 3 98 | (3, 4, 1), # bottleneck 4 99 | (4, 6, 10), 100 | (5, 6, 10) 101 | ] 102 | g = Graph(from_list=edges) 103 | flow, g2 = g.maximum_flow(start=1, end=6) 104 | assert flow == 4, flow 105 | 106 | 107 | def test_maximum_flow05(): 108 | edges = [ 109 | (1, 2, 10), 110 | (1, 3, 1), 111 | (2, 3, 1) 112 | ] 113 | g = Graph(from_list=edges) 114 | flow, g2 = g.maximum_flow(start=1, end=3) 115 | assert flow == 2, flow 116 | 117 | 118 | def test_maximum_flow06(): 119 | edges = [ 120 | (1, 2, 1), 121 | (1, 3, 1), 122 | (2, 4, 1), 123 | (3, 4, 1), 124 | (4, 5, 2), 125 | (5, 6, 1), 126 | (5, 7, 1), 127 | (6, 8, 1), 128 | (7, 8, 1) 129 | ] 130 | g = Graph(from_list=edges) 131 | flow, g2 = g.maximum_flow(start=1, end=8) 132 | assert flow == 2, flow 133 | assert set(g2.edges()) == set(edges) 134 | 135 | 136 | def lecture_23_max_flow_problem(): 137 | """ graph and stock from https://youtu.be/UtSrgTsKUfU """ 138 | edges = [(1, 2, 8), 139 | (1, 3, 6), 140 | (2, 4, 5), 141 | (2, 5, 7), 142 | (3, 4, 6), 143 | (3, 5, 3), 144 | (4, 5, 4)] # s,e,cost/unit 145 | g = Graph(from_list=edges) 146 | stock = {1: 6, 2: 0, 3: 4, 4: -5, 5: -5} # supply > 0 > demand, stock == 0 147 | return g, stock 148 | 149 | 150 | lec_23_optimum_cost = 81 151 | 152 | 153 | def test_minimum_cost_flow_successive_shortest_path_unlimited(): 154 | costs, inventory = lecture_23_max_flow_problem() 155 | mcf = minimum_cost_flow_using_successive_shortest_path 156 | total_cost, movements = mcf(costs, inventory) 157 | assert isinstance(movements, Graph) 158 | assert total_cost == lec_23_optimum_cost 159 | expected = [ 160 | (1, 3, 6), 161 | (3, 4, 5), 162 | (3, 5, 5) 163 | ] 164 | for edge in movements.edges(): 165 | expected.remove(edge) # will raise error if edge is missing. 166 | assert expected == [] # will raise error if edge wasn't removed. 167 | 168 | 169 | def test_minimum_cost_flow_successive_shortest_path_plenty(): 170 | costs, inventory = lecture_23_max_flow_problem() 171 | capacity = Graph(from_list=[(s, e, lec_23_optimum_cost) for s, e, d in costs.edges()]) 172 | mcf = minimum_cost_flow_using_successive_shortest_path 173 | total_cost, movements = mcf(costs, inventory, capacity) 174 | assert isinstance(movements, Graph) 175 | assert total_cost == lec_23_optimum_cost 176 | expected = [ 177 | (1, 3, 6), 178 | (3, 4, 5), 179 | (3, 5, 5) 180 | ] 181 | for edge in movements.edges(): 182 | expected.remove(edge) # will raise error if edge is missing. 183 | assert expected == [] # will raise error if edge wasn't removed. 184 | 185 | 186 | def test_minimum_cost_flow_successive_shortest_path_35_constrained(): 187 | costs, inventory = lecture_23_max_flow_problem() 188 | capacity = Graph(from_list=[(s, e, lec_23_optimum_cost) for s, e, d in costs.edges()]) 189 | capacity.add_edge(3, 5, 4) 190 | 191 | mcf = minimum_cost_flow_using_successive_shortest_path 192 | total_cost, movements = mcf(costs, inventory, capacity) 193 | assert isinstance(movements, Graph) 194 | assert total_cost == lec_23_optimum_cost - 3 - 6 + 8 + 7 195 | expected = [ 196 | (1, 3, 5), 197 | (1, 2, 1), 198 | (2, 5, 1), 199 | (3, 5, 4), 200 | (3, 4, 5) 201 | ] 202 | for edge in movements.edges(): 203 | expected.remove(edge) # will raise error if edge is missing. 204 | assert expected == [] # will raise error if edge wasn't removed. 205 | 206 | 207 | def test_minimum_cost_flow_successive_shortest_path_unlimited_excess_supply(): 208 | costs, inventory = lecture_23_max_flow_problem() 209 | inventory[1] = 1000 210 | mcf = minimum_cost_flow_using_successive_shortest_path 211 | total_cost, movements = mcf(costs, inventory) 212 | assert isinstance(movements, Graph) 213 | assert total_cost == lec_23_optimum_cost 214 | expected = [ 215 | (1, 3, 6), 216 | (3, 4, 5), 217 | (3, 5, 5) 218 | ] 219 | for edge in movements.edges(): 220 | expected.remove(edge) # will raise error if edge is missing. 221 | assert expected == [] # will raise error if edge wasn't removed. 222 | 223 | 224 | def test_minimum_cost_flow_successive_shortest_path_unlimited_inadequate_supply(): 225 | costs, inventory = lecture_23_max_flow_problem() 226 | inventory[5] = -10 227 | mcf = minimum_cost_flow_using_successive_shortest_path 228 | total_cost, movements = mcf(costs, inventory) 229 | assert isinstance(movements, Graph) 230 | assert total_cost == 6 * 6 + 10 * 3 231 | expected = [ 232 | (1, 3, 6), 233 | (3, 5, 10) 234 | ] 235 | for edge in movements.edges(): 236 | expected.remove(edge) # will raise error if edge is missing. 237 | assert expected == [] # will raise error if edge wasn't removed. 238 | 239 | 240 | def test_min_flow_cost_problem_r4er(): 241 | """ example source http://www.iems.northwestern.edu/~4er/ """ 242 | costs = Graph(from_list=[ 243 | (1, 2, 12), 244 | (1, 4, 12), 245 | (2, 4, 10), 246 | (2, 5, 9), 247 | (3, 2, 13), 248 | (3, 5, 7), 249 | (4, 5, 4), 250 | (4, 6, 8), 251 | (4, 7, 6), 252 | (5, 4, 3), 253 | (5, 7, 9), 254 | (5, 8, 13), 255 | (6, 7, 7), 256 | (8, 7, 3) 257 | ]) 258 | inventory = {1: 100, 2: 80, 3: 130, 6: -200, 7: -60, 8: -40} 259 | mcf = minimum_cost_flow_using_successive_shortest_path 260 | total_cost, movements = mcf(costs, inventory) 261 | assert total_cost == 5820, total_cost 262 | 263 | -------------------------------------------------------------------------------- /tests/test_hashgraph.py: -------------------------------------------------------------------------------- 1 | from graph import Graph 2 | from graph.hash_methods import graph_hash, flow_graph_hash, merkle_tree 3 | 4 | 5 | def test_merkle_tree_1_block(): 6 | data_blocks = [b"this"] 7 | g = merkle_tree(data_blocks) 8 | assert len(g.nodes()) == 1 9 | 10 | 11 | def test_merkle_tree_2_blocks(): 12 | data_blocks = [b"this", 13 | b"that"] 14 | g = merkle_tree(data_blocks) 15 | assert len(g.nodes()) == 3 16 | 17 | 18 | def test_merkle_tree_3_blocks(): 19 | data_blocks = [b"this", 20 | b"that", 21 | b"them"] 22 | g = merkle_tree(data_blocks) 23 | assert len(g.nodes()) == 5 24 | 25 | 26 | def test_merkle_tree_4_blocks(): 27 | data_blocks = [b"this", 28 | b"that", 29 | b"them", 30 | b"they"] 31 | g = merkle_tree(data_blocks) 32 | assert len(g.nodes()) == 7 33 | 34 | 35 | def test_flow_graph_hash_01(): 36 | """ 37 | This example includes a loop to distinguish it from the common merkle tree. 38 | 39 | S-1 S-2 S-3 S-4 40 | (hash S1) (hash S2) (hash S3) (hash S4) 41 | + + + + 42 | | | +----------->+ 43 | | | +<-------------+ 44 | v v v | 45 | I-1 I-2 | (loop) 46 | (hash S1+S2+I1) (hash S3 + I2) | 47 | + + + | 48 | | | +------------->+ 49 | v | | 50 | E-1 +---> E-2 <------+ 51 | (hash I1+E1) (hash I1+I2+E2) 52 | 53 | """ 54 | links = [ 55 | ('s-1', 'i-1', 1), 56 | ('s-2', 'i-1', 1), 57 | ('i-1', 'e-1', 1), 58 | ('i-1', 'e-2', 1), 59 | ('s-3', 'i-2', 1), 60 | ('i-2', 'i-2', 1), 61 | ('i-2', 'e-2', 1), 62 | ] 63 | g = Graph(from_list=links) 64 | g.add_node('s-4') 65 | g2 = flow_graph_hash(g) 66 | assert len(g2.nodes()) == len(g.nodes()) 67 | 68 | 69 | def test_flow_graph_loop_01(): 70 | links = [ 71 | (1, 2, 1), 72 | (2, 3, 1), 73 | (3, 4, 1), 74 | (3, 2, 1) 75 | ] 76 | g = Graph(from_list=links) 77 | g2 = flow_graph_hash(g) 78 | assert len(g2.nodes()) == len(g.nodes()) 79 | 80 | 81 | def test_flow_graph_async_01(): 82 | """ 83 | 84 | (s1) --> (i2) --> (e4) 85 | / 86 | (s3) -->/ 87 | """ 88 | links = [ 89 | (1, 2, 1), 90 | (2, 4, 1), 91 | (3, 4, 1) 92 | ] 93 | g = Graph(from_list=links) 94 | g2 = flow_graph_hash(g) 95 | assert len(g2.nodes()) == len(g.nodes()) 96 | 97 | 98 | def test_graph_hash(): 99 | """ 100 | Simple test of the graph hash function. 101 | """ 102 | links = [ 103 | (1, 2, 1), 104 | (2, 3, 1), 105 | (3, 4, 1), 106 | (3, 2, 1) 107 | ] 108 | g = Graph(from_list=links) 109 | h = graph_hash(g) 110 | assert isinstance(h, int) 111 | assert sum((int(d) for d in str(h))) == 312 112 | 113 | -------------------------------------------------------------------------------- /tests/test_random.py: -------------------------------------------------------------------------------- 1 | from graph import Graph 2 | from graph.random import random_xy_graph 3 | 4 | 5 | def graph07(): 6 | nodes = 10 7 | links = 30 8 | g = random_xy_graph(nodes=nodes, edges=links, x_max=800, y_max=400, seed=42) 9 | assert isinstance(g, Graph) 10 | assert len(g.nodes()) == nodes 11 | assert len(g.edges()) == links 12 | return g 13 | 14 | 15 | def test_random_graph(): 16 | g = graph07() 17 | assert isinstance(g, Graph) 18 | 19 | 20 | def test_random_graph_2(): 21 | nodes = 10000 22 | links = 1 23 | 24 | err1 = err2 = "" 25 | try: 26 | g = random_xy_graph(nodes=nodes, edges=links, x_max=80, y_max=40, seed=42) 27 | except ValueError as e: 28 | err1 = str(e) 29 | pass 30 | nodes = 10 31 | links = (sum(range(nodes)) * 2) + 1 32 | try: 33 | g = random_xy_graph(nodes=nodes, edges=links, x_max=800, y_max=400, seed=42) 34 | except ValueError as e: 35 | err2 = str(e) 36 | pass 37 | assert str(err1) != str(err2) 38 | 39 | links = 1 40 | g = random_xy_graph(nodes=nodes, edges=links, x_max=7, y_max=2, seed=42) 41 | # the xy_space above is so small that the generator must switch from random 42 | # mode, to search mode. 43 | 44 | links = nodes * nodes # this is a fully connected graph. 45 | g = random_xy_graph(nodes=nodes, edges=links, x_max=800, y_max=400, seed=42) 46 | 47 | # edges=None creates a fully connected graph 48 | g2 = random_xy_graph(nodes=nodes, edges=None, x_max=800, y_max=400, seed=42) 49 | assert len(g.nodes()) == len(g2.nodes()) 50 | assert len(g.edges()) == len(g2.edges()) 51 | 52 | 53 | def test_random_graph_4(): 54 | """ check that the string method is correct. """ 55 | g = random_xy_graph(1000, 1000, 1000, 7000) 56 | assert len(g.edges()) == 7000 57 | s = str(g) 58 | assert s == 'Graph(1000 nodes, 7000 edges)', s 59 | -------------------------------------------------------------------------------- /tests/test_spatial_graph.py: -------------------------------------------------------------------------------- 1 | from math import sin, cos, isclose 2 | from graph import Graph3D 3 | 4 | 5 | def spiral_graph(): 6 | xyz = [(sin(i / 150), cos(i / 150), i / 150) for i in range(0, 1500, 30)] 7 | g = Graph3D() 8 | for t in xyz: 9 | g.add_node(t) 10 | 11 | xyz.sort(key=lambda x: x[2]) # sorted by z axis which is linear. 12 | 13 | n1 = xyz[0] 14 | for n2 in xyz[1:]: 15 | distance = g.distance(n1, n2) 16 | g.add_edge(n1, n2, distance) 17 | n1 = n2 18 | return g 19 | 20 | 21 | def fishbone_graph(levels=5, lengths=10, depths=2): 22 | """ Creates a multi level fishbone graph. 23 | 24 | :param levels: int: number of levels 25 | :param lengths: int: number of ribs 26 | :param depths: int: number of joints on each rib. 27 | :return: Graph3D 28 | """ 29 | g = Graph3D() 30 | prev_level = None 31 | for level in range(1, levels+1): # z axis. 32 | g.add_node((0, 0, level)) 33 | if prev_level is None: 34 | pass 35 | else: 36 | g.add_edge((0, 0, prev_level), (0, 0, level), value=1, bidirectional=True) 37 | 38 | prev_spine = (0, 0, level) # the lift. 39 | for step in range(1, lengths+1): # step along the x axis. 40 | spine = (step, 0, level) 41 | g.add_edge(prev_spine, spine, 1, bidirectional=True) 42 | 43 | for side in [-1, 1]: 44 | rib_1 = spine 45 | for depth in range(1, depths+1): 46 | rib_2 = (step, side * depth, level) 47 | g.add_edge(rib_1, rib_2, 1, bidirectional=True) 48 | rib_1 = rib_2 49 | 50 | prev_spine = spine 51 | prev_level = level 52 | 53 | g.add_node((-1, 0, 2)) # entry point 54 | g.add_edge((-1, 0, 2), (0, 0, 2), 1, bidirectional=True) 55 | g.add_node((-1, 0, 1)) # exit point 56 | g.add_edge((-1, 0, 1), (0, 0, 1), 1, bidirectional=True) 57 | return g 58 | 59 | 60 | def test_basics(): 61 | g = Graph3D() 62 | a, b, c = (0, 0, 0), (1, 1, 1), (2, 2, 2) 63 | g.add_node(a) 64 | g.add_node(b) 65 | g.add_node(c) 66 | assert g.n_nearest_neighbours(a)[0] == b 67 | assert g.n_nearest_neighbours(c)[0] == b 68 | 69 | L = g.to_list() 70 | g2 = Graph3D(from_list=L) 71 | assert g2.nodes() == g.nodes() 72 | assert g2.edges() == g.edges() 73 | 74 | d = g.to_dict() 75 | g3 = Graph3D(from_dict=d) 76 | assert g3.nodes() == g.nodes() 77 | assert g3.edges() == g.edges() 78 | 79 | g4 = g.copy() 80 | assert g4.edges() == g.edges() 81 | 82 | 83 | def test_path_finding(): 84 | xyz = [(sin(i / 150), cos(i / 150), i / 150) for i in range(0, 1500, 30)] 85 | g = Graph3D() 86 | for t in xyz: 87 | g.add_node(t) 88 | 89 | xyz.sort(key=lambda x: x[2]) 90 | 91 | total_distance = 0.0 92 | n1 = xyz[0] 93 | for n2 in xyz[1:]: 94 | distance = g.distance(n1, n2) 95 | total_distance += distance 96 | g.add_edge(n1, n2, distance) 97 | n1 = n2 98 | 99 | d, p = g.shortest_path(xyz[0], xyz[-1]) 100 | assert isclose(d, 13.847754085278877), d 101 | assert p == xyz, [(idx, a, b) for idx, (a, b) in enumerate(zip(p, xyz)) if a != b] 102 | 103 | 104 | def test_shortest_path(): 105 | """ assure that the fishbone graphs entry and exits are connected. """ 106 | g = fishbone_graph() 107 | entry_point = (-1, 0, 2) 108 | exit_point = (-1, 0, 1) 109 | d, p = g.shortest_path(entry_point, exit_point) 110 | assert d == 3, d 111 | 112 | 113 | def test_no_nearest_neighbour(): 114 | """ checks that when you're alone, you have no neighbours.""" 115 | g = Graph3D() 116 | xyz = (1, 1, 1) 117 | g.add_node(xyz) 118 | assert g.n_nearest_neighbours(xyz) is None 119 | 120 | 121 | def test_bfs(): 122 | g = fishbone_graph() 123 | entry_point = (-1, 0, 2) 124 | exit_point = (-1, 0, 1) 125 | p = g.breadth_first_search(entry_point, exit_point) 126 | assert len(p) == 4, p 127 | 128 | 129 | def test_dfs(): 130 | g = fishbone_graph() 131 | entry_point = (-1, 0, 2) 132 | exit_point = (-1, 0, 1) 133 | p = g.depth_first_search(entry_point, exit_point) 134 | assert len(p) == 4, p 135 | 136 | 137 | def test_distance_from_path(): 138 | g = fishbone_graph() 139 | entry_point = (-1, 0, 2) 140 | exit_point = (-1, 0, 1) 141 | p = g.breadth_first_search(entry_point, exit_point) 142 | assert len(p) == 4 143 | assert g.distance_from_path(p) == 3 144 | 145 | 146 | def test_maximum_flow(): 147 | g = fishbone_graph() 148 | entry_point = (-1, 0, 2) 149 | exit_point = (-1, 0, 1) 150 | total_flow, flow_graph = g.maximum_flow(entry_point, exit_point) 151 | assert total_flow == 1, total_flow 152 | assert len(flow_graph.nodes()) == 4, len(flow_graph.nodes()) 153 | 154 | 155 | def test_subgraph_from_nodes(): 156 | g = fishbone_graph() 157 | entry_point = (-1, 0, 2) 158 | exit_point = (-1, 0, 1) 159 | p = g.breadth_first_search(entry_point, exit_point) 160 | subgraph = g.subgraph_from_nodes(p) 161 | assert isinstance(subgraph, Graph3D), type(subgraph) 162 | new_p = subgraph.breadth_first_search(entry_point, exit_point) 163 | assert p == new_p, (p, new_p) 164 | 165 | 166 | def test_has_cycles(): 167 | g = Graph3D() 168 | a, b, c = (0, 0, 0), (1, 1, 1), (2, 2, 2) 169 | g.add_edge(a, b, 1) 170 | g.add_edge(b, c, 1) 171 | assert not g.has_cycles() 172 | g.add_edge(c, a, 1) 173 | assert g.has_cycles() 174 | 175 | 176 | def test_network_size(): 177 | g = fishbone_graph(levels=3, lengths=3, depths=3) 178 | entry_point = (-1, 0, 2) 179 | assert g.network_size(entry_point) 180 | 181 | 182 | def test_has_path(): 183 | g = Graph3D() 184 | a, b, c = (0, 0, 0), (1, 1, 1), (2, 2, 2) 185 | g.add_edge(a, b, 1) 186 | g.add_edge(b, c, 1) 187 | assert not g.has_path([c, a, b, c]) 188 | g.add_edge(c, a, 1) 189 | assert g.has_path([c, a, b]) 190 | assert g.has_path([a, b, c]) 191 | 192 | 193 | def test_degree_of_separation(): 194 | g = fishbone_graph() 195 | entry_point = (-1, 0, 2) 196 | exit_point = (-1, 0, 1) 197 | des = g.degree_of_separation(entry_point, exit_point) 198 | assert des == 3 199 | 200 | 201 | def test_number_of_components(): 202 | g = fishbone_graph(3, 3, 3) 203 | cs = g.components() 204 | assert len(cs) == 1, cs 205 | 206 | g = fishbone_graph() 207 | cs = g.components() 208 | assert len(cs) == 1, cs 209 | 210 | 211 | def test_bad_config(): 212 | g = Graph3D() 213 | try: 214 | g.distance((1, 2), (3, 4)) 215 | raise AssertionError 216 | except ValueError: 217 | pass 218 | 219 | try: 220 | g.add_edge(1, 2) 221 | raise AssertionError 222 | except TypeError: 223 | pass 224 | 225 | try: 226 | g.add_edge((1,), (2,)) 227 | raise AssertionError 228 | except ValueError: 229 | pass 230 | 231 | try: 232 | g.add_edge((1, 2, 3), (2,)) 233 | raise AssertionError 234 | except ValueError: 235 | pass 236 | 237 | try: 238 | g.add_edge((1, 2, 3), 2) 239 | raise AssertionError 240 | except TypeError: 241 | pass 242 | 243 | try: 244 | g.add_edge((1.0, 1.0, 1.0), (1, 1.0, "1")) 245 | raise AssertionError 246 | except TypeError: 247 | pass 248 | 249 | try: 250 | g.add_node(1) 251 | raise AssertionError 252 | except TypeError: 253 | pass 254 | 255 | try: 256 | g.add_node((1,)) 257 | raise AssertionError 258 | except ValueError: 259 | pass 260 | 261 | try: 262 | g.n_nearest_neighbours(1) 263 | raise AssertionError 264 | except TypeError: 265 | pass 266 | 267 | try: 268 | g.n_nearest_neighbours((1,)) 269 | raise AssertionError 270 | except ValueError: 271 | pass 272 | 273 | try: 274 | g.n_nearest_neighbours((1, 2, 3), n='abc') 275 | raise AssertionError 276 | except TypeError: 277 | pass 278 | 279 | try: 280 | g.n_nearest_neighbours((1, 2, 3), n=-1) 281 | raise AssertionError 282 | except ValueError: 283 | pass 284 | -------------------------------------------------------------------------------- /tests/test_topology.py: -------------------------------------------------------------------------------- 1 | import time 2 | from graph import Graph 3 | from graph.critical_path import Task, critical_path, critical_path_minimize_for_slack 4 | from graph.dag import phase_lines 5 | from tests import profileit 6 | from tests.test_graph import ( 7 | graph02, 8 | graph3x3, 9 | graph_cycle_6, 10 | graph_cycle_5, 11 | fully_connected_4, 12 | mountain_river_map, 13 | sycamore, 14 | small_project_for_critical_path_method, 15 | ) 16 | 17 | 18 | def test_subgraph(): 19 | g = graph3x3() 20 | g2 = g.subgraph_from_nodes([1, 2, 3, 4]) 21 | d = { 22 | 1: {2: 1, 4: 1}, 23 | 2: {3: 1}, 24 | } 25 | assert g2.is_subgraph(g) 26 | for k, v in d.items(): 27 | for k2, d2 in v.items(): 28 | assert g.edge(k, k2) == g2.edge(k, k2) 29 | 30 | g3 = graph3x3() 31 | g3.add_edge(3, 100, 7) 32 | assert not g3.is_subgraph(g2) 33 | 34 | 35 | def test_is_partite(): 36 | g = graph_cycle_6() 37 | bol, partitions = g.is_partite(n=2) 38 | assert bol is True 39 | 40 | g = graph_cycle_5() 41 | bol, part = g.is_partite(n=2) 42 | assert bol is False 43 | bol, part = g.is_partite(n=5) 44 | assert bol is True 45 | assert len(part) == 5 46 | 47 | 48 | def test_is_cyclic(): 49 | g = graph_cycle_5() 50 | assert g.has_cycles() 51 | 52 | 53 | def test_is_not_cyclic(): 54 | g = graph3x3() 55 | assert not g.has_cycles() 56 | 57 | def test_has_cycles(): 58 | g = Graph(from_list=[(1, 2), (2, 3), (3, 1)]) 59 | assert g.has_cycles() 60 | g.add_node(4) 61 | assert g.has_cycles() 62 | 63 | g.add_edge(4,5) 64 | assert g.has_cycles() 65 | g.add_edge(5,4) 66 | assert g.has_cycles() 67 | 68 | 69 | def test_is_really_cyclic(): 70 | g = Graph(from_list=[(1, 1, 1), (2, 2, 1)]) # two loops onto themselves. 71 | assert g.has_cycles() 72 | 73 | 74 | def test_components(): 75 | g = Graph( 76 | from_list=[ 77 | (1, 2, 1), # component 1 78 | (2, 1, 1), 79 | (3, 3, 1), # component 2 80 | (4, 5, 1), 81 | (5, 6, 1), # component 3 82 | (5, 7, 1), 83 | (6, 8, 1), 84 | (7, 8, 1), 85 | (8, 9, 1), 86 | ] 87 | ) 88 | g.add_node(10) # component 4 89 | components = g.components() 90 | assert len(components) == 4 91 | assert {1, 2} in components 92 | assert {3} in components 93 | assert {4, 5, 6, 7, 8, 9} in components 94 | assert {10} in components 95 | 96 | 97 | def test_network_size(): 98 | g = graph3x3() 99 | ns1 = g.network_size(n1=1) 100 | assert len(ns1) == 9 # all nodes. 101 | 102 | ns1_2 = g.network_size(n1=1, degrees_of_separation=2) 103 | assert all(i not in ns1_2 for i in [6, 8, 9]) 104 | 105 | ns5 = g.network_size(n1=5) 106 | assert len(ns5) == 4 # all nodes downstream from 5 (plus 5 itself) 107 | 108 | ns9 = g.network_size(n1=9) 109 | assert len(ns9) == 1 # just node 9, as there are no downstream peers. 110 | 111 | 112 | def test_network_size_when_fully_connected(): 113 | """tests network size when the peer has already been seen during search.""" 114 | g = fully_connected_4() 115 | ns = g.network_size(n1=1) 116 | assert len(ns) == len(g.nodes()) 117 | 118 | 119 | def test_phase_lines_with_loop(): 120 | g = graph3x3() 121 | g.add_edge(9, 1) 122 | try: 123 | _ = g.phase_lines() 124 | assert False, "the graph is cyclic" 125 | except AttributeError: 126 | assert True 127 | 128 | 129 | def test_phase_lines_with_inner_loop(): 130 | g = graph3x3() 131 | g.add_edge(9, 2) 132 | try: 133 | _ = g.phase_lines() 134 | assert False, "the graph is cyclic" 135 | except AttributeError: 136 | assert True 137 | 138 | 139 | def test_phase_lines_with_inner_loop2(): 140 | g = graph3x3() 141 | g.add_edge(3, 2) 142 | try: 143 | _ = g.phase_lines() 144 | assert False, "the graph is cyclic" 145 | except AttributeError: 146 | assert True 147 | 148 | 149 | def test_phase_lines_with_inner_loop3(): 150 | g = graph3x3() 151 | g.add_edge(9, 10) 152 | g.add_edge(10, 5) 153 | try: 154 | _ = g.phase_lines() 155 | assert False, "the graph is cyclic" 156 | except AttributeError: 157 | assert True 158 | 159 | 160 | def test_offset_phase_lines(): 161 | """ 162 | This test recreates a bug 163 | 164 | 1 165 | | 166 | 2 167 | | 168 | 3 <--- 169 | | / | 170 | 4 A ^ 171 | | | | 172 | 5 B | 173 | |___| / 174 | 6____/ 175 | | 176 | 7 177 | 178 | """ 179 | g = Graph( 180 | from_list=[ 181 | (1, 2, 1), 182 | (2, 3, 1), 183 | (3, 4, 1), 184 | (4, 5, 1), 185 | (5, 6, 1), 186 | (6, 7, 1), 187 | (6, 4, 1), 188 | ("a", "b", 1), 189 | ("b", 6, 1), 190 | ] 191 | ) 192 | try: 193 | _ = g.phase_lines() 194 | assert False, "the graph is cyclic" 195 | except AttributeError: 196 | assert True 197 | 198 | 199 | def test_phaselines(): 200 | """ 201 | 1 +---> 3 +--> 5 +---> 6 [7] 202 | ^ ^ 203 | +------------+ | 204 | | 205 | 2 +---> 4 +----------> + 206 | """ 207 | g = Graph( 208 | from_list=[ 209 | (1, 3, 1), 210 | (2, 4, 1), 211 | (2, 5, 1), 212 | (3, 5, 1), 213 | (4, 6, 1), 214 | (5, 6, 1), 215 | ] 216 | ) 217 | g.add_node(7) 218 | 219 | p = g.phase_lines() 220 | assert set(g.nodes()) == set(p.keys()) 221 | expects = {1: 0, 2: 0, 7: 0, 3: 1, 4: 1, 5: 2, 6: 3} 222 | assert p == expects, (p, expects) 223 | 224 | 225 | def test_phaselines_for_ordering(): 226 | """ 227 | u1 u4 u2 u3 228 | | | |_______| 229 | csg cs3 append 230 | | | | 231 | op1 | op3 232 | | | | 233 | op2 | cs2 234 | | |___________| 235 | cs1 join 236 | | | 237 | map1 map2 238 | |___________| 239 | save 240 | 241 | """ 242 | L = [ 243 | ("u1", "csg", 1), 244 | ("csg", "op1", 1), 245 | ("op1", "op2", 1), 246 | ("op2", "cs1", 1), 247 | ("cs1", "map1", 1), 248 | ("map1", "save", 1), 249 | ("u4", "cs3", 1), 250 | ("cs3", "join", 1), 251 | ("join", "map2", 1), 252 | ("map2", "save", 1), 253 | ("u2", "append", 1), 254 | ("u3", "append", 1), 255 | ("append", "op3", 1), 256 | ("op3", "cs2", 1), 257 | ("cs2", "join", 1), 258 | ] 259 | 260 | g = Graph(from_list=L) 261 | 262 | p = g.phase_lines() 263 | 264 | expected = { 265 | "u1": 0, 266 | "u4": 0, 267 | "u2": 0, 268 | "u3": 0, 269 | "csg": 1, 270 | "cs3": 1, 271 | "append": 1, 272 | "op1": 2, 273 | "op3": 2, 274 | "op2": 3, 275 | "cs2": 3, 276 | "cs1": 4, 277 | "join": 4, 278 | "map1": 5, 279 | "map2": 5, 280 | "save": 6, 281 | } 282 | 283 | assert p == expected, {(k, v) for k, v in p.items()} - {(k, v) for k, v in expected.items()} 284 | 285 | 286 | def test_phaselines_for_larger_graph(): 287 | g = mountain_river_map() 288 | start = time.time() 289 | p = g.phase_lines() 290 | g.has_cycles() 291 | end = time.time() 292 | 293 | # primary objective: correctness. 294 | assert len(p) == 253, len(p) 295 | 296 | # secondary objective: timeliness 297 | assert end - start < 1 # second. 298 | 299 | # third objective: efficiency. 300 | max_calls = 13000 301 | 302 | profiled_phaseline_func = profileit(phase_lines) 303 | 304 | calls, text = profiled_phaseline_func(g) 305 | if calls > max_calls: 306 | raise Exception(f"too many function calls: {text}") 307 | 308 | 309 | def test_phaselines_for_sycamore(): 310 | g = sycamore() 311 | start = time.time() 312 | p = g.phase_lines() 313 | end = time.time() 314 | 315 | # primary objective: correctness. 316 | assert len(p) == 1086, len(p) 317 | 318 | # secondary objective: timeliness 319 | assert end - start < 1 # second. 320 | 321 | # third objective: efficiency. 322 | max_calls = 17845 323 | 324 | profiled_phaseline_func = profileit(phase_lines) 325 | 326 | calls, text = profiled_phaseline_func(g) 327 | if calls > max_calls: 328 | raise Exception(f"too many function calls:\n{text}") 329 | 330 | t = list(g.topological_sort()) 331 | assert t!=p 332 | 333 | 334 | def test_sources(): 335 | g = graph02() 336 | s = g.sources(5) 337 | e = {1, 2, 3} 338 | assert s == e 339 | 340 | s2 = g.sources(1) 341 | e2 = set() 342 | assert s2 == e2, s2 343 | 344 | s3 = g.sources(6) 345 | e3 = {1, 2, 3, 4, 5} 346 | assert s3 == e3 347 | 348 | s4 = g.sources(7) 349 | e4 = set() 350 | assert s4 == e4 351 | 352 | 353 | def test_topological_sort(): 354 | g = graph02() 355 | outcome = [n for n in g.topological_sort()] 356 | assert outcome == [1, 2, 7, 3, 4, 5, 6] 357 | 358 | outcome = [n for n in g.topological_sort(key=lambda x: -x)] 359 | assert outcome == [7, 2, 1, 4, 3, 5, 6] 360 | 361 | 362 | def test_critical_path(): 363 | g = small_project_for_critical_path_method() 364 | 365 | critical_path_length, schedule = critical_path(g) 366 | assert critical_path_length == 65 367 | expected_schedule = [ 368 | Task("A", 10, 0, 0, 10, 10), 369 | Task("B", 20, 10, 10, 30, 30), 370 | Task("C", 5, 30, 30, 35, 35), 371 | Task("D", 10, 35, 35, 45, 45), 372 | Task("E", 20, 45, 45, 65, 65), 373 | Task("F", 15, 10, 25, 25, 40), 374 | Task("G", 5, 25, 40, 30, 45), 375 | Task("H", 15, 10, 30, 25, 45), 376 | ] 377 | 378 | for task in expected_schedule[:]: 379 | t2 = schedule[task.task_id] 380 | if task == t2: 381 | expected_schedule.remove(task) 382 | else: 383 | print(task, t2) 384 | raise Exception 385 | assert expected_schedule == [] 386 | 387 | for tid, slack in {"F": 15, "G": 15, "H": 20}.items(): 388 | task = schedule[tid] 389 | assert task.slack == slack 390 | 391 | # Note: By introducing a fake dependency from H to F. 392 | # the most efficient schedule is constructed, as all 393 | # paths become critical paths, e.g. where slack is 394 | # minimised as slack --> 0. 395 | 396 | g2 = critical_path_minimize_for_slack(g) 397 | critical_path_length, schedule = critical_path(g2) 398 | assert sum(t.slack for t in schedule.values()) == 0 399 | 400 | 401 | def test_critical_path2(): 402 | tasks = {"A": 1, "B": 10, "C": 1, "D": 5, "E": 2, "F": 1, "G": 1, "H": 1, "I": 1} 403 | dependencies = [ 404 | ("A", "B"), 405 | ("B", "C"), 406 | ] 407 | for letter in "DEFGHI": 408 | dependencies.append(("A", letter)) 409 | dependencies.append((letter, "C")) 410 | 411 | g = Graph() 412 | for n, d in tasks.items(): 413 | g.add_node(n, obj=d) 414 | for n1, n2 in dependencies: 415 | g.add_edge(n1, n2) 416 | 417 | g2 = critical_path_minimize_for_slack(g) 418 | critical_path_length, schedule = critical_path(g2) 419 | assert sum(t.slack for t in schedule.values()) == 0, schedule 420 | 421 | 422 | def test_critical_path3(): 423 | g = small_project_for_critical_path_method() 424 | g.add_node("H", obj=5) # reducing duration from 15 to 5, will produce more options. 425 | g2 = critical_path_minimize_for_slack(g) 426 | critical_path_length, schedule = critical_path(g2) 427 | assert critical_path_length == 65 428 | assert sum(t.slack for t in schedule.values()) == 30, schedule 429 | -------------------------------------------------------------------------------- /tests/test_transform.py: -------------------------------------------------------------------------------- 1 | from graph import Graph 2 | from tests.test_graph import graph3x3, graph03 3 | 4 | 5 | def test_adjacency_matrix(): 6 | g = graph3x3() 7 | am = g.adjacency_matrix() 8 | g2 = Graph(from_dict=am) 9 | assert g.is_subgraph(g2) 10 | assert not g2.is_subgraph(g) 11 | 12 | 13 | def test_all_pairs_shortest_path(): 14 | g = graph03() 15 | d = g.all_pairs_shortest_paths() 16 | g2 = Graph(from_dict=d) 17 | for n1 in g.nodes(): 18 | for n2 in g.nodes(): 19 | if n1 == n2: 20 | continue 21 | d, path = g.shortest_path(n1, n2) 22 | d2 = g2.edge(n1, n2) 23 | assert d == d2 24 | 25 | g2.add_node(100) 26 | d = g2.all_pairs_shortest_paths() 27 | # should trigger print of isolated node. -------------------------------------------------------------------------------- /tests/test_transshipment_problem.py: -------------------------------------------------------------------------------- 1 | from graph import Graph 2 | from graph.transshipment_problem import clondike_transshipment_problem, Train, schedule_rail_system 3 | 4 | 5 | def test_mining_train(): 6 | """ 7 | Assures that a train can schedule a number of in, out and in/out jobs 8 | using TSP. 9 | """ 10 | g = clondike_transshipment_problem() 11 | assert isinstance(g, Graph) 12 | 13 | equipment_deliveries = [ 14 | ("L-1", "L-1-1"), 15 | ("L-1", "L-1-2"), # origin, destination 16 | ("L-1", "L-1-3"), 17 | ("L-1", "L-1-4") 18 | ] 19 | 20 | mineral_deliveries = [ 21 | ("L-1-1", "L-1"), 22 | ("L-1-2", "L-1"), 23 | ("L-1-3", "L-1"), 24 | ("L-1-4", "L-1"), 25 | ] 26 | 27 | access_nodes = {"L-1", "L-1-1", "L-1-2", "L-1-3", "L-1-4"} 28 | 29 | train = Train(rail_network=g, start_location="L-1", access=access_nodes) 30 | 31 | s1 = train.schedule(equipment_deliveries) 32 | s2 = train.schedule(mineral_deliveries) 33 | s3 = train.schedule(equipment_deliveries[:] + mineral_deliveries[:]) 34 | 35 | s1_expected = [ 36 | ('L-1', 'L-1-1'), ('L-1', 'L-1-2'), ('L-1', 'L-1-3'), ('L-1', 'L-1-4') 37 | ] # shortest jobs first.! 38 | 39 | s2_expected = [ 40 | ('L-1-1', 'L-1'), ('L-1-2', 'L-1'), ('L-1-3', 'L-1'), ('L-1-4', 'L-1') 41 | ] # shortest job first! 42 | 43 | s3_expected = [ 44 | ('L-1', 'L-1-1'), ('L-1-1', 'L-1'), # circuit 1 45 | ('L-1', 'L-1-2'), ('L-1-2', 'L-1'), # circuit 2 46 | ('L-1', 'L-1-3'), ('L-1-3', 'L-1'), # circuit 3 47 | ('L-1', 'L-1-4'), ('L-1-4', 'L-1') # circuit 4 48 | ] # shortest circuit first. 49 | 50 | assert s1 == s1_expected 51 | assert s2 == s2_expected 52 | assert s3 == s3_expected 53 | 54 | 55 | def test_surface_mining_equipment_delivery(): 56 | """ 57 | Assures that equipment from the surface can arrive in the mine 58 | """ 59 | g = clondike_transshipment_problem() 60 | 61 | equipment_deliveries = [ 62 | ("Surface", "L-1-1"), 63 | ("Surface", "L-1-2"), # origin, destination 64 | ] 65 | 66 | lift_access = {"Surface", "L-1", "L-2"} 67 | lift = Train(rail_network=g, start_location="Surface", access=lift_access) 68 | 69 | L1_access = {"L-1", "L-1-1", "L-1-2", "L-1-3", "L-1-4"} 70 | level_1_train = Train(rail_network=g, start_location="L-1", access=L1_access) 71 | 72 | assert lift_access.intersection(L1_access), "routing not possible!" 73 | 74 | schedule_rail_system(rail_network=g, trains=[lift, level_1_train], 75 | jobs=equipment_deliveries) 76 | s1 = level_1_train.schedule() 77 | s2 = lift.schedule() 78 | 79 | s1_expected = [('L-1', 'L-1-1'), ('L-1', 'L-1-2')] 80 | s2_expected = [("Surface", "L-1"), ("Surface", "L-1")] 81 | 82 | assert s1 == s1_expected 83 | assert s2 == s2_expected 84 | 85 | 86 | def test_double_direction_delivery(): 87 | """ 88 | Tests a double delivery schedule: 89 | Lift is delivering equipment into the mine as Job-1, Job-2 90 | Train is delivering gold out of the mine as Job-3, Job-4 91 | The schedules are thereby: 92 | Lift: [Job-1][go back][Job-2] 93 | Train: [Job-3][go back][Job-4] 94 | The combined schedule should thereby be: 95 | Lift: [Job-1][Job-3][Job-2][Job-4] 96 | Train: [Job-3][Job-1][Job-4][Job-2] 97 | which yields zero idle runs. 98 | """ 99 | g = clondike_transshipment_problem() 100 | 101 | equipment_deliveries = [ 102 | ("Surface", "L-1-1"), 103 | ("Surface", "L-1-2") 104 | ] 105 | 106 | mineral_deliveries = [ 107 | ("L-1-1", "Surface"), 108 | ("L-1-2", "Surface") 109 | ] 110 | lift_access = {"Surface", "L-1", "L-2"} 111 | lift = Train(rail_network=g, start_location="Surface", access=lift_access) 112 | 113 | L1_access = {"L-1", "L-1-1", "L-1-2", "L-1-3", "L-1-4"} 114 | level_1_train = Train(rail_network=g, start_location="L-1", access=L1_access) 115 | 116 | assert lift_access.intersection(L1_access), "routing not possible!" 117 | 118 | schedule_rail_system(rail_network=g, trains=[lift, level_1_train], 119 | jobs=equipment_deliveries + mineral_deliveries) 120 | s1 = level_1_train.schedule() 121 | s2 = lift.schedule() 122 | 123 | s1_expected = [('L-1', 'L-1-1'), ('L-1-1', 'L-1'), ('L-1', 'L-1-2'), ('L-1-2', 'L-1')] 124 | s2_expected = [('Surface', 'L-1'), ('L-1', 'Surface'), ('Surface', 'L-1'), ('L-1', 'Surface')] 125 | 126 | assert s1 == s1_expected 127 | assert s2 == s2_expected 128 | -------------------------------------------------------------------------------- /tests/test_visuals.py: -------------------------------------------------------------------------------- 1 | from graph.visuals import plot_2d 2 | from graph import Graph 3 | 4 | 5 | def test_bad_input(): 6 | g = Graph(from_list=[ 7 | (0, 1, 1), # 3 edges: 8 | ]) 9 | try: 10 | _ = plot_2d(g) 11 | raise AssertionError 12 | except (ValueError, ImportError): 13 | pass 14 | 15 | 16 | def test_bad_input1(): 17 | g = Graph(from_list=[ 18 | ((1,), (0, 1), 1), 19 | ]) 20 | try: 21 | _ = plot_2d(g) 22 | raise AssertionError 23 | except (ValueError, ImportError): 24 | pass 25 | 26 | 27 | def test_bad_input2(): 28 | g = Graph(from_list=[ 29 | ((1, 2), ('a', 'b'), 1), 30 | ]) 31 | try: 32 | _ = plot_2d(g) 33 | raise AssertionError 34 | except (ValueError, ImportError): 35 | pass 36 | 37 | 38 | def test_bad_input3(): 39 | g = Graph(from_list=[ 40 | ((1, 'b'), ('b', 1), 1) 41 | ]) 42 | try: 43 | _ = plot_2d(g) 44 | raise AssertionError 45 | except (ValueError, ImportError): 46 | pass 47 | 48 | -------------------------------------------------------------------------------- /tests/test_wtap.py: -------------------------------------------------------------------------------- 1 | from fractions import Fraction as F 2 | from itertools import permutations, combinations_with_replacement 3 | from random import random 4 | from graph import Graph 5 | from graph.assignment_problem import wtap_solver 6 | 7 | 8 | def test_damage_assessment_calculation(): 9 | g, weapons, target_values = wikipedia_wtap_setup() 10 | 11 | edges = [ 12 | ("tank-0", 1, 0.3), 13 | ("tank-1", 1, 0.3), 14 | ("tank-2", 1, 0.3), 15 | ("tank-3", 2, 0.2), 16 | ("tank-4", 2, 0.2), 17 | ("aircraft-0", 3, 0.5), 18 | ("aircraft-1", 3, 0.5), 19 | ("ship-0", 2, 0.5), 20 | ] 21 | assignment = Graph(from_list=edges) 22 | assert 9.915 == wtap_damage_assessment(probabilities=g, assignment=assignment, target_values=target_values) 23 | 24 | 25 | def test_basic_wtap(): 26 | weapons = [1, 2, 3] 27 | probabilities = [ 28 | (1, 5, 0.1), 29 | (1, 6, 0.1), 30 | (1, 7, 0.1), 31 | (2, 5, 0.1), 32 | (2, 6, 0.1), 33 | (2, 7, 0.1), 34 | (3, 5, 0.1), 35 | (3, 6, 0.1), 36 | (3, 7, 0.1), 37 | ] 38 | target_values = {5: 5, 6: 6, 7: 7} 39 | g = Graph(from_list=probabilities) 40 | 41 | value, assignments = wtap_solver(probabilities=g, weapons=weapons, target_values=target_values) 42 | assert isinstance(assignments, Graph) 43 | assert set(assignments.edges()) == {(2, 7, 0.1), (3, 6, 0.1), (1, 7, 0.1)} 44 | assert value == 16.07 45 | 46 | 47 | def test_wtap_with_fractional_probabilities(): 48 | weapons = [1, 2, 3] 49 | probabilities = [ 50 | (1, 5, F(1, 10)), 51 | (1, 6, F(1, 10)), 52 | (1, 7, F(1, 10)), 53 | (2, 5, F(1, 10)), 54 | (2, 6, F(1, 10)), 55 | (2, 7, F(1, 10)), 56 | (3, 5, F(1, 10)), 57 | (3, 6, F(1, 10)), 58 | (3, 7, F(1, 10)), 59 | ] 60 | target_values = {5: 5, 6: 6, 7: 7} 61 | g = Graph(from_list=probabilities) 62 | 63 | value, assignments = wtap_solver(probabilities=g, weapons=weapons, target_values=target_values) 64 | assert isinstance(assignments, Graph) 65 | assert set(assignments.edges()) == {(2, 7, F(1, 10)), (3, 6, F(1, 10)), (1, 7, F(1, 10))} 66 | assert float(value) == 16.07 67 | 68 | 69 | def test_exhaust_all_initialisation_permutations(): 70 | """Uses the wikipedia WTAP setup.""" 71 | g, weapons, target_values = wikipedia_wtap_setup() 72 | 73 | perfect_score = 4.95 74 | quality_score = 0 75 | quality_required = 0.97 76 | 77 | variations = {} 78 | damages = {} 79 | perms = set(permutations(weapons, len(weapons))) 80 | 81 | c = 0 82 | while perms: 83 | perm = perms.pop() 84 | perm2 = tuple(reversed(perm)) 85 | perms.remove(perm2) 86 | 87 | if random() < 0.5: 88 | continue # skip 50 % of tests at random. 89 | 90 | damage1, ass1 = wtap_solver(probabilities=g, weapons=list(perm), target_values=target_values) 91 | damage2, ass2 = wtap_solver(probabilities=g, weapons=list(perm2), target_values=target_values) 92 | 93 | damage_n = min(damage1, damage2) 94 | if damage1 == damage_n: 95 | assignment = ass1 96 | else: 97 | assignment = ass2 98 | 99 | damage = wtap_damage_assessment(probabilities=g, assignment=assignment, target_values=target_values) 100 | assert round(damage_n, 2) == round(damage, 2) 101 | damage = round(damage, 2) 102 | 103 | quality_score += damage 104 | c += 1 105 | 106 | if damage not in damages: 107 | s = "{:.3f} : {}".format(damage, wikipedia_wtap_pretty_printer(assignment)) 108 | damages[damage] = s 109 | if damage not in variations: 110 | variations[damage] = 1 111 | else: 112 | variations[damage] += 1 113 | 114 | print("tested", c, "permutations. Found", len(damages), "variation(s)") 115 | if len(variations) > 1: 116 | for k, v in sorted(damages.items()): 117 | print(k, "frq: {}".format(variations[k])) 118 | 119 | solution_quality = perfect_score * c / quality_score 120 | if solution_quality >= quality_required: 121 | raise AssertionError("achieved {:.0f}%".format(solution_quality * 100)) 122 | print("achieved {:.0f}%".format(solution_quality * 100)) 123 | 124 | 125 | def test_exhaustive_search_to_verify_wtap(): 126 | """Uses the wikipedia WTAP setup.""" 127 | g, weapons, target_values = wikipedia_wtap_setup() 128 | 129 | best_result = sum(target_values.values()) + 1 130 | best_assignment = None 131 | c = 0 132 | for perm in permutations(weapons, len(weapons)): 133 | for combination in combinations_with_replacement([1, 2, 3], len(weapons)): 134 | edges = [(w, t, g.edge(w, t)) for w, t in zip(perm, combination)] 135 | a = Graph(from_list=edges) 136 | r = wtap_damage_assessment(probabilities=g, assignment=a, target_values=target_values) 137 | if r < best_result: 138 | best_result = r 139 | best_assignment = edges 140 | c += 1 141 | print("{} is best result out of {:,} options(exhaustive search):\n{}".format(best_result, c, best_assignment)) 142 | assert best_result == 4.95, best_result 143 | 144 | 145 | def wikipedia_wtap_setup(): 146 | """ 147 | A commander has 5 tanks, 2 aircraft and 1 sea vessel and is told to 148 | engage 3 targets with values 5,10,20 ... 149 | """ 150 | tanks = ["tank-{}".format(i) for i in range(5)] 151 | aircrafts = ["aircraft-{}".format(i) for i in range(2)] 152 | ships = ["ship-{}".format(i) for i in range(1)] 153 | weapons = tanks + aircrafts + ships 154 | target_values = {1: 5, 2: 10, 3: 20} 155 | 156 | tank_probabilities = [ 157 | (1, 0.3), 158 | (2, 0.2), 159 | (3, 0.5), 160 | ] 161 | 162 | aircraft_probabilities = [ 163 | (1, 0.1), 164 | (2, 0.6), 165 | (3, 0.5), 166 | ] 167 | 168 | sea_vessel_probabilities = [(1, 0.4), (2, 0.5), (3, 0.4)] 169 | 170 | category_and_probabilities = [ 171 | (tanks, tank_probabilities), 172 | (aircrafts, aircraft_probabilities), 173 | (ships, sea_vessel_probabilities), 174 | ] 175 | 176 | probabilities = [] 177 | for category, probs in category_and_probabilities: 178 | for vehicle in category: 179 | for prob in probs: 180 | probabilities.append((vehicle,) + prob) 181 | 182 | g = Graph(from_list=probabilities) 183 | return g, weapons, target_values 184 | 185 | 186 | def wtap_damage_assessment(probabilities, assignment, target_values): 187 | """ 188 | :param probabilities: graph. 189 | :param assignment: graph 190 | :param target_values: dictionary 191 | :return: total survival value of the targets. 192 | """ 193 | assert isinstance(probabilities, Graph) 194 | assert isinstance(assignment, Graph) 195 | result = assignment.edges() 196 | assert isinstance(target_values, dict) 197 | 198 | survival_value = {} 199 | for item in result: 200 | weapon, target, damage = item 201 | if target not in survival_value: 202 | survival_value[target] = {} 203 | wtype = weapon.split("-")[0] 204 | if wtype not in survival_value[target]: 205 | survival_value[target][wtype] = 0 206 | survival_value[target][wtype] += 1 207 | 208 | total_survival_value = 0 209 | for target, assigned_weapons in survival_value.items(): 210 | p = 1 211 | for wtype, quantity in assigned_weapons.items(): 212 | weapon = wtype + "-0" 213 | p_base = 1 - probabilities.edge(weapon, target) 214 | p *= p_base**quantity 215 | 216 | total_survival_value += p * target_values[target] 217 | 218 | for target in target_values: 219 | if target not in survival_value: 220 | total_survival_value += target_values[target] 221 | 222 | return total_survival_value 223 | 224 | 225 | def wikipedia_wtap_pretty_printer(assignment): 226 | """Produces a human readable print out of the assignment 227 | :param assignment: graph 228 | :return: str 229 | """ 230 | assert isinstance(assignment, Graph) 231 | result = assignment.edges() 232 | survival_value = {} 233 | for item in result: 234 | weapon, target, damage = item 235 | if target not in survival_value: 236 | survival_value[target] = {} 237 | wtype = weapon.split("-")[0] 238 | if wtype not in survival_value[target]: 239 | survival_value[target][wtype] = 0 240 | survival_value[target][wtype] += 1 241 | 242 | lines = [] 243 | for target, wtypes in sorted(survival_value.items()): 244 | lines.append("T-{}: ".format(target)) 245 | _and = " + " 246 | for wtype, qty in sorted(wtypes.items()): 247 | if qty > 1: 248 | _wtype = wtype + "s" 249 | else: 250 | _wtype = wtype 251 | lines.append("{} {}".format(qty, _wtype)) 252 | lines.append(_and) 253 | lines.pop(-1) 254 | lines.append(", ") 255 | s = "".join(lines) 256 | return s 257 | --------------------------------------------------------------------------------