diff --git a/.github/workflows/main_push.yml b/.github/workflows/main_push.yml index 1772efd..e1cba00 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: @@ -15,7 +19,7 @@ jobs: include: - image: ubuntu-latest platform: linux - - image: macos-13 + - image: macos-15-intel platform: macos-intel - image: macos-14 platform: macos-arm @@ -52,7 +56,7 @@ 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 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 f6c9a0c..83a586a 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.9" - 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: | @@ -33,9 +41,11 @@ jobs: - name: "Install pyvroom" env: CXX: g++-14 + # Request gcov coverage; setup.py adds -fprofile-arcs -ftest-coverage for C++ (setuptools does not use CXXFLAGS by default) + CXXFLAGS: "--coverage -O0 -g" run: | # Because `pip install -e .` does not play nice with gcov, we go old school: - CFLAGS="-coverage" python setup.py build_ext --inplace + python setup.py build_ext --inplace python setup.py develop - name: "Run tests" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d6c4cf1..b643e3d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: include: - image: ubuntu-latest platform: linux - - image: macos-13 + - image: macos-15-intel platform: macos-intel - image: macos-14 platform: macos-arm diff --git a/Makefile b/Makefile index 04e9e9e..1b0cc4d 100644 --- a/Makefile +++ b/Makefile @@ -13,9 +13,10 @@ develop: test: coverage run -m pytest --doctest-modules README.rst test src/vroom mkdir -p coverage - coverage xml -o coverage/coverage.xml - gcov -abcfumlpr -o build/temp*/src src/_vroom.cpp - mv *.gcov coverage + -coverage xml -o coverage/coverage.xml + GCOV_DIR=$$(find build -maxdepth 2 -type d -name src 2>/dev/null | head -1); \ + if [ -n "$$GCOV_DIR" ]; then gcov -abcfumlpr -o "$$GCOV_DIR" src/_vroom.cpp || true; fi + -mv *.gcov coverage 2>/dev/null || true lint: python -m black --check src/vroom diff --git a/README.rst b/README.rst index 569c673..5cbf858 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 --------------------------- @@ -96,7 +89,7 @@ Usage with a routing engine >>> sol = problem_instance.solve(exploration_level=5, nb_threads=4) >>> print(sol.summary.duration) - 4041 + 3922 Installation ------------ @@ -168,7 +161,7 @@ To install using Conan, do the following: .. code:: bash cd pyvroom/ - conan install --build=openssl --install-folder conan_build . + conan install --build=missing --install-folder conan_build . Documentation ------------- 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 6a4d67a..2db1b46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,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"] @@ -40,9 +41,11 @@ 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 eb6f328..9b89369 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,8 @@ "-DWIN32_LEAN_AND_MEAN", "-DASIO_STANDALONE", "-DUSE_PYTHON_BINDINGS", - "-DUSE_ROUTING=true" + "-DUSE_ROUTING=true", + "-DUSE_LIBGLPK=true", ] extra_link_args = [] @@ -38,14 +39,24 @@ "-DASIO_STANDALONE", "-DNDEBUG", "-DUSE_PYTHON_BINDINGS", - "-DUSE_ROUTING=true" + "-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")[:-1] @@ -66,6 +77,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 53552cb..23f3230 100644 --- a/src/bind/input/input.cpp +++ b/src/bind/input/input.cpp @@ -59,5 +59,5 @@ void init_input(py::module_ &m) { "Solve routing problem", py::arg("exploration_level"), py::arg("nb_threads"), py::arg("timeout"), py::arg("h_param") ) - .def("check", &vroom::Input::check); + .def("check", &vroom::Input::check, "Check solution feasability", py::arg("nb_thread") = 1); } diff --git a/test/test_libvroom_examples.py b/test/test_libvroom_examples.py index dc7baa5..b112261 100644 --- a/test/test_libvroom_examples.py +++ b/test/test_libvroom_examples.py @@ -1,6 +1,5 @@ """Reproduce the libvroom_example as tests.""" import numpy -import pandas import vroom @@ -36,13 +35,72 @@ def test_example_with_custom_matrix(): assert solution.unassigned == [] routes = solution.routes + import pandas # used for DataFrame; import here to avoid collection-time dependency 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. + """ + import pytest + + if not hasattr(vroom.Input, "check"): + pytest.skip("Plan mode (Input.check) not available (build without USE_LIBGLPK)") + 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"), + ], + ), + ]) + # Plan mode: check feasibility and set ETAs (requires libglpk at build time) + try: + problem_instance.check() + except Exception as e: + pytest.skip(f"Plan mode check() failed (e.g. input not valid for plan mode): {e}")