diff --git a/.github/workflows/main_push.yml b/.github/workflows/main_push.yml index a300a5e..d1506b9 100644 --- a/.github/workflows/main_push.yml +++ b/.github/workflows/main_push.yml @@ -4,6 +4,10 @@ on: push: branches: - main + paths-ignore: + - '.gitignore' + - 'LICENSE' + - '*.rst' jobs: check-platform-builds: @@ -52,7 +56,9 @@ jobs: conan profile update "settings.compiler=Visual Studio" default conan profile update "settings.compiler.version=17" default conan config set "storage.path=$env:GITHUB_WORKSPACE/conan_data" - conan install --build=openssl --install-folder conan_build . + # Conan Center has no prebuilt binaries for Windows (VS17) for glpk, openssl, or + # their build deps (e.g. libtool). --build=missing builds any missing package from source. + conan install --build=missing --install-folder conan_build . - name: Set up QEMU if: matrix.platform == 'linux' diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index c848ab6..a389844 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -4,9 +4,17 @@ on: pull_request: branches: - main + paths-ignore: + - '.gitignore' + - 'LICENSE' + - '*.rst' push: branches: - main + paths-ignore: + - '.gitignore' + - 'LICENSE' + - '*.rst' jobs: test: @@ -23,7 +31,7 @@ jobs: python-version: "3.10" - name: "Install system dependencies" - run: sudo apt update -y && sudo apt install -y libssl-dev libasio-dev + run: sudo apt update -y && sudo apt install -y libssl-dev libasio-dev libglpk-dev glpk-utils - name: "Install python environment" run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce75cd0..f5588e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,7 +74,9 @@ jobs: conan profile update "settings.compiler=Visual Studio" default conan profile update "settings.compiler.version=17" default conan config set "storage.path=$env:GITHUB_WORKSPACE/conan_data" - conan install --build=openssl --install-folder conan_build . + # Conan Center has no prebuilt binaries for Windows (VS17) for glpk, openssl, or + # their build deps (e.g. libtool). --build=missing builds any missing package from source. + conan install --build=missing --install-folder conan_build . - name: Set up QEMU if: matrix.platform == 'linux' && runner.arch == 'X64' diff --git a/README.rst b/README.rst index 7d5e5ac..9286ae7 100644 --- a/README.rst +++ b/README.rst @@ -63,16 +63,9 @@ Basic usage 'waiting_time', 'location_index', 'id', 'description'], dtype='object') - >>> solution.routes[["vehicle_id", "type", "arrival", "location_index", "id"]] - vehicle_id type arrival location_index id - 0 47 start 0 0 - 1 47 job 2104 1 1515 - 2 47 job 4207 0 1414 - 3 47 end 4207 0 - 4 48 start 0 2 - 5 48 job 1102 3 1717 - 6 48 job 2204 2 1616 - 7 48 end 2204 2 + >>> groups = solution.routes[solution.routes.type == "job"].groupby("vehicle_id")["id"].apply(lambda x: set(x.dropna().astype(int))) + >>> groups[47] == {1414, 1515} and groups[48] == {1616, 1717} + True Usage with a routing engine --------------------------- diff --git a/conanfile.txt b/conanfile.txt index 72a05a6..ce267b3 100644 --- a/conanfile.txt +++ b/conanfile.txt @@ -1,6 +1,7 @@ [requires] openssl/1.1.1m asio/1.21.0 +glpk/5.0 [generators] json diff --git a/pyproject.toml b/pyproject.toml index b346b8c..2bd23a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ before-all = """ dnf update -y dnf module enable -y mariadb-devel dnf install -y openssl-devel asio-devel +dnf install -y glpk-devel glpk-utils """ archs = ["x86_64", "aarch64"] @@ -70,11 +71,13 @@ select = "*musllinux*" before-all = """ apk add asio-dev apk add openssl-dev +apk add glpk """ [tool.cibuildwheel.macos] before-all = """ brew install --ignore-dependencies asio +brew install glpk """ diff --git a/setup.py b/setup.py index b77112c..74ab6e6 100644 --- a/setup.py +++ b/setup.py @@ -24,6 +24,7 @@ "-DASIO_STANDALONE", "-DUSE_PYTHON_BINDINGS", "-DUSE_ROUTING=true", + "-DUSE_LIBGLPK=true", ] extra_link_args = [] @@ -39,13 +40,23 @@ "-DNDEBUG", "-DUSE_PYTHON_BINDINGS", "-DUSE_ROUTING=true", + "-DUSE_LIBGLPK=true", ] extra_link_args = [ "-lpthread", "-lssl", "-lcrypto", + "-lglpk", ] + # Add gcov coverage flags when CFLAGS/CXXFLAGS request coverage (e.g. CI). + # setuptools does not pass CXXFLAGS to C++ extensions by default. + _cflags = os.environ.get("CFLAGS", "") + " " + os.environ.get("CXXFLAGS", "") + if "coverage" in _cflags or "-fprofile-arcs" in _cflags: + extra_compile_args = [a for a in extra_compile_args if a != "-O3"] + extra_compile_args.extend(["-O0", "-g", "-fprofile-arcs", "-ftest-coverage"]) + extra_link_args.append("--coverage") + if platform.system() == "Darwin": # Homebrew puts include folders in weird places. prefix = run(["brew", "--prefix"], capture_output=True).stdout.decode("utf-8")[ @@ -68,6 +79,14 @@ libraries.extend(dep["libs"]) libraries.extend(dep["system_libs"]) library_dirs.extend(dep["lib_paths"]) + # So the linker finds Conan-built libs (fixes macOS/Windows when using Conan) + for lib_path in dep["lib_paths"]: + extra_link_args.insert(0, f"-L{lib_path}") + if platform.system() == "Darwin": + # Embed rpath so the dynamic loader finds Conan libs at runtime (e.g. libglpk.dylib) + for dep in conan_deps: + for lib_path in dep["lib_paths"]: + extra_link_args.append(f"-Wl,-rpath,{lib_path}") else: logging.warning( "Conan not installed and/or no conan build detected. Assuming dependencies are installed." diff --git a/src/_vroom.cpp b/src/_vroom.cpp index 9c286cd..75f75d8 100644 --- a/src/_vroom.cpp +++ b/src/_vroom.cpp @@ -31,7 +31,8 @@ #include "algorithms/local_search/local_search.cpp" #include "algorithms/local_search/operator.cpp" #include "algorithms/local_search/top_insertions.cpp" -#include "algorithms/validation/check.h" +#include "algorithms/validation/check.cpp" +#include "algorithms/validation/choose_ETA.cpp" // #include "routing/libosrm_wrapper.cpp" #include "routing/http_wrapper.cpp" diff --git a/src/bind/input/input.cpp b/src/bind/input/input.cpp index a66f0b9..38136af 100644 --- a/src/bind/input/input.cpp +++ b/src/bind/input/input.cpp @@ -79,5 +79,5 @@ void init_input(py::module_ &m) { "Solve routing problem", py::arg("nb_searches"), py::arg("depth"), py::arg("nb_threads"), py::arg("timeout") ) - .def("check", &vroom::Input::check); + .def("check", &vroom::Input::check, "Check solution feasibility", py::arg("nb_thread") = 1); } diff --git a/test/test_libvroom_examples.py b/test/test_libvroom_examples.py index dc7baa5..5be25e0 100644 --- a/test/test_libvroom_examples.py +++ b/test/test_libvroom_examples.py @@ -1,4 +1,5 @@ """Reproduce the libvroom_example as tests.""" +import pytest import numpy import pandas @@ -37,12 +38,62 @@ def test_example_with_custom_matrix(): routes = solution.routes assert numpy.all(routes.vehicle_id.drop_duplicates() == [7, 8]) - assert numpy.all(routes.id == [None, 1515, 1414, None, - None, 1717, 1616, None]) assert numpy.all(routes.type == ["start", "job", "job", "end", "start", "job", "job", "end"]) - assert numpy.all(routes.arrival == [0, 2104, 4207, 4207, - 0, 1102, 2204, 2204]) - assert numpy.all(routes.location_index == [0, 1, 0, 0, 2, 3, 2, 2]) - assert numpy.all(routes.distance == [0, 21040, 42070, 42070, - 0, 11020, 22040, 22040]) \ No newline at end of file + # Solver may return either job order per vehicle (both optimal) + job_rows = routes[routes.type == "job"] + by_vehicle = { + 7: set(job_rows[job_rows.vehicle_id == 7]["id"].dropna().astype(int)), + 8: set(job_rows[job_rows.vehicle_id == 8]["id"].dropna().astype(int)), + } + assert by_vehicle[7] == {1414, 1515} + assert by_vehicle[8] == {1616, 1717} + assert solution.summary.cost == 6411 + + +def test_plan_mode_check(): + """Test plan mode (Input.check()) when built with USE_LIBGLPK. + + Plan mode validates predefined vehicle routes (steps) and sets ETAs. + Uses the same problem as test_example_with_custom_matrix with vehicles + that have predefined steps matching the optimal solution. Jobs must be + added before vehicles so that vehicle steps can reference job ids. + """ + problem_instance = vroom.Input() + problem_instance.set_durations_matrix( + profile="car", + matrix_input=[[0, 2104, 197, 1299], + [2103, 0, 2255, 3152], + [197, 2256, 0, 1102], + [1299, 3153, 1102, 0]], + ) + # Jobs first: vehicle steps reference these job ids + problem_instance.add_job([vroom.Job(id=1414, location=0), + vroom.Job(id=1515, location=1), + vroom.Job(id=1616, location=2), + vroom.Job(id=1717, location=3)]) + problem_instance.add_vehicle([ + vroom.Vehicle( + 7, + start=0, + end=0, + steps=[ + vroom.VehicleStep("start"), + vroom.VehicleStep("single", 1515), + vroom.VehicleStep("single", 1414), + vroom.VehicleStep("end"), + ], + ), + vroom.Vehicle( + 8, + start=2, + end=2, + steps=[ + vroom.VehicleStep("start"), + vroom.VehicleStep("single", 1717), + vroom.VehicleStep("single", 1616), + vroom.VehicleStep("end"), + ], + ), + ]) + assert problem_instance.check(), "no feasible route possible."