├── hygese ├── tests │ ├── __init__.py │ ├── test_tsp.py │ └── test_cvrp.py ├── __init__.py └── hygese.py ├── pyproject.toml ├── LICENSE ├── .github └── workflows │ ├── ci.yml │ └── pypi.yml ├── .gitignore ├── setup.py └── README.md /hygese/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /hygese/__init__.py: -------------------------------------------------------------------------------- 1 | from .hygese import * -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "cmake"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Changhyun Kwon and contributors 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. -------------------------------------------------------------------------------- /.github/workflows/ci.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: CI 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", "windows-2019", "macos-latest" ] 20 | python-version: [ "3.11", "3.10" ] 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | 25 | - name: Set up Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v2 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | python -m pip install flake8 pytest 34 | # if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 35 | 36 | - name: Lint with flake8 37 | run: | 38 | # stop the build if there are Python syntax errors or undefined names 39 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 40 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 41 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 42 | 43 | - name: Install and Build 44 | run: | 45 | pip install . 46 | python setup.py build 47 | 48 | - name: Test with pytest 49 | run: | 50 | pip install pytest-cov 51 | pytest -s --cov=hygese --cov-report=xml 52 | 53 | - name: Upload coverage to Codecov 54 | uses: codecov/codecov-action@v2 55 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 source distribution to PyPI 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | name: Build source distribution 📦 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | persist-credentials: false 14 | - name: Set up Python 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: "3.x" 18 | - name: Install pypa/build 19 | run: python3 -m pip install build --user 20 | - name: Build a source tarball (sdist) only 21 | run: python3 -m build --sdist 22 | - name: Store the distribution package 23 | uses: actions/upload-artifact@v4 24 | with: 25 | name: python-package-sdist 26 | path: dist/ 27 | 28 | publish-to-pypi: 29 | name: Publish Python 🐍 source distribution 📦 to PyPI 30 | if: startsWith(github.ref, 'refs/tags/') 31 | needs: build 32 | runs-on: ubuntu-latest 33 | environment: 34 | name: pypi 35 | url: https://pypi.org/project/ # Replace with your PyPI project name. 36 | permissions: 37 | id-token: write 38 | steps: 39 | - name: Download the sdist artifact 40 | uses: actions/download-artifact@v4 41 | with: 42 | name: python-package-sdist 43 | path: dist/ 44 | - name: Publish source distribution 📦 to PyPI 45 | uses: pypa/gh-action-pypi-publish@release/v1 46 | 47 | github-release: 48 | name: Sign the Python 🐍 source distribution with Sigstore and create GitHub Release 49 | needs: publish-to-pypi 50 | runs-on: ubuntu-latest 51 | permissions: 52 | contents: write 53 | id-token: write 54 | steps: 55 | - name: Download the sdist artifact 56 | uses: actions/download-artifact@v4 57 | with: 58 | name: python-package-sdist 59 | path: dist/ 60 | - name: Sign the sdist with Sigstore 61 | uses: sigstore/gh-action-sigstore-python@v3.0.0 62 | with: 63 | inputs: | 64 | ./dist/*.tar.gz 65 | - name: Create GitHub Release 66 | env: 67 | GITHUB_TOKEN: ${{ github.token }} 68 | run: | 69 | gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --notes "" 70 | - name: Upload artifact signatures to GitHub Release 71 | env: 72 | GITHUB_TOKEN: ${{ github.token }} 73 | run: | 74 | gh release upload "$GITHUB_REF_NAME" dist/** --repo "$GITHUB_REPOSITORY" 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build_tmp/ 2 | .idea 3 | *.dylib 4 | *.so 5 | *.dll 6 | *.dll.a 7 | 8 | # Byte-compiled / optimized / DLL files 9 | __pycache__/ 10 | *.py[cod] 11 | *$py.class 12 | 13 | # C extensions 14 | *.so 15 | 16 | # Distribution / packaging 17 | .Python 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | var/ 29 | wheels/ 30 | pip-wheel-metadata/ 31 | share/python-wheels/ 32 | *.egg-info/ 33 | .installed.cfg 34 | *.egg 35 | MANIFEST 36 | 37 | # PyInstaller 38 | # Usually these files are written by a python script from a template 39 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 40 | *.manifest 41 | *.spec 42 | 43 | # Installer logs 44 | pip-log.txt 45 | pip-delete-this-directory.txt 46 | 47 | # Unit test / coverage reports 48 | htmlcov/ 49 | .tox/ 50 | .nox/ 51 | .coverage 52 | .coverage.* 53 | .cache 54 | nosetests.xml 55 | coverage.xml 56 | *.cover 57 | *.py,cover 58 | .hypothesis/ 59 | .pytest_cache/ 60 | 61 | # Translations 62 | *.mo 63 | *.pot 64 | 65 | # Django stuff: 66 | *.log 67 | local_settings.py 68 | db.sqlite3 69 | db.sqlite3-journal 70 | 71 | # Flask stuff: 72 | instance/ 73 | .webassets-cache 74 | 75 | # Scrapy stuff: 76 | .scrapy 77 | 78 | # Sphinx documentation 79 | docs/_build/ 80 | 81 | # PyBuilder 82 | target/ 83 | 84 | # Jupyter Notebook 85 | .ipynb_checkpoints 86 | 87 | # IPython 88 | profile_default/ 89 | ipython_config.py 90 | 91 | # pyenv 92 | .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 102 | __pypackages__/ 103 | 104 | # Celery stuff 105 | celerybeat-schedule 106 | celerybeat.pid 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # Environments 112 | .env 113 | .venv 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | .spyproject 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | /site 129 | 130 | # mypy 131 | .mypy_cache/ 132 | .dmypy.json 133 | dmypy.json 134 | 135 | # Pyre type checker 136 | .pyre/ 137 | -------------------------------------------------------------------------------- /hygese/tests/test_tsp.py: -------------------------------------------------------------------------------- 1 | from hygese import AlgorithmParameters, Solver 2 | # import random 3 | # import elkai 4 | # import numpy as np 5 | 6 | def test_tsp(): 7 | data = dict() 8 | data['distance_matrix'] = [ 9 | [0, 2451, 713, 1018, 1631, 1374, 2408, 213, 2571, 875, 1420, 2145, 1972], 10 | [2451, 0, 1745, 1524, 831, 1240, 959, 2596, 403, 1589, 1374, 357, 579], 11 | [713, 1745, 0, 355, 920, 803, 1737, 851, 1858, 262, 940, 1453, 1260], 12 | [1018, 1524, 355, 0, 700, 862, 1395, 1123, 1584, 466, 1056, 1280, 987], 13 | [1631, 831, 920, 700, 0, 663, 1021, 1769, 949, 796, 879, 586, 371], 14 | [1374, 1240, 803, 862, 663, 0, 1681, 1551, 1765, 547, 225, 887, 999], 15 | [2408, 959, 1737, 1395, 1021, 1681, 0, 2493, 678, 1724, 1891, 1114, 701], 16 | [213, 2596, 851, 1123, 1769, 1551, 2493, 0, 2699, 1038, 1605, 2300, 2099], 17 | [2571, 403, 1858, 1584, 949, 1765, 678, 2699, 0, 1744, 1645, 653, 600], 18 | [875, 1589, 262, 466, 796, 547, 1724, 1038, 1744, 0, 679, 1272, 1162], 19 | [1420, 1374, 940, 1056, 879, 225, 1891, 1605, 1645, 679, 0, 1017, 1200], 20 | [2145, 357, 1453, 1280, 586, 887, 1114, 2300, 653, 1272, 1017, 0, 504], 21 | [1972, 579, 1260, 987, 371, 999, 701, 2099, 600, 1162, 1200, 504, 0], 22 | ] 23 | 24 | # Solver initialization 25 | ap = AlgorithmParameters(timeLimit=0.8) # seconds 26 | 27 | # solve TSP using both coordinates and dist_mtx 28 | hgs_solver = Solver(parameters=ap, verbose=True) 29 | result = hgs_solver.solve_tsp(data) 30 | print(result.cost) 31 | print(result.routes) 32 | 33 | assert (result.cost == 7293) 34 | 35 | # elkai not working in python 3.10 36 | # def test_elkai(): 37 | # for i in range(10): 38 | # n = random.randint(10, 70) 39 | # x = np.random.rand(n) * 1000 40 | # y = np.random.rand(n) * 1000 41 | # 42 | # data = dict() 43 | # data['x_coordinates'] = x 44 | # data['y_coordinates'] = y 45 | # 46 | # ap = AlgorithmParameters(timeLimit=1.1) 47 | # hgs_solver = Solver(parameters=ap, verbose=True) 48 | # result = hgs_solver.solve_tsp(data) 49 | # 50 | # dist_mtx = np.zeros((n, n)) 51 | # for i in range(n): 52 | # for j in range(n): 53 | # dist_mtx[i][j] = np.sqrt( 54 | # np.square(x[i] - x[j]) + np.square(y[i] - y[j]) 55 | # ) 56 | # dist_mtx_int = np.rint(dist_mtx).astype(int) 57 | # route = elkai.solve_int_matrix(dist_mtx_int) 58 | # 59 | # cost = 0 60 | # for i in range(n-1): 61 | # cost += dist_mtx_int[route[i], route[i+1]] 62 | # 63 | # cost += dist_mtx_int[route[n-1], route[0]] 64 | # 65 | # assert result.cost == cost 66 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from setuptools.command.build_ext import build_ext as _build_ext 3 | from setuptools.command.build_py import build_py as _build_py 4 | import subprocess 5 | import os 6 | import platform 7 | from os.path import exists, join as pjoin 8 | import shutil 9 | 10 | import urllib.request 11 | 12 | urlretrieve = urllib.request.urlretrieve 13 | 14 | # try: 15 | # import urllib.request 16 | # urlretrieve = urllib.request.urlretrieve 17 | # except ImportError: # python 2 18 | # from urllib import urlretrieve 19 | 20 | 21 | # read the contents of your README file 22 | from pathlib import Path 23 | 24 | this_directory = Path(__file__).parent 25 | long_description = (this_directory / "README.md").read_text() 26 | 27 | 28 | def _run(cmd, cwd): 29 | subprocess.check_call(cmd, shell=True, cwd=cwd) 30 | 31 | 32 | def _safe_makedirs(*paths): 33 | for path in paths: 34 | try: 35 | os.makedirs(path) 36 | except os.error: 37 | pass 38 | 39 | 40 | HGS_VERSION = "2.0.0" 41 | HGS_SRC = f"https://github.com/vidalt/HGS-CVRP/archive/v{HGS_VERSION}.tar.gz" 42 | 43 | LIB_DIR = "lib" 44 | BUILD_DIR = "lib/build" 45 | BIN_DIR = "lib/bin" 46 | 47 | 48 | def get_lib_filename(): 49 | if platform.system() == "Linux": 50 | lib_ext = "so" 51 | elif platform.system() == "Darwin": 52 | lib_ext = "dylib" 53 | elif platform.system() == "Windows": 54 | lib_ext = "dll" 55 | else: 56 | lib_ext = "so" 57 | return f"libhgscvrp.{lib_ext}" 58 | 59 | 60 | LIB_FILENAME = get_lib_filename() 61 | 62 | 63 | def download_build_hgs(): 64 | _safe_makedirs(LIB_DIR) 65 | _safe_makedirs(BUILD_DIR) 66 | hgs_src_tarball_name = "{}.tar.gz".format(HGS_VERSION) 67 | hgs_src_path = pjoin(LIB_DIR, hgs_src_tarball_name) 68 | urlretrieve(HGS_SRC, hgs_src_path) 69 | _run(f"tar xzvf {hgs_src_tarball_name}", LIB_DIR) 70 | _run( 71 | f'cmake -DCMAKE_BUILD_TYPE=Release -G "Unix Makefiles" ../HGS-CVRP-{HGS_VERSION}', 72 | BUILD_DIR, 73 | ) 74 | _run("make lib", BUILD_DIR) 75 | 76 | shutil.copyfile(f"{BUILD_DIR}/{LIB_FILENAME}", f"hygese/{LIB_FILENAME}") 77 | 78 | 79 | # LIB_VERSION = "0.0.1" 80 | # HGS_CVRP_WIN = f"https://github.com/chkwon/Libhgscvrp_jll.jl/releases/download/libhgscvrp-v{LIB_VERSION}%2B0/" + \ 81 | # f"libhgscvrp.v{LIB_VERSION}.x86_64-w64-mingw32-cxx11.tar.gz" 82 | 83 | # def download_binary_hgs(): 84 | # print(HGS_CVRP_WIN) 85 | 86 | # _safe_makedirs(LIB_DIR) 87 | # dll_tarball_name = "win_bin.tar.gz" 88 | # hgs_bin_path = pjoin(LIB_DIR, dll_tarball_name) 89 | # urlretrieve(HGS_CVRP_WIN, hgs_bin_path) 90 | # _run(f"tar xzvf {dll_tarball_name}", LIB_DIR) 91 | # shutil.copyfile(f"{BIN_DIR}/{LIB_FILENAME}", f"hygese/{LIB_FILENAME}") 92 | 93 | 94 | class BuildPyCommand(_build_py): 95 | def run(self): 96 | print("Build!!!!!! Run!!!!") 97 | 98 | if platform.system() == "Windows": 99 | # download_binary_hgs() 100 | download_build_hgs() 101 | else: 102 | download_build_hgs() 103 | 104 | _build_py.run(self) 105 | 106 | 107 | setup( 108 | name="hygese", 109 | version="0.0.0.10", 110 | description="A Python wrapper for the HGS-CVRP solver", 111 | long_description=long_description, 112 | long_description_content_type="text/markdown", 113 | url="https://github.com/chkwon/PyHygese", 114 | author="Changhyun Kwon", 115 | author_email="chkwon@gmail.com", 116 | project_urls={ 117 | "Bug Tracker": "https://github.com/chkwon/PyHygese/issues", 118 | }, 119 | classifiers=[ 120 | "Programming Language :: Python :: 3", 121 | "License :: OSI Approved :: MIT License", 122 | "Operating System :: OS Independent", 123 | ], 124 | package_dir={"": "."}, 125 | packages=find_packages(), 126 | python_requires=">=3.6", 127 | cmdclass={ 128 | "build_py": BuildPyCommand, 129 | }, 130 | package_data={ 131 | "": ["libhgscvrp.*"], 132 | }, 133 | install_requires=["numpy"], 134 | ) 135 | -------------------------------------------------------------------------------- /hygese/tests/test_cvrp.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from hygese import AlgorithmParameters, Solver 3 | 4 | 5 | def get_data(): 6 | data = dict() 7 | data['distance_matrix'] = [ 8 | [0, 548, 776, 696, 582, 274, 502, 194, 308, 194, 536, 502, 388, 354, 468, 776, 662], 9 | [548, 0, 684, 308, 194, 502, 730, 354, 696, 742, 1084, 594, 480, 674, 1016, 868, 1210], 10 | [776, 684, 0, 992, 878, 502, 274, 810, 468, 742, 400, 1278, 1164, 1130, 788, 1552, 754], 11 | [696, 308, 992, 0, 114, 650, 878, 502, 844, 890, 1232, 514, 628, 822, 1164, 560, 1358], 12 | [582, 194, 878, 114, 0, 536, 764, 388, 730, 776, 1118, 400, 514, 708, 1050, 674, 1244], 13 | [274, 502, 502, 650, 536, 0, 228, 308, 194, 240, 582, 776, 662, 628, 514, 1050, 708], 14 | [502, 730, 274, 878, 764, 228, 0, 536, 194, 468, 354, 1004, 890, 856, 514, 1278, 480], 15 | [194, 354, 810, 502, 388, 308, 536, 0, 342, 388, 730, 468, 354, 320, 662, 742, 856], 16 | [308, 696, 468, 844, 730, 194, 194, 342, 0, 274, 388, 810, 696, 662, 320, 1084, 514], 17 | [194, 742, 742, 890, 776, 240, 468, 388, 274, 0, 342, 536, 422, 388, 274, 810, 468], 18 | [536, 1084, 400, 1232, 1118, 582, 354, 730, 388, 342, 0, 878, 764, 730, 388, 1152, 354], 19 | [502, 594, 1278, 514, 400, 776, 1004, 468, 810, 536, 878, 0, 114, 308, 650, 274, 844], 20 | [388, 480, 1164, 628, 514, 662, 890, 354, 696, 422, 764, 114, 0, 194, 536, 388, 730], 21 | [354, 674, 1130, 822, 708, 628, 856, 320, 662, 388, 730, 308, 194, 0, 342, 422, 536], 22 | [468, 1016, 788, 1164, 1050, 514, 514, 662, 320, 274, 388, 650, 536, 342, 0, 764, 194], 23 | [776, 868, 1552, 560, 674, 1050, 1278, 742, 1084, 810, 1152, 274, 388, 422, 764, 0, 798], 24 | [662, 1210, 754, 1358, 1244, 708, 480, 856, 514, 468, 354, 844, 730, 536, 194, 798, 0] 25 | ] 26 | data['num_vehicles'] = 4 27 | data['depot'] = 0 28 | data['demands'] = [0, 1, 1, 2, 4, 2, 4, 8, 8, 1, 2, 1, 2, 4, 4, 8, 8] 29 | data['vehicle_capacity'] = 15 # different from OR-Tools: homogeneous capacity 30 | return data 31 | 32 | 33 | def test_cvrp(): 34 | data = get_data() 35 | 36 | # Solver initialization 37 | ap = AlgorithmParameters() 38 | ap.timeLimit = 1.1 39 | hgs_solver = Solver(ap, True) 40 | 41 | # Solve 42 | result = hgs_solver.solve_cvrp(data) 43 | print(result.cost) 44 | print(result.routes) 45 | assert (result.cost == 6208) 46 | 47 | 48 | def test_cvrp_inputs(): 49 | data = get_data() 50 | 51 | # Solver initialization 52 | ap = AlgorithmParameters(timeLimit=1.1) 53 | hgs_solver = Solver(parameters=ap, verbose=True) 54 | 55 | # Solve 56 | result = hgs_solver.solve_cvrp(data) 57 | print(result.cost) 58 | print(result.routes) 59 | assert (result.cost == 6208) 60 | 61 | 62 | def test_cvrp_dist_mtx(): 63 | # Solver initialization 64 | ap = AlgorithmParameters(timeLimit=3.1) 65 | hgs_solver = Solver(parameters=ap, verbose=True) 66 | 67 | data = dict() 68 | n = 17 69 | x = np.random.rand(n) * 1000 70 | y = np.random.rand(n) * 1000 71 | data['x_coordinates'] = x 72 | data['y_coordinates'] = y 73 | 74 | data['depot'] = 0 75 | data['demands'] = [0, 1, 1, 2, 4, 2, 4, 8, 8, 1, 2, 1, 2, 4, 4, 8, 8] 76 | data['vehicle_capacity'] = 15 # different from OR-Tools: homogeneous capacity 77 | 78 | # Solve with calculated distances 79 | dist_mtx = np.zeros((n, n)) 80 | for i in range(n): 81 | for j in range(n): 82 | dist_mtx[i][j] = np.sqrt( 83 | np.square(x[i] - x[j]) + np.square(y[i] - y[j]) 84 | ) 85 | data['distance_matrix'] = dist_mtx 86 | result1 = hgs_solver.solve_cvrp(data) 87 | 88 | # solve without distance_matrix 89 | data.pop("distance_matrix", None) 90 | result2 = hgs_solver.solve_cvrp(data, rounding=False) 91 | assert abs(result1.cost - result2.cost) < 1e-3 92 | 93 | 94 | def test_cvrp_duration(): 95 | n = 10 96 | data = dict() 97 | data['x_coordinates'] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 98 | data['y_coordinates'] = [5, 4, 3, 2, 1, 9, 8, 7, 6, 5] 99 | data['demands'] = [0, 2, 3, 1, 2, 3, 1, 2, 3, 1] 100 | data['vehicle_capacity'] = 10 101 | data['duration_limit'] = 18 102 | data['num_vehicles'] = 5 103 | 104 | # Solver initialization 105 | ap = AlgorithmParameters(timeLimit=1.1, seed=12, useSwapStar=True) 106 | hgs_solver = Solver(parameters=ap, verbose=True) 107 | 108 | result = hgs_solver.solve_cvrp(data, rounding=True) 109 | assert result.cost == 42 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyHygese 2 | 3 | [![Build Status](https://github.com/chkwon/PyHygese/workflows/CI/badge.svg?branch=master)](https://github.com/chkwon/PyHygese/actions/workflows/ci.yml?query=workflow%3ACI) 4 | [![codecov](https://codecov.io/gh/chkwon/PyHygese/branch/master/graph/badge.svg)](https://codecov.io/gh/chkwon/PyHygese) 5 | [![PyPI version](https://badge.fury.io/py/hygese.svg)](https://badge.fury.io/py/hygese) 6 | 7 | *This package is under active development. It can introduce breaking changes anytime. Please use it at your own risk.* 8 | 9 | **A solver for the Capacitated Vehicle Routing Problem (CVRP)** 10 | 11 | This package provides a simple Python wrapper for the Hybrid Genetic Search solver for Capacitated Vehicle Routing Problems [(HGS-CVRP)](https://github.com/vidalt/HGS-CVRP). 12 | 13 | The installation requires `gcc`, `make`, and `cmake` to build. 14 | On Windows, for example, you can install them by `scoop install gcc make cmake` using [Scoop](scoop.sh). 15 | Then, install the PyHygese package: 16 | ``` 17 | pip install hygese 18 | ``` 19 | 22 | 23 | 24 | ## CVRP Example (random) 25 | ```python 26 | import numpy as np 27 | import hygese as hgs 28 | 29 | n = 20 30 | x = (np.random.rand(n) * 1000) 31 | y = (np.random.rand(n) * 1000) 32 | 33 | # Solver initialization 34 | ap = hgs.AlgorithmParameters(timeLimit=3.2) # seconds 35 | hgs_solver = hgs.Solver(parameters=ap, verbose=True) 36 | 37 | # data preparation 38 | data = dict() 39 | data['x_coordinates'] = x 40 | data['y_coordinates'] = y 41 | 42 | # You may also supply distance_matrix instead of coordinates, or in addition to coordinates 43 | # If you supply distance_matrix, it will be used for cost calculation. 44 | # The additional coordinates will be helpful in speeding up the algorithm. 45 | # data['distance_matrix'] = dist_mtx 46 | 47 | data['service_times'] = np.zeros(n) 48 | demands = np.ones(n) 49 | demands[0] = 0 # depot demand = 0 50 | data['demands'] = demands 51 | data['vehicle_capacity'] = np.ceil(n/3).astype(int) 52 | data['num_vehicles'] = 3 53 | data['depot'] = 0 54 | 55 | result = hgs_solver.solve_cvrp(data) 56 | print(result.cost) 57 | print(result.routes) 58 | 59 | ``` 60 | 61 | **NOTE:** The `result.routes` above does not include the depot. All vehicles start from the depot and return to the depot. 62 | 63 | 64 | ## another CVRP example 65 | 66 | ```python 67 | # A CVRP from https://developers.google.com/optimization/routing/cvrp 68 | import numpy as np 69 | import hygese as hgs 70 | 71 | data = dict() 72 | data['distance_matrix'] = [ 73 | [0, 548, 776, 696, 582, 274, 502, 194, 308, 194, 536, 502, 388, 354, 468, 776, 662], 74 | [548, 0, 684, 308, 194, 502, 730, 354, 696, 742, 1084, 594, 480, 674, 1016, 868, 1210], 75 | [776, 684, 0, 992, 878, 502, 274, 810, 468, 742, 400, 1278, 1164, 1130, 788, 1552, 754], 76 | [696, 308, 992, 0, 114, 650, 878, 502, 844, 890, 1232, 514, 628, 822, 1164, 560, 1358], 77 | [582, 194, 878, 114, 0, 536, 764, 388, 730, 776, 1118, 400, 514, 708, 1050, 674, 1244], 78 | [274, 502, 502, 650, 536, 0, 228, 308, 194, 240, 582, 776, 662, 628, 514, 1050, 708], 79 | [502, 730, 274, 878, 764, 228, 0, 536, 194, 468, 354, 1004, 890, 856, 514, 1278, 480], 80 | [194, 354, 810, 502, 388, 308, 536, 0, 342, 388, 730, 468, 354, 320, 662, 742, 856], 81 | [308, 696, 468, 844, 730, 194, 194, 342, 0, 274, 388, 810, 696, 662, 320, 1084, 514], 82 | [194, 742, 742, 890, 776, 240, 468, 388, 274, 0, 342, 536, 422, 388, 274, 810, 468], 83 | [536, 1084, 400, 1232, 1118, 582, 354, 730, 388, 342, 0, 878, 764, 730, 388, 1152, 354], 84 | [502, 594, 1278, 514, 400, 776, 1004, 468, 810, 536, 878, 0, 114, 308, 650, 274, 844], 85 | [388, 480, 1164, 628, 514, 662, 890, 354, 696, 422, 764, 114, 0, 194, 536, 388, 730], 86 | [354, 674, 1130, 822, 708, 628, 856, 320, 662, 388, 730, 308, 194, 0, 342, 422, 536], 87 | [468, 1016, 788, 1164, 1050, 514, 514, 662, 320, 274, 388, 650, 536, 342, 0, 764, 194], 88 | [776, 868, 1552, 560, 674, 1050, 1278, 742, 1084, 810, 1152, 274, 388, 422, 764, 0, 798], 89 | [662, 1210, 754, 1358, 1244, 708, 480, 856, 514, 468, 354, 844, 730, 536, 194, 798, 0] 90 | ] 91 | data['num_vehicles'] = 4 92 | data['depot'] = 0 93 | data['demands'] = [0, 1, 1, 2, 4, 2, 4, 8, 8, 1, 2, 1, 2, 4, 4, 8, 8] 94 | data['vehicle_capacity'] = 15 # different from OR-Tools: homogeneous capacity 95 | data['service_times'] = np.zeros(len(data['demands'])) 96 | 97 | # Solver initialization 98 | ap = hgs.AlgorithmParameters(timeLimit=3.2) # seconds 99 | hgs_solver = hgs.Solver(parameters=ap, verbose=True) 100 | 101 | # Solve 102 | result = hgs_solver.solve_cvrp(data) 103 | print(result.cost) 104 | print(result.routes) 105 | ``` 106 | 107 | 108 | ## TSP example 109 | 110 | ```python 111 | # A TSP example from https://developers.google.com/optimization/routing/tsp 112 | import hygese as hgs 113 | 114 | data = dict() 115 | data['distance_matrix'] = [ 116 | [0, 2451, 713, 1018, 1631, 1374, 2408, 213, 2571, 875, 1420, 2145, 1972], 117 | [2451, 0, 1745, 1524, 831, 1240, 959, 2596, 403, 1589, 1374, 357, 579], 118 | [713, 1745, 0, 355, 920, 803, 1737, 851, 1858, 262, 940, 1453, 1260], 119 | [1018, 1524, 355, 0, 700, 862, 1395, 1123, 1584, 466, 1056, 1280, 987], 120 | [1631, 831, 920, 700, 0, 663, 1021, 1769, 949, 796, 879, 586, 371], 121 | [1374, 1240, 803, 862, 663, 0, 1681, 1551, 1765, 547, 225, 887, 999], 122 | [2408, 959, 1737, 1395, 1021, 1681, 0, 2493, 678, 1724, 1891, 1114, 701], 123 | [213, 2596, 851, 1123, 1769, 1551, 2493, 0, 2699, 1038, 1605, 2300, 2099], 124 | [2571, 403, 1858, 1584, 949, 1765, 678, 2699, 0, 1744, 1645, 653, 600], 125 | [875, 1589, 262, 466, 796, 547, 1724, 1038, 1744, 0, 679, 1272, 1162], 126 | [1420, 1374, 940, 1056, 879, 225, 1891, 1605, 1645, 679, 0, 1017, 1200], 127 | [2145, 357, 1453, 1280, 586, 887, 1114, 2300, 653, 1272, 1017, 0, 504], 128 | [1972, 579, 1260, 987, 371, 999, 701, 2099, 600, 1162, 1200, 504, 0], 129 | ] 130 | 131 | # Solver initialization 132 | ap = hgs.AlgorithmParameters(timeLimit=0.8) # seconds 133 | hgs_solver = hgs.Solver(parameters=ap, verbose=True) 134 | 135 | # Solve 136 | result = hgs_solver.solve_tsp(data) 137 | print(result.cost) 138 | print(result.routes) 139 | ``` 140 | 141 | ## Algorithm Parameters 142 | Configurable algorithm parameters are defined in the `AlgorithmParameters` dataclass with default values: 143 | ```python 144 | @dataclass 145 | class AlgorithmParameters: 146 | nbGranular: int = 20 147 | mu: int = 25 148 | lambda_: int = 40 149 | nbElite: int = 4 150 | nbClose: int = 5 151 | targetFeasible: float = 0.2 152 | seed: int = 1 153 | nbIter: int = 20000 154 | timeLimit: float = 0.0 155 | useSwapStar: bool = True 156 | ``` 157 | 158 | ## Others 159 | A Julia wrapper is available: [Hygese.jl](https://github.com/chkwon/Hygese.jl) 160 | 161 | -------------------------------------------------------------------------------- /hygese/hygese.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | from ctypes import ( 4 | Structure, 5 | CDLL, 6 | POINTER, 7 | c_int, 8 | c_double, 9 | c_char, 10 | sizeof, 11 | cast, 12 | byref, 13 | ) 14 | from dataclasses import dataclass 15 | import numpy as np 16 | import sys 17 | 18 | 19 | def get_lib_filename(): 20 | if platform.system() == "Linux": 21 | lib_ext = "so" 22 | elif platform.system() == "Darwin": 23 | lib_ext = "dylib" 24 | elif platform.system() == "Windows": 25 | lib_ext = "dll" 26 | else: 27 | lib_ext = "so" 28 | return f"libhgscvrp.{lib_ext}" 29 | 30 | 31 | # basedir = os.path.abspath(os.path.dirname(__file__)) 32 | basedir = os.path.dirname(os.path.realpath(__file__)) 33 | # os.add_dll_directory(basedir) 34 | HGS_LIBRARY_FILEPATH = os.path.join(basedir, get_lib_filename()) 35 | 36 | c_double_p = POINTER(c_double) 37 | c_int_p = POINTER(c_int) 38 | C_INT_MAX = 2 ** (sizeof(c_int) * 8 - 1) - 1 39 | C_DBL_MAX = sys.float_info.max 40 | 41 | 42 | # Must match with AlgorithmParameters.h in HGS-CVRP: https://github.com/vidalt/HGS-CVRP 43 | class CAlgorithmParameters(Structure): 44 | _fields_ = [ 45 | ("nbGranular", c_int), 46 | ("mu", c_int), 47 | ("lambda", c_int), 48 | ("nbElite", c_int), 49 | ("nbClose", c_int), 50 | ("targetFeasible", c_double), 51 | ("seed", c_int), 52 | ("nbIter", c_int), 53 | ("timeLimit", c_double), 54 | ("useSwapStar", c_int), 55 | ] 56 | 57 | 58 | @dataclass 59 | class AlgorithmParameters: 60 | nbGranular: int = 20 61 | mu: int = 25 62 | lambda_: int = 40 63 | nbElite: int = 4 64 | nbClose: int = 5 65 | targetFeasible: float = 0.2 66 | seed: int = 0 67 | nbIter: int = 20000 68 | timeLimit: float = 0.0 69 | useSwapStar: bool = True 70 | 71 | @property 72 | def ctypes(self) -> CAlgorithmParameters: 73 | return CAlgorithmParameters( 74 | self.nbGranular, 75 | self.mu, 76 | self.lambda_, 77 | self.nbElite, 78 | self.nbClose, 79 | self.targetFeasible, 80 | self.seed, 81 | self.nbIter, 82 | self.timeLimit, 83 | int(self.useSwapStar), 84 | ) 85 | 86 | 87 | class _SolutionRoute(Structure): 88 | _fields_ = [("length", c_int), ("path", c_int_p)] 89 | 90 | 91 | class _Solution(Structure): 92 | _fields_ = [ 93 | ("cost", c_double), 94 | ("time", c_double), 95 | ("n_routes", c_int), 96 | ("routes", POINTER(_SolutionRoute)), 97 | ] 98 | 99 | 100 | class RoutingSolution: 101 | def __init__(self, sol_ptr): 102 | if not sol_ptr: 103 | raise TypeError("The solution pointer is null.") 104 | 105 | self.cost = sol_ptr[0].cost 106 | self.time = sol_ptr[0].time 107 | self.n_routes = sol_ptr[0].n_routes 108 | self.routes = [] 109 | for i in range(self.n_routes): 110 | r = sol_ptr[0].routes[i] 111 | path = r.path[0 : r.length] 112 | self.routes.append(path) 113 | 114 | 115 | class Solver: 116 | def __init__(self, parameters=AlgorithmParameters(), verbose=True): 117 | if platform.system() == "Windows": 118 | hgs_library = CDLL(HGS_LIBRARY_FILEPATH, winmode=0) 119 | else: 120 | hgs_library = CDLL(HGS_LIBRARY_FILEPATH) 121 | 122 | self.algorithm_parameters = parameters 123 | self.verbose = verbose 124 | 125 | # solve_cvrp 126 | self._c_api_solve_cvrp = hgs_library.solve_cvrp 127 | self._c_api_solve_cvrp.argtypes = [ 128 | c_int, 129 | c_double_p, 130 | c_double_p, 131 | c_double_p, 132 | c_double_p, 133 | c_double, 134 | c_double, 135 | c_char, 136 | c_char, 137 | c_int, 138 | POINTER(CAlgorithmParameters), 139 | c_char, 140 | ] 141 | self._c_api_solve_cvrp.restype = POINTER(_Solution) 142 | 143 | # solve_cvrp_dist_mtx 144 | self._c_api_solve_cvrp_dist_mtx = hgs_library.solve_cvrp_dist_mtx 145 | self._c_api_solve_cvrp_dist_mtx.argtypes = [ 146 | c_int, 147 | c_double_p, 148 | c_double_p, 149 | c_double_p, 150 | c_double_p, 151 | c_double_p, 152 | c_double, 153 | c_double, 154 | c_char, 155 | c_int, 156 | POINTER(CAlgorithmParameters), 157 | c_char, 158 | ] 159 | self._c_api_solve_cvrp_dist_mtx.restype = POINTER(_Solution) 160 | 161 | # delete_solution 162 | self._c_api_delete_sol = hgs_library.delete_solution 163 | self._c_api_delete_sol.restype = None 164 | self._c_api_delete_sol.argtypes = [POINTER(_Solution)] 165 | 166 | def solve_cvrp(self, data, rounding=True): 167 | # required data 168 | demand = np.asarray(data["demands"]) 169 | vehicle_capacity = data["vehicle_capacity"] 170 | n_nodes = len(demand) 171 | 172 | # optional depot 173 | depot = data.get("depot", 0) 174 | if depot != 0: 175 | raise ValueError("In HGS, the depot location must be 0.") 176 | 177 | # optional num_vehicles 178 | maximum_number_of_vehicles = data.get("num_vehicles", C_INT_MAX) 179 | 180 | # optional service_times 181 | service_times = data.get("service_times") 182 | if service_times is None: 183 | service_times = np.zeros(n_nodes) 184 | else: 185 | service_times = np.asarray(service_times) 186 | 187 | # optional duration_limit 188 | duration_limit = data.get("duration_limit") 189 | if duration_limit is None: 190 | is_duration_constraint = False 191 | duration_limit = C_DBL_MAX 192 | else: 193 | is_duration_constraint = True 194 | 195 | is_rounding_integer = rounding 196 | 197 | x_coords = data.get("x_coordinates") 198 | y_coords = data.get("y_coordinates") 199 | dist_mtx = data.get("distance_matrix") 200 | 201 | if x_coords is None or y_coords is None: 202 | assert dist_mtx is not None 203 | x_coords = np.zeros(n_nodes) 204 | y_coords = np.zeros(n_nodes) 205 | else: 206 | x_coords = np.asarray(x_coords) 207 | y_coords = np.asarray(y_coords) 208 | 209 | assert len(x_coords) == len(y_coords) == len(service_times) == len(demand) 210 | assert (x_coords >= 0.0).all() 211 | assert (y_coords >= 0.0).all() 212 | assert (service_times >= 0.0).all() 213 | assert (demand >= 0.0).all() 214 | 215 | if dist_mtx is not None: 216 | dist_mtx = np.asarray(dist_mtx) 217 | assert dist_mtx.shape[0] == dist_mtx.shape[1] 218 | assert (dist_mtx >= 0.0).all() 219 | return self._solve_cvrp_dist_mtx( 220 | x_coords, 221 | y_coords, 222 | dist_mtx, 223 | service_times, 224 | demand, 225 | vehicle_capacity, 226 | duration_limit, 227 | is_duration_constraint, 228 | maximum_number_of_vehicles, 229 | self.algorithm_parameters, 230 | self.verbose, 231 | ) 232 | else: 233 | return self._solve_cvrp( 234 | x_coords, 235 | y_coords, 236 | service_times, 237 | demand, 238 | vehicle_capacity, 239 | duration_limit, 240 | is_rounding_integer, 241 | is_duration_constraint, 242 | maximum_number_of_vehicles, 243 | self.algorithm_parameters, 244 | self.verbose, 245 | ) 246 | 247 | def solve_tsp(self, data, rounding=True): 248 | x_coords = data.get("x_coordinates") 249 | dist_mtx = data.get("distance_matrix") 250 | if dist_mtx is None: 251 | n_nodes = x_coords.size 252 | else: 253 | dist_mtx = np.asarray(dist_mtx) 254 | n_nodes = dist_mtx.shape[0] 255 | 256 | data["num_vehicles"] = 1 257 | data["depot"] = 0 258 | data["demands"] = np.ones(n_nodes) 259 | data["vehicle_capacity"] = n_nodes 260 | 261 | return self.solve_cvrp(data, rounding=rounding) 262 | 263 | def _solve_cvrp( 264 | self, 265 | x_coords: np.ndarray, 266 | y_coords: np.ndarray, 267 | service_times: np.ndarray, 268 | demand: np.ndarray, 269 | vehicle_capacity: int, 270 | duration_limit: float, 271 | is_rounding_integer: bool, 272 | is_duration_constraint: bool, 273 | maximum_number_of_vehicles: int, 274 | algorithm_parameters: AlgorithmParameters, 275 | verbose: bool, 276 | ): 277 | n_nodes = x_coords.size 278 | x_ct = x_coords.astype(c_double).ctypes 279 | y_ct = y_coords.astype(c_double).ctypes 280 | s_ct = service_times.astype(c_double).ctypes 281 | d_ct = demand.astype(c_double).ctypes 282 | ap_ct = algorithm_parameters.ctypes 283 | 284 | # struct Solution * solve_cvrp( 285 | # int n, double* x, double* y, double* serv_time, double* dem, 286 | # double vehicleCapacity, double durationLimit, char isRoundingInteger, char isDurationConstraint, 287 | # int max_nbVeh, const struct AlgorithmParameters* ap, char verbose); 288 | sol_p = self._c_api_solve_cvrp( 289 | n_nodes, 290 | cast(x_ct, c_double_p), 291 | cast(y_ct, c_double_p), 292 | cast(s_ct, c_double_p), 293 | cast(d_ct, c_double_p), 294 | vehicle_capacity, 295 | duration_limit, 296 | is_rounding_integer, 297 | is_duration_constraint, 298 | maximum_number_of_vehicles, 299 | byref(ap_ct), 300 | verbose, 301 | ) 302 | 303 | result = RoutingSolution(sol_p) 304 | self._c_api_delete_sol(sol_p) 305 | return result 306 | 307 | def _solve_cvrp_dist_mtx( 308 | self, 309 | x_coords: np.ndarray, 310 | y_coords: np.ndarray, 311 | dist_mtx: np.ndarray, 312 | service_times: np.ndarray, 313 | demand: np.ndarray, 314 | vehicle_capacity: int, 315 | duration_limit: float, 316 | is_duration_constraint: bool, 317 | maximum_number_of_vehicles: int, 318 | algorithm_parameters: AlgorithmParameters, 319 | verbose: bool, 320 | ): 321 | n_nodes = x_coords.size 322 | 323 | x_ct = x_coords.astype(c_double).ctypes 324 | y_ct = y_coords.astype(c_double).ctypes 325 | s_ct = service_times.astype(c_double).ctypes 326 | d_ct = demand.astype(c_double).ctypes 327 | 328 | m_ct = dist_mtx.reshape(n_nodes * n_nodes).astype(c_double).ctypes 329 | ap_ct = algorithm_parameters.ctypes 330 | 331 | # struct Solution *solve_cvrp_dist_mtx( 332 | # int n, double* x, double* y, double *dist_mtx, double *serv_time, double *dem, 333 | # double vehicleCapacity, double durationLimit, char isDurationConstraint, 334 | # int max_nbVeh, const struct AlgorithmParameters *ap, char verbose); 335 | sol_p = self._c_api_solve_cvrp_dist_mtx( 336 | n_nodes, 337 | cast(x_ct, c_double_p), 338 | cast(y_ct, c_double_p), 339 | cast(m_ct, c_double_p), 340 | cast(s_ct, c_double_p), 341 | cast(d_ct, c_double_p), 342 | vehicle_capacity, 343 | duration_limit, 344 | is_duration_constraint, 345 | maximum_number_of_vehicles, 346 | byref(ap_ct), 347 | verbose, 348 | ) 349 | 350 | result = RoutingSolution(sol_p) 351 | self._c_api_delete_sol(sol_p) 352 | return result 353 | --------------------------------------------------------------------------------