From 3235a8774e0d46faaaac001ead2a9e98cd43e010 Mon Sep 17 00:00:00 2001 From: BQ Date: Sat, 21 Mar 2026 13:13:11 +0100 Subject: [PATCH 01/23] Added `VehicleType.durationCost` to the problem data. --- pyvrp/cpp/ProblemData.cpp | 59 ++++++++++++++++-------- pyvrp/cpp/ProblemData.h | 96 ++++++++++++++++++++++----------------- 2 files changed, 95 insertions(+), 60 deletions(-) diff --git a/pyvrp/cpp/ProblemData.cpp b/pyvrp/cpp/ProblemData.cpp index 07e11e62b..8cb761813 100644 --- a/pyvrp/cpp/ProblemData.cpp +++ b/pyvrp/cpp/ProblemData.cpp @@ -5,10 +5,12 @@ #include #include +using pyvrp::Cost; using pyvrp::Distance; using pyvrp::Duration; using pyvrp::Load; using pyvrp::Matrix; +using pyvrp::PiecewiseLinearFunction; using pyvrp::ProblemData; namespace @@ -292,25 +294,27 @@ bool ProblemData::Depot::operator==(Depot const &other) const // clang-format on } -ProblemData::VehicleType::VehicleType(size_t numAvailable, - std::vector capacity, - size_t startDepot, - size_t endDepot, - Cost fixedCost, - Duration twEarly, - Duration twLate, - Duration shiftDuration, - Distance maxDistance, - Cost unitDistanceCost, - Cost unitDurationCost, - size_t profile, - std::optional startLate, - std::vector initialLoad, - std::vector reloadDepots, - size_t maxReloads, - Duration maxOvertime, - Cost unitOvertimeCost, - std::string name) +ProblemData::VehicleType::VehicleType( + size_t numAvailable, + std::vector capacity, + size_t startDepot, + size_t endDepot, + Cost fixedCost, + Duration twEarly, + Duration twLate, + Duration shiftDuration, + Distance maxDistance, + Cost unitDistanceCost, + Cost unitDurationCost, + size_t profile, + std::optional startLate, + std::vector initialLoad, + std::vector reloadDepots, + size_t maxReloads, + Duration maxOvertime, + Cost unitOvertimeCost, + std::optional> durationCost, + std::string name) : numAvailable(numAvailable), startDepot(startDepot), endDepot(endDepot), @@ -337,6 +341,16 @@ ProblemData::VehicleType::VehicleType(size_t numAvailable, - shiftDuration ? shiftDuration + maxOvertime : std::numeric_limits::max()), + durationCost(durationCost.has_value() + ? std::move(*durationCost) + : PiecewiseLinearFunction( + {}, {{Duration{0}, Duration{0}}})), + hasDurationCost(std::any_of(this->durationCost.segments().begin(), + this->durationCost.segments().end(), + [](auto const &seg) + { return seg.first != 0 || seg.second != 0; }) + || this->maxDuration + != std::numeric_limits::max()), name(duplicate(name.data())) { if (numAvailable == 0) @@ -403,6 +417,8 @@ ProblemData::VehicleType::VehicleType(VehicleType const &vehicleType) maxOvertime(vehicleType.maxOvertime), unitOvertimeCost(vehicleType.unitOvertimeCost), maxDuration(vehicleType.maxDuration), + durationCost(vehicleType.durationCost), + hasDurationCost(vehicleType.hasDurationCost), name(duplicate(vehicleType.name)) { } @@ -427,6 +443,8 @@ ProblemData::VehicleType::VehicleType(VehicleType &&vehicleType) maxOvertime(vehicleType.maxOvertime), unitOvertimeCost(vehicleType.unitOvertimeCost), maxDuration(vehicleType.maxDuration), + durationCost(std::move(vehicleType.durationCost)), + hasDurationCost(vehicleType.hasDurationCost), name(vehicleType.name) // we can steal { vehicleType.name = nullptr; // stolen @@ -453,6 +471,7 @@ ProblemData::VehicleType ProblemData::VehicleType::replace( std::optional maxReloads, std::optional maxOvertime, std::optional unitOvertimeCost, + std::optional> durationCost, std::optional name) const { return {numAvailable.value_or(this->numAvailable), @@ -473,6 +492,7 @@ ProblemData::VehicleType ProblemData::VehicleType::replace( maxReloads.value_or(this->maxReloads), maxOvertime.value_or(this->maxOvertime), unitOvertimeCost.value_or(this->unitOvertimeCost), + durationCost.value_or(this->durationCost), name.value_or(this->name)}; } @@ -504,6 +524,7 @@ bool ProblemData::VehicleType::operator==(VehicleType const &other) const && maxReloads == other.maxReloads && maxOvertime == other.maxOvertime && unitOvertimeCost == other.unitOvertimeCost + && durationCost == other.durationCost && std::strcmp(name, other.name) == 0; // clang-format on } diff --git a/pyvrp/cpp/ProblemData.h b/pyvrp/cpp/ProblemData.h index 878ed4930..cd7038e57 100644 --- a/pyvrp/cpp/ProblemData.h +++ b/pyvrp/cpp/ProblemData.h @@ -3,6 +3,7 @@ #include "Matrix.h" #include "Measure.h" +#include "PiecewiseLinearFunction.h" #include #include @@ -509,9 +510,14 @@ class ProblemData * :py:attr:`~shift_duration`. * unit_overtime_cost * Additional cost of a unit of overtime. + * duration_cost + * Piecewise linear duration cost function :math:`f(\text{duration})`. * max_duration * Hard maximum route duration constraint, computed as the sum of * :py:attr:`~shift_duration` and :py:attr:`~max_overtime`. + * has_duration_cost + * Precomputed flag indicating whether any non-zero duration cost or + * hard duration constraint applies to this vehicle type. * name * Free-form name field for this vehicle type. */ @@ -536,28 +542,34 @@ class ProblemData Duration const maxOvertime; // Maximum allowed overtime Cost const unitOvertimeCost; // Cost per unit of overtime Duration const maxDuration; // Maximum route duration, incl. overtime - char const *name; // Type name (for reference) - - VehicleType(size_t numAvailable = 1, - std::vector capacity = {}, - size_t startDepot = 0, - size_t endDepot = 0, - Cost fixedCost = 0, - Duration twEarly = 0, - Duration twLate = std::numeric_limits::max(), - Duration shiftDuration - = std::numeric_limits::max(), - Distance maxDistance = std::numeric_limits::max(), - Cost unitDistanceCost = 1, - Cost unitDurationCost = 0, - size_t profile = 0, - std::optional startLate = std::nullopt, - std::vector initialLoad = {}, - std::vector reloadDepots = {}, - size_t maxReloads = std::numeric_limits::max(), - Duration maxOvertime = 0, - Cost unitOvertimeCost = 0, - std::string name = ""); + PiecewiseLinearFunction const + durationCost; // Cost f(duration) + bool const + hasDurationCost; // Any non-zero duration cost or hard constraint + char const *name; // Type name (for reference) + + VehicleType( + size_t numAvailable = 1, + std::vector capacity = {}, + size_t startDepot = 0, + size_t endDepot = 0, + Cost fixedCost = 0, + Duration twEarly = 0, + Duration twLate = std::numeric_limits::max(), + Duration shiftDuration = std::numeric_limits::max(), + Distance maxDistance = std::numeric_limits::max(), + Cost unitDistanceCost = 1, + Cost unitDurationCost = 0, + size_t profile = 0, + std::optional startLate = std::nullopt, + std::vector initialLoad = {}, + std::vector reloadDepots = {}, + size_t maxReloads = std::numeric_limits::max(), + Duration maxOvertime = 0, + Cost unitOvertimeCost = 0, + std::optional> durationCost + = std::nullopt, + std::string name = ""); bool operator==(VehicleType const &other) const; @@ -573,25 +585,27 @@ class ProblemData * Returns a new ``VehicleType`` with the same data as this one, except * for the given parameters, which are used instead. */ - VehicleType replace(std::optional numAvailable, - std::optional> capacity, - std::optional startDepot, - std::optional endDepot, - std::optional fixedCost, - std::optional twEarly, - std::optional twLate, - std::optional shiftDuration, - std::optional maxDistance, - std::optional unitDistanceCost, - std::optional unitDurationCost, - std::optional profile, - std::optional startLate, - std::optional> initialLoad, - std::optional> reloadDepots, - std::optional maxReloads, - std::optional maxOvertime, - std::optional unitOvertimeCost, - std::optional name) const; + VehicleType replace( + std::optional numAvailable, + std::optional> capacity, + std::optional startDepot, + std::optional endDepot, + std::optional fixedCost, + std::optional twEarly, + std::optional twLate, + std::optional shiftDuration, + std::optional maxDistance, + std::optional unitDistanceCost, + std::optional unitDurationCost, + std::optional profile, + std::optional startLate, + std::optional> initialLoad, + std::optional> reloadDepots, + std::optional maxReloads, + std::optional maxOvertime, + std::optional unitOvertimeCost, + std::optional> durationCost, + std::optional name) const; /** * Returns the maximum number of trips these vehicle can execute. From 70591d9273263cadf1c5f50ba5cc7c42907fdcbe Mon Sep 17 00:00:00 2001 From: BQ Date: Sat, 21 Mar 2026 13:39:09 +0100 Subject: [PATCH 02/23] Use the PWL as neighbourhood deduplication key. --- pyvrp/cpp/PiecewiseLinearFunction.h | 1 + pyvrp/cpp/search/neighbourhood.cpp | 12 +++++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/pyvrp/cpp/PiecewiseLinearFunction.h b/pyvrp/cpp/PiecewiseLinearFunction.h index 949744e58..e0e62b2a0 100644 --- a/pyvrp/cpp/PiecewiseLinearFunction.h +++ b/pyvrp/cpp/PiecewiseLinearFunction.h @@ -87,6 +87,7 @@ template class PiecewiseLinearFunction [[nodiscard]] bool isMonotonicallyIncreasing() const; bool operator==(PiecewiseLinearFunction const &other) const = default; + auto operator<=>(PiecewiseLinearFunction const &other) const = default; }; template diff --git a/pyvrp/cpp/search/neighbourhood.cpp b/pyvrp/cpp/search/neighbourhood.cpp index 96d267133..625040139 100644 --- a/pyvrp/cpp/search/neighbourhood.cpp +++ b/pyvrp/cpp/search/neighbourhood.cpp @@ -33,12 +33,13 @@ Matrix computeProximity(ProblemData const &data, data.numClients(), std::numeric_limits::max()); - std::set> seen = {}; + using DurationCost + = pyvrp::PiecewiseLinearFunction; + std::set> seen = {}; for (auto const &vehType : data.vehicleTypes()) { - auto const key = std::make_tuple(vehType.unitDistanceCost, - vehType.unitDurationCost, - vehType.profile); + auto const key = std::make_tuple( + vehType.unitDistanceCost, vehType.durationCost, vehType.profile); if (seen.contains(key)) // then proximity has already been updated continue; // based on this cost profile @@ -74,7 +75,8 @@ Matrix computeProximity(ProblemData const &data, auto const cost // minimum edge cost using this vehicle type = static_cast(vehType.unitDistanceCost) * distance - + static_cast(vehType.unitDurationCost) * duration + + static_cast( + vehType.durationCost(pyvrp::Duration{duration})) + params.weightWaitTime * std::max(minWait, 0.0); prox(frm, to) = std::min(cost, prox(frm, to)); From 90a3e1d562a3a8faccd3e89c9e8da32bc2efaaa1 Mon Sep 17 00:00:00 2001 From: BQ Date: Sat, 21 Mar 2026 13:56:07 +0100 Subject: [PATCH 03/23] Added validation for `ProblemData::VehicleType::VehicleType.durationCost` --- pyvrp/cpp/ProblemData.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pyvrp/cpp/ProblemData.cpp b/pyvrp/cpp/ProblemData.cpp index 8cb761813..8aa1b78c8 100644 --- a/pyvrp/cpp/ProblemData.cpp +++ b/pyvrp/cpp/ProblemData.cpp @@ -395,6 +395,16 @@ ProblemData::VehicleType::VehicleType( if (unitOvertimeCost < 0) throw std::invalid_argument("unit_overtime_cost must be >= 0."); + + // NOTE: @N-Wouda: + // Currently, we use the monotonicity of the duration cost function, however, + // some use cases might require non-negativity instead. For example, if a user + // prefers medium duration routes over short and long routes, they might want a + // duration cost function that has a minimum at some point greater than 0. + // What is your preference? Do you prefer to enforce monotonicity, or non-negativity? + if (!this->durationCost.isMonotonicallyIncreasing()) + throw std::invalid_argument("duration_cost must be monotonically " + "increasing."); } ProblemData::VehicleType::VehicleType(VehicleType const &vehicleType) From aa46ec682f5a91a0692889052fa1607106855e9d Mon Sep 17 00:00:00 2001 From: BQ Date: Sat, 21 Mar 2026 14:29:24 +0100 Subject: [PATCH 04/23] Add the python stubs for `VehicleType.duration_cost`. --- pyvrp/Model.py | 3 +++ pyvrp/_pyvrp.pyi | 3 +++ pyvrp/cpp/bindings.cpp | 12 ++++++++++-- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/pyvrp/Model.py b/pyvrp/Model.py index c7f8ce667..5a7810425 100644 --- a/pyvrp/Model.py +++ b/pyvrp/Model.py @@ -10,6 +10,7 @@ ClientGroup, Depot, Location, + PiecewiseLinearFunction, ProblemData, Solution, VehicleType, @@ -390,6 +391,7 @@ def add_vehicle_type( max_reloads: int = np.iinfo(np.uint64).max, max_overtime: int = 0, unit_overtime_cost: int = 0, + duration_cost: PiecewiseLinearFunction | None = None, *, name: str = "", ) -> VehicleType: @@ -461,6 +463,7 @@ def add_vehicle_type( max_reloads=max_reloads, max_overtime=max_overtime, unit_overtime_cost=unit_overtime_cost, + duration_cost=duration_cost, name=name, ) diff --git a/pyvrp/_pyvrp.pyi b/pyvrp/_pyvrp.pyi index 933d7ef79..6cfbbdd8e 100644 --- a/pyvrp/_pyvrp.pyi +++ b/pyvrp/_pyvrp.pyi @@ -178,6 +178,7 @@ class VehicleType: max_reloads: int max_overtime: int unit_overtime_cost: int + duration_cost: PiecewiseLinearFunction max_duration: int name: str def __init__( @@ -200,6 +201,7 @@ class VehicleType: max_reloads: int = ..., max_overtime: int = 0, unit_overtime_cost: int = 0, + duration_cost: PiecewiseLinearFunction | None = None, *, name: str = "", ) -> None: ... @@ -225,6 +227,7 @@ class VehicleType: max_reloads: int | None = None, max_overtime: int | None = None, unit_overtime_cost: int | None = None, + duration_cost: PiecewiseLinearFunction | None = None, *, name: str | None = None, ) -> VehicleType: ... diff --git a/pyvrp/cpp/bindings.cpp b/pyvrp/cpp/bindings.cpp index 88e36377d..cf38996f2 100644 --- a/pyvrp/cpp/bindings.cpp +++ b/pyvrp/cpp/bindings.cpp @@ -386,6 +386,7 @@ PYBIND11_MODULE(_pyvrp, m) size_t, pyvrp::Duration, pyvrp::Cost, + std::optional, char const *>(), py::arg("num_available") = 1, py::arg("capacity") = py::list(), @@ -407,6 +408,7 @@ PYBIND11_MODULE(_pyvrp, m) py::arg("max_reloads") = std::numeric_limits::max(), py::arg("max_overtime") = 0, py::arg("unit_overtime_cost") = 0, + py::arg("duration_cost") = py::none(), py::kw_only(), py::arg("name") = "") .def_readonly("num_available", &ProblemData::VehicleType::numAvailable) @@ -437,6 +439,9 @@ PYBIND11_MODULE(_pyvrp, m) .def_readonly("max_overtime", &ProblemData::VehicleType::maxOvertime) .def_readonly("unit_overtime_cost", &ProblemData::VehicleType::unitOvertimeCost) + .def_readonly("duration_cost", + &ProblemData::VehicleType::durationCost, + py::return_value_policy::reference_internal) .def_readonly("max_duration", &ProblemData::VehicleType::maxDuration) .def_property_readonly("max_trips", &ProblemData::VehicleType::maxTrips) .def_readonly("name", @@ -462,6 +467,7 @@ PYBIND11_MODULE(_pyvrp, m) py::arg("max_reloads") = py::none(), py::arg("max_overtime") = py::none(), py::arg("unit_overtime_cost") = py::none(), + py::arg("duration_cost") = py::none(), py::kw_only(), py::arg("name") = py::none(), DOC(pyvrp, ProblemData, VehicleType, replace)) @@ -486,6 +492,7 @@ PYBIND11_MODULE(_pyvrp, m) vehicleType.maxReloads, vehicleType.maxOvertime, vehicleType.unitOvertimeCost, + vehicleType.durationCost, vehicleType.name); }, [](py::tuple t) { // __setstate__ @@ -507,8 +514,9 @@ PYBIND11_MODULE(_pyvrp, m) t[14].cast>(), // reload depots t[15].cast(), // max reloads t[16].cast(), // max overtime - t[17].cast(), // unit overtime cost - t[18].cast()); // name + t[17].cast(), // unit overtime cost + t[18].cast(), // duration cost + t[19].cast()); // name return vehicleType; })) From 090656053b9d4b90321c81080a330795671e2fe7 Mon Sep 17 00:00:00 2001 From: BQ Date: Sat, 21 Mar 2026 15:00:40 +0100 Subject: [PATCH 05/23] Added tests for the introduced `duration_cost`'s. --- tests/test_Model.py | 12 ++++++++++++ tests/test_ProblemData.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/tests/test_Model.py b/tests/test_Model.py index 17780069d..de6dbe386 100644 --- a/tests/test_Model.py +++ b/tests/test_Model.py @@ -9,6 +9,7 @@ Location, Model, PenaltyParams, + PiecewiseLinearFunction, Profile, SolveParams, VehicleType, @@ -795,6 +796,17 @@ def test_minimise_distance_or_duration(ok_small): assert_equal(new_res.cost(), orig_res.cost() + service) +def test_add_vehicle_type_with_duration_cost(): + """ + Tests that add_vehicle_type() correctly passes a duration_cost PWL through + to the underlying VehicleType. + """ + m = Model() + pwl = PiecewiseLinearFunction([(0, 0), (100, 100)]) + veh_type = m.add_vehicle_type(duration_cost=pwl) + assert_equal(veh_type.duration_cost, pwl) + + def test_adding_vehicle_type_with_unknown_profile_raises(): """ Tests that adding a vehicle type with a routing profile that is not in the diff --git a/tests/test_ProblemData.py b/tests/test_ProblemData.py index 7256b448b..5813eab4f 100644 --- a/tests/test_ProblemData.py +++ b/tests/test_ProblemData.py @@ -10,6 +10,7 @@ ClientGroup, Depot, Location, + PiecewiseLinearFunction, ProblemData, VehicleType, ) @@ -581,6 +582,16 @@ def test_vehicle_type_raises_negative_overtime_data( ) +def test_vehicle_type_raises_non_monotone_duration_cost(): + """ + Tests that the vehicle type constructor raises when the duration cost + function is not monotonically increasing. + """ + non_monotone = PiecewiseLinearFunction([(0, 0), (5, 10), (10, 0)]) + with assert_raises(ValueError): + VehicleType(duration_cost=non_monotone) + + def test_vehicle_type_does_not_raise_for_all_zero_edge_case(): """ The vehicle type constructor should allow the following edge case where all @@ -731,6 +742,24 @@ def test_vehicle_type_replace(): assert_equal(new.name, "new") +def test_vehicle_type_duration_cost(): + """ + Tests that the duration_cost field is correctly stored, defaults to a zero + function, can be updated via replace(), and survives a pickle round-trip. + """ + zero = PiecewiseLinearFunction([(0, 0), (1, 0)]) + assert_equal(VehicleType().duration_cost, zero) + + pwl = PiecewiseLinearFunction([(0, 0), (10, 10)]) + veh_type = VehicleType(duration_cost=pwl) + assert_equal(veh_type.duration_cost, pwl) + + replaced = veh_type.replace(duration_cost=zero) + assert_equal(replaced.duration_cost, zero) + + assert_equal(pickle.loads(pickle.dumps(veh_type)), veh_type) + + def test_vehicle_type_multiple_capacities(): """ Tests that vehicle types correctly handle multiple capacities. From 1d1ff7ea95820869b62e8972a937a0622de98b76 Mon Sep 17 00:00:00 2001 From: BQ Date: Sat, 21 Mar 2026 15:00:51 +0100 Subject: [PATCH 06/23] reverted the neighbourhood changes. --- pyvrp/cpp/search/neighbourhood.cpp | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/pyvrp/cpp/search/neighbourhood.cpp b/pyvrp/cpp/search/neighbourhood.cpp index 625040139..96d267133 100644 --- a/pyvrp/cpp/search/neighbourhood.cpp +++ b/pyvrp/cpp/search/neighbourhood.cpp @@ -33,13 +33,12 @@ Matrix computeProximity(ProblemData const &data, data.numClients(), std::numeric_limits::max()); - using DurationCost - = pyvrp::PiecewiseLinearFunction; - std::set> seen = {}; + std::set> seen = {}; for (auto const &vehType : data.vehicleTypes()) { - auto const key = std::make_tuple( - vehType.unitDistanceCost, vehType.durationCost, vehType.profile); + auto const key = std::make_tuple(vehType.unitDistanceCost, + vehType.unitDurationCost, + vehType.profile); if (seen.contains(key)) // then proximity has already been updated continue; // based on this cost profile @@ -75,8 +74,7 @@ Matrix computeProximity(ProblemData const &data, auto const cost // minimum edge cost using this vehicle type = static_cast(vehType.unitDistanceCost) * distance - + static_cast( - vehType.durationCost(pyvrp::Duration{duration})) + + static_cast(vehType.unitDurationCost) * duration + params.weightWaitTime * std::max(minWait, 0.0); prox(frm, to) = std::min(cost, prox(frm, to)); From 2be0aefc6f4cfb30a12db7c9100450bb5b4c84e6 Mon Sep 17 00:00:00 2001 From: BQ Date: Sat, 21 Mar 2026 15:16:57 +0100 Subject: [PATCH 07/23] clang changes. --- pyvrp/cpp/ProblemData.cpp | 11 ++++++----- pyvrp/cpp/bindings.cpp | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pyvrp/cpp/ProblemData.cpp b/pyvrp/cpp/ProblemData.cpp index 8aa1b78c8..3e42906d4 100644 --- a/pyvrp/cpp/ProblemData.cpp +++ b/pyvrp/cpp/ProblemData.cpp @@ -397,11 +397,12 @@ ProblemData::VehicleType::VehicleType( throw std::invalid_argument("unit_overtime_cost must be >= 0."); // NOTE: @N-Wouda: - // Currently, we use the monotonicity of the duration cost function, however, - // some use cases might require non-negativity instead. For example, if a user - // prefers medium duration routes over short and long routes, they might want a - // duration cost function that has a minimum at some point greater than 0. - // What is your preference? Do you prefer to enforce monotonicity, or non-negativity? + // Currently, we use the monotonicity of the duration cost function, + // however, some use cases might require non-negativity instead. For + // example, if a user prefers medium duration routes over short and long + // routes, they might want a duration cost function that has a minimum at + // some point greater than 0. What is your preference? Do you prefer to + // enforce monotonicity, or non-negativity? if (!this->durationCost.isMonotonicallyIncreasing()) throw std::invalid_argument("duration_cost must be monotonically " "increasing."); diff --git a/pyvrp/cpp/bindings.cpp b/pyvrp/cpp/bindings.cpp index cf38996f2..e9709c9dc 100644 --- a/pyvrp/cpp/bindings.cpp +++ b/pyvrp/cpp/bindings.cpp @@ -514,7 +514,7 @@ PYBIND11_MODULE(_pyvrp, m) t[14].cast>(), // reload depots t[15].cast(), // max reloads t[16].cast(), // max overtime - t[17].cast(), // unit overtime cost + t[17].cast(), // unit overtime cost t[18].cast(), // duration cost t[19].cast()); // name From 578c3319b1048a59ac002a7da2ab55e388eefef1 Mon Sep 17 00:00:00 2001 From: BQ Date: Sat, 21 Mar 2026 15:38:34 +0100 Subject: [PATCH 08/23] Fix PiecewiseLinearFunction type mismatch in VehicleType bindings. --- pyvrp/cpp/ProblemData.cpp | 6 +-- pyvrp/cpp/ProblemData.h | 90 ++++++++++++++++++++------------------- 2 files changed, 49 insertions(+), 47 deletions(-) diff --git a/pyvrp/cpp/ProblemData.cpp b/pyvrp/cpp/ProblemData.cpp index 3e42906d4..5af9ad18f 100644 --- a/pyvrp/cpp/ProblemData.cpp +++ b/pyvrp/cpp/ProblemData.cpp @@ -313,7 +313,7 @@ ProblemData::VehicleType::VehicleType( size_t maxReloads, Duration maxOvertime, Cost unitOvertimeCost, - std::optional> durationCost, + std::optional> durationCost, std::string name) : numAvailable(numAvailable), startDepot(startDepot), @@ -343,7 +343,7 @@ ProblemData::VehicleType::VehicleType( : std::numeric_limits::max()), durationCost(durationCost.has_value() ? std::move(*durationCost) - : PiecewiseLinearFunction( + : PiecewiseLinearFunction( {}, {{Duration{0}, Duration{0}}})), hasDurationCost(std::any_of(this->durationCost.segments().begin(), this->durationCost.segments().end(), @@ -482,7 +482,7 @@ ProblemData::VehicleType ProblemData::VehicleType::replace( std::optional maxReloads, std::optional maxOvertime, std::optional unitOvertimeCost, - std::optional> durationCost, + std::optional> durationCost, std::optional name) const { return {numAvailable.value_or(this->numAvailable), diff --git a/pyvrp/cpp/ProblemData.h b/pyvrp/cpp/ProblemData.h index cd7038e57..53cf876d0 100644 --- a/pyvrp/cpp/ProblemData.h +++ b/pyvrp/cpp/ProblemData.h @@ -542,34 +542,35 @@ class ProblemData Duration const maxOvertime; // Maximum allowed overtime Cost const unitOvertimeCost; // Cost per unit of overtime Duration const maxDuration; // Maximum route duration, incl. overtime - PiecewiseLinearFunction const + PiecewiseLinearFunction const durationCost; // Cost f(duration) bool const hasDurationCost; // Any non-zero duration cost or hard constraint char const *name; // Type name (for reference) - VehicleType( - size_t numAvailable = 1, - std::vector capacity = {}, - size_t startDepot = 0, - size_t endDepot = 0, - Cost fixedCost = 0, - Duration twEarly = 0, - Duration twLate = std::numeric_limits::max(), - Duration shiftDuration = std::numeric_limits::max(), - Distance maxDistance = std::numeric_limits::max(), - Cost unitDistanceCost = 1, - Cost unitDurationCost = 0, - size_t profile = 0, - std::optional startLate = std::nullopt, - std::vector initialLoad = {}, - std::vector reloadDepots = {}, - size_t maxReloads = std::numeric_limits::max(), - Duration maxOvertime = 0, - Cost unitOvertimeCost = 0, - std::optional> durationCost - = std::nullopt, - std::string name = ""); + VehicleType(size_t numAvailable = 1, + std::vector capacity = {}, + size_t startDepot = 0, + size_t endDepot = 0, + Cost fixedCost = 0, + Duration twEarly = 0, + Duration twLate = std::numeric_limits::max(), + Duration shiftDuration + = std::numeric_limits::max(), + Distance maxDistance = std::numeric_limits::max(), + Cost unitDistanceCost = 1, + Cost unitDurationCost = 0, + size_t profile = 0, + std::optional startLate = std::nullopt, + std::vector initialLoad = {}, + std::vector reloadDepots = {}, + size_t maxReloads = std::numeric_limits::max(), + Duration maxOvertime = 0, + Cost unitOvertimeCost = 0, + std::optional> + durationCost + = std::nullopt, + std::string name = ""); bool operator==(VehicleType const &other) const; @@ -585,27 +586,28 @@ class ProblemData * Returns a new ``VehicleType`` with the same data as this one, except * for the given parameters, which are used instead. */ - VehicleType replace( - std::optional numAvailable, - std::optional> capacity, - std::optional startDepot, - std::optional endDepot, - std::optional fixedCost, - std::optional twEarly, - std::optional twLate, - std::optional shiftDuration, - std::optional maxDistance, - std::optional unitDistanceCost, - std::optional unitDurationCost, - std::optional profile, - std::optional startLate, - std::optional> initialLoad, - std::optional> reloadDepots, - std::optional maxReloads, - std::optional maxOvertime, - std::optional unitOvertimeCost, - std::optional> durationCost, - std::optional name) const; + VehicleType + replace(std::optional numAvailable, + std::optional> capacity, + std::optional startDepot, + std::optional endDepot, + std::optional fixedCost, + std::optional twEarly, + std::optional twLate, + std::optional shiftDuration, + std::optional maxDistance, + std::optional unitDistanceCost, + std::optional unitDurationCost, + std::optional profile, + std::optional startLate, + std::optional> initialLoad, + std::optional> reloadDepots, + std::optional maxReloads, + std::optional maxOvertime, + std::optional unitOvertimeCost, + std::optional> + durationCost, + std::optional name) const; /** * Returns the maximum number of trips these vehicle can execute. From 9ea645ee727cca832c0d2d87c91a2e88c10f1fa9 Mon Sep 17 00:00:00 2001 From: BQ Date: Sat, 21 Mar 2026 15:43:14 +0100 Subject: [PATCH 09/23] Fixed pyvrp build --- pyvrp/cpp/ProblemData.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyvrp/cpp/ProblemData.cpp b/pyvrp/cpp/ProblemData.cpp index 5af9ad18f..3b4489d81 100644 --- a/pyvrp/cpp/ProblemData.cpp +++ b/pyvrp/cpp/ProblemData.cpp @@ -344,7 +344,7 @@ ProblemData::VehicleType::VehicleType( durationCost(durationCost.has_value() ? std::move(*durationCost) : PiecewiseLinearFunction( - {}, {{Duration{0}, Duration{0}}})), + {}, {std::make_pair(int64_t{Duration{0}}, int64_t{Duration{0}})})), hasDurationCost(std::any_of(this->durationCost.segments().begin(), this->durationCost.segments().end(), [](auto const &seg) From 0a869f4df509878a4387d98ccb20a3ef750d9240 Mon Sep 17 00:00:00 2001 From: BQ Date: Sat, 21 Mar 2026 15:46:19 +0100 Subject: [PATCH 10/23] clang fixed --- pyvrp/cpp/ProblemData.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyvrp/cpp/ProblemData.cpp b/pyvrp/cpp/ProblemData.cpp index 3b4489d81..773042235 100644 --- a/pyvrp/cpp/ProblemData.cpp +++ b/pyvrp/cpp/ProblemData.cpp @@ -344,7 +344,7 @@ ProblemData::VehicleType::VehicleType( durationCost(durationCost.has_value() ? std::move(*durationCost) : PiecewiseLinearFunction( - {}, {std::make_pair(int64_t{Duration{0}}, int64_t{Duration{0}})})), + {}, {{int64_t{0}, int64_t{0}}})), hasDurationCost(std::any_of(this->durationCost.segments().begin(), this->durationCost.segments().end(), [](auto const &seg) From 52a4b77473ee2fa5f60ca25d7dfcaea644999c40 Mon Sep 17 00:00:00 2001 From: BQ Date: Sat, 21 Mar 2026 16:11:32 +0100 Subject: [PATCH 11/23] resolved the docs workflow issue. --- pyvrp/cpp/ProblemData.h | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pyvrp/cpp/ProblemData.h b/pyvrp/cpp/ProblemData.h index 53cf876d0..752ca73a3 100644 --- a/pyvrp/cpp/ProblemData.h +++ b/pyvrp/cpp/ProblemData.h @@ -399,6 +399,7 @@ class ProblemData * max_reloads: int = np.iinfo(np.uint64).max, * max_overtime: int = 0, * unit_overtime_cost: int = 0, + * duration_cost: PiecewiseLinearFunction | None = None, * *, * name: str = "", * ) @@ -462,6 +463,9 @@ class ProblemData * unit_overtime_cost * Cost of a unit of overtime. This is in addition to the regular * :py:attr:`~unit_duration_cost` of route durations. Default 0. + * duration_cost + * Piecewise linear duration cost function :math:`f(\text{duration})`. + * When not provided, a zero-cost function is used. * name * Free-form name field for this vehicle type. Default empty. * @@ -515,9 +519,6 @@ class ProblemData * max_duration * Hard maximum route duration constraint, computed as the sum of * :py:attr:`~shift_duration` and :py:attr:`~max_overtime`. - * has_duration_cost - * Precomputed flag indicating whether any non-zero duration cost or - * hard duration constraint applies to this vehicle type. * name * Free-form name field for this vehicle type. */ From 14193173cddcaf1ed909f2d74d801891f4b3a3ff Mon Sep 17 00:00:00 2001 From: BQ Date: Mon, 23 Mar 2026 12:16:42 +0100 Subject: [PATCH 12/23] Add `VehicleType::DurationCost` type alias. Default to the zero-cost function. Add non-negative checks to PWL. --- pyvrp/cpp/PiecewiseLinearFunction.h | 40 +++++++++++++++++++++++++++++ pyvrp/cpp/ProblemData.cpp | 26 ++++++++----------- pyvrp/cpp/ProblemData.h | 13 +++++----- 3 files changed, 57 insertions(+), 22 deletions(-) diff --git a/pyvrp/cpp/PiecewiseLinearFunction.h b/pyvrp/cpp/PiecewiseLinearFunction.h index e0e62b2a0..abc692893 100644 --- a/pyvrp/cpp/PiecewiseLinearFunction.h +++ b/pyvrp/cpp/PiecewiseLinearFunction.h @@ -86,6 +86,11 @@ template class PiecewiseLinearFunction */ [[nodiscard]] bool isMonotonicallyIncreasing() const; + /** + * Returns whether this function is non-negative everywhere. + */ + [[nodiscard]] bool isNonNegative() const; + bool operator==(PiecewiseLinearFunction const &other) const = default; auto operator<=>(PiecewiseLinearFunction const &other) const = default; }; @@ -197,6 +202,41 @@ bool PiecewiseLinearFunction::isMonotonicallyIncreasing() const return true; } + +template +bool PiecewiseLinearFunction::isNonNegative() const +{ + // The first segment extends to -inf. A positive slope would drive the + // function to -inf, violating non-negativity. + if (segments_.front().second > 0) + return false; + + // The last segment extends to +inf. A negative slope would drive the + // function to -inf, violating non-negativity. + if (segments_.back().second < 0) + return false; + + for (size_t idx = 0; idx != breakpoints_.size(); ++idx) + { + auto const breakpoint = breakpoints_[idx]; + auto const [prevIntercept, prevSlope] = segments_[idx]; + auto const [nextIntercept, nextSlope] = segments_[idx + 1]; + + auto const left = prevIntercept + prevSlope * breakpoint; + auto const right = nextIntercept + nextSlope * breakpoint; + + if (left < 0 || right < 0) + return false; + } + + // When there are no breakpoints the function is constant (slope is zero + // after the checks above). Non-negativity then reduces to checking the + // intercept. + if (breakpoints_.empty() && segments_.front().first < 0) + return false; + + return true; +} } // namespace pyvrp #endif // PYVRP_PIECEWISELINEARFUNCTION_H diff --git a/pyvrp/cpp/ProblemData.cpp b/pyvrp/cpp/ProblemData.cpp index 773042235..46fe0725d 100644 --- a/pyvrp/cpp/ProblemData.cpp +++ b/pyvrp/cpp/ProblemData.cpp @@ -313,7 +313,7 @@ ProblemData::VehicleType::VehicleType( size_t maxReloads, Duration maxOvertime, Cost unitOvertimeCost, - std::optional> durationCost, + VehicleType::DurationCost durationCost, std::string name) : numAvailable(numAvailable), startDepot(startDepot), @@ -341,10 +341,7 @@ ProblemData::VehicleType::VehicleType( - shiftDuration ? shiftDuration + maxOvertime : std::numeric_limits::max()), - durationCost(durationCost.has_value() - ? std::move(*durationCost) - : PiecewiseLinearFunction( - {}, {{int64_t{0}, int64_t{0}}})), + durationCost(std::move(durationCost)), hasDurationCost(std::any_of(this->durationCost.segments().begin(), this->durationCost.segments().end(), [](auto const &seg) @@ -397,15 +394,14 @@ ProblemData::VehicleType::VehicleType( throw std::invalid_argument("unit_overtime_cost must be >= 0."); // NOTE: @N-Wouda: - // Currently, we use the monotonicity of the duration cost function, - // however, some use cases might require non-negativity instead. For - // example, if a user prefers medium duration routes over short and long - // routes, they might want a duration cost function that has a minimum at - // some point greater than 0. What is your preference? Do you prefer to - // enforce monotonicity, or non-negativity? - if (!this->durationCost.isMonotonicallyIncreasing()) - throw std::invalid_argument("duration_cost must be monotonically " - "increasing."); + // Currently, isNonNegative() checks the entire domain. However, in practice + // durations are always >= 0, so a user might define a PWL with a positive + // slope on the first segment (making it negative for x < 0) while still + // intending a non-negative cost on the actual domain [0, +inf). Should we + // pass a lower bound (e.g. 0) to isNonNegative() so the check is restricted + // to the meaningful domain? + if (!this->durationCost.isNonNegative()) + throw std::invalid_argument("duration_cost must be non-negative."); } ProblemData::VehicleType::VehicleType(VehicleType const &vehicleType) @@ -482,7 +478,7 @@ ProblemData::VehicleType ProblemData::VehicleType::replace( std::optional maxReloads, std::optional maxOvertime, std::optional unitOvertimeCost, - std::optional> durationCost, + std::optional durationCost, std::optional name) const { return {numAvailable.value_or(this->numAvailable), diff --git a/pyvrp/cpp/ProblemData.h b/pyvrp/cpp/ProblemData.h index 752ca73a3..f1601187f 100644 --- a/pyvrp/cpp/ProblemData.h +++ b/pyvrp/cpp/ProblemData.h @@ -524,6 +524,8 @@ class ProblemData */ struct VehicleType { + using DurationCost = PiecewiseLinearFunction; + size_t const numAvailable; // Available vehicles of this type size_t const startDepot; // Departure depot location size_t const endDepot; // Return depot location @@ -543,8 +545,7 @@ class ProblemData Duration const maxOvertime; // Maximum allowed overtime Cost const unitOvertimeCost; // Cost per unit of overtime Duration const maxDuration; // Maximum route duration, incl. overtime - PiecewiseLinearFunction const - durationCost; // Cost f(duration) + DurationCost const durationCost; // Cost f(duration) bool const hasDurationCost; // Any non-zero duration cost or hard constraint char const *name; // Type name (for reference) @@ -568,9 +569,8 @@ class ProblemData size_t maxReloads = std::numeric_limits::max(), Duration maxOvertime = 0, Cost unitOvertimeCost = 0, - std::optional> - durationCost - = std::nullopt, + DurationCost durationCost = DurationCost( + {}, {DurationCost::Segment{0, 0}}), std::string name = ""); bool operator==(VehicleType const &other) const; @@ -606,8 +606,7 @@ class ProblemData std::optional maxReloads, std::optional maxOvertime, std::optional unitOvertimeCost, - std::optional> - durationCost, + std::optional durationCost, std::optional name) const; /** From ff279dfa060a9d635bb13590eb0d06d7ff24225a Mon Sep 17 00:00:00 2001 From: BQ Date: Mon, 23 Mar 2026 12:34:50 +0100 Subject: [PATCH 13/23] CI fixes --- pyvrp/Model.py | 4 +++- pyvrp/cpp/ProblemData.cpp | 41 +++++++++++++++++------------------ pyvrp/cpp/ProblemData.h | 45 +++++++++++++++++++-------------------- pyvrp/cpp/bindings.cpp | 5 +++-- 4 files changed, 48 insertions(+), 47 deletions(-) diff --git a/pyvrp/Model.py b/pyvrp/Model.py index 5a7810425..f6973cf59 100644 --- a/pyvrp/Model.py +++ b/pyvrp/Model.py @@ -391,7 +391,9 @@ def add_vehicle_type( max_reloads: int = np.iinfo(np.uint64).max, max_overtime: int = 0, unit_overtime_cost: int = 0, - duration_cost: PiecewiseLinearFunction | None = None, + duration_cost: PiecewiseLinearFunction = PiecewiseLinearFunction( + [(np.int64(0), np.int64(0)), (np.int64(1), np.int64(0))] + ), *, name: str = "", ) -> VehicleType: diff --git a/pyvrp/cpp/ProblemData.cpp b/pyvrp/cpp/ProblemData.cpp index 46fe0725d..f920188bf 100644 --- a/pyvrp/cpp/ProblemData.cpp +++ b/pyvrp/cpp/ProblemData.cpp @@ -294,27 +294,26 @@ bool ProblemData::Depot::operator==(Depot const &other) const // clang-format on } -ProblemData::VehicleType::VehicleType( - size_t numAvailable, - std::vector capacity, - size_t startDepot, - size_t endDepot, - Cost fixedCost, - Duration twEarly, - Duration twLate, - Duration shiftDuration, - Distance maxDistance, - Cost unitDistanceCost, - Cost unitDurationCost, - size_t profile, - std::optional startLate, - std::vector initialLoad, - std::vector reloadDepots, - size_t maxReloads, - Duration maxOvertime, - Cost unitOvertimeCost, - VehicleType::DurationCost durationCost, - std::string name) +ProblemData::VehicleType::VehicleType(size_t numAvailable, + std::vector capacity, + size_t startDepot, + size_t endDepot, + Cost fixedCost, + Duration twEarly, + Duration twLate, + Duration shiftDuration, + Distance maxDistance, + Cost unitDistanceCost, + Cost unitDurationCost, + size_t profile, + std::optional startLate, + std::vector initialLoad, + std::vector reloadDepots, + size_t maxReloads, + Duration maxOvertime, + Cost unitOvertimeCost, + VehicleType::DurationCost durationCost, + std::string name) : numAvailable(numAvailable), startDepot(startDepot), endDepot(endDepot), diff --git a/pyvrp/cpp/ProblemData.h b/pyvrp/cpp/ProblemData.h index f1601187f..7cf400b52 100644 --- a/pyvrp/cpp/ProblemData.h +++ b/pyvrp/cpp/ProblemData.h @@ -569,8 +569,8 @@ class ProblemData size_t maxReloads = std::numeric_limits::max(), Duration maxOvertime = 0, Cost unitOvertimeCost = 0, - DurationCost durationCost = DurationCost( - {}, {DurationCost::Segment{0, 0}}), + DurationCost durationCost + = DurationCost({}, {DurationCost::Segment{0, 0}}), std::string name = ""); bool operator==(VehicleType const &other) const; @@ -587,27 +587,26 @@ class ProblemData * Returns a new ``VehicleType`` with the same data as this one, except * for the given parameters, which are used instead. */ - VehicleType - replace(std::optional numAvailable, - std::optional> capacity, - std::optional startDepot, - std::optional endDepot, - std::optional fixedCost, - std::optional twEarly, - std::optional twLate, - std::optional shiftDuration, - std::optional maxDistance, - std::optional unitDistanceCost, - std::optional unitDurationCost, - std::optional profile, - std::optional startLate, - std::optional> initialLoad, - std::optional> reloadDepots, - std::optional maxReloads, - std::optional maxOvertime, - std::optional unitOvertimeCost, - std::optional durationCost, - std::optional name) const; + VehicleType replace(std::optional numAvailable, + std::optional> capacity, + std::optional startDepot, + std::optional endDepot, + std::optional fixedCost, + std::optional twEarly, + std::optional twLate, + std::optional shiftDuration, + std::optional maxDistance, + std::optional unitDistanceCost, + std::optional unitDurationCost, + std::optional profile, + std::optional startLate, + std::optional> initialLoad, + std::optional> reloadDepots, + std::optional maxReloads, + std::optional maxOvertime, + std::optional unitOvertimeCost, + std::optional durationCost, + std::optional name) const; /** * Returns the maximum number of trips these vehicle can execute. diff --git a/pyvrp/cpp/bindings.cpp b/pyvrp/cpp/bindings.cpp index e9709c9dc..675c1bcbd 100644 --- a/pyvrp/cpp/bindings.cpp +++ b/pyvrp/cpp/bindings.cpp @@ -386,7 +386,7 @@ PYBIND11_MODULE(_pyvrp, m) size_t, pyvrp::Duration, pyvrp::Cost, - std::optional, + PiecewiseLinearFunction, char const *>(), py::arg("num_available") = 1, py::arg("capacity") = py::list(), @@ -408,7 +408,8 @@ PYBIND11_MODULE(_pyvrp, m) py::arg("max_reloads") = std::numeric_limits::max(), py::arg("max_overtime") = 0, py::arg("unit_overtime_cost") = 0, - py::arg("duration_cost") = py::none(), + py::arg("duration_cost") = PiecewiseLinearFunction( + {}, {PiecewiseLinearFunction::Segment{0, 0}}), py::kw_only(), py::arg("name") = "") .def_readonly("num_available", &ProblemData::VehicleType::numAvailable) From 31a38171b45f3786ed1e7491ee78c07b44668e09 Mon Sep 17 00:00:00 2001 From: BQ Date: Mon, 23 Mar 2026 13:24:07 +0100 Subject: [PATCH 14/23] Added and fixed tests for `is_non_negative()`. --- pyvrp/_pyvrp.pyi | 1 + pyvrp/cpp/PiecewiseLinearFunction.h | 28 +++++++++++++-------------- pyvrp/cpp/ProblemData.cpp | 9 +-------- pyvrp/cpp/bindings.cpp | 4 ++++ tests/test_PiecewiseLinearFunction.py | 26 +++++++++++++++++++++++++ 5 files changed, 46 insertions(+), 22 deletions(-) diff --git a/pyvrp/_pyvrp.pyi b/pyvrp/_pyvrp.pyi index 6cfbbdd8e..e701220db 100644 --- a/pyvrp/_pyvrp.pyi +++ b/pyvrp/_pyvrp.pyi @@ -76,6 +76,7 @@ class PiecewiseLinearFunction: @property def segments(self) -> list[tuple[np.int64, np.int64]]: ... def is_monotonically_increasing(self) -> bool: ... + def is_non_negative(self, lb: np.int64) -> bool: ... def __eq__(self, other: object) -> bool: ... def __getstate__(self) -> tuple: ... def __setstate__(self, state: tuple, /) -> None: ... diff --git a/pyvrp/cpp/PiecewiseLinearFunction.h b/pyvrp/cpp/PiecewiseLinearFunction.h index abc692893..a61a9f0c9 100644 --- a/pyvrp/cpp/PiecewiseLinearFunction.h +++ b/pyvrp/cpp/PiecewiseLinearFunction.h @@ -87,9 +87,9 @@ template class PiecewiseLinearFunction [[nodiscard]] bool isMonotonicallyIncreasing() const; /** - * Returns whether this function is non-negative everywhere. + * Returns whether this function is non-negative for all :math:`x \ge lb`. */ - [[nodiscard]] bool isNonNegative() const; + [[nodiscard]] bool isNonNegative(Dom lb) const; bool operator==(PiecewiseLinearFunction const &other) const = default; auto operator<=>(PiecewiseLinearFunction const &other) const = default; @@ -204,21 +204,27 @@ bool PiecewiseLinearFunction::isMonotonicallyIncreasing() const } template -bool PiecewiseLinearFunction::isNonNegative() const +bool PiecewiseLinearFunction::isNonNegative(Dom lb) const { - // The first segment extends to -inf. A positive slope would drive the - // function to -inf, violating non-negativity. - if (segments_.front().second > 0) - return false; - // The last segment extends to +inf. A negative slope would drive the // function to -inf, violating non-negativity. if (segments_.back().second < 0) return false; + // Check the value at the lower bound of the domain. + if ((*this)(lb) < 0) + return false; + + // Check all breakpoints strictly greater than lb. Since each segment is + // linear, its minimum over any interval is at one of the endpoints; those + // endpoints are lb (already checked) and the breakpoints below. for (size_t idx = 0; idx != breakpoints_.size(); ++idx) { auto const breakpoint = breakpoints_[idx]; + + if (breakpoint <= lb) + continue; + auto const [prevIntercept, prevSlope] = segments_[idx]; auto const [nextIntercept, nextSlope] = segments_[idx + 1]; @@ -229,12 +235,6 @@ bool PiecewiseLinearFunction::isNonNegative() const return false; } - // When there are no breakpoints the function is constant (slope is zero - // after the checks above). Non-negativity then reduces to checking the - // intercept. - if (breakpoints_.empty() && segments_.front().first < 0) - return false; - return true; } } // namespace pyvrp diff --git a/pyvrp/cpp/ProblemData.cpp b/pyvrp/cpp/ProblemData.cpp index f920188bf..3345bb670 100644 --- a/pyvrp/cpp/ProblemData.cpp +++ b/pyvrp/cpp/ProblemData.cpp @@ -392,14 +392,7 @@ ProblemData::VehicleType::VehicleType(size_t numAvailable, if (unitOvertimeCost < 0) throw std::invalid_argument("unit_overtime_cost must be >= 0."); - // NOTE: @N-Wouda: - // Currently, isNonNegative() checks the entire domain. However, in practice - // durations are always >= 0, so a user might define a PWL with a positive - // slope on the first segment (making it negative for x < 0) while still - // intending a non-negative cost on the actual domain [0, +inf). Should we - // pass a lower bound (e.g. 0) to isNonNegative() so the check is restricted - // to the meaningful domain? - if (!this->durationCost.isNonNegative()) + if (!this->durationCost.isNonNegative(Duration{0})) throw std::invalid_argument("duration_cost must be non-negative."); } diff --git a/pyvrp/cpp/bindings.cpp b/pyvrp/cpp/bindings.cpp index 675c1bcbd..7c8ab3050 100644 --- a/pyvrp/cpp/bindings.cpp +++ b/pyvrp/cpp/bindings.cpp @@ -155,6 +155,10 @@ PYBIND11_MODULE(_pyvrp, m) .def("is_monotonically_increasing", &PiecewiseLinearFunction::isMonotonicallyIncreasing, DOC(pyvrp, PiecewiseLinearFunction, isMonotonicallyIncreasing)) + .def("is_non_negative", + &PiecewiseLinearFunction::isNonNegative, + py::arg("lb"), + DOC(pyvrp, PiecewiseLinearFunction, isNonNegative)) .def(py::self == py::self) // this is __eq__ .def(py::pickle( [](PiecewiseLinearFunction const &function) // __getstate__ diff --git a/tests/test_PiecewiseLinearFunction.py b/tests/test_PiecewiseLinearFunction.py index 88db389de..7a95b9aee 100644 --- a/tests/test_PiecewiseLinearFunction.py +++ b/tests/test_PiecewiseLinearFunction.py @@ -160,6 +160,32 @@ def test_is_monotonically_increasing( assert_equal(fn.is_monotonically_increasing(), expected) +@pytest.mark.parametrize( + ("points", "lb", "expected"), + [ + ([(0, 0), (10, 10)], 0, True), # positive slope, f(0) = 0 + ([(0, 0), (1, 0)], 0, True), # constant zero function + ([(0, 5), (1, 5)], 0, True), # constant positive function + ([(0, 0), (10, 10)], -5, False), # positive slope, f(-5) < 0 + ([(0, -1), (1, 0)], 0, False), # f(lb) < 0 + ([(0, 5), (5, 0), (5, -1), (10, 4)], 0, False), # jump to negative + ([(0, 5), (5, 3), (10, 1)], 0, False), # negative last-segment slope + ([(0, 0), (5, 5), (10, 3)], 0, False), # negative after last bp + ([(0, 0), (5, 5), (5, 3), (10, 8)], 0, True), # jump stays >= 0 + ([(0, 0), (10, 10)], 5, True), # lb inside domain, f(5) = 5 >= 0 + ], +) +def test_is_non_negative( + points: list[tuple[int, int]], lb: int, expected: bool +): + """ + Tests is_non_negative for a range of cases, including positive slopes, + negative values at the lower bound, jumps, and varying lb values. + """ + fn = PiecewiseLinearFunction(points=points) + assert_equal(fn.is_non_negative(lb), expected) + + def test_pickle(): """ Tests that piecewise linear functions can be pickled and unpickled From 043ad2d523e8feed712bc3bb40cebc9166ee6cdb Mon Sep 17 00:00:00 2001 From: BQ Date: Mon, 23 Mar 2026 13:26:48 +0100 Subject: [PATCH 15/23] Use `int64_t` instead of `Duration`. --- pyvrp/cpp/ProblemData.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyvrp/cpp/ProblemData.cpp b/pyvrp/cpp/ProblemData.cpp index 3345bb670..cca72f6c6 100644 --- a/pyvrp/cpp/ProblemData.cpp +++ b/pyvrp/cpp/ProblemData.cpp @@ -392,7 +392,7 @@ ProblemData::VehicleType::VehicleType(size_t numAvailable, if (unitOvertimeCost < 0) throw std::invalid_argument("unit_overtime_cost must be >= 0."); - if (!this->durationCost.isNonNegative(Duration{0})) + if (!this->durationCost.isNonNegative(int64_t{0})) throw std::invalid_argument("duration_cost must be non-negative."); } From 1b35c333ee5866ae6bac078ad293169c4688438f Mon Sep 17 00:00:00 2001 From: BQ Date: Mon, 23 Mar 2026 13:39:21 +0100 Subject: [PATCH 16/23] fix non-integral slope tests. --- tests/test_PiecewiseLinearFunction.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_PiecewiseLinearFunction.py b/tests/test_PiecewiseLinearFunction.py index 7a95b9aee..c2a1f4cc3 100644 --- a/tests/test_PiecewiseLinearFunction.py +++ b/tests/test_PiecewiseLinearFunction.py @@ -169,8 +169,8 @@ def test_is_monotonically_increasing( ([(0, 0), (10, 10)], -5, False), # positive slope, f(-5) < 0 ([(0, -1), (1, 0)], 0, False), # f(lb) < 0 ([(0, 5), (5, 0), (5, -1), (10, 4)], 0, False), # jump to negative - ([(0, 5), (5, 3), (10, 1)], 0, False), # negative last-segment slope - ([(0, 0), (5, 5), (10, 3)], 0, False), # negative after last bp + ([(0, 10), (5, 5), (10, 0)], 0, False), # negative last-segment slope + ([(0, 5), (5, -5), (10, 5)], 0, False), # negative at breakpoint ([(0, 0), (5, 5), (5, 3), (10, 8)], 0, True), # jump stays >= 0 ([(0, 0), (10, 10)], 5, True), # lb inside domain, f(5) = 5 >= 0 ], From 682876a390b11d7898330c6ad0f13f305bbc12dc Mon Sep 17 00:00:00 2001 From: BQ Date: Mon, 23 Mar 2026 16:45:56 +0100 Subject: [PATCH 17/23] changed casting and typing. `Segment = std::pair`. `binding.cpp` now exposes explicit typed `DurationCost` instead of general typed `PiecewiseLinearFunction`. --- pyvrp/_pyvrp.pyi | 2 +- pyvrp/cpp/PiecewiseLinearFunction.h | 25 +++++++++------ pyvrp/cpp/ProblemData.cpp | 2 +- pyvrp/cpp/ProblemData.h | 7 +++-- pyvrp/cpp/bindings.cpp | 47 ++++++++++++++--------------- 5 files changed, 44 insertions(+), 39 deletions(-) diff --git a/pyvrp/_pyvrp.pyi b/pyvrp/_pyvrp.pyi index e701220db..e9976075c 100644 --- a/pyvrp/_pyvrp.pyi +++ b/pyvrp/_pyvrp.pyi @@ -202,7 +202,7 @@ class VehicleType: max_reloads: int = ..., max_overtime: int = 0, unit_overtime_cost: int = 0, - duration_cost: PiecewiseLinearFunction | None = None, + duration_cost: PiecewiseLinearFunction = ..., *, name: str = "", ) -> None: ... diff --git a/pyvrp/cpp/PiecewiseLinearFunction.h b/pyvrp/cpp/PiecewiseLinearFunction.h index a61a9f0c9..9c82a3380 100644 --- a/pyvrp/cpp/PiecewiseLinearFunction.h +++ b/pyvrp/cpp/PiecewiseLinearFunction.h @@ -51,7 +51,7 @@ namespace pyvrp template class PiecewiseLinearFunction { public: - using Segment = std::pair; + using Segment = std::pair; using Point = std::pair; private: @@ -124,14 +124,17 @@ PiecewiseLinearFunction::PiecewiseLinearFunction( if (dx < 0) throw std::invalid_argument("Points must be non-decreasing in x."); - if (dy % dx != 0) + auto const dxCo = static_cast(dx); + + if (dy % dxCo != 0) throw std::invalid_argument("Slope is not integral."); if (idx != 0) // breakpoints separate segments breakpoints_.push_back(curr.first); - auto const slope = dy / dx; - auto const intercept = curr.second - slope * curr.first; + auto const slope = dy / dxCo; + auto const intercept + = curr.second - slope * static_cast(curr.first); segments_.emplace_back(intercept, slope); } @@ -164,7 +167,7 @@ Co PiecewiseLinearFunction::operator()(Dom x) const std::upper_bound(breakpoints_.begin(), breakpoints_.end(), x)); auto const [intercept, slope] = segments_[idx]; - return static_cast(intercept + slope * x); + return intercept + slope * static_cast(x); } template @@ -193,8 +196,10 @@ bool PiecewiseLinearFunction::isMonotonicallyIncreasing() const auto const [prevIntercept, prevSlope] = segments_[idx]; auto const [nextIntercept, nextSlope] = segments_[idx + 1]; - auto const left = prevIntercept + prevSlope * breakpoint; - auto const right = nextIntercept + nextSlope * breakpoint; + auto const left + = prevIntercept + prevSlope * static_cast(breakpoint); + auto const right + = nextIntercept + nextSlope * static_cast(breakpoint); if (right < left) return false; @@ -228,8 +233,10 @@ bool PiecewiseLinearFunction::isNonNegative(Dom lb) const auto const [prevIntercept, prevSlope] = segments_[idx]; auto const [nextIntercept, nextSlope] = segments_[idx + 1]; - auto const left = prevIntercept + prevSlope * breakpoint; - auto const right = nextIntercept + nextSlope * breakpoint; + auto const left + = prevIntercept + prevSlope * static_cast(breakpoint); + auto const right + = nextIntercept + nextSlope * static_cast(breakpoint); if (left < 0 || right < 0) return false; diff --git a/pyvrp/cpp/ProblemData.cpp b/pyvrp/cpp/ProblemData.cpp index cca72f6c6..3345bb670 100644 --- a/pyvrp/cpp/ProblemData.cpp +++ b/pyvrp/cpp/ProblemData.cpp @@ -392,7 +392,7 @@ ProblemData::VehicleType::VehicleType(size_t numAvailable, if (unitOvertimeCost < 0) throw std::invalid_argument("unit_overtime_cost must be >= 0."); - if (!this->durationCost.isNonNegative(int64_t{0})) + if (!this->durationCost.isNonNegative(Duration{0})) throw std::invalid_argument("duration_cost must be non-negative."); } diff --git a/pyvrp/cpp/ProblemData.h b/pyvrp/cpp/ProblemData.h index 7cf400b52..54e720e48 100644 --- a/pyvrp/cpp/ProblemData.h +++ b/pyvrp/cpp/ProblemData.h @@ -524,7 +524,7 @@ class ProblemData */ struct VehicleType { - using DurationCost = PiecewiseLinearFunction; + using DurationCost = PiecewiseLinearFunction; size_t const numAvailable; // Available vehicles of this type size_t const startDepot; // Departure depot location @@ -545,7 +545,7 @@ class ProblemData Duration const maxOvertime; // Maximum allowed overtime Cost const unitOvertimeCost; // Cost per unit of overtime Duration const maxDuration; // Maximum route duration, incl. overtime - DurationCost const durationCost; // Cost f(duration) + DurationCost const durationCost; bool const hasDurationCost; // Any non-zero duration cost or hard constraint char const *name; // Type name (for reference) @@ -570,7 +570,8 @@ class ProblemData Duration maxOvertime = 0, Cost unitOvertimeCost = 0, DurationCost durationCost - = DurationCost({}, {DurationCost::Segment{0, 0}}), + = DurationCost({}, + {DurationCost::Segment{Cost{0}, Cost{0}}}), std::string name = ""); bool operator==(VehicleType const &other) const; diff --git a/pyvrp/cpp/bindings.cpp b/pyvrp/cpp/bindings.cpp index 7c8ab3050..fd666cc6e 100644 --- a/pyvrp/cpp/bindings.cpp +++ b/pyvrp/cpp/bindings.cpp @@ -36,9 +36,6 @@ using pyvrp::RandomNumberGenerator; using pyvrp::Route; using pyvrp::Solution; -using PiecewiseLinearFunction - = pyvrp::PiecewiseLinearFunction; - PYBIND11_MODULE(_pyvrp, m) { py::options options; @@ -132,47 +129,47 @@ PYBIND11_MODULE(_pyvrp, m) blocks.end()); })); - py::class_( + using DurationCost = ProblemData::VehicleType::DurationCost; + + py::class_( m, "PiecewiseLinearFunction", DOC(pyvrp, PiecewiseLinearFunction)) - .def(py::init>(), - py::arg("points")) - .def(py::init, - std::vector>(), + .def(py::init>(), py::arg("points")) + .def(py::init, + std::vector>(), py::arg("breakpoints"), py::arg("segments")) .def("__call__", - &PiecewiseLinearFunction::operator(), + &DurationCost::operator(), py::arg("x"), DOC(pyvrp, PiecewiseLinearFunction, __call__)) .def_property_readonly("breakpoints", - &PiecewiseLinearFunction::breakpoints, + &DurationCost::breakpoints, py::return_value_policy::reference_internal, DOC(pyvrp, PiecewiseLinearFunction, breakpoints)) .def_property_readonly("segments", - &PiecewiseLinearFunction::segments, + &DurationCost::segments, py::return_value_policy::reference_internal, DOC(pyvrp, PiecewiseLinearFunction, segments)) .def("is_monotonically_increasing", - &PiecewiseLinearFunction::isMonotonicallyIncreasing, + &DurationCost::isMonotonicallyIncreasing, DOC(pyvrp, PiecewiseLinearFunction, isMonotonicallyIncreasing)) .def("is_non_negative", - &PiecewiseLinearFunction::isNonNegative, + &DurationCost::isNonNegative, py::arg("lb"), DOC(pyvrp, PiecewiseLinearFunction, isNonNegative)) .def(py::self == py::self) // this is __eq__ .def(py::pickle( - [](PiecewiseLinearFunction const &function) // __getstate__ + [](DurationCost const &function) // __getstate__ { return py::make_tuple(function.breakpoints(), function.segments()); }, [](py::tuple t) // __setstate__ { - using Breakpoints = std::vector; - using Segments = std::vector; - return PiecewiseLinearFunction( - t[0].cast(), // breakpoints - t[1].cast()); // segments + using Breakpoints = std::vector; + using Segments = std::vector; + return DurationCost(t[0].cast(), // breakpoints + t[1].cast()); // segments })); py::class_( @@ -390,7 +387,7 @@ PYBIND11_MODULE(_pyvrp, m) size_t, pyvrp::Duration, pyvrp::Cost, - PiecewiseLinearFunction, + DurationCost, char const *>(), py::arg("num_available") = 1, py::arg("capacity") = py::list(), @@ -412,8 +409,8 @@ PYBIND11_MODULE(_pyvrp, m) py::arg("max_reloads") = std::numeric_limits::max(), py::arg("max_overtime") = 0, py::arg("unit_overtime_cost") = 0, - py::arg("duration_cost") = PiecewiseLinearFunction( - {}, {PiecewiseLinearFunction::Segment{0, 0}}), + py::arg("duration_cost") = DurationCost( + {}, {DurationCost::Segment{pyvrp::Cost{0}, pyvrp::Cost{0}}}), py::kw_only(), py::arg("name") = "") .def_readonly("num_available", &ProblemData::VehicleType::numAvailable) @@ -519,9 +516,9 @@ PYBIND11_MODULE(_pyvrp, m) t[14].cast>(), // reload depots t[15].cast(), // max reloads t[16].cast(), // max overtime - t[17].cast(), // unit overtime cost - t[18].cast(), // duration cost - t[19].cast()); // name + t[17].cast(), // unit overtime cost + t[18].cast(), // duration cost + t[19].cast()); // name return vehicleType; })) From b211d0f266238944cd9253a46df79e97bf9da2d7 Mon Sep 17 00:00:00 2001 From: BQ Date: Mon, 23 Mar 2026 16:56:54 +0100 Subject: [PATCH 18/23] `Measure`s do not have the `operator%` and `operator/` so cast to `int64_t` for those operators. --- pyvrp/cpp/PiecewiseLinearFunction.h | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/pyvrp/cpp/PiecewiseLinearFunction.h b/pyvrp/cpp/PiecewiseLinearFunction.h index 9c82a3380..9bcb3adfd 100644 --- a/pyvrp/cpp/PiecewiseLinearFunction.h +++ b/pyvrp/cpp/PiecewiseLinearFunction.h @@ -118,21 +118,19 @@ PiecewiseLinearFunction::PiecewiseLinearFunction( if (curr.first == next.first) // nothing to do; we have a jump at this continue; // point, but not a new segment. - auto const dy = next.second - curr.second; - auto const dx = next.first - curr.first; + auto const dy = static_cast(next.second - curr.second); + auto const dx = static_cast(next.first - curr.first); if (dx < 0) throw std::invalid_argument("Points must be non-decreasing in x."); - auto const dxCo = static_cast(dx); - - if (dy % dxCo != 0) + if (dy % dx != 0) throw std::invalid_argument("Slope is not integral."); if (idx != 0) // breakpoints separate segments breakpoints_.push_back(curr.first); - auto const slope = dy / dxCo; + auto const slope = static_cast(dy / dx); auto const intercept = curr.second - slope * static_cast(curr.first); segments_.emplace_back(intercept, slope); From 21f465ee49bae8dbf03423229203d7497f0f96b6 Mon Sep 17 00:00:00 2001 From: BQ Date: Mon, 23 Mar 2026 17:23:09 +0100 Subject: [PATCH 19/23] add test coverage. --- tests/test_PiecewiseLinearFunction.py | 6 ++++++ tests/test_ProblemData.py | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/tests/test_PiecewiseLinearFunction.py b/tests/test_PiecewiseLinearFunction.py index c2a1f4cc3..ed9163a1a 100644 --- a/tests/test_PiecewiseLinearFunction.py +++ b/tests/test_PiecewiseLinearFunction.py @@ -173,6 +173,12 @@ def test_is_monotonically_increasing( ([(0, 5), (5, -5), (10, 5)], 0, False), # negative at breakpoint ([(0, 0), (5, 5), (5, 3), (10, 8)], 0, True), # jump stays >= 0 ([(0, 0), (10, 10)], 5, True), # lb inside domain, f(5) = 5 >= 0 + # lb equals a breakpoint: the breakpoint at x=3 is skipped (<=lb), + # the one at x=6 is checked and stays non-negative. + ([(0, 2), (3, 5), (3, 6), (6, 9), (10, 13)], 3, True), + # lb equals a breakpoint: the breakpoint at x=3 is skipped (<=lb), + # the one at x=6 has a jump down to -1, so non-negativity fails. + ([(0, 2), (3, 5), (3, 6), (6, 9), (6, -1), (10, 3)], 3, False), ], ) def test_is_non_negative( diff --git a/tests/test_ProblemData.py b/tests/test_ProblemData.py index 5813eab4f..9bd02925c 100644 --- a/tests/test_ProblemData.py +++ b/tests/test_ProblemData.py @@ -754,6 +754,11 @@ def test_vehicle_type_duration_cost(): veh_type = VehicleType(duration_cost=pwl) assert_equal(veh_type.duration_cost, pwl) + # PLF with non-zero intercept: covers the seg.first != 0 branch in the + # hasDurationCost check. + pwl2 = PiecewiseLinearFunction([(0, 5), (10, 15)]) + assert_equal(VehicleType(duration_cost=pwl2).duration_cost, pwl2) + replaced = veh_type.replace(duration_cost=zero) assert_equal(replaced.duration_cost, zero) @@ -1081,6 +1086,10 @@ def test_vehicle_type_eq(): veh_type3 = VehicleType(num_available=3, profile=0) assert_(veh_type1 == veh_type3) + # Two types differing only in duration_cost are not equal. + pwl = PiecewiseLinearFunction([(0, 0), (10, 10)]) + assert_(VehicleType() != VehicleType(duration_cost=pwl)) + # And some things that are not vehicle types. assert_(veh_type1 != "text") assert_(veh_type1 != 5) From b3250d1abf07e1df31faa417a1ec6bd06b2256d8 Mon Sep 17 00:00:00 2001 From: BQ Date: Fri, 27 Mar 2026 15:30:07 +0100 Subject: [PATCH 20/23] Implemented the duration cost function and removed the unit costs. --- notebooks/duration_constraints.ipynb | 21 ++++++--- pyvrp/Model.py | 4 -- pyvrp/_pyvrp.pyi | 6 --- pyvrp/cpp/ProblemData.cpp | 20 --------- pyvrp/cpp/ProblemData.h | 16 ------- pyvrp/cpp/Route.cpp | 3 +- pyvrp/cpp/bindings.cpp | 30 ++++--------- pyvrp/cpp/search/Route.cpp | 4 +- pyvrp/cpp/search/Route.h | 32 +++++-------- pyvrp/cpp/search/bindings.cpp | 2 - pyvrp/cpp/search/neighbourhood.cpp | 13 +++--- pyvrp/search/_search.pyi | 2 - tests/conftest.py | 7 +-- tests/search/test_RelocateWithDepot.py | 7 ++- tests/search/test_RemoveAdjacentDepot.py | 9 +++- tests/search/test_Route.py | 13 +++--- tests/search/test_neighbourhood.py | 34 ++++++++++++-- tests/test_CostEvaluator.py | 13 +++++- tests/test_Model.py | 8 +++- tests/test_ProblemData.py | 57 +++++++++--------------- tests/test_Route.py | 13 +++++- tests/test_Solution.py | 13 +++++- 22 files changed, 159 insertions(+), 168 deletions(-) diff --git a/notebooks/duration_constraints.ipynb b/notebooks/duration_constraints.ipynb index d58837400..8ba4f0716 100644 --- a/notebooks/duration_constraints.ipynb +++ b/notebooks/duration_constraints.ipynb @@ -81,7 +81,11 @@ "outputs": [], "source": [ "m = pyvrp.Model()\n", - "m.add_vehicle_type(2, unit_distance_cost=0, unit_duration_cost=1)\n", + "m.add_vehicle_type(\n", + " 2,\n", + " unit_distance_cost=0,\n", + " duration_cost=pyvrp.PiecewiseLinearFunction([], [(0, 1)]),\n", + ")\n", "\n", "m.add_depot(\n", " location=m.add_location(x=COORDS[0][0], y=COORDS[0][1]),\n", @@ -214,7 +218,11 @@ "outputs": [], "source": [ "m = pyvrp.Model()\n", - "m.add_vehicle_type(2, unit_distance_cost=0, unit_duration_cost=1)\n", + "m.add_vehicle_type(\n", + " 2,\n", + " unit_distance_cost=0,\n", + " duration_cost=pyvrp.PiecewiseLinearFunction([], [(0, 1)]),\n", + ")\n", "\n", "m.add_depot(\n", " location=m.add_location(x=COORDS[0][0], y=COORDS[0][1]),\n", @@ -269,7 +277,10 @@ "PyVRP also supports this.\n", "\n", "Let's consider a small example where each vehicle *must* start at $t=0$, and must return to the depot by $t=30$.\n", - "Additionally, a regular shift takes at most 20 time units, but a maximum overtime of 10 units is allowed, at additional cost." + "Additionally, a regular shift takes at most 20 time units, but a maximum overtime of 10 units is allowed, at additional cost.\n", + "\n", + "The duration cost is expressed as a piecewise linear function of route duration.\n", + "Here, the cost increases linearly at rate 1 for durations up to the nominal shift of 20, and then at the higher rate of 6 (= 1 + 5 overtime surcharge) for any overtime beyond that." ] }, { @@ -287,8 +298,8 @@ " shift_duration=20, # nominal shift duration\n", " max_overtime=10, # maximum allowed overtime\n", " unit_distance_cost=0,\n", - " unit_duration_cost=1,\n", - " unit_overtime_cost=5, # additional unit overtime cost\n", + " # slope=1 for d <= 20 (regular shift), slope=6 (=1+5 surcharge) for d > 20\n", + " duration_cost=pyvrp.PiecewiseLinearFunction([20], [(0, 1), (-100, 6)]),\n", ")\n", "\n", "m.add_depot(\n", diff --git a/pyvrp/Model.py b/pyvrp/Model.py index f6973cf59..b7b85da3f 100644 --- a/pyvrp/Model.py +++ b/pyvrp/Model.py @@ -383,14 +383,12 @@ def add_vehicle_type( shift_duration: int = np.iinfo(np.int64).max, max_distance: int = np.iinfo(np.int64).max, unit_distance_cost: int = 1, - unit_duration_cost: int = 0, profile: Profile | None = None, start_late: int | None = None, initial_load: int | list[int] = [], reload_depots: list[Depot] = [], max_reloads: int = np.iinfo(np.uint64).max, max_overtime: int = 0, - unit_overtime_cost: int = 0, duration_cost: PiecewiseLinearFunction = PiecewiseLinearFunction( [(np.int64(0), np.int64(0)), (np.int64(1), np.int64(0))] ), @@ -457,14 +455,12 @@ def add_vehicle_type( shift_duration=shift_duration, max_distance=max_distance, unit_distance_cost=unit_distance_cost, - unit_duration_cost=unit_duration_cost, profile=profile_idx, start_late=start_late, initial_load=init_load, reload_depots=reloads, max_reloads=max_reloads, max_overtime=max_overtime, - unit_overtime_cost=unit_overtime_cost, duration_cost=duration_cost, name=name, ) diff --git a/pyvrp/_pyvrp.pyi b/pyvrp/_pyvrp.pyi index e9976075c..d734a0f7a 100644 --- a/pyvrp/_pyvrp.pyi +++ b/pyvrp/_pyvrp.pyi @@ -171,14 +171,12 @@ class VehicleType: max_distance: int fixed_cost: int unit_distance_cost: int - unit_duration_cost: int profile: int start_late: int initial_load: list[int] reload_depots: list[int] max_reloads: int max_overtime: int - unit_overtime_cost: int duration_cost: PiecewiseLinearFunction max_duration: int name: str @@ -194,14 +192,12 @@ class VehicleType: shift_duration: int = ..., max_distance: int = ..., unit_distance_cost: int = 1, - unit_duration_cost: int = 0, profile: int = 0, start_late: int | None = None, initial_load: list[int] = [], reload_depots: list[int] = [], max_reloads: int = ..., max_overtime: int = 0, - unit_overtime_cost: int = 0, duration_cost: PiecewiseLinearFunction = ..., *, name: str = "", @@ -220,14 +216,12 @@ class VehicleType: shift_duration: int | None = None, max_distance: int | None = None, unit_distance_cost: int | None = None, - unit_duration_cost: int | None = None, profile: int | None = None, start_late: int | None = None, initial_load: list[int] | None = None, reload_depots: list[int] | None = None, max_reloads: int | None = None, max_overtime: int | None = None, - unit_overtime_cost: int | None = None, duration_cost: PiecewiseLinearFunction | None = None, *, name: str | None = None, diff --git a/pyvrp/cpp/ProblemData.cpp b/pyvrp/cpp/ProblemData.cpp index 3345bb670..59cd6b183 100644 --- a/pyvrp/cpp/ProblemData.cpp +++ b/pyvrp/cpp/ProblemData.cpp @@ -304,14 +304,12 @@ ProblemData::VehicleType::VehicleType(size_t numAvailable, Duration shiftDuration, Distance maxDistance, Cost unitDistanceCost, - Cost unitDurationCost, size_t profile, std::optional startLate, std::vector initialLoad, std::vector reloadDepots, size_t maxReloads, Duration maxOvertime, - Cost unitOvertimeCost, VehicleType::DurationCost durationCost, std::string name) : numAvailable(numAvailable), @@ -324,14 +322,12 @@ ProblemData::VehicleType::VehicleType(size_t numAvailable, maxDistance(maxDistance), fixedCost(fixedCost), unitDistanceCost(unitDistanceCost), - unitDurationCost(unitDurationCost), profile(profile), startLate(startLate.value_or(twLate)), initialLoad(pad(initialLoad, capacity)), reloadDepots(reloadDepots), maxReloads(maxReloads), maxOvertime(maxOvertime), - unitOvertimeCost(unitOvertimeCost), // We need to check >= 0 here to avoid overflow. If the arguments are // negative the validation checks further below will raise, so it doesn't // matter what we set as long as we get to those checks. @@ -376,9 +372,6 @@ ProblemData::VehicleType::VehicleType(size_t numAvailable, if (unitDistanceCost < 0) throw std::invalid_argument("unit_distance_cost must be >= 0."); - if (unitDurationCost < 0) - throw std::invalid_argument("unit_duration_cost must be >= 0."); - if (std::any_of(initialLoad.begin(), initialLoad.end(), isNegative)) throw std::invalid_argument("initial load amounts must be >= 0."); @@ -389,9 +382,6 @@ ProblemData::VehicleType::VehicleType(size_t numAvailable, if (maxOvertime < 0) throw std::invalid_argument("max_overtime must be >= 0."); - if (unitOvertimeCost < 0) - throw std::invalid_argument("unit_overtime_cost must be >= 0."); - if (!this->durationCost.isNonNegative(Duration{0})) throw std::invalid_argument("duration_cost must be non-negative."); } @@ -407,14 +397,12 @@ ProblemData::VehicleType::VehicleType(VehicleType const &vehicleType) maxDistance(vehicleType.maxDistance), fixedCost(vehicleType.fixedCost), unitDistanceCost(vehicleType.unitDistanceCost), - unitDurationCost(vehicleType.unitDurationCost), profile(vehicleType.profile), startLate(vehicleType.startLate), initialLoad(vehicleType.initialLoad), reloadDepots(vehicleType.reloadDepots), maxReloads(vehicleType.maxReloads), maxOvertime(vehicleType.maxOvertime), - unitOvertimeCost(vehicleType.unitOvertimeCost), maxDuration(vehicleType.maxDuration), durationCost(vehicleType.durationCost), hasDurationCost(vehicleType.hasDurationCost), @@ -433,14 +421,12 @@ ProblemData::VehicleType::VehicleType(VehicleType &&vehicleType) maxDistance(vehicleType.maxDistance), fixedCost(vehicleType.fixedCost), unitDistanceCost(vehicleType.unitDistanceCost), - unitDurationCost(vehicleType.unitDurationCost), profile(vehicleType.profile), startLate(vehicleType.startLate), initialLoad(std::move(vehicleType.initialLoad)), reloadDepots(std::move(vehicleType.reloadDepots)), maxReloads(vehicleType.maxReloads), maxOvertime(vehicleType.maxOvertime), - unitOvertimeCost(vehicleType.unitOvertimeCost), maxDuration(vehicleType.maxDuration), durationCost(std::move(vehicleType.durationCost)), hasDurationCost(vehicleType.hasDurationCost), @@ -462,14 +448,12 @@ ProblemData::VehicleType ProblemData::VehicleType::replace( std::optional shiftDuration, std::optional maxDistance, std::optional unitDistanceCost, - std::optional unitDurationCost, std::optional profile, std::optional startLate, std::optional> initialLoad, std::optional> reloadDepots, std::optional maxReloads, std::optional maxOvertime, - std::optional unitOvertimeCost, std::optional durationCost, std::optional name) const { @@ -483,14 +467,12 @@ ProblemData::VehicleType ProblemData::VehicleType::replace( shiftDuration.value_or(this->shiftDuration), maxDistance.value_or(this->maxDistance), unitDistanceCost.value_or(this->unitDistanceCost), - unitDurationCost.value_or(this->unitDurationCost), profile.value_or(this->profile), startLate.value_or(this->startLate), initialLoad.value_or(this->initialLoad), reloadDepots.value_or(this->reloadDepots), maxReloads.value_or(this->maxReloads), maxOvertime.value_or(this->maxOvertime), - unitOvertimeCost.value_or(this->unitOvertimeCost), durationCost.value_or(this->durationCost), name.value_or(this->name)}; } @@ -515,14 +497,12 @@ bool ProblemData::VehicleType::operator==(VehicleType const &other) const && shiftDuration == other.shiftDuration && maxDistance == other.maxDistance && unitDistanceCost == other.unitDistanceCost - && unitDurationCost == other.unitDurationCost && profile == other.profile && startLate == other.startLate && initialLoad == other.initialLoad && reloadDepots == other.reloadDepots && maxReloads == other.maxReloads && maxOvertime == other.maxOvertime - && unitOvertimeCost == other.unitOvertimeCost && durationCost == other.durationCost && std::strcmp(name, other.name) == 0; // clang-format on diff --git a/pyvrp/cpp/ProblemData.h b/pyvrp/cpp/ProblemData.h index 54e720e48..ec36ff863 100644 --- a/pyvrp/cpp/ProblemData.h +++ b/pyvrp/cpp/ProblemData.h @@ -437,9 +437,6 @@ class ProblemData * unit_distance_cost * Cost per unit of distance travelled by vehicles of this type. Default * 1. - * unit_duration_cost - * Cost per unit of duration on routes serviced by vehicles of this - * type. Default 0. * profile * This vehicle type's routing profile. Default 0, the first profile. * start_late @@ -460,9 +457,6 @@ class ProblemData * max_overtime * Maximum allowed overtime, on top of the :py:attr:`~shift_duration`. * Default 0, that is, overtime is not allowed. - * unit_overtime_cost - * Cost of a unit of overtime. This is in addition to the regular - * :py:attr:`~unit_duration_cost` of route durations. Default 0. * duration_cost * Piecewise linear duration cost function :math:`f(\text{duration})`. * When not provided, a zero-cost function is used. @@ -494,8 +488,6 @@ class ProblemData * unconstrained. * unit_distance_cost * Cost per unit of distance travelled by vehicles of this type. - * unit_duration_cost - * Cost per unit of duration on routes using vehicles of this type. * profile * This vehicle type's routing profile. * start_late @@ -512,8 +504,6 @@ class ProblemData * max_overtime * Maximum amount of allowed overtime, on top of the nominal * :py:attr:`~shift_duration`. - * unit_overtime_cost - * Additional cost of a unit of overtime. * duration_cost * Piecewise linear duration cost function :math:`f(\text{duration})`. * max_duration @@ -536,14 +526,12 @@ class ProblemData Distance const maxDistance; // Maximum route distance Cost const fixedCost; // Fixed cost of using this vehicle type Cost const unitDistanceCost; // Variable cost per unit of distance - Cost const unitDurationCost; // Variable cost per unit of duration size_t const profile; // Distance and duration profile Duration const startLate; // Latest start of shift std::vector const initialLoad; // Initially used capacity std::vector const reloadDepots; // Reload locations size_t const maxReloads; // Maximum number of reloads Duration const maxOvertime; // Maximum allowed overtime - Cost const unitOvertimeCost; // Cost per unit of overtime Duration const maxDuration; // Maximum route duration, incl. overtime DurationCost const durationCost; bool const @@ -561,14 +549,12 @@ class ProblemData = std::numeric_limits::max(), Distance maxDistance = std::numeric_limits::max(), Cost unitDistanceCost = 1, - Cost unitDurationCost = 0, size_t profile = 0, std::optional startLate = std::nullopt, std::vector initialLoad = {}, std::vector reloadDepots = {}, size_t maxReloads = std::numeric_limits::max(), Duration maxOvertime = 0, - Cost unitOvertimeCost = 0, DurationCost durationCost = DurationCost({}, {DurationCost::Segment{Cost{0}, Cost{0}}}), @@ -598,14 +584,12 @@ class ProblemData std::optional shiftDuration, std::optional maxDistance, std::optional unitDistanceCost, - std::optional unitDurationCost, std::optional profile, std::optional startLate, std::optional> initialLoad, std::optional> reloadDepots, std::optional maxReloads, std::optional maxOvertime, - std::optional unitOvertimeCost, std::optional durationCost, std::optional name) const; diff --git a/pyvrp/cpp/Route.cpp b/pyvrp/cpp/Route.cpp index 5b37a2b7f..ab365f6d5 100644 --- a/pyvrp/cpp/Route.cpp +++ b/pyvrp/cpp/Route.cpp @@ -137,8 +137,7 @@ void Route::setSchedule(ProblemData const &data, Activities const &activities) duration_ = ds.duration(); overtime_ = std::max(duration_ - vehData.shiftDuration, 0); - durationCost_ = vehData.unitDurationCost * static_cast(duration_) - + vehData.unitOvertimeCost * static_cast(overtime_); + durationCost_ = vehData.durationCost(duration_); startTime_ = ds.startEarly(); releaseTime_ = ds.releaseTime(); slack_ = ds.slack(); diff --git a/pyvrp/cpp/bindings.cpp b/pyvrp/cpp/bindings.cpp index fd666cc6e..6c27bc9d8 100644 --- a/pyvrp/cpp/bindings.cpp +++ b/pyvrp/cpp/bindings.cpp @@ -379,14 +379,12 @@ PYBIND11_MODULE(_pyvrp, m) pyvrp::Duration, pyvrp::Distance, pyvrp::Cost, - pyvrp::Cost, size_t, std::optional, std::vector, std::vector, size_t, pyvrp::Duration, - pyvrp::Cost, DurationCost, char const *>(), py::arg("num_available") = 1, @@ -401,14 +399,12 @@ PYBIND11_MODULE(_pyvrp, m) py::arg("max_distance") = std::numeric_limits::max(), py::arg("unit_distance_cost") = 1, - py::arg("unit_duration_cost") = 0, py::arg("profile") = 0, py::arg("start_late") = py::none(), py::arg("initial_load") = py::list(), py::arg("reload_depots") = py::list(), py::arg("max_reloads") = std::numeric_limits::max(), py::arg("max_overtime") = 0, - py::arg("unit_overtime_cost") = 0, py::arg("duration_cost") = DurationCost( {}, {DurationCost::Segment{pyvrp::Cost{0}, pyvrp::Cost{0}}}), py::kw_only(), @@ -427,8 +423,6 @@ PYBIND11_MODULE(_pyvrp, m) .def_readonly("max_distance", &ProblemData::VehicleType::maxDistance) .def_readonly("unit_distance_cost", &ProblemData::VehicleType::unitDistanceCost) - .def_readonly("unit_duration_cost", - &ProblemData::VehicleType::unitDurationCost) .def_readonly("profile", &ProblemData::VehicleType::profile) .def_readonly("start_late", &ProblemData::VehicleType::startLate) .def_readonly("initial_load", @@ -439,8 +433,6 @@ PYBIND11_MODULE(_pyvrp, m) py::return_value_policy::reference_internal) .def_readonly("max_reloads", &ProblemData::VehicleType::maxReloads) .def_readonly("max_overtime", &ProblemData::VehicleType::maxOvertime) - .def_readonly("unit_overtime_cost", - &ProblemData::VehicleType::unitOvertimeCost) .def_readonly("duration_cost", &ProblemData::VehicleType::durationCost, py::return_value_policy::reference_internal) @@ -461,14 +453,12 @@ PYBIND11_MODULE(_pyvrp, m) py::arg("shift_duration") = py::none(), py::arg("max_distance") = py::none(), py::arg("unit_distance_cost") = py::none(), - py::arg("unit_duration_cost") = py::none(), py::arg("profile") = py::none(), py::arg("start_late") = py::none(), py::arg("initial_load") = py::none(), py::arg("reload_depots") = py::none(), py::arg("max_reloads") = py::none(), py::arg("max_overtime") = py::none(), - py::arg("unit_overtime_cost") = py::none(), py::arg("duration_cost") = py::none(), py::kw_only(), py::arg("name") = py::none(), @@ -486,14 +476,12 @@ PYBIND11_MODULE(_pyvrp, m) vehicleType.shiftDuration, vehicleType.maxDistance, vehicleType.unitDistanceCost, - vehicleType.unitDurationCost, vehicleType.profile, vehicleType.startLate, vehicleType.initialLoad, vehicleType.reloadDepots, vehicleType.maxReloads, vehicleType.maxOvertime, - vehicleType.unitOvertimeCost, vehicleType.durationCost, vehicleType.name); }, @@ -509,16 +497,14 @@ PYBIND11_MODULE(_pyvrp, m) t[7].cast(), // shift duration t[8].cast(), // max distance t[9].cast(), // unit distance cost - t[10].cast(), // unit duration cost - t[11].cast(), // profile - t[12].cast(), // start late - t[13].cast>(), // initial load - t[14].cast>(), // reload depots - t[15].cast(), // max reloads - t[16].cast(), // max overtime - t[17].cast(), // unit overtime cost - t[18].cast(), // duration cost - t[19].cast()); // name + t[10].cast(), // profile + t[11].cast(), // start late + t[12].cast>(), // initial load + t[13].cast>(), // reload depots + t[14].cast(), // max reloads + t[15].cast(), // max overtime + t[16].cast(), // duration cost + t[17].cast()); // name return vehicleType; })) diff --git a/pyvrp/cpp/search/Route.cpp b/pyvrp/cpp/search/Route.cpp index dee621160..e6f782abd 100644 --- a/pyvrp/cpp/search/Route.cpp +++ b/pyvrp/cpp/search/Route.cpp @@ -324,9 +324,7 @@ void Route::update() duration_ = durAfter[0].duration(); timeWarp_ = durAfter[0].timeWarp(maxDuration()); - auto const overtime = std::max(duration_ - shiftDuration(), 0); - durationCost_ = unitDurationCost() * static_cast(duration_) - + unitOvertimeCost() * static_cast(overtime); + durationCost_ = durationCostFn()(duration_); #ifndef NDEBUG dirty = false; diff --git a/pyvrp/cpp/search/Route.h b/pyvrp/cpp/search/Route.h index 5c34287d6..1bc87fa2a 100644 --- a/pyvrp/cpp/search/Route.h +++ b/pyvrp/cpp/search/Route.h @@ -458,14 +458,10 @@ class Route [[nodiscard]] inline Cost durationCost() const; /** - * @return Cost per unit of duration travelled on this route. + * @return The piecewise linear duration cost function for this route. */ - [[nodiscard]] inline Cost unitDurationCost() const; - - /** - * @return Cost per unit of overtime on this route. - */ - [[nodiscard]] inline Cost unitOvertimeCost() const; + [[nodiscard]] inline ProblemData::VehicleType::DurationCost const & + durationCostFn() const; /** * Returns true if this route has duration-related cost components, either @@ -973,18 +969,14 @@ Cost Route::durationCost() const return durationCost_; } -Cost Route::unitDurationCost() const { return vehicleType_.unitDurationCost; } - -Cost Route::unitOvertimeCost() const { return vehicleType_.unitOvertimeCost; } +ProblemData::VehicleType::DurationCost const &Route::durationCostFn() const +{ + return vehicleType_.durationCost; +} bool Route::hasDurationCost() const { - // clang-format off - return data.hasTimeWindows() - || unitDurationCost() != 0 - || (unitOvertimeCost() != 0 && maxOvertime() != 0) - || maxDuration() != std::numeric_limits::max(); - // clang-format on + return data.hasTimeWindows() || vehicleType_.hasDurationCost; } Duration Route::shiftDuration() const { return vehicleType_.shiftDuration; } @@ -1116,9 +1108,7 @@ std::pair Route::Proposal::duration() const return std::make_pair(0, 0); auto const &data = route()->data; - auto const unitDurationCost = route()->unitDurationCost(); - auto const unitOvertimeCost = route()->unitOvertimeCost(); - auto const shiftDuration = route()->shiftDuration(); + auto const &durationCost = route()->durationCostFn(); auto const maxDuration = route()->maxDuration(); auto const profile = route()->profile(); auto const &matrix = data.durationMatrix(profile); @@ -1173,9 +1163,7 @@ std::pair Route::Proposal::duration() const merge(merge, std::forward(args)...); auto const duration = ds.duration(); - auto const overtime = std::max(duration - shiftDuration, 0); - auto const cost = unitDurationCost * static_cast(duration) - + unitOvertimeCost * static_cast(overtime); + auto const cost = durationCost(duration); auto const timeWarp = ds.timeWarp(maxDuration); return std::make_pair(cost, timeWarp); }; diff --git a/pyvrp/cpp/search/bindings.cpp b/pyvrp/cpp/search/bindings.cpp index 27e683b1e..3037cfc22 100644 --- a/pyvrp/cpp/search/bindings.cpp +++ b/pyvrp/cpp/search/bindings.cpp @@ -515,8 +515,6 @@ PYBIND11_MODULE(_search, m) .def("duration", &Route::duration) .def("overtime", &Route::overtime) .def("duration_cost", &Route::durationCost) - .def("unit_duration_cost", &Route::unitDurationCost) - .def("unit_overtime_cost", &Route::unitOvertimeCost) .def("has_duration_cost", &Route::hasDurationCost) .def("shift_duration", &Route::shiftDuration) .def("max_duration", &Route::maxDuration) diff --git a/pyvrp/cpp/search/neighbourhood.cpp b/pyvrp/cpp/search/neighbourhood.cpp index 96d267133..9e72a2d6d 100644 --- a/pyvrp/cpp/search/neighbourhood.cpp +++ b/pyvrp/cpp/search/neighbourhood.cpp @@ -33,12 +33,13 @@ Matrix computeProximity(ProblemData const &data, data.numClients(), std::numeric_limits::max()); - std::set> seen = {}; + using Key = std:: + tuple; + std::set seen = {}; for (auto const &vehType : data.vehicleTypes()) { - auto const key = std::make_tuple(vehType.unitDistanceCost, - vehType.unitDurationCost, - vehType.profile); + auto const key = std::make_tuple( + vehType.unitDistanceCost, vehType.durationCost, vehType.profile); if (seen.contains(key)) // then proximity has already been updated continue; // based on this cost profile @@ -72,9 +73,11 @@ Matrix computeProximity(ProblemData const &data, auto const minWait = toEarly - edgeDur - frmServ - frmLate; auto const duration = edgeDur + std::max(minWait, 0.0); + auto const durationCostVal = static_cast( + vehType.durationCost(Duration(duration))); auto const cost // minimum edge cost using this vehicle type = static_cast(vehType.unitDistanceCost) * distance - + static_cast(vehType.unitDurationCost) * duration + + durationCostVal + params.weightWaitTime * std::max(minWait, 0.0); prox(frm, to) = std::min(cost, prox(frm, to)); diff --git a/pyvrp/search/_search.pyi b/pyvrp/search/_search.pyi index 4bf5f65a3..a1011836a 100644 --- a/pyvrp/search/_search.pyi +++ b/pyvrp/search/_search.pyi @@ -181,8 +181,6 @@ class Route: def duration(self) -> int: ... def overtime(self) -> int: ... def duration_cost(self) -> int: ... - def unit_duration_cost(self) -> int: ... - def unit_overtime_cost(self) -> int: ... def has_duration_cost(self) -> bool: ... def shift_duration(self) -> int: ... def max_duration(self) -> int: ... diff --git a/tests/conftest.py b/tests/conftest.py index 95be555e8..8079b804a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,6 @@ import pytest -from pyvrp import VehicleType +from pyvrp import PiecewiseLinearFunction, VehicleType from tests.helpers import read @@ -141,12 +141,13 @@ def ok_small_overtime(ok_small): Fixture that returns the OkSmall instance with a duration and overtime limit, and duration and overtime costs. """ + # slope=1 for d <= 5_000, slope=11 (=1+10) for d > 5_000 + duration_cost = PiecewiseLinearFunction([5_000], [(0, 1), (-50_000, 11)]) veh_type = ok_small.vehicle_type(0).replace( shift_duration=5_000, max_overtime=1_000, unit_distance_cost=0, - unit_duration_cost=1, - unit_overtime_cost=10, + duration_cost=duration_cost, ) return ok_small.replace(vehicle_types=[veh_type]) diff --git a/tests/search/test_RelocateWithDepot.py b/tests/search/test_RelocateWithDepot.py index cdfc35880..b59ee2aa7 100644 --- a/tests/search/test_RelocateWithDepot.py +++ b/tests/search/test_RelocateWithDepot.py @@ -8,6 +8,7 @@ CostEvaluator, Depot, Location, + PiecewiseLinearFunction, ProblemData, VehicleType, ) @@ -362,7 +363,11 @@ def test_depot_service_duration(ok_small_multiple_trips): veh_type = ok_small_multiple_trips.vehicle_type(0) data = ok_small_multiple_trips.replace( depots=[Depot(0, 0, service_duration=200)], - vehicle_types=[veh_type.replace(unit_duration_cost=1)], + vehicle_types=[ + veh_type.replace( + duration_cost=PiecewiseLinearFunction([], [(0, 1)]), + ) + ], distance_matrices=[np.zeros((5, 5))], duration_matrices=[np.zeros((5, 5))], ) diff --git a/tests/search/test_RemoveAdjacentDepot.py b/tests/search/test_RemoveAdjacentDepot.py index 06a92d6fb..4cc3a5ef2 100644 --- a/tests/search/test_RemoveAdjacentDepot.py +++ b/tests/search/test_RemoveAdjacentDepot.py @@ -1,6 +1,6 @@ from numpy.testing import assert_, assert_equal -from pyvrp import CostEvaluator, Depot +from pyvrp import CostEvaluator, Depot, PiecewiseLinearFunction from pyvrp.search import RemoveAdjacentDepot from pyvrp.search._search import Node from tests.helpers import make_search_route @@ -104,7 +104,12 @@ def test_remove_reload_depots_service_duration(ok_small_multiple_trips): veh_type = ok_small_multiple_trips.vehicle_type(0) data = ok_small_multiple_trips.replace( depots=[Depot(location=0, service_duration=90)], - vehicle_types=[veh_type.replace(max_reloads=2, unit_duration_cost=1)], + vehicle_types=[ + veh_type.replace( + max_reloads=2, + duration_cost=PiecewiseLinearFunction([], [(0, 1)]), + ) + ], ) op = RemoveAdjacentDepot(data) diff --git a/tests/search/test_Route.py b/tests/search/test_Route.py index 8ef7c10cb..d56358d5f 100644 --- a/tests/search/test_Route.py +++ b/tests/search/test_Route.py @@ -8,6 +8,7 @@ Client, Depot, Location, + PiecewiseLinearFunction, ProblemData, VehicleType, ) @@ -1079,11 +1080,12 @@ def test_has_distance_cost(veh_type: VehicleType, expected: bool): (VehicleType(tw_early=5), Depot(0), True), # constraint (vehicle) (VehicleType(tw_late=5), Depot(0), True), # constraint (vehicle) (VehicleType(shift_duration=0), Depot(0), True), # constraint (veh) - (VehicleType(unit_duration_cost=1), Depot(0), True), # unit cost - # unit cost but no max_overtime, so never relevant - (VehicleType(unit_overtime_cost=1), Depot(0), False), - # unit cost and overtime, so could be relevant - (VehicleType(unit_overtime_cost=1, max_overtime=1), Depot(0), True), + # linear duration cost function -> has duration cost + ( + VehicleType(duration_cost=PiecewiseLinearFunction([], [(0, 1)])), + Depot(0), + True, + ), (VehicleType(max_overtime=5), Depot(0), False), # not constrained ], ) @@ -1118,7 +1120,6 @@ def test_overtime(ok_small_overtime): assert_equal(route.shift_duration(), 5_000) assert_equal(route.max_overtime(), 1_000) assert_equal(route.max_duration(), 6_000) - assert_equal(route.unit_overtime_cost(), 10) # Route cost and feasibility attributes. assert_(not route.has_time_warp()) diff --git a/tests/search/test_neighbourhood.py b/tests/search/test_neighbourhood.py index b0f46dba3..e6e31b4e1 100644 --- a/tests/search/test_neighbourhood.py +++ b/tests/search/test_neighbourhood.py @@ -2,7 +2,13 @@ from numpy.testing import assert_, assert_equal, assert_raises from pytest import mark -from pyvrp import Depot, Location, ProblemData, VehicleType +from pyvrp import ( + Depot, + Location, + PiecewiseLinearFunction, + ProblemData, + VehicleType, +) from pyvrp.search import NeighbourhoodParams, compute_neighbours @@ -157,14 +163,36 @@ def test_different_routing_costs(ok_small): orig_type = ok_small.vehicle_type(0) different_cost_data = new_data.replace( vehicle_types=[ - orig_type.replace(unit_distance_cost=1, unit_duration_cost=0), - orig_type.replace(unit_distance_cost=0, unit_duration_cost=1), + orig_type.replace(unit_distance_cost=1), + orig_type.replace( + unit_distance_cost=0, + duration_cost=PiecewiseLinearFunction([], [(0, 1)]), + ), ], ) different_cost_neighbours = compute_neighbours(different_cost_data) assert_(different_cost_neighbours != new_neighbours) +def test_duration_cost_pwl_affects_neighbourhood(ok_small): + """ + Tests that a non-trivial duration_cost PWL affects the computed + neighbourhood, exercising the PWL evaluation in the proximity calculation. + """ + rng = np.random.default_rng(seed=42) + new_dur = rng.integers(0, 1_000, size=(5, 5)) + np.fill_diagonal(new_dur, 0) + data = ok_small.replace(duration_matrices=[new_dur]) + + orig_type = data.vehicle_type(0) + pwl = PiecewiseLinearFunction([(0, 0), (1_000, 1_000)]) + pwl_data = data.replace( + vehicle_types=[orig_type.replace(duration_cost=pwl)] + ) + + assert_(compute_neighbours(pwl_data) != compute_neighbours(data)) + + def test_multiple_routing_profiles(ok_small): """ Tests the granular neighbourhood selects the right profiles in the diff --git a/tests/test_CostEvaluator.py b/tests/test_CostEvaluator.py index 129716551..0fa1f0e97 100644 --- a/tests/test_CostEvaluator.py +++ b/tests/test_CostEvaluator.py @@ -7,6 +7,7 @@ CostEvaluator, Depot, Location, + PiecewiseLinearFunction, ProblemData, Route, Solution, @@ -300,8 +301,16 @@ def test_unit_distance_duration_cost(ok_small): duration costs can vary between routes. """ vehicle_types = [ - VehicleType(capacity=[10], unit_distance_cost=5, unit_duration_cost=1), - VehicleType(capacity=[10], unit_distance_cost=1, unit_duration_cost=5), + VehicleType( + capacity=[10], + unit_distance_cost=5, + duration_cost=PiecewiseLinearFunction([], [(0, 1)]), + ), + VehicleType( + capacity=[10], + unit_distance_cost=1, + duration_cost=PiecewiseLinearFunction([], [(0, 5)]), + ), ] data = ok_small.replace(vehicle_types=vehicle_types) diff --git a/tests/test_Model.py b/tests/test_Model.py index de6dbe386..76849b0ee 100644 --- a/tests/test_Model.py +++ b/tests/test_Model.py @@ -776,8 +776,12 @@ def test_minimise_distance_or_duration(ok_small): orig_model = Model.from_data(ok_small) vehicle_types = [ - VehicleType(capacity=[10], unit_distance_cost=1, unit_duration_cost=0), - VehicleType(capacity=[10], unit_distance_cost=0, unit_duration_cost=1), + VehicleType(capacity=[10], unit_distance_cost=1), + VehicleType( + capacity=[10], + unit_distance_cost=0, + duration_cost=PiecewiseLinearFunction([], [(0, 1)]), + ), ] data = ok_small.replace(vehicle_types=vehicle_types) new_model = Model.from_data(data) diff --git a/tests/test_ProblemData.py b/tests/test_ProblemData.py index 9bd02925c..2edad34dc 100644 --- a/tests/test_ProblemData.py +++ b/tests/test_ProblemData.py @@ -512,26 +512,24 @@ def test_matrices_are_not_copies(): "max_distance", "fixed_cost", "unit_distance_cost", - "unit_duration_cost", "start_late", "initial_load", ), [ - (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), # num_available must be positive - (-1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0), # capacity cannot be negative - (-100, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0), # this is just wrong - (0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0), # early > start late - (0, 1, 1, 1, 0, 0, 0, 0, 0, 2, 0), # start late > late - (0, 1, -1, 0, 0, 0, 0, 0, 0, 0, 0), # negative early - (0, 1, 0, -1, 0, 0, 0, 0, 0, 0, 0), # negative late - (0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 0), # negative shift_duration - (0, 1, 0, 0, 0, -1, 0, 0, 0, 0, 0), # negative max_distance - (0, 1, 0, 0, 0, 0, -1, 0, 0, 0, 0), # negative fixed_cost - (0, 1, 0, 0, 0, 0, 0, -1, 0, 0, 0), # negative unit_distance_cost - (0, 1, 0, 0, 0, 0, 0, 0, -1, 0, 0), # negative unit_duration_cost - (0, 1, 0, 0, 0, 0, 0, 0, 0, -1, 0), # negative start late - (0, 1, 0, 0, 0, 0, 0, 0, 0, 0, -1), # negative initial load - (0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 2), # initial load exceeds capacity + (0, 0, 0, 0, 0, 0, 0, 0, 0, 0), # num_available must be positive + (-1, 1, 0, 0, 0, 0, 1, 0, 0, 0), # capacity cannot be negative + (-100, 1, 0, 0, 0, 0, 0, 0, 0, 0), # this is just wrong + (0, 1, 1, 1, 0, 0, 0, 0, 0, 0), # early > start late + (0, 1, 1, 1, 0, 0, 0, 0, 2, 0), # start late > late + (0, 1, -1, 0, 0, 0, 0, 0, 0, 0), # negative early + (0, 1, 0, -1, 0, 0, 0, 0, 0, 0), # negative late + (0, 1, 0, 0, -1, 0, 0, 0, 0, 0), # negative shift_duration + (0, 1, 0, 0, 0, -1, 0, 0, 0, 0), # negative max_distance + (0, 1, 0, 0, 0, 0, -1, 0, 0, 0), # negative fixed_cost + (0, 1, 0, 0, 0, 0, 0, -1, 0, 0), # negative unit_distance_cost + (0, 1, 0, 0, 0, 0, 0, 0, -1, 0), # negative start late + (0, 1, 0, 0, 0, 0, 0, 0, 0, -1), # negative initial load + (0, 1, 0, 0, 0, 0, 0, 0, 0, 2), # initial load exceeds capacity ], ) def test_vehicle_type_raises_invalid_data( @@ -543,7 +541,6 @@ def test_vehicle_type_raises_invalid_data( max_distance: int, fixed_cost: int, unit_distance_cost: int, - unit_duration_cost: int, start_late: int, initial_load: int, ): @@ -561,25 +558,19 @@ def test_vehicle_type_raises_invalid_data( shift_duration=shift_duration, max_distance=max_distance, unit_distance_cost=unit_distance_cost, - unit_duration_cost=unit_duration_cost, start_late=start_late, initial_load=[initial_load], ) -@pytest.mark.parametrize( - ("max_overtime", "unit_overtime_cost"), - [(-1, 0), (0, -1)], -) -def test_vehicle_type_raises_negative_overtime_data( - max_overtime: int, - unit_overtime_cost: int, -): +def test_vehicle_type_raises_negative_max_overtime(): with assert_raises(ValueError): - VehicleType( - max_overtime=max_overtime, - unit_overtime_cost=unit_overtime_cost, - ) + VehicleType(max_overtime=-1) + + +def test_vehicle_type_raises_negative_duration_cost(): + with assert_raises(ValueError): + VehicleType(duration_cost=PiecewiseLinearFunction([], [(0, -1)])) def test_vehicle_type_raises_non_monotone_duration_cost(): @@ -608,7 +599,6 @@ def test_vehicle_type_does_not_raise_for_all_zero_edge_case(): shift_duration=0, max_distance=0, unit_distance_cost=0, - unit_duration_cost=0, start_late=0, ) @@ -622,7 +612,6 @@ def test_vehicle_type_does_not_raise_for_all_zero_edge_case(): assert_equal(vehicle_type.shift_duration, 0) assert_equal(vehicle_type.max_distance, 0) assert_equal(vehicle_type.unit_distance_cost, 0) - assert_equal(vehicle_type.unit_duration_cost, 0) assert_equal(vehicle_type.start_late, 0) @@ -639,8 +628,6 @@ def test_vehicle_type_default_values(): assert_equal(vehicle_type.fixed_cost, 0) assert_equal(vehicle_type.tw_early, 0) assert_equal(vehicle_type.unit_distance_cost, 1) - assert_equal(vehicle_type.unit_duration_cost, 0) - assert_equal(vehicle_type.unit_overtime_cost, 0) assert_equal(vehicle_type.name, "") # The default value for the following fields is the largest representable @@ -670,7 +657,6 @@ def test_vehicle_type_attribute_access(): shift_duration=23, max_distance=31, unit_distance_cost=37, - unit_duration_cost=41, start_late=18, max_overtime=43, name="vehicle_type name", @@ -686,7 +672,6 @@ def test_vehicle_type_attribute_access(): assert_equal(vehicle_type.shift_duration, 23) assert_equal(vehicle_type.max_distance, 31) assert_equal(vehicle_type.unit_distance_cost, 37) - assert_equal(vehicle_type.unit_duration_cost, 41) assert_equal(vehicle_type.start_late, 18) assert_equal(vehicle_type.max_overtime, 43) diff --git a/tests/test_Route.py b/tests/test_Route.py index 216ae4bf4..28f7a4546 100644 --- a/tests/test_Route.py +++ b/tests/test_Route.py @@ -8,6 +8,7 @@ Client, Depot, Location, + PiecewiseLinearFunction, ProblemData, RandomNumberGenerator, Route, @@ -344,8 +345,16 @@ def test_distance_duration_cost_calculations(ok_small): Tests route-level distance and duration cost calculations. """ vehicle_types = [ - VehicleType(capacity=[10], unit_distance_cost=5, unit_duration_cost=1), - VehicleType(capacity=[10], unit_distance_cost=1, unit_duration_cost=5), + VehicleType( + capacity=[10], + unit_distance_cost=5, + duration_cost=PiecewiseLinearFunction([], [(0, 1)]), + ), + VehicleType( + capacity=[10], + unit_distance_cost=1, + duration_cost=PiecewiseLinearFunction([], [(0, 5)]), + ), ] data = ok_small.replace(vehicle_types=vehicle_types) diff --git a/tests/test_Solution.py b/tests/test_Solution.py index fa398797e..a47be5b40 100644 --- a/tests/test_Solution.py +++ b/tests/test_Solution.py @@ -10,6 +10,7 @@ ClientGroup, Depot, Location, + PiecewiseLinearFunction, ProblemData, RandomNumberGenerator, Route, @@ -790,8 +791,16 @@ def test_distance_duration_cost_calculations(ok_small): Tests solution-level distance and duration cost calculations. """ vehicle_types = [ - VehicleType(capacity=[10], unit_distance_cost=5, unit_duration_cost=1), - VehicleType(capacity=[10], unit_distance_cost=1, unit_duration_cost=5), + VehicleType( + capacity=[10], + unit_distance_cost=5, + duration_cost=PiecewiseLinearFunction([], [(0, 1)]), + ), + VehicleType( + capacity=[10], + unit_distance_cost=1, + duration_cost=PiecewiseLinearFunction([], [(0, 5)]), + ), ] data = ok_small.replace(vehicle_types=vehicle_types) routes = [Route(data, [0, 1], 0), Route(data, [2, 3], 1)] From 9b879b40561fea68e8685b5627492d09dcbbe13b Mon Sep 17 00:00:00 2001 From: BQ Date: Fri, 27 Mar 2026 15:38:21 +0100 Subject: [PATCH 21/23] resolved the neighbourhood issue. --- pyvrp/cpp/search/neighbourhood.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/pyvrp/cpp/search/neighbourhood.cpp b/pyvrp/cpp/search/neighbourhood.cpp index 9e72a2d6d..358c8c7ba 100644 --- a/pyvrp/cpp/search/neighbourhood.cpp +++ b/pyvrp/cpp/search/neighbourhood.cpp @@ -9,6 +9,7 @@ #include #include +using pyvrp::Duration; using pyvrp::Matrix; using pyvrp::ProblemData; using pyvrp::search::NeighbourhoodParams; From 829009e67e195c2b05b703586f4303cf477ebac1 Mon Sep 17 00:00:00 2001 From: BQ Date: Fri, 27 Mar 2026 15:45:14 +0100 Subject: [PATCH 22/23] moved to a pytest coverage instead of codecov. --- .github/workflows/CI.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 994e618f7..06f0bc51b 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -93,12 +93,12 @@ jobs: run: | uv run pytest uv run ninja coverage-xml -C build - - uses: codecov/codecov-action@v5 + - uses: actions/upload-artifact@v4 with: - fail_ci_if_error: true - token: ${{ secrets.CODECOV_TOKEN }} - plugins: pycoverage - files: build/meson-logs/coverage.xml + name: coverage-${{ matrix.compiler }} + path: | + coverage.xml + build/meson-logs/coverage.xml - if: matrix.compiler == 'gcc' name: Install Valgrind run: sudo apt-get install -y valgrind From 44953e1bbb5b113ea5f032a072f46d5a6d01ff2e Mon Sep 17 00:00:00 2001 From: BQ Date: Fri, 27 Mar 2026 15:59:44 +0100 Subject: [PATCH 23/23] ci fixes --- .github/workflows/CI.yml | 2 +- .github/workflows/CodSpeed.yml | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 06f0bc51b..7ddc0ced9 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -95,7 +95,7 @@ jobs: uv run ninja coverage-xml -C build - uses: actions/upload-artifact@v4 with: - name: coverage-${{ matrix.compiler }} + name: coverage-${{ matrix.compiler }}-${{ matrix.compiler-version }}-py${{ matrix.python-version }} path: | coverage.xml build/meson-logs/coverage.xml diff --git a/.github/workflows/CodSpeed.yml b/.github/workflows/CodSpeed.yml index b158b7cf0..be5ae05c0 100644 --- a/.github/workflows/CodSpeed.yml +++ b/.github/workflows/CodSpeed.yml @@ -53,9 +53,13 @@ jobs: run: | uv sync uv run buildtools/build_extensions.py --build_type debugoptimized --clean - - name: Run benchmarks + - name: Run benchmarks with CodSpeed + if: ${{ secrets.CODSPEED_TOKEN != '' }} # We evaluate the microbenchmarks using the release build. uses: CodSpeedHQ/action@v3 with: token: ${{ secrets.CODSPEED_TOKEN }} run: uv run pytest benchmarks/ --codspeed + - name: Run benchmarks without CodSpeed upload + if: ${{ secrets.CODSPEED_TOKEN == '' }} + run: uv run pytest benchmarks/