From f5f7cc630cb216c9669dddfa83426510225eaee6 Mon Sep 17 00:00:00 2001 From: Leon Lan Date: Tue, 23 Jun 2026 14:17:52 +0200 Subject: [PATCH 1/2] Support for depot capacities and fixed cost --- .github/workflows/CI.yml | 6 - .github/workflows/CodSpeed.yml | 4 +- README.md | 8 +- pyproject.toml | 3 +- pyvrp/Model.py | 4 + pyvrp/_pyvrp.pyi | 5 + pyvrp/cpp/CostEvaluator.cpp | 9 + pyvrp/cpp/CostEvaluator.h | 233 ++++++++++++++++++++++- pyvrp/cpp/ProblemData.cpp | 68 +++++++ pyvrp/cpp/ProblemData.h | 19 +- pyvrp/cpp/Solution.cpp | 35 +++- pyvrp/cpp/Solution.h | 14 +- pyvrp/cpp/bindings.cpp | 41 ++-- pyvrp/cpp/search/LocalSearch.cpp | 27 ++- pyvrp/cpp/search/PerturbationManager.cpp | 2 + pyvrp/cpp/search/Route.h | 63 +++++- pyvrp/cpp/search/Solution.cpp | 98 +++++++++- pyvrp/cpp/search/Solution.h | 21 ++ tests/search/test_LocalSearch.py | 82 ++++++++ tests/test_CostEvaluator.py | 66 +++++++ tests/test_Model.py | 29 ++- tests/test_ProblemData.py | 95 ++++++++- tests/test_Solution.py | 63 ++++++ 23 files changed, 944 insertions(+), 51 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 21a4da2..c01b7f1 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -86,12 +86,6 @@ jobs: run: | uv run pytest uv run ninja coverage-xml -C build - - uses: codecov/codecov-action@v5 - with: - fail_ci_if_error: true - token: ${{ secrets.CODECOV_TOKEN }} - plugins: pycoverage - files: build/meson-logs/coverage.xml - if: matrix.compiler == 'gcc' name: Install Valgrind run: | diff --git a/.github/workflows/CodSpeed.yml b/.github/workflows/CodSpeed.yml index e5bb7e1..046fd9c 100644 --- a/.github/workflows/CodSpeed.yml +++ b/.github/workflows/CodSpeed.yml @@ -1,9 +1,7 @@ name: CodSpeed on: - push: - branches: [ main ] - pull_request: + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/README.md b/README.md index b08093d..07a86bf 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,13 @@ [![PyPI version](https://img.shields.io/pypi/v/PyVRP?style=flat-square&label=PyPI)](https://pypi.org/project/pyvrp/) [![CI](https://img.shields.io/github/actions/workflow/status/PyVRP/PyVRP/.github%2Fworkflows%2FCI.yml?branch=main&style=flat-square&logo=github&label=CI)](https://github.com/PyVRP/PyVRP/actions/workflows/CI.yml) [![DOC](https://img.shields.io/github/actions/workflow/status/PyVRP/PyVRP/.github%2Fworkflows%2FDOC.yml?branch=main&style=flat-square&logo=github&label=DOC)](https://pyvrp.org/) -[![codecov](https://img.shields.io/codecov/c/github/PyVRP/PyVRP?style=flat-square&logo=codecov&label=Codecov)](https://codecov.io/gh/PyVRP/PyVRP) [![DOI:10.1287/ijoc.2023.0055](https://img.shields.io/badge/DOI-ijoc.2023.0055-green?style=flat-square&color=blue)](https://doi.org/10.1287/ijoc.2023.0055) +> [!NOTE] +> This is a special fork of PyVRP v0.13.3 for the [SMIO-Hexaly Location Routing Challenge 2026][10]. +> See that repository's README for more details about the challenge and problem setting. +> This fork extends PyVRP with depot capacities and fixed depot opening costs, so solutions are charged for opening a depot (i.e., there is at least one route that starts at this depot) and penalised when the total load assigned to a depot exceeds its capacity. + PyVRP is an open-source, state-of-the-art vehicle routing problem (VRP) solver developed by [RoutingLab](https://routinglab.tech). It currently supports VRPs with: - Pickups and deliveries between depots and clients (capacitated VRP, VRP with simultaneous pickup and delivery, VRP with backhaul); @@ -100,3 +104,5 @@ A preprint of this paper is available on [arXiv][9]. [8]: https://pyvrp.org/examples/using_pyvrp_components.html [9]: https://arxiv.org/abs/2403.13795 + +[10]: https://github.com/AppliedRouting/Location-Routing-Challenge diff --git a/pyproject.toml b/pyproject.toml index a4460f2..c7bc30d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ requires-python = ">=3.11" dependencies = [ "numpy>=1.15.2; python_version < '3.12'", "numpy>=1.26.0; python_version >= '3.12'", - "matplotlib>=2.2.0", + "matplotlib>=2.2.0,<3.11.0", "vrplib>=1.4.0", "tqdm>=4.64.1", ] @@ -65,7 +65,6 @@ dev = [ "pytest-codspeed>=2.2.1", "pytest-xdist>=3.6.1", "pytest-sugar>=1.0.0", - "codecov", ] docs = [ diff --git a/pyvrp/Model.py b/pyvrp/Model.py index 808fffc..8ed6c2a 100644 --- a/pyvrp/Model.py +++ b/pyvrp/Model.py @@ -284,6 +284,8 @@ def add_depot( tw_early: int = 0, tw_late: int = np.iinfo(np.int64).max, service_duration: int = 0, + capacity: int | list[int] = [], + fixed_cost: int = 0, *, name: str = "", ) -> Depot: @@ -297,6 +299,8 @@ def add_depot( tw_early=tw_early, tw_late=tw_late, service_duration=service_duration, + capacity=[capacity] if isinstance(capacity, int) else capacity, + fixed_cost=fixed_cost, name=name, ) diff --git a/pyvrp/_pyvrp.pyi b/pyvrp/_pyvrp.pyi index 376f80d..c1779eb 100644 --- a/pyvrp/_pyvrp.pyi +++ b/pyvrp/_pyvrp.pyi @@ -94,6 +94,8 @@ class Depot: tw_early: int tw_late: int service_duration: int + capacity: list[int] + fixed_cost: int name: str def __init__( self, @@ -102,6 +104,8 @@ class Depot: tw_early: int = 0, tw_late: int = ..., service_duration: int = 0, + capacity: list[int] = [], + fixed_cost: int = 0, *, name: str = "", ) -> None: ... @@ -345,6 +349,7 @@ class Solution: def excess_load(self) -> list[int]: ... def excess_distance(self) -> int: ... def fixed_vehicle_cost(self) -> int: ... + def fixed_depot_cost(self) -> int: ... def time_warp(self) -> int: ... def prizes(self) -> int: ... def uncollected_prizes(self) -> int: ... diff --git a/pyvrp/cpp/CostEvaluator.cpp b/pyvrp/cpp/CostEvaluator.cpp index ba524d9..cce84e4 100644 --- a/pyvrp/cpp/CostEvaluator.cpp +++ b/pyvrp/cpp/CostEvaluator.cpp @@ -1,8 +1,10 @@ #include "CostEvaluator.h" #include +#include using pyvrp::CostEvaluator; +using pyvrp::DepotContext; CostEvaluator::CostEvaluator(std::vector loadPenalties, double twPenalty, @@ -21,3 +23,10 @@ CostEvaluator::CostEvaluator(std::vector loadPenalties, if (distPenalty_ < 0) throw std::invalid_argument("dist_penalty must be >= 0."); } + +CostEvaluator CostEvaluator::withDepotContext(DepotContext context) const +{ + auto copy = *this; + copy.depotCtx_ = context; + return copy; +} diff --git a/pyvrp/cpp/CostEvaluator.h b/pyvrp/cpp/CostEvaluator.h index c1981c3..76c4663 100644 --- a/pyvrp/cpp/CostEvaluator.h +++ b/pyvrp/cpp/CostEvaluator.h @@ -20,6 +20,7 @@ concept CostEvaluatable = requires(T arg) { { arg.distanceCost() } -> std::same_as; { arg.durationCost() } -> std::same_as; { arg.fixedVehicleCost() } -> std::same_as; + { arg.fixedDepotCost() } -> std::same_as; { arg.excessLoad() } -> std::convertible_to>; { arg.excessDistance() } -> std::same_as; { arg.timeWarp() } -> std::same_as; @@ -42,7 +43,21 @@ concept DeltaCostEvaluatable = requires(T arg, size_t dimension) { { arg.route() }; { arg.distance() } -> std::convertible_to>; { arg.duration() } -> std::convertible_to>; + { arg.load(dimension) } -> std::same_as; { arg.excessLoad(dimension) } -> std::same_as; + { arg.empty() } -> std::same_as; +}; + +/** + * Bundles the depot aggregate state needed during search delta evaluation. + * The search layer owns this state; the cost evaluator only points at it. + */ +struct DepotContext +{ + std::vector> const *capacities = nullptr; + std::vector> const *loads = nullptr; + std::vector const *fixedCosts = nullptr; + std::vector const *counts = nullptr; }; /** @@ -78,6 +93,7 @@ class CostEvaluator std::vector loadPenalties_; // per load dimension double twPenalty_; double distPenalty_; + DepotContext depotCtx_; /** * Computes the cost penalty incurred from the given excess loads. This is @@ -86,11 +102,47 @@ class CostEvaluator [[nodiscard]] inline Cost excessLoadPenalties(std::vector const &excessLoads) const; + /** + * Computes the depot capacity penalty delta from the route proposal. + * This only does something when a depot context is configured. + */ + template + [[nodiscard]] inline Cost depotLoadDeltaPenalty(T const &proposal) const; + + /** + * Computes the depot capacity penalty delta from two route proposals. + * This only does something when a depot context is configured. + */ + template + [[nodiscard]] inline Cost depotLoadDeltaPenalty(U const &uProposal, + V const &vProposal) const; + + /** + * Computes the fixed depot cost delta from the route proposal. + * This only does something when a depot context is configured. + */ + template + [[nodiscard]] inline Cost fixedDepotDeltaCost(T const &proposal) const; + + /** + * Computes the fixed depot cost delta from two route proposals. + * This only does something when a depot context is configured. + */ + template + [[nodiscard]] inline Cost fixedDepotDeltaCost(U const &uProposal, + V const &vProposal) const; + public: CostEvaluator(std::vector loadPenalties, double twPenalty, double distPenalty); + /** + * Returns a copy of this cost evaluator that accounts for depot aggregates + * using the given context. The context must outlive the returned evaluator. + */ + [[nodiscard]] CostEvaluator withDepotContext(DepotContext context) const; + /** * Computes the total excess load penalty for the given load and vehicle * capacity, and dimension. @@ -129,7 +181,9 @@ class CostEvaluator * route :math:`R` has an assigned vehicle type that equips the route with * fixed vehicle cost :math:`f_R`, and unit distance, duration and overtime * costs :math:`c^\text{distance}_R`, :math:`c^\text{duration}_R`, - * :math:`c^\text{overtime}_R`, respectively. Let + * :math:`c^\text{overtime}_R`, respectively. Each depot :math:`d` has + * fixed depot cost :math:`g_d` that is incurred if at least one route + * starts there. Let * :math:`V_R = \{i : (i, j) \in R \}` be the set of locations visited by * route :math:`R`, and :math:`d_R`, :math:`t_R`, and :math:`o_R` the total * route distance, duration, and overtime, respectively. The objective value @@ -137,6 +191,9 @@ class CostEvaluator * * .. math:: * + * \sum_{d \in D : \exists R \in \mathcal{R}: R \text{ starts at } d} + * g_d + * + * \sum_{R \in \mathcal{R}} * \left[ * f_R + c^\text{distance}_R d_R @@ -145,9 +202,10 @@ class CostEvaluator * \right] * + \sum_{i \in V} p_i - \sum_{R \in \mathcal{R}} \sum_{i \in V_R} p_i, * - * where the first part lists each route's fixed, distance, duration and - * overtime costs, respectively, and the second part the uncollected prizes - * of unvisited clients. + * where the first part is the active depot fixed cost, the second part + * lists each route's fixed, distance, duration and overtime costs, + * respectively, and the final part the uncollected prizes of unvisited + * clients. * * .. note:: * @@ -211,6 +269,157 @@ Cost CostEvaluator::excessLoadPenalties( return cost; } +template +Cost CostEvaluator::depotLoadDeltaPenalty(T const &proposal) const +{ + if (!depotCtx_.loads) + return 0; + + assert(depotCtx_.capacities); + + auto const *route = proposal.route(); + auto const depot = route->startDepot(); + auto const &capacity = (*depotCtx_.capacities)[depot]; + if (capacity.empty()) + return 0; + + auto const ¤tLoads = (*depotCtx_.loads)[depot]; + auto const &routeLoads = route->load(); + + Cost cost = 0; + for (size_t dim = 0; dim != capacity.size(); ++dim) + { + auto const newLoad + = currentLoads[dim] - routeLoads[dim] + proposal.load(dim); + + cost -= loadPenalty(currentLoads[dim], capacity[dim], dim); + cost += loadPenalty(newLoad, capacity[dim], dim); + } + + return cost; +} + +template +Cost CostEvaluator::depotLoadDeltaPenalty(U const &uProposal, + V const &vProposal) const +{ + if (!depotCtx_.loads) + return 0; + + assert(depotCtx_.capacities); + + auto const *uRoute = uProposal.route(); + auto const *vRoute = vProposal.route(); + + // Both proposals describe the same route, so the single-route delta already + // captures the full change to that route's depot. + if (uRoute == vRoute) + return depotLoadDeltaPenalty(uProposal); + + auto const uDepot = uRoute->startDepot(); + auto const vDepot = vRoute->startDepot(); + + // Prices the before/after penalty of a single depot, applying whichever of + // the two route proposals start at it. + auto const penaltyDelta = [&](size_t depot) + { + auto const &capacity = (*depotCtx_.capacities)[depot]; + if (capacity.empty()) + return Cost(0); + + auto const ¤tLoads = (*depotCtx_.loads)[depot]; + + Cost cost = 0; + for (size_t dim = 0; dim != capacity.size(); ++dim) + { + auto newLoad = currentLoads[dim]; + if (uDepot == depot) + newLoad += uProposal.load(dim) - uRoute->load()[dim]; + + if (vDepot == depot) + newLoad += vProposal.load(dim) - vRoute->load()[dim]; + + cost -= loadPenalty(currentLoads[dim], capacity[dim], dim); + cost += loadPenalty(newLoad, capacity[dim], dim); + } + + return cost; + }; + + return penaltyDelta(uDepot) + (vDepot != uDepot ? penaltyDelta(vDepot) : 0); +} + +template +Cost CostEvaluator::fixedDepotDeltaCost(T const &proposal) const +{ + if (!depotCtx_.counts) + return 0; + + assert(depotCtx_.fixedCosts); + + auto const *route = proposal.route(); + auto const depot = route->startDepot(); + auto const oldCount = (*depotCtx_.counts)[depot]; + auto newCount = oldCount; + if (!route->empty()) + --newCount; + + if (!proposal.empty()) + ++newCount; + + auto const fixedCost = (*depotCtx_.fixedCosts)[depot]; + return Cost(oldCount == 0 && newCount > 0) * fixedCost + - Cost(oldCount > 0 && newCount == 0) * fixedCost; +} + +template +Cost CostEvaluator::fixedDepotDeltaCost(U const &uProposal, + V const &vProposal) const +{ + if (!depotCtx_.counts) + return 0; + + assert(depotCtx_.fixedCosts); + + auto const *uRoute = uProposal.route(); + auto const *vRoute = vProposal.route(); + + if (uRoute == vRoute) + return fixedDepotDeltaCost(uProposal); + + auto const uDepot = uRoute->startDepot(); + auto const vDepot = vRoute->startDepot(); + + auto const costDelta = [&](size_t depot) + { + auto const oldCount = (*depotCtx_.counts)[depot]; + auto newCount = oldCount; + if (uDepot == depot) + { + if (!uRoute->empty()) + --newCount; + + if (!uProposal.empty()) + ++newCount; + } + + if (vDepot == depot) + { + if (!vRoute->empty()) + --newCount; + + if (!vProposal.empty()) + ++newCount; + } + + auto const fixedCost = (*depotCtx_.fixedCosts)[depot]; + return Cost(oldCount == 0 && newCount > 0) * fixedCost + - Cost(oldCount > 0 && newCount == 0) * fixedCost; + }; + + return costDelta(uDepot) + (vDepot != uDepot ? costDelta(vDepot) : 0); +} + Cost CostEvaluator::loadPenalty(Load load, Load capacity, size_t dimension) const @@ -248,9 +457,9 @@ Cost CostEvaluator::penalisedCost(T const &arg) const // Standard objective plus infeasibility-related penalty terms. auto const cost - = arg.distanceCost() + arg.durationCost() + arg.fixedVehicleCost() - + excessLoadPenalties(arg.excessLoad()) + twPenalty(arg.timeWarp()) - + distPenalty(arg.excessDistance(), 0); + = arg.fixedDepotCost() + arg.distanceCost() + arg.durationCost() + + arg.fixedVehicleCost() + excessLoadPenalties(arg.excessLoad()) + + twPenalty(arg.timeWarp()) + distPenalty(arg.excessDistance(), 0); if constexpr (PrizeCostEvaluatable) return cost + arg.uncollectedPrizes(); @@ -287,6 +496,11 @@ bool CostEvaluator::deltaCost(Cost &out, T const &proposal) const out -= twPenalty(route->timeWarp()); } + if constexpr (!skipLoad) + out += depotLoadDeltaPenalty(proposal); + + out += fixedDepotDeltaCost(proposal); + if (route->hasDistanceCost()) { auto const [cost, excess] = proposal.distance(); @@ -355,6 +569,11 @@ bool CostEvaluator::deltaCost(Cost &out, out -= twPenalty(vRoute->timeWarp()); } + if constexpr (!skipLoad) + out += depotLoadDeltaPenalty(uProposal, vProposal); + + out += fixedDepotDeltaCost(uProposal, vProposal); + if (uRoute->hasDistanceCost()) { auto const [cost, excess] = uProposal.distance(); diff --git a/pyvrp/cpp/ProblemData.cpp b/pyvrp/cpp/ProblemData.cpp index e9f0df0..3a49df2 100644 --- a/pyvrp/cpp/ProblemData.cpp +++ b/pyvrp/cpp/ProblemData.cpp @@ -5,6 +5,7 @@ #include #include +using pyvrp::Cost; using pyvrp::Distance; using pyvrp::Duration; using pyvrp::Load; @@ -228,12 +229,16 @@ ProblemData::Depot::Depot(Coordinate x, Duration twEarly, Duration twLate, Duration serviceDuration, + std::vector capacity, + Cost fixedCost, std::string name) : x(x), y(y), serviceDuration(serviceDuration), twEarly(twEarly), twLate(twLate), + capacity(std::move(capacity)), + fixedCost(fixedCost), name(duplicate(name.data())) { if (serviceDuration < 0) @@ -244,6 +249,13 @@ ProblemData::Depot::Depot(Coordinate x, if (twEarly < 0) throw std::invalid_argument("tw_early must be >= 0."); + + if (std::any_of( + this->capacity.begin(), this->capacity.end(), isNegative)) + throw std::invalid_argument("capacity amounts must be >= 0."); + + if (fixedCost < 0) + throw std::invalid_argument("fixed_cost must be >= 0."); } ProblemData::Depot::Depot(Depot const &depot) @@ -252,6 +264,8 @@ ProblemData::Depot::Depot(Depot const &depot) serviceDuration(depot.serviceDuration), twEarly(depot.twEarly), twLate(depot.twLate), + capacity(depot.capacity), + fixedCost(depot.fixedCost), name(duplicate(depot.name)) { } @@ -262,6 +276,8 @@ ProblemData::Depot::Depot(Depot &&depot) serviceDuration(depot.serviceDuration), twEarly(depot.twEarly), twLate(depot.twLate), + capacity(std::move(depot.capacity)), + fixedCost(depot.fixedCost), name(depot.name) // we can steal { depot.name = nullptr; // stolen @@ -277,6 +293,8 @@ bool ProblemData::Depot::operator==(Depot const &other) const && twEarly == other.twEarly && twLate == other.twLate && serviceDuration == other.serviceDuration + && capacity == other.capacity + && fixedCost == other.fixedCost && std::strcmp(name, other.name) == 0; // clang-format on } @@ -568,6 +586,11 @@ size_t ProblemData::numLoadDimensions() const { return numLoadDimensions_; } void ProblemData::validate() const { + auto const hasDepotCapacities = std::any_of( + depots_.begin(), + depots_.end(), + [](auto const &depot) { return !depot.capacity.empty(); }); + // Client checks. for (size_t idx = numDepots(); idx != numLocations(); ++idx) { @@ -585,6 +608,15 @@ void ProblemData::validate() const throw std::invalid_argument(msg); } + if (hasDepotCapacities + && std::any_of(client.pickup.begin(), + client.pickup.end(), + [](auto const load) { return load > 0; })) + { + auto const *msg = "Depot capacities require delivery-only clients."; + throw std::invalid_argument(msg); + } + if (!client.group) continue; @@ -609,6 +641,16 @@ void ProblemData::validate() const if (depots_.empty()) throw std::invalid_argument("Expected at least one depot."); + for (auto const &depot : depots_) + { + if (!depot.capacity.empty() + && depot.capacity.size() != numLoadDimensions_) + { + auto const *msg = "Depot has inconsistent capacity size."; + throw std::invalid_argument(msg); + } + } + // Group checks. for (size_t idx = 0; idx != numGroups(); ++idx) { @@ -660,6 +702,32 @@ void ProblemData::validate() const throw std::invalid_argument("Vehicle and its end depot have no " "overlapping time windows."); + if (hasDepotCapacities) + { + if (vehicleType.startDepot != vehicleType.endDepot) + { + auto const *msg + = "Depot capacities require matching start and end depots."; + throw std::invalid_argument(msg); + } + + if (!vehicleType.reloadDepots.empty()) + { + auto const *msg + = "Depot capacities do not support reload depots."; + throw std::invalid_argument(msg); + } + + if (std::any_of(vehicleType.initialLoad.begin(), + vehicleType.initialLoad.end(), + [](auto const load) { return load > 0; })) + { + auto const *msg + = "Depot capacities do not support initial loads."; + throw std::invalid_argument(msg); + } + } + for (auto const depot : vehicleType.reloadDepots) if (depot >= numDepots()) throw std::out_of_range("Vehicle has invalid reload depot."); diff --git a/pyvrp/cpp/ProblemData.h b/pyvrp/cpp/ProblemData.h index f78cabf..5745d2d 100644 --- a/pyvrp/cpp/ProblemData.h +++ b/pyvrp/cpp/ProblemData.h @@ -282,6 +282,8 @@ class ProblemData * tw_early: int = 0, * tw_late: int = np.iinfo(np.int64).max, * service_duration: int = 0, + * capacity: list[int] = [], + * fixed_cost: int = 0, * *, * name: str = "", * ) @@ -305,6 +307,13 @@ class ProblemData * service_duration * Time it takes to e.g. load a vehicle at this depot, at the start of * a trip. Default 0. + * capacity + * Capacities of this depot, per load dimension. This capacity is used + * when a vehicle type that starts at this depot services deliveries to + * clients. Default unconstrained. + * fixed_cost + * Fixed cost of this depot. This cost is incurred if the solution + * uses at least one vehicle that starts at this depot. Default 0. * name * Free-form name field for this depot. Default empty. * @@ -321,6 +330,10 @@ class ProblemData * service_duration * Time it takes to e.g. load a vehicle at this depot, at the start of * a trip. + * capacity + * Capacities of this depot, per load dimension. + * fixed_cost + * Fixed cost of this depot. * name * Free-form name field for this depot. */ @@ -331,13 +344,17 @@ class ProblemData Duration const serviceDuration; Duration const twEarly; // Depot opening time Duration const twLate; // Depot closing time - char const *name; // Depot name (for reference) + std::vector const capacity; + Cost const fixedCost; // Fixed cost of this depot + char const *name; // Depot name (for reference) Depot(Coordinate x, Coordinate y, Duration twEarly = 0, Duration twLate = std::numeric_limits::max(), Duration serviceDuration = 0, + std::vector capacity = {}, + Cost fixedCost = 0, std::string name = ""); bool operator==(Depot const &other) const; diff --git a/pyvrp/cpp/Solution.cpp b/pyvrp/cpp/Solution.cpp index c232693..204d520 100644 --- a/pyvrp/cpp/Solution.cpp +++ b/pyvrp/cpp/Solution.cpp @@ -24,7 +24,12 @@ void Solution::evaluate(ProblemData const &data) for (auto const &client : data.clients()) allPrizes += client.prize; - excessLoad_ = std::vector(data.numLoadDimensions(), 0); + auto const numLoadDims = data.numLoadDimensions(); + excessLoad_ = std::vector(numLoadDims, 0); + std::vector> depotLoad(data.numDepots(), + std::vector(numLoadDims, 0)); + std::vector depotCounts(data.numDepots(), 0); + for (auto const &route : routes_) { // Whole solution statistics. @@ -40,8 +45,30 @@ void Solution::evaluate(ProblemData const &data) fixedVehicleCost_ += data.vehicleType(route.vehicleType()).fixedCost; auto const &excessLoad = route.excessLoad(); - for (size_t dim = 0; dim != data.numLoadDimensions(); ++dim) + for (size_t dim = 0; dim != numLoadDims; ++dim) excessLoad_[dim] += excessLoad[dim]; + + // Depot capacities assume all delivery on a route is served from the + // route's start depot. + auto const &delivery = route.delivery(); + auto const startDepot = route.startDepot(); + ++depotCounts[startDepot]; + for (size_t dim = 0; dim != numLoadDims; ++dim) + depotLoad[startDepot][dim] += delivery[dim]; + } + + for (size_t depot = 0; depot != data.numDepots(); ++depot) + { + ProblemData::Depot const &depotData = data.location(depot); + auto const &capacity = depotData.capacity; + fixedDepotCost_ += Cost(depotCounts[depot] > 0) * depotData.fixedCost; + + if (capacity.empty()) + continue; + + for (size_t dim = 0; dim != numLoadDims; ++dim) + excessLoad_[dim] + += std::max(depotLoad[depot][dim] - capacity[dim], 0); } uncollectedPrizes_ = allPrizes - prizes_; @@ -110,6 +137,8 @@ Distance Solution::excessDistance() const { return excessDistance_; } Cost Solution::fixedVehicleCost() const { return fixedVehicleCost_; } +Cost Solution::fixedDepotCost() const { return fixedDepotCost_; } + Cost Solution::prizes() const { return prizes_; } Cost Solution::uncollectedPrizes() const { return uncollectedPrizes_; } @@ -300,6 +329,7 @@ Solution::Solution(size_t numClients, Distance excessDistance, std::vector excessLoad, Cost fixedVehicleCost, + Cost fixedDepotCost, Cost prizes, Cost uncollectedPrizes, Duration timeWarp, @@ -316,6 +346,7 @@ Solution::Solution(size_t numClients, excessDistance_(excessDistance), excessLoad_(std::move(excessLoad)), fixedVehicleCost_(fixedVehicleCost), + fixedDepotCost_(fixedDepotCost), prizes_(prizes), uncollectedPrizes_(uncollectedPrizes), timeWarp_(timeWarp), diff --git a/pyvrp/cpp/Solution.h b/pyvrp/cpp/Solution.h index 01dcaef..694fd5f 100644 --- a/pyvrp/cpp/Solution.h +++ b/pyvrp/cpp/Solution.h @@ -55,6 +55,7 @@ class Solution Distance excessDistance_ = 0; // Total excess distance over all routes std::vector excessLoad_; // Total excess load over all routes Cost fixedVehicleCost_ = 0; // Fixed cost of all used vehicles + Cost fixedDepotCost_ = 0; // Fixed cost of all used depots Cost prizes_ = 0; // Total collected prize value Cost uncollectedPrizes_ = 0; // Total uncollected prize value Duration timeWarp_ = 0; // Total time warp over all routes @@ -146,7 +147,8 @@ class Solution [[nodiscard]] bool isComplete() const; /** - * Returns whether this solution violates capacity constraints. + * Returns whether this solution violates vehicle or depot capacity + * constraints. */ [[nodiscard]] bool hasExcessLoad() const; @@ -194,8 +196,8 @@ class Solution [[nodiscard]] Cost durationCost() const; /** - * Aggregate pickup or delivery loads in excess of the vehicle's capacity - * of all routes. + * Aggregate pickup or delivery loads in excess of vehicle and depot + * capacities. */ [[nodiscard]] std::vector const &excessLoad() const; @@ -210,6 +212,11 @@ class Solution */ [[nodiscard]] Cost fixedVehicleCost() const; + /** + * Returns the fixed depot cost of all depots used in this solution. + */ + [[nodiscard]] Cost fixedDepotCost() const; + /** * Returns the total collected prize value over all routes. */ @@ -268,6 +275,7 @@ class Solution Distance excessDistance, std::vector excessLoad, Cost fixedVehicleCost, + Cost fixedDepotCost, Cost prizes, Cost uncollectedPrizes, Duration timeWarp, diff --git a/pyvrp/cpp/bindings.cpp b/pyvrp/cpp/bindings.cpp index 4e539fd..60e4744 100644 --- a/pyvrp/cpp/bindings.cpp +++ b/pyvrp/cpp/bindings.cpp @@ -151,12 +151,16 @@ PYBIND11_MODULE(_pyvrp, m) pyvrp::Duration, pyvrp::Duration, pyvrp::Duration, + std::vector, + pyvrp::Cost, char const *>(), py::arg("x"), py::arg("y"), py::arg("tw_early") = 0, py::arg("tw_late") = std::numeric_limits::max(), py::arg("service_duration") = 0, + py::arg("capacity") = py::list(), + py::arg("fixed_cost") = 0, py::kw_only(), py::arg("name") = "") .def_readonly("x", &ProblemData::Depot::x) @@ -164,6 +168,10 @@ PYBIND11_MODULE(_pyvrp, m) .def_readonly("tw_early", &ProblemData::Depot::twEarly) .def_readonly("tw_late", &ProblemData::Depot::twLate) .def_readonly("service_duration", &ProblemData::Depot::serviceDuration) + .def_readonly("capacity", + &ProblemData::Depot::capacity, + py::return_value_policy::reference_internal) + .def_readonly("fixed_cost", &ProblemData::Depot::fixedCost) .def_readonly("name", &ProblemData::Depot::name, py::return_value_policy::reference_internal) @@ -175,16 +183,20 @@ PYBIND11_MODULE(_pyvrp, m) depot.twEarly, depot.twLate, depot.serviceDuration, + depot.capacity, + depot.fixedCost, depot.name); }, [](py::tuple t) { // __setstate__ ProblemData::Depot depot( - t[0].cast(), // x - t[1].cast(), // y - t[2].cast(), // tw early - t[3].cast(), // tw late - t[4].cast(), // service duration - t[5].cast()); // name + t[0].cast(), // x + t[1].cast(), // y + t[2].cast(), // tw early + t[3].cast(), // tw late + t[4].cast(), // service duration + t[5].cast>(), // capacity + t[6].cast(), // fixed cost + t[7].cast()); // name return depot; })) @@ -916,6 +928,9 @@ PYBIND11_MODULE(_pyvrp, m) .def("fixed_vehicle_cost", &Solution::fixedVehicleCost, DOC(pyvrp, Solution, fixedVehicleCost)) + .def("fixed_depot_cost", + &Solution::fixedDepotCost, + DOC(pyvrp, Solution, fixedDepotCost)) .def("time_warp", &Solution::timeWarp, DOC(pyvrp, Solution, timeWarp)) .def("prizes", &Solution::prizes, DOC(pyvrp, Solution, prizes)) .def("uncollected_prizes", @@ -942,6 +957,7 @@ PYBIND11_MODULE(_pyvrp, m) sol.excessDistance(), sol.excessLoad(), sol.fixedVehicleCost(), + sol.fixedDepotCost(), sol.prizes(), sol.uncollectedPrizes(), sol.timeWarp(), @@ -965,12 +981,13 @@ PYBIND11_MODULE(_pyvrp, m) t[7].cast(), // excess distance t[8].cast>(), // excess load t[9].cast(), // fixed veh cost - t[10].cast(), // prizes - t[11].cast(), // uncollected - t[12].cast(), // time warp - t[13].cast(), // is group feasible - t[14].cast(), // routes - t[15].cast()); // neighbours + t[10].cast(), // fixed depot cost + t[11].cast(), // prizes + t[12].cast(), // uncollected + t[13].cast(), // time warp + t[14].cast(), // is group feasible + t[15].cast(), // routes + t[16].cast()); // neighbours return sol; })) diff --git a/pyvrp/cpp/search/LocalSearch.cpp b/pyvrp/cpp/search/LocalSearch.cpp index 695c6e9..a4c2aee 100644 --- a/pyvrp/cpp/search/LocalSearch.cpp +++ b/pyvrp/cpp/search/LocalSearch.cpp @@ -18,11 +18,14 @@ pyvrp::Solution LocalSearch::operator()(pyvrp::Solution const &solution, bool exhaustive) { loadSolution(solution); + auto const searchCostEvaluator + = costEvaluator.withDepotContext(solution_.depotContext()); if (!exhaustive) - perturbationManager_.perturb(solution_, searchSpace_, costEvaluator); + perturbationManager_.perturb( + solution_, searchSpace_, searchCostEvaluator); - search(costEvaluator); + search(searchCostEvaluator); return solution_.unload(); } @@ -30,7 +33,9 @@ pyvrp::Solution LocalSearch::search(pyvrp::Solution const &solution, CostEvaluator const &costEvaluator) { loadSolution(solution); - search(costEvaluator); + auto const searchCostEvaluator + = costEvaluator.withDepotContext(solution_.depotContext()); + search(searchCostEvaluator); return solution_.unload(); } @@ -122,7 +127,9 @@ bool LocalSearch::applyNodeOps(Route::Node *U, [[maybe_unused]] auto const costBefore = costEvaluator.penalisedCost(*rU) - + Cost(rU != rV) * costEvaluator.penalisedCost(*rV); + + Cost(rU != rV) * costEvaluator.penalisedCost(*rV) + + solution_.depotLoadPenalty(costEvaluator) + + solution_.fixedDepotCost(); searchSpace_.markPromising(U); searchSpace_.markPromising(V); @@ -130,13 +137,15 @@ bool LocalSearch::applyNodeOps(Route::Node *U, nodeOp->apply(U, V); update(rU, rV); - [[maybe_unused]] auto const costAfter - = costEvaluator.penalisedCost(*rU) - + Cost(rU != rV) * costEvaluator.penalisedCost(*rV); - // When there is an improving move, the delta cost evaluation must // be exact. The resulting cost is then the sum of the cost before // the move, plus the delta cost. + [[maybe_unused]] auto const costAfter + = costEvaluator.penalisedCost(*rU) + + Cost(rU != rV) * costEvaluator.penalisedCost(*rV) + + solution_.depotLoadPenalty(costEvaluator) + + solution_.fixedDepotCost(); + assert(costAfter == costBefore + deltaCost); return true; @@ -361,11 +370,13 @@ void LocalSearch::update(Route *U, Route *V) searchCompleted_ = false; U->update(); + solution_.updateDepotAggregates(*U); lastUpdated[U->idx()] = numUpdates_; if (U != V) { V->update(); + solution_.updateDepotAggregates(*V); lastUpdated[V->idx()] = numUpdates_; } } diff --git a/pyvrp/cpp/search/PerturbationManager.cpp b/pyvrp/cpp/search/PerturbationManager.cpp index cdb264b..60b3aa5 100644 --- a/pyvrp/cpp/search/PerturbationManager.cpp +++ b/pyvrp/cpp/search/PerturbationManager.cpp @@ -70,12 +70,14 @@ void PerturbationManager::perturb(Solution &solution, searchSpace.markPromising(node); route->remove(node->idx()); route->update(); + solution.updateDepotAggregates(*route); } // Insert if node is not in a route and we are currently inserting. else if (!route && action == PerturbType::INSERT) { solution.insert(node, searchSpace, costEvaluator, true); node->route()->update(); + solution.updateDepotAggregates(*node->route()); searchSpace.markPromising(node); } else // no-op diff --git a/pyvrp/cpp/search/Route.h b/pyvrp/cpp/search/Route.h index 85017fd..5f19eb6 100644 --- a/pyvrp/cpp/search/Route.h +++ b/pyvrp/cpp/search/Route.h @@ -81,12 +81,12 @@ class Route */ size_t size() const; + public: /** * Returns whether the proposed route is empty. */ bool empty() const; - public: Proposal(Segments &&...segments); /** @@ -108,6 +108,11 @@ class Route */ std::pair duration() const; + /** + * Returns the load of the proposed route. + */ + Load load(size_t dimension) const; + /** * Returns the excess load of the proposed route. */ @@ -425,6 +430,11 @@ class Route */ [[nodiscard]] inline Cost fixedVehicleCost() const; + /** + * Depot fixed costs are solution-level costs, so routes contribute zero. + */ + [[nodiscard]] inline Cost fixedDepotCost() const; + /** * @return Total distance travelled on this route. */ @@ -921,6 +931,8 @@ size_t Route::endDepot() const { return vehicleType_.endDepot; } Cost Route::fixedVehicleCost() const { return vehicleType_.fixedCost; } +Cost Route::fixedDepotCost() const { return 0; } + Distance Route::distance() const { assert(!dirty); @@ -1164,6 +1176,55 @@ std::pair Route::Proposal::duration() const return std::apply(fn, detail::reverse(segments_)); } +template +Load Route::Proposal::load(size_t dimension) const +{ + if (empty()) + return 0; + + auto const &capacities = route()->capacity(); + auto const capacity = capacities[dimension]; + + auto const fn = [&](auto &&segment, auto &&...args) + { + auto total = Load{0}; + auto ls = segment.load(dimension); + + auto const finalise = [&]() + { + total += ls.load(); + ls = ls.finalise(capacity); + }; + + if (segment.endsAtReloadDepot()) + finalise(); + + auto const merge = [&](auto const &self, auto &&other, auto &&...args) + { + if (other.startsAtReloadDepot()) + finalise(); + + ls = LoadSegment::merge(ls, other.load(dimension)); + + if constexpr (sizeof...(args) != 0) + { + if (other.endsAtReloadDepot() && other.size() > 1) + // Only when the segment contains more than just the depot. + // Checking for size speeds up the common case of a reload + // depot insertion. + finalise(); + + self(self, std::forward(args)...); + } + }; + + merge(merge, std::forward(args)...); + return total + ls.load(); + }; + + return std::apply(fn, segments_); +} + template Load Route::Proposal::excessLoad(size_t dimension) const { diff --git a/pyvrp/cpp/search/Solution.cpp b/pyvrp/cpp/search/Solution.cpp index 42f24d6..390f1e3 100644 --- a/pyvrp/cpp/search/Solution.cpp +++ b/pyvrp/cpp/search/Solution.cpp @@ -6,10 +6,27 @@ #include #include +using pyvrp::Cost; +using pyvrp::Load; using pyvrp::search::Solution; -Solution::Solution(ProblemData const &data) : data_(data) +Solution::Solution(ProblemData const &data) + : data_(data), + depotLoads_(data.numDepots(), + std::vector(data.numLoadDimensions(), 0)), + depotCounts_(data.numDepots(), 0), + routeLoads_(data.numVehicles(), + std::vector(data.numLoadDimensions(), 0)), + routeUsed_(data.numVehicles(), false) { + depotCapacities_.reserve(data.numDepots()); + depotFixedCosts_.reserve(data.numDepots()); + for (auto const &depot : data.depots()) + { + depotCapacities_.push_back(depot.capacity); + depotFixedCosts_.push_back(depot.fixedCost); + } + nodes.reserve(data.numLocations()); for (size_t loc = 0; loc != data.numLocations(); ++loc) nodes.emplace_back(loc); @@ -83,6 +100,8 @@ void Solution::load(pyvrp::Solution const &solution) firstOfType = firstOfNextType; } + + updateDepotAggregates(); } pyvrp::Solution Solution::unload() const @@ -131,6 +150,83 @@ pyvrp::Solution Solution::unload() const return {data_, std::move(solRoutes)}; } +pyvrp::DepotContext Solution::depotContext() const +{ + return {&depotCapacities_, &depotLoads_, &depotFixedCosts_, &depotCounts_}; +} + +void Solution::updateDepotAggregates() +{ + auto const numLoadDims = data_.numLoadDimensions(); + for (auto &load : depotLoads_) + std::fill(load.begin(), load.end(), Load{0}); + + std::fill(depotCounts_.begin(), depotCounts_.end(), 0); + + for (auto const &route : routes) + { + auto const &load = route.load(); + routeLoads_[route.idx()] = load; + + auto const isUsed = !route.empty(); + routeUsed_[route.idx()] = isUsed; + if (isUsed) + ++depotCounts_[route.startDepot()]; + + auto &depotLoad = depotLoads_[route.startDepot()]; + for (size_t dim = 0; dim != numLoadDims; ++dim) + depotLoad[dim] += load[dim]; + } +} + +void Solution::updateDepotAggregates(Route const &route) +{ + auto const routeIdx = route.idx(); + auto const &newLoad = route.load(); + auto &oldLoad = routeLoads_[routeIdx]; + auto &depotLoad = depotLoads_[route.startDepot()]; + + auto const newUsed = !route.empty(); + if (routeUsed_[routeIdx] && !newUsed) + --depotCounts_[route.startDepot()]; + else if (!routeUsed_[routeIdx] && newUsed) + ++depotCounts_[route.startDepot()]; + + routeUsed_[routeIdx] = newUsed; + + for (size_t dim = 0; dim != data_.numLoadDimensions(); ++dim) + { + depotLoad[dim] += newLoad[dim] - oldLoad[dim]; + oldLoad[dim] = newLoad[dim]; + } +} + +Cost Solution::depotLoadPenalty(CostEvaluator const &costEvaluator) const +{ + Cost cost = 0; + for (size_t depot = 0; depot != data_.numDepots(); ++depot) + { + auto const &capacity = depotCapacities_[depot]; + if (capacity.empty()) + continue; + + for (size_t dim = 0; dim != capacity.size(); ++dim) + cost += costEvaluator.loadPenalty( + depotLoads_[depot][dim], capacity[dim], dim); + } + + return cost; +} + +Cost Solution::fixedDepotCost() const +{ + Cost cost = 0; + for (size_t depot = 0; depot != data_.numDepots(); ++depot) + cost += Cost(depotCounts_[depot] > 0) * depotFixedCosts_[depot]; + + return cost; +} + bool Solution::insert(Route::Node *U, SearchSpace const &searchSpace, CostEvaluator const &costEvaluator, diff --git a/pyvrp/cpp/search/Solution.h b/pyvrp/cpp/search/Solution.h index 6895919..686e893 100644 --- a/pyvrp/cpp/search/Solution.h +++ b/pyvrp/cpp/search/Solution.h @@ -7,6 +7,7 @@ #include "Route.h" // pyvrp::search::Route #include "SearchSpace.h" +#include #include namespace pyvrp::search @@ -30,6 +31,14 @@ namespace pyvrp::search class Solution { ProblemData const &data_; + std::vector> depotCapacities_; + std::vector depotFixedCosts_; + std::vector> depotLoads_; + std::vector depotCounts_; + std::vector> routeLoads_; + std::vector routeUsed_; + + void updateDepotAggregates(); public: std::vector nodes; // size numLocations() @@ -43,6 +52,18 @@ class Solution // Converts from our representation to a proper solution. pyvrp::Solution unload() const; + // Depot aggregate context for the cost evaluator. + DepotContext depotContext() const; + + // Updates depot aggregate state after the given route has been updated. + void updateDepotAggregates(Route const &route); + + // Computes the depot capacity penalty for the current aggregate loads. + Cost depotLoadPenalty(CostEvaluator const &costEvaluator) const; + + // Computes the fixed cost of the currently used depots. + Cost fixedDepotCost() const; + // Inserts the given node into the solution - either in its neighbourhood, // or in an empty route, if improving or required. Returns true if the node // was successfully inserted, false otherwise. Updating the search space and diff --git a/tests/search/test_LocalSearch.py b/tests/search/test_LocalSearch.py index 35f3ea1..e7fc0a0 100644 --- a/tests/search/test_LocalSearch.py +++ b/tests/search/test_LocalSearch.py @@ -499,6 +499,88 @@ def test_local_search_removes_useless_reload_depots(ok_small_multiple_trips): assert_(str(routes[1]), "2 4") +def test_local_search_uses_depot_capacity_delta(): + """ + Tests that local search can apply a route-local neutral move that improves + the depot capacity excess load. + """ + data = ProblemData( + clients=[ + Client(x=0, y=0, delivery=[4], pickup=[0]), + Client(x=0, y=0, delivery=[4], pickup=[0]), + Client(x=0, y=0, delivery=[1], pickup=[0]), + ], + depots=[ + Depot(x=0, y=0, capacity=[5]), + Depot(x=0, y=0, capacity=[100]), + ], + vehicle_types=[ + VehicleType(capacity=[10], start_depot=0, end_depot=0), + VehicleType(capacity=[10], start_depot=1, end_depot=1), + ], + distance_matrices=[np.zeros((5, 5), dtype=int)], + duration_matrices=[np.zeros((5, 5), dtype=int)], + ) + + sol = Solution( + data, + [ + Route(data, [2, 3], 0), + Route(data, [4], 1), + ], + ) + assert_equal(sol.excess_load(), [3]) + + neighbours = [[], [], [4], [4], [2, 3]] + rng = RandomNumberGenerator(seed=42) + ls = LocalSearch(data, rng, neighbours) + ls.add_node_operator(Exchange10(data)) + + cost_eval = CostEvaluator([10], 0, 0) + improved = ls.search(sol, cost_eval) + + assert_(cost_eval.penalised_cost(improved) < cost_eval.penalised_cost(sol)) + assert_equal(improved.excess_load(), [0]) + + +def test_local_search_uses_fixed_depot_cost_delta(): + """ + Tests that local search can apply a route-local neutral move that makes an + expensive depot inactive. + """ + data = ProblemData( + clients=[ + Client(x=0, y=0), + Client(x=0, y=0), + ], + depots=[ + Depot(x=0, y=0), + Depot(x=0, y=0, fixed_cost=50), + ], + vehicle_types=[ + VehicleType(start_depot=0, end_depot=0), + VehicleType(start_depot=1, end_depot=1), + ], + distance_matrices=[np.zeros((4, 4), dtype=int)], + duration_matrices=[np.zeros((4, 4), dtype=int)], + ) + + sol = Solution(data, [Route(data, [2], 0), Route(data, [3], 1)]) + assert_equal(sol.fixed_depot_cost(), 50) + + neighbours = [[], [], [3], [2]] + rng = RandomNumberGenerator(seed=42) + ls = LocalSearch(data, rng, neighbours) + ls.add_node_operator(Exchange10(data)) + + cost_eval = CostEvaluator([], 0, 0) + improved = ls.search(sol, cost_eval) + + assert_(cost_eval.penalised_cost(improved) < cost_eval.penalised_cost(sol)) + assert_equal(improved.num_routes(), 1) + assert_equal(improved.fixed_depot_cost(), 0) + + def test_search_statistics(ok_small): """ Tests that the local search's search statistics return meaningful diff --git a/tests/test_CostEvaluator.py b/tests/test_CostEvaluator.py index 6f74435..5acdf16 100644 --- a/tests/test_CostEvaluator.py +++ b/tests/test_CostEvaluator.py @@ -258,6 +258,29 @@ def test_excess_load_penalised_cost(): assert_equal(cost_eval.penalised_cost(sol), 10 * (1 + 2) + 10 * (0 + 1)) +def test_depot_capacity_excess_load_penalised_cost(): + """ + Tests that depot capacity excess load is properly penalised in the cost + computations. + """ + data = ProblemData( + clients=[ + Client(x=1, y=0, delivery=[6], pickup=[0]), + Client(x=2, y=0, delivery=[5], pickup=[0]), + ], + depots=[Depot(x=0, y=0, capacity=[10])], + vehicle_types=[VehicleType(2, capacity=[10])], + distance_matrices=[np.zeros((3, 3), dtype=int)], + duration_matrices=[np.zeros((3, 3), dtype=int)], + ) + + sol = Solution(data, [[1], [2]]) + assert_equal(sol.excess_load(), [1]) + + cost_eval = CostEvaluator([10], 0, 0) + assert_equal(cost_eval.penalised_cost(sol), 10) + + @pytest.mark.parametrize( ("assignment", "expected"), [((0, 0), 0), ((0, 1), 10), ((1, 1), 20)] ) @@ -292,6 +315,49 @@ def test_cost_with_fixed_vehicle_cost( assert_equal(cost_eval.penalised_cost(sol), sol.distance() + expected) +def test_cost_with_fixed_depot_cost_for_used_depots_only(): + """ + Tests that depot fixed costs are only part of the full solution cost when + at least one used vehicle starts at that depot. + """ + data = ProblemData( + clients=[ + Client(x=0, y=0, required=False), + Client(x=1, y=0, required=False), + ], + depots=[ + Depot(x=0, y=0, fixed_cost=10), + Depot(x=1, y=0, fixed_cost=20), + ], + vehicle_types=[ + VehicleType(start_depot=0, end_depot=0), + VehicleType(start_depot=1, end_depot=1), + ], + distance_matrices=[np.zeros((4, 4), dtype=int)], + duration_matrices=[np.zeros((4, 4), dtype=int)], + ) + + cost_eval = CostEvaluator([], 0, 0) + + empty = Solution(data, []) + assert_(empty.is_feasible()) + assert_equal(empty.fixed_depot_cost(), 0) + assert_equal(cost_eval.cost(empty), 0) + assert_equal(cost_eval.penalised_cost(empty), 0) + + one_depot = Solution(data, [Route(data, [2], 0)]) + assert_(one_depot.is_feasible()) + assert_equal(one_depot.fixed_depot_cost(), 10) + assert_equal(cost_eval.cost(one_depot), 10) + assert_equal(cost_eval.penalised_cost(one_depot), 10) + + two_depots = Solution(data, [Route(data, [2], 0), Route(data, [3], 1)]) + assert_(two_depots.is_feasible()) + assert_equal(two_depots.fixed_depot_cost(), 30) + assert_equal(cost_eval.cost(two_depots), 30) + assert_equal(cost_eval.penalised_cost(two_depots), 30) + + def test_unit_distance_duration_cost(ok_small): """ Tests that the cost evaluator takes into account that unit distance and diff --git a/tests/test_Model.py b/tests/test_Model.py index 68598cd..2ff1012 100644 --- a/tests/test_Model.py +++ b/tests/test_Model.py @@ -129,11 +129,24 @@ def test_add_depot_attributes(): in. """ model = Model() - depot = model.add_depot(x=1, y=0, tw_early=5, tw_late=7) + depot = model.add_depot( + x=1, + y=0, + tw_early=5, + tw_late=7, + service_duration=3, + capacity=[11, 13], + fixed_cost=17, + name="test", + ) assert_equal(depot.x, 1) assert_equal(depot.y, 0) assert_equal(depot.tw_early, 5) assert_equal(depot.tw_late, 7) + assert_equal(depot.service_duration, 3) + assert_equal(depot.capacity, [11, 13]) + assert_equal(depot.fixed_cost, 17) + assert_equal(depot.name, "test") def test_add_edge(): @@ -1015,6 +1028,20 @@ def test_integer_vehicle_capacity_and_load_arguments_are_promoted_to_lists(): assert_equal(veh2.initial_load, [1]) +def test_integer_depot_capacity_argument_is_promoted_to_list(): + """ + Tests that passing an integer depot capacity functions the same way as + passing a list of a single integer. + """ + m = Model() + + depot1 = m.add_depot(0, 0, capacity=10) + assert_equal(depot1.capacity, [10]) + + depot2 = m.add_depot(0, 0, capacity=[10]) + assert_equal(depot2.capacity, [10]) + + def test_adding_vehicle_reload_depots(): """ Smoke test that checks adding reload depots to the vehicle type works diff --git a/tests/test_ProblemData.py b/tests/test_ProblemData.py index a7f084d..161b575 100644 --- a/tests/test_ProblemData.py +++ b/tests/test_ProblemData.py @@ -161,6 +161,14 @@ def test_raises_for_invalid_depot_data( Depot(x, y, tw_early, tw_late, service_duration) +def test_raises_for_negative_depot_fixed_cost(): + """ + Tests that depot fixed costs must be non-negative. + """ + with assert_raises(ValueError): + Depot(0, 0, fixed_cost=-1) + + def test_depot_initialises_data_correctly(): """ Tests that the depot constructor correctly initialises its member data, and @@ -172,6 +180,7 @@ def test_depot_initialises_data_correctly(): tw_early=5, tw_late=7, service_duration=3, + fixed_cost=11, name="test", ) @@ -180,6 +189,7 @@ def test_depot_initialises_data_correctly(): assert_equal(depot.tw_early, 5) assert_equal(depot.tw_late, 7) assert_equal(depot.service_duration, 3) + assert_equal(depot.fixed_cost, 11) assert_equal(depot.name, "test") @@ -1006,6 +1016,9 @@ def test_depot_eq(): depot4 = Depot(x=0, y=0, name="test") assert_(depot1 != depot4) + depot5 = Depot(x=0, y=0, fixed_cost=1) + assert_(depot1 != depot5) + def test_vehicle_type_eq(): """ @@ -1066,6 +1079,15 @@ def test_pickle_locations(cls): assert_equal(pickle.loads(bytes), before_pickle) +def test_pickle_depot_with_fixed_cost(): + """ + Tests that depot fixed costs survive serialisation. + """ + before_pickle = Depot(x=0, y=1, fixed_cost=123, name="test") + bytes = pickle.dumps(before_pickle) + assert_equal(pickle.loads(bytes), before_pickle) + + def test_pickle_client_group(): """ Tests that client groups can be serialised and unserialised. @@ -1181,6 +1203,72 @@ def test_problem_data_raises_when_capacity_dimensions_differ(): ) +def test_problem_data_raises_when_depot_capacity_dimensions_differ(): + """ + Tests that the ``ProblemData`` constructor raises a ``ValueError`` when a + depot capacity is provided with different dimensions. + """ + with assert_raises(ValueError): + ProblemData( + clients=[ + Client(x=0, y=0, delivery=[1, 2], pickup=[1, 2]), + Client(x=1, y=1, delivery=[1, 2], pickup=[1, 2]), + ], + depots=[Depot(x=0, y=0, capacity=[10])], + vehicle_types=[VehicleType(2, capacity=[1, 2])], + distance_matrices=[np.zeros((3, 3), dtype=int)], + duration_matrices=[np.zeros((3, 3), dtype=int)], + ) + + +def test_problem_data_allows_empty_depot_capacity(): + """ + Tests that an empty depot capacity is understood as unconstrained. + """ + data = ProblemData( + clients=[ + Client(x=0, y=0, delivery=[1, 2], pickup=[0, 0]), + Client(x=1, y=1, delivery=[1, 2], pickup=[0, 0]), + ], + depots=[Depot(x=0, y=0)], + vehicle_types=[VehicleType(2, capacity=[1, 2])], + distance_matrices=[np.zeros((3, 3), dtype=int)], + duration_matrices=[np.zeros((3, 3), dtype=int)], + ) + + assert_equal(data.depots()[0].capacity, []) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"clients": [Client(x=1, y=0, delivery=[1], pickup=[1])]}, + { + "vehicle_types": [ + VehicleType(capacity=[10], start_depot=0, end_depot=1) + ] + }, + {"vehicle_types": [VehicleType(capacity=[10], initial_load=[1])]}, + {"vehicle_types": [VehicleType(capacity=[10], reload_depots=[0])]}, + ], +) +def test_problem_data_depot_capacity_assumptions(kwargs): + """ + Tests that depot capacities are only accepted for the simplified case + handled by the solver: delivery-only routes without reloads or initial + loads that start and end at the same depot. + """ + clients = kwargs.get( + "clients", [Client(x=1, y=0, delivery=[1], pickup=[0])] + ) + vehicle_types = kwargs.get("vehicle_types", [VehicleType(capacity=[10])]) + depots = [Depot(x=0, y=0, capacity=[10]), Depot(x=1, y=0)] + mat = np.zeros((len(clients) + len(depots),) * 2, dtype=int) + + with assert_raises(ValueError): + ProblemData(clients, depots, vehicle_types, [mat], [mat]) + + def test_problem_data_raises_when_pickup_delivery_capacity_dimensions_differ(): """ Tests that the ``ProblemData`` constructor raises a ``ValueError`` when @@ -1208,10 +1296,10 @@ def test_problem_data_constructor_valid_load_dimensions(): """ data = ProblemData( clients=[ - Client(x=0, y=0, delivery=[1, 2], pickup=[1, 2]), - Client(x=1, y=1, delivery=[1, 2], pickup=[1, 2]), + Client(x=0, y=0, delivery=[1, 2], pickup=[0, 0]), + Client(x=1, y=1, delivery=[1, 2], pickup=[0, 0]), ], - depots=[Depot(x=0, y=0)], + depots=[Depot(x=0, y=0, capacity=[10, 10])], vehicle_types=[ VehicleType(2, capacity=[1, 2]), VehicleType(2, capacity=[1, 2]), @@ -1220,6 +1308,7 @@ def test_problem_data_constructor_valid_load_dimensions(): duration_matrices=[np.zeros((3, 3), dtype=int)], ) assert_equal(data.num_load_dimensions, 2) + assert_equal(data.depots()[0].capacity, [10, 10]) @pytest.mark.parametrize( diff --git a/tests/test_Solution.py b/tests/test_Solution.py index 66e9c48..1e8d6ec 100644 --- a/tests/test_Solution.py +++ b/tests/test_Solution.py @@ -490,6 +490,35 @@ def test_excess_load_calculation_with_multiple_load_dimensions( assert_equal(solution.excess_load(), expected_excess_load) +def test_excess_load_calculation_with_depot_capacity(): + """ + Tests that delivery load served from a capacitated depot contributes to the + Solution's excess load. + """ + data = ProblemData( + clients=[ + Client(x=1, y=0, delivery=[6], pickup=[0]), + Client(x=2, y=0, delivery=[5], pickup=[0]), + ], + depots=[Depot(x=0, y=0, capacity=[10])], + vehicle_types=[VehicleType(2, capacity=[10])], + distance_matrices=[np.zeros((3, 3), dtype=int)], + duration_matrices=[np.zeros((3, 3), dtype=int)], + ) + solution = Solution(data, [[1], [2]]) + + routes = solution.routes() + assert_(all(route.is_feasible() for route in routes)) + assert_equal(routes[0].delivery(), [6]) + assert_equal(routes[1].delivery(), [5]) + assert_equal(routes[0].excess_load(), [0]) + assert_equal(routes[1].excess_load(), [0]) + + assert_(solution.has_excess_load()) + assert_(not solution.is_feasible()) + assert_equal(solution.excess_load(), [1]) + + @pytest.mark.parametrize( "dist_mat", [ @@ -816,6 +845,40 @@ def test_fixed_vehicle_cost( assert_equal(sol.fixed_vehicle_cost(), expected) +def test_fixed_depot_cost_of_used_depots(): + """ + Tests that the solution tracks the total fixed cost of depots that have at + least one route starting at that depot. + """ + data = ProblemData( + clients=[ + Client(x=0, y=0, required=False), + Client(x=1, y=0, required=False), + ], + depots=[ + Depot(x=0, y=0, fixed_cost=10), + Depot(x=1, y=0, fixed_cost=20), + ], + vehicle_types=[ + VehicleType(start_depot=0, end_depot=0), + VehicleType(start_depot=1, end_depot=1), + ], + distance_matrices=[np.zeros((4, 4), dtype=int)], + duration_matrices=[np.zeros((4, 4), dtype=int)], + ) + + empty = Solution(data, []) + assert_equal(empty.fixed_vehicle_cost(), 0) + assert_equal(empty.fixed_depot_cost(), 0) + + one_depot = Solution(data, [Route(data, [2], 0)]) + assert_equal(one_depot.fixed_depot_cost(), 10) + + two_depots = Solution(data, [Route(data, [2], 0), Route(data, [3], 1)]) + assert_equal(two_depots.fixed_depot_cost(), 30) + assert_equal(pickle.loads(pickle.dumps(two_depots)).fixed_depot_cost(), 30) + + @pytest.mark.parametrize( ("routes", "feasible"), [ From 12f9a1e6fe09282cdd6c21b70aa147714aac779b Mon Sep 17 00:00:00 2001 From: Leon Lan Date: Tue, 23 Jun 2026 15:45:25 +0200 Subject: [PATCH 2/2] Use DynamicBitset --- pyvrp/cpp/search/Solution.cpp | 2 +- pyvrp/cpp/search/Solution.h | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyvrp/cpp/search/Solution.cpp b/pyvrp/cpp/search/Solution.cpp index 390f1e3..bb97544 100644 --- a/pyvrp/cpp/search/Solution.cpp +++ b/pyvrp/cpp/search/Solution.cpp @@ -17,7 +17,7 @@ Solution::Solution(ProblemData const &data) depotCounts_(data.numDepots(), 0), routeLoads_(data.numVehicles(), std::vector(data.numLoadDimensions(), 0)), - routeUsed_(data.numVehicles(), false) + routeUsed_(data.numVehicles()) { depotCapacities_.reserve(data.numDepots()); depotFixedCosts_.reserve(data.numDepots()); diff --git a/pyvrp/cpp/search/Solution.h b/pyvrp/cpp/search/Solution.h index 686e893..ebba2e6 100644 --- a/pyvrp/cpp/search/Solution.h +++ b/pyvrp/cpp/search/Solution.h @@ -3,11 +3,11 @@ #include "../Solution.h" // pyvrp::Solution #include "CostEvaluator.h" +#include "DynamicBitset.h" #include "ProblemData.h" #include "Route.h" // pyvrp::search::Route #include "SearchSpace.h" -#include #include namespace pyvrp::search @@ -36,7 +36,7 @@ class Solution std::vector> depotLoads_; std::vector depotCounts_; std::vector> routeLoads_; - std::vector routeUsed_; + DynamicBitset routeUsed_; void updateDepotAggregates();