diff --git a/ci/validate_wheel.sh b/ci/validate_wheel.sh index a45c97b415..da044db65a 100755 --- a/ci/validate_wheel.sh +++ b/ci/validate_wheel.sh @@ -22,7 +22,7 @@ PYDISTCHECK_ARGS=( if [[ "${package_dir}" == "python/libcuopt" ]]; then if [[ "${RAPIDS_CUDA_MAJOR}" == "12" ]]; then PYDISTCHECK_ARGS+=( - --max-allowed-size-compressed '695Mi' + --max-allowed-size-compressed '725Mi' ) else PYDISTCHECK_ARGS+=( diff --git a/cpp/include/cuopt/routing/cpu_routing_problem.hpp b/cpp/include/cuopt/routing/cpu_routing_problem.hpp index 9c2d7971dc..128eac84c8 100644 --- a/cpp/include/cuopt/routing/cpu_routing_problem.hpp +++ b/cpp/include/cuopt/routing/cpu_routing_problem.hpp @@ -48,6 +48,13 @@ struct cpu_vehicle_break_t { std::vector locations; }; +struct cpu_vehicle_distance_break_t { + float distance_min = 0.f; + float distance_max = 0.f; + int32_t duration = 0; + std::vector locations; +}; + struct cpu_uniform_break_t { std::vector earliest; std::vector latest; @@ -96,6 +103,7 @@ class cpu_routing_problem_t { std::vector break_locations; std::vector uniform_breaks; std::map> vehicle_breaks; + std::map> vehicle_distance_breaks; std::map> vehicle_order_match; std::map> order_vehicle_match; diff --git a/cpp/include/cuopt/routing/data_model_view.hpp b/cpp/include/cuopt/routing/data_model_view.hpp index b025469144..48cbd400c7 100644 --- a/cpp/include/cuopt/routing/data_model_view.hpp +++ b/cpp/include/cuopt/routing/data_model_view.hpp @@ -162,6 +162,39 @@ class data_model_view_t { i_t num_break_locations, bool validate_input = true); + /** + * @brief Add a distance-windowed break for a vehicle. + * + * The solver inserts one break stop per call no later than distance_max, + * measured as cumulative distance along the cost matrix. distance_max is a + * hard upper bound. distance_min is a soft target whose shortfall contributes to + * objective_t::DISTANCE_BREAK_COST when that objective has a positive + * weight. The objective value is the maximum lower-bound shortfall on each + * route, summed across routes. Its default weight is 1.0 when distance + * breaks are configured; explicitly set its weight to 0.0 to disable it. + * Call this function multiple times for the same vehicle to model successive + * distance cycles (e.g. first stop: [0, 150], second stop: [150, 300]). + * + * @param vehicle_id Vehicle to apply the break to. + * @param distance_min Soft lower bound on cumulative route distance at + * the break. + * @param distance_max Latest cumulative route distance by which the + * vehicle must have stopped. + * @param break_duration Service time at the break location (same unit + * as other service times in the model). + * @param break_locations Device pointer to eligible break location IDs. + * Pass nullptr to allow any location. + * @param num_break_locations Number of entries in break_locations. + * @param validate_input Run input validation. Defaults to true. + */ + void add_vehicle_distance_break(i_t vehicle_id, + f_t distance_min, + f_t distance_max, + i_t break_duration, + i_t const* break_locations, + i_t num_break_locations, + bool validate_input = true); + /** * @brief During improvement phase the solver only optimizes for the cost. * This function is used to select the best solution accross all climbers @@ -491,7 +524,7 @@ class data_model_view_t { */ std::vector> const& get_uniform_breaks() const noexcept; - std::map>> const& get_non_uniform_breaks() + std::map>> const& get_non_uniform_breaks() const noexcept; /** @@ -662,7 +695,7 @@ class data_model_view_t { raft::device_span initial_routes_{}; raft::device_span initial_types_{}; raft::device_span initial_sol_offsets_{}; - std::map>> vehicle_breaks_{}; + std::map>> vehicle_breaks_{}; }; } // namespace CUOPT_EXPORT routing } // namespace cuopt diff --git a/cpp/include/cuopt/routing/routing_structures.hpp b/cpp/include/cuopt/routing/routing_structures.hpp index 64bcc22ce5..3e0fb9bb76 100644 --- a/cpp/include/cuopt/routing/routing_structures.hpp +++ b/cpp/include/cuopt/routing/routing_structures.hpp @@ -29,6 +29,7 @@ enum class objective_t { VARIANCE_ROUTE_SERVICE_TIME, // Variance in route service times PRIZE, // Sum of prizes of all orders that are served VEHICLE_FIXED_COST, // Used when fixed vehicle cost are enabled + DISTANCE_BREAK_COST, // Maximum distance-break lower-bound shortfall per route SIZE // Helper enum to keep track of number of supported objective functions }; @@ -58,11 +59,38 @@ class break_dimension_t { i_t const* break_duration_; }; -template +/** + * @brief A mandatory break a vehicle must take during its route, triggered either by time + * or by cumulative route distance. If @p locations is empty the break may be taken + * anywhere; otherwise it must occur at one of the specified location IDs. + */ +template class vehicle_break_t { public: + /// Time-windowed break: must start within [earliest, latest]. vehicle_break_t(i_t earliest, i_t latest, i_t duration, raft::device_span locations) - : earliest_(earliest), latest_(latest), duration_(duration), locations_(locations) + : earliest_(earliest), + latest_(latest), + duration_(duration), + locations_(locations), + is_distance_based_(false), + distance_min_(0), + distance_max_(std::numeric_limits::max()) + { + } + + /// Distance-windowed break: distance_min is soft and distance_max is hard. + vehicle_break_t(f_t distance_min, + f_t distance_max, + i_t duration, + raft::device_span locations) + : earliest_(0), + latest_(std::numeric_limits::max()), + duration_(duration), + locations_(locations), + is_distance_based_(true), + distance_min_(distance_min), + distance_max_(distance_max) { } @@ -70,6 +98,9 @@ class vehicle_break_t { i_t latest_; i_t duration_; raft::device_span locations_{}; + bool is_distance_based_; + f_t distance_min_; + f_t distance_max_; }; template diff --git a/cpp/src/grpc/routing/cuopt_routing.proto b/cpp/src/grpc/routing/cuopt_routing.proto index 4ff59ff117..7121298aa5 100644 --- a/cpp/src/grpc/routing/cuopt_routing.proto +++ b/cpp/src/grpc/routing/cuopt_routing.proto @@ -32,6 +32,18 @@ message PerVehicleBreaks { repeated VehicleBreak breaks = 2; } +message VehicleDistanceBreak { + float distance_min = 1; + float distance_max = 2; + int32 duration = 3; + repeated int32 locations = 4; +} + +message PerVehicleDistanceBreaks { + int32 vehicle_id = 1; + repeated VehicleDistanceBreak breaks = 2; +} + message UniformBreakDimension { repeated int32 earliest = 1; repeated int32 latest = 2; @@ -105,6 +117,7 @@ message RoutingProblem { repeated int32 break_locations = 60; repeated UniformBreakDimension uniform_breaks = 61; repeated PerVehicleBreaks vehicle_breaks = 62; + repeated PerVehicleDistanceBreaks vehicle_distance_breaks = 63; // Matching repeated MatchEntry vehicle_order_match = 70; diff --git a/cpp/src/grpc/routing/grpc_routing_problem_mapper.cpp b/cpp/src/grpc/routing/grpc_routing_problem_mapper.cpp index a1125d5f53..95ea1e2d7b 100644 --- a/cpp/src/grpc/routing/grpc_routing_problem_mapper.cpp +++ b/cpp/src/grpc/routing/grpc_routing_problem_mapper.cpp @@ -93,6 +93,18 @@ void map_proto_to_routing_problem(const cuopt::remote::RoutingProblem& pb, } p.vehicle_breaks[pvb.vehicle_id()] = std::move(breaks); } + for (auto const& pvb : pb.vehicle_distance_breaks()) { + std::vector breaks; + for (auto const& b : pvb.breaks()) { + cuopt::routing::cpu_vehicle_distance_break_t out; + out.distance_min = b.distance_min(); + out.distance_max = b.distance_max(); + out.duration = b.duration(); + copy_repeated_to_vector(b.locations(), out.locations); + breaks.push_back(std::move(out)); + } + p.vehicle_distance_breaks[pvb.vehicle_id()] = std::move(breaks); + } for (auto const& m : pb.vehicle_order_match()) { std::vector matches; @@ -199,6 +211,17 @@ void map_routing_problem_to_proto(const cuopt::routing::cpu_routing_problem_t& p copy_vector_to_repeated(b.locations, brk->mutable_locations()); } } + for (auto const& [vehicle_id, breaks] : p.vehicle_distance_breaks) { + auto* out = pb->add_vehicle_distance_breaks(); + out->set_vehicle_id(vehicle_id); + for (auto const& b : breaks) { + auto* brk = out->add_breaks(); + brk->set_distance_min(b.distance_min); + brk->set_distance_max(b.distance_max); + brk->set_duration(b.duration); + copy_vector_to_repeated(b.locations, brk->mutable_locations()); + } + } for (auto const& [id, matches] : p.vehicle_order_match) { auto* out = pb->add_vehicle_order_match(); diff --git a/cpp/src/routing/cpu_routing_problem.cu b/cpp/src/routing/cpu_routing_problem.cu index fb61c7f8cf..25edf9e9dc 100644 --- a/cpp/src/routing/cpu_routing_problem.cu +++ b/cpp/src/routing/cpu_routing_problem.cu @@ -257,6 +257,17 @@ cpu_routing_problem_t::to_device(raft::handle_t* handle) const } } + for (auto const& [vehicle_id, breaks] : vehicle_distance_breaks) { + for (auto const& brk : breaks) { + auto d_locs = copy_vector(brk.locations, stream); + int32_t n_locs = d_locs ? static_cast(d_locs->size()) : 0; + int32_t const* loc_ptr = d_locs ? d_locs->data() : nullptr; + view.add_vehicle_distance_break( + vehicle_id, brk.distance_min, brk.distance_max, brk.duration, loc_ptr, n_locs, false); + if (d_locs) { data->vehicle_break_locations.push_back(std::move(d_locs)); } + } + } + for (auto const& [vehicle_id, orders] : vehicle_order_match) { auto d = copy_vector(orders, stream); if (!d) { continue; } diff --git a/cpp/src/routing/data_model_view.cu b/cpp/src/routing/data_model_view.cu index 392c7a10fb..40b82a2f09 100644 --- a/cpp/src/routing/data_model_view.cu +++ b/cpp/src/routing/data_model_view.cu @@ -15,8 +15,42 @@ #include #include +#include #include +namespace { + +/** + * @brief Validates that break locations are within the valid range + * of the location matrix and that all entries are unique. + */ +template +void validate_break_locations(i_t const* locations, + i_t n, + i_t num_locations, + raft::handle_t const* handle) +{ + cuopt::cuopt_expects( + n >= 0, cuopt::error_type_t::ValidationError, "Number of break locations must be non-negative"); + if (n == 0) { return; } + cuopt::cuopt_expects(locations != nullptr, + cuopt::error_type_t::ValidationError, + "Break locations cannot be null when num_break_locations > 0"); + cuopt::cuopt_expects(cuopt::routing::detail::check_min_max_values( + locations, n, i_t{0}, num_locations - 1, handle->get_stream()), + cuopt::error_type_t::ValidationError, + "Break locations should be in [0, num_locations) range"); + rmm::device_uvector tmp(n, handle->get_stream()); + raft::copy(tmp.begin(), locations, n, handle->get_stream()); + thrust::sort(handle->get_thrust_policy(), tmp.begin(), tmp.end()); + auto end = thrust::unique(handle->get_thrust_policy(), tmp.begin(), tmp.end()); + i_t unique_items = end - tmp.begin(); + cuopt::cuopt_expects(n == unique_items, + cuopt::error_type_t::ValidationError, + "There should be unique break locations"); +} +} // namespace + namespace cuopt { namespace routing { @@ -86,7 +120,7 @@ void data_model_view_t::set_break_locations(i_t const* break_locations detail::check_min_max_values( break_locations, n_break_locations, 0, num_locations_ - 1, handle_ptr_->get_stream()), error_type_t::ValidationError, - "Break locations should be at the end of the matrix"); + "Break locations must be within [0, num_locations)"); rmm::device_uvector tmp_break_nodes(n_break_locations, handle_ptr_->get_stream()); raft::copy( tmp_break_nodes.begin(), break_locations, n_break_locations, handle_ptr_->get_stream()); @@ -156,28 +190,55 @@ void data_model_view_t::add_vehicle_break(i_t vehicle_id, i_t num_break_locations, bool validate_input) { - vehicle_breaks_[vehicle_id].push_back(detail::vehicle_break_t( + cuopt_expects(0 <= vehicle_id && vehicle_id < fleet_size_, + error_type_t::ValidationError, + "vehicle_id must be in [0, fleet_size)"); + cuopt_expects(break_earliest <= break_latest, + error_type_t::ValidationError, + "Break earliest must be less than or equal than break latest!"); + cuopt_expects( + break_duration >= 0, error_type_t::ValidationError, "break_duration must be non-negative!"); + + if (validate_input) { + validate_break_locations(break_locations, num_break_locations, num_locations_, handle_ptr_); + } + + vehicle_breaks_[vehicle_id].push_back(detail::vehicle_break_t( break_earliest, break_latest, break_duration, raft::device_span(break_locations, num_break_locations))); +} - if (validate_input && num_break_locations > 0) { - cuopt_expects( - detail::check_min_max_values( - break_locations, num_break_locations, 0, num_locations_ - 1, handle_ptr_->get_stream()), - error_type_t::ValidationError, - "Break locations should be at the end of the matrix"); - rmm::device_uvector tmp_break_nodes(num_break_locations, handle_ptr_->get_stream()); - raft::copy( - tmp_break_nodes.begin(), break_locations, num_break_locations, handle_ptr_->get_stream()); - auto end = thrust::unique( - handle_ptr_->get_thrust_policy(), tmp_break_nodes.begin(), tmp_break_nodes.end()); - i_t unique_items = end - tmp_break_nodes.begin(); - cuopt_expects(num_break_locations == unique_items, - error_type_t::ValidationError, - "There should be unique break locations"); +template +void data_model_view_t::add_vehicle_distance_break(i_t vehicle_id, + f_t distance_min, + f_t distance_max, + i_t break_duration, + i_t const* break_locations, + i_t num_break_locations, + bool validate_input) +{ + cuopt_expects(0 <= vehicle_id && vehicle_id < fleet_size_, + error_type_t::ValidationError, + "vehicle_id must be in [0, fleet_size)"); + cuopt_expects( + distance_min >= 0, error_type_t::ValidationError, "distance_min must be non-negative!"); + cuopt_expects(distance_max > distance_min, + error_type_t::ValidationError, + "distance break distance_max must be greater than distance_min!"); + cuopt_expects( + break_duration >= 0, error_type_t::ValidationError, "break_duration must be non-negative!"); + + if (validate_input) { + validate_break_locations(break_locations, num_break_locations, num_locations_, handle_ptr_); } + + vehicle_breaks_[vehicle_id].push_back(detail::vehicle_break_t( + distance_min, + distance_max, + break_duration, + raft::device_span(break_locations, num_break_locations))); } template @@ -615,7 +676,7 @@ data_model_view_t::get_uniform_breaks() const noexcept } template -std::map>> const& +std::map>> const& data_model_view_t::get_non_uniform_breaks() const noexcept { return vehicle_breaks_; diff --git a/cpp/src/routing/dimensions.cuh b/cpp/src/routing/dimensions.cuh index af50b2c56d..2895ad2c12 100644 --- a/cpp/src/routing/dimensions.cuh +++ b/cpp/src/routing/dimensions.cuh @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2023-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ @@ -182,8 +182,10 @@ using infeasible_cost_t = static_vec_t; using objective_cost_t = static_vec_t; struct cost_dimension_info_t { - bool has_max_constraint = false; - HDI bool has_constraints() const { return has_max_constraint; } + bool has_max_constraint = false; + bool has_distance_window = false; + bool has_distance_break_cost = false; + HDI bool has_constraints() const { return has_max_constraint || has_distance_window; } }; struct time_dimension_info_t { diff --git a/cpp/src/routing/ges/squeeze.cuh b/cpp/src/routing/ges/squeeze.cuh index 538ebe6c1e..64fc8ea264 100644 --- a/cpp/src/routing/ges/squeeze.cuh +++ b/cpp/src/routing/ges/squeeze.cuh @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2022-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ @@ -402,6 +402,10 @@ __global__ void eject_inserted_requests( } } +/** + * @brief Inserts each missing break dimension into routes that lack it, one break per outer + * iteration, picking the least-cost insertion position for each. One block per route. + */ template __global__ void squeeze_breaks_kernel(typename solution_t::view_t solution, const bool include_objective, @@ -479,7 +483,8 @@ __global__ void squeeze_breaks_kernel(typename solution_t::vi __shared__ double reduction_buf[2 * raft::WarpSize]; block_reduce_ranked(thread_best_cost, t_id, reduction_buf, &reduction_idx); - if (threadIdx.x == reduction_idx) { + if (threadIdx.x == reduction_idx && thread_best_break_node_id >= 0 && + reduction_buf[0] != std::numeric_limits::max()) { auto break_node = create_break_node( break_nodes, thread_best_break_node_id, solution.problem.dimensions_info); // do not update the intra indices yet diff --git a/cpp/src/routing/local_search/breaks_insertion.cu b/cpp/src/routing/local_search/breaks_insertion.cu index 8fd06d83f1..537e91220b 100644 --- a/cpp/src/routing/local_search/breaks_insertion.cu +++ b/cpp/src/routing/local_search/breaks_insertion.cu @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2023-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ @@ -15,6 +15,11 @@ namespace cuopt { namespace routing { namespace detail { +/** + * @brief Looks for a cost-reducing relocation of an existing break node within its route by + * evaluating every alternative position and break-location choice for the same break + * dimension. One block per (route, break_dimension) pair. + */ template __global__ void find_break_insertions_kernel( typename solution_t::view_t solution, diff --git a/cpp/src/routing/node/distance_node.cuh b/cpp/src/routing/node/distance_node.cuh index eabf962d8b..a8691e4bf4 100644 --- a/cpp/src/routing/node/distance_node.cuh +++ b/cpp/src/routing/node/distance_node.cuh @@ -1,17 +1,30 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2021-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ #pragma once + +#include +#include "routing/dimensions.cuh" +#include "routing/vehicle_info.hpp" + #include namespace cuopt { namespace routing { namespace detail { +constexpr double DISTANCE_WINDOW_INFINITY = 1e18; + +// Distance dimension. Tracks the cumulative route distance (for vehicle.max_cost) and, when +// distance-based charging breaks are configured, per-node distance windows. The upper-bound +// state and lower-bound state propagate independently: arriving after window_end is hard +// infeasibility, while arriving before window_start contributes to a separately weighted +// objective. distance_forward / distance_backward keep raw-sum semantics for CVRP/TSP and +// fragment kernels that read them directly. template class distance_node_t { public: @@ -19,27 +32,65 @@ class distance_node_t { double distance_forward = 0.0; //! Distance gathered after node double distance_backward = 0.0; + // Upper-bound propagation: clamped cumulative-from-start (forward) and latest-allowable + // cumulative-from-start (backward). + // [window_start, window_end] = [0, DISTANCE_WINDOW_INFINITY] means unconstrained + // (non-break node). + double distance_window_forward = 0.0; + double distance_window_backward = DISTANCE_WINDOW_INFINITY; + double window_start = 0.0; + double window_end = DISTANCE_WINDOW_INFINITY; + double excess_forward = 0.0; + double excess_backward = 0.0; + // Lower-bound propagation: maximum raw-distance shortfall in the prefix and the earliest + // cumulative distance required by the suffix. + double distance_window_backward_min = 0.0; + double distance_break_cost_forward = 0.0; /*! \brief { Calculate next node forward gathered distance data based on actual node} */ void HDI calculate_forward(distance_node_t& next, double distance_between) const noexcept { next.distance_forward = distance_forward + distance_between; + + next.distance_window_forward = distance_window_forward + distance_between; + next.excess_forward = excess_forward; + if (next.distance_window_forward > next.window_end) { + next.excess_forward += next.distance_window_forward - next.window_end; + next.distance_window_forward = next.window_end; + } + + next.distance_break_cost_forward = + max(distance_break_cost_forward, next.window_start - next.distance_forward); } /*! \brief { Calculate prev node gathered distance backward data based on actual node} */ void HDI calculate_backward(distance_node_t& prev, double distance_between) const noexcept { prev.distance_backward = distance_backward + distance_between; + + prev.distance_window_backward = distance_window_backward - distance_between; + prev.excess_backward = excess_backward; + if (prev.distance_window_backward > prev.window_end) { + prev.distance_window_backward = prev.window_end; + } else if (prev.distance_window_backward < 0.) { + prev.excess_backward -= prev.distance_window_backward; + prev.distance_window_backward = 0.; + } + + prev.distance_window_backward_min = distance_window_backward_min - distance_between; + if (prev.distance_window_backward_min < prev.window_start) { + prev.distance_window_backward_min = prev.window_start; + } } HDI double forward_excess(const VehicleInfo& vehicle_info) const noexcept { - return max(0.f, distance_forward - vehicle_info.max_cost); + return excess_forward + max(0., distance_forward - vehicle_info.max_cost); } HDI double backward_excess(const VehicleInfo& vehicle_info) const noexcept { - return max(0.f, distance_backward - vehicle_info.max_cost); + return excess_backward + max(0., distance_backward - vehicle_info.max_cost); } HDI bool forward_feasible(const VehicleInfo& vehicle_info, @@ -56,8 +107,11 @@ class distance_node_t { const VehicleInfo& vehicle_info, f_t distance_between) noexcept { - double total_distance = prev.distance_forward + next.distance_backward + distance_between; - return max(0., total_distance - vehicle_info.max_cost); + double total_distance = prev.distance_forward + distance_between + next.distance_backward; + double arrival_f = prev.distance_window_forward + distance_between; + return prev.excess_forward + next.excess_backward + + max(0., arrival_f - next.distance_window_backward) + + max(0., total_distance - vehicle_info.max_cost); } HDI bool backward_feasible(const VehicleInfo& vehicle_info, @@ -74,12 +128,22 @@ class distance_node_t { objective_cost_t& obj_cost, infeasible_cost_t& inf_cost) const noexcept { - double total_distance = ((double)distance_forward + (double)distance_backward); - + double total_distance = distance_forward + distance_backward; obj_cost[objective_t::COST] = total_distance; + + if (dim_info.has_distance_window && dim_info.has_distance_break_cost) { + obj_cost[objective_t::DISTANCE_BREAK_COST] = + max(distance_break_cost_forward, distance_window_backward_min - distance_forward); + } + + inf_cost[dim_t::DIST] = 0.; if (dim_info.has_max_constraint) { inf_cost[dim_t::DIST] = max(0., total_distance - vehicle_info.max_cost); } + if (dim_info.has_distance_window) { + inf_cost[dim_t::DIST] += excess_forward + excess_backward + + max(0., distance_window_forward - distance_window_backward); + } } }; diff --git a/cpp/src/routing/problem/problem.cu b/cpp/src/routing/problem/problem.cu index 6868736fc3..813e3afba7 100644 --- a/cpp/src/routing/problem/problem.cu +++ b/cpp/src/routing/problem/problem.cu @@ -253,6 +253,14 @@ void problem_t::populate_dimensions_info() if (auto vehicle_max_costs = data_view_ptr->get_vehicle_max_costs(); !vehicle_max_costs.empty()) { cost_dim_info.has_max_constraint = true; } + if (special_nodes.has_distance_break) { + cost_dim_info.has_distance_window = true; + if (!specified_weights.count(objective_t::DISTANCE_BREAK_COST)) { + dimensions_info.enable_objective(objective_t::DISTANCE_BREAK_COST, 1.0); + } + cost_dim_info.has_distance_break_cost = + dimensions_info.has_objective(objective_t::DISTANCE_BREAK_COST); + } // TIME dimensions info // check vehicle max times exists @@ -519,6 +527,10 @@ NodeInfo<> problem_t::get_brother_node_info(const NodeInfo<>& node) co brother_id, brother_location, node.is_pickup() ? node_type_t::DELIVERY : node_type_t::PICKUP); } +/** + * @brief Builds the device-side break-node tables (locations, time and distance windows, + * per-vehicle offsets) from the user-facing vehicle break specification. + */ template void problem_t::populate_special_nodes() { @@ -536,6 +548,7 @@ void problem_t::populate_special_nodes() std::vector> node_infos_h; std::vector node_earliest_h, node_latest_h; + std::vector node_distance_min_h, node_distance_max_h; std::vector break_loc_to_idx_h; if (!uniform_breaks.empty()) { @@ -632,9 +645,13 @@ void problem_t::populate_special_nodes() node_infos_h.reserve(2 * n_vehicles); node_earliest_h.reserve(2 * n_vehicles); node_latest_h.reserve(2 * n_vehicles); + node_distance_min_h.reserve(2 * n_vehicles); + node_distance_max_h.reserve(2 * n_vehicles); break_nodes_offset_h.push_back(0); + bool any_distance_break = false; + std::vector all_locations(data_view_ptr->get_num_locations()); std::iota(all_locations.begin(), all_locations.end(), 0); for (i_t v = 0; v < n_vehicles; ++v) { @@ -643,27 +660,38 @@ void problem_t::populate_special_nodes() break_offset_h[v + 1] = break_offset_h[v] + this_vehicle_breaks.size(); n_max_break_dims = std::max((i_t)this_vehicle_breaks.size(), n_max_break_dims); // FIXME:: sort the breaks based on TW ?? + // Track the latest time-window endpoint of the most recent prior time-based break so + // distance breaks interleaved with time breaks don't bypass the overlap validation. + std::optional previous_time_break_latest; for (auto& vehicle_break : this_vehicle_breaks) { i_t dim = break_duration_h[v].size(); break_duration_h[v].push_back(vehicle_break.duration_); break_earliest_h[v].push_back(vehicle_break.earliest_); break_latest_h[v].push_back(vehicle_break.latest_); - bool expected = - (break_earliest_h[v][dim] + break_duration_h[v][dim] <= vehicle_latest_h[v]) && - (vehicle_earliest_h[v] <= break_latest_h[v][dim]); - cuopt_expects(expected, - error_type_t::ValidationError, - "break times should be within the range of vehicle time windows!"); - - expected = break_latest_h[v][dim] >= break_earliest_h[v][dim]; - cuopt_expects(expected, - error_type_t::ValidationError, - "break latest should be higher than the break earliest!"); - if (dim > 0) { - expected = break_earliest_h[v][dim] >= break_latest_h[v][dim - 1]; - cuopt_expects( - expected, error_type_t::ValidationError, "breaks should not be overlapping!"); + if (!vehicle_break.is_distance_based_) { + bool expected = + (break_earliest_h[v][dim] + break_duration_h[v][dim] <= vehicle_latest_h[v]) && + (vehicle_earliest_h[v] <= break_latest_h[v][dim]); + cuopt_expects(expected, + error_type_t::ValidationError, + "break times should be within the range of vehicle time windows!"); + + expected = break_latest_h[v][dim] >= break_earliest_h[v][dim]; + cuopt_expects(expected, + error_type_t::ValidationError, + "break latest should be higher than the break earliest!"); + if (previous_time_break_latest.has_value()) { + expected = break_earliest_h[v][dim] >= previous_time_break_latest.value(); + cuopt_expects( + expected, error_type_t::ValidationError, "breaks should not be overlapping!"); + } + previous_time_break_latest = break_latest_h[v][dim]; + } else { + cuopt_expects(vehicle_break.distance_max_ > vehicle_break.distance_min_, + error_type_t::ValidationError, + "distance break distance_max must be greater than distance_min!"); + any_distance_break = true; } auto this_break_locations = @@ -678,6 +706,8 @@ void problem_t::populate_special_nodes() node_infos_h.push_back(NodeInfo<>{node_id, loc, node_type_t::BREAK}); node_earliest_h.push_back(break_earliest_h[v][dim]); node_latest_h.push_back(break_latest_h[v][dim]); + node_distance_min_h.push_back(vehicle_break.distance_min_); + node_distance_max_h.push_back(vehicle_break.distance_max_); } break_nodes_offset_h.push_back(offset); @@ -686,6 +716,7 @@ void problem_t::populate_special_nodes() break_offset_h[v + 1] = break_offset_h[v]; } } + special_nodes.has_distance_break = any_distance_break; } fleet_info.v_break_offset_ = cuopt::device_copy(break_offset_h, handle_ptr->get_stream()); @@ -716,9 +747,13 @@ void problem_t::populate_special_nodes() special_nodes.num_breaks_offset = cuopt::device_copy(break_offset_h, handle_ptr->get_stream()); special_nodes.break_nodes_offset = cuopt::device_copy(break_nodes_offset_h, handle_ptr->get_stream()); - special_nodes.node_infos = cuopt::device_copy(node_infos_h, handle_ptr->get_stream()); - special_nodes.earliest_time = cuopt::device_copy(node_earliest_h, handle_ptr->get_stream()); - special_nodes.latest_time = cuopt::device_copy(node_latest_h, handle_ptr->get_stream()); + special_nodes.node_infos = cuopt::device_copy(node_infos_h, handle_ptr->get_stream()); + special_nodes.earliest_time = cuopt::device_copy(node_earliest_h, handle_ptr->get_stream()); + special_nodes.latest_time = cuopt::device_copy(node_latest_h, handle_ptr->get_stream()); + if (special_nodes.has_distance_break) { + special_nodes.distance_min = cuopt::device_copy(node_distance_min_h, handle_ptr->get_stream()); + special_nodes.distance_max = cuopt::device_copy(node_distance_max_h, handle_ptr->get_stream()); + } special_nodes.break_loc_to_idx = cuopt::device_copy(break_loc_to_idx_h, handle_ptr->get_stream()); RAFT_CHECK_CUDA(handle_ptr->get_stream()); } diff --git a/cpp/src/routing/problem/special_nodes.cuh b/cpp/src/routing/problem/special_nodes.cuh index 869f20139f..0398f6e222 100644 --- a/cpp/src/routing/problem/special_nodes.cuh +++ b/cpp/src/routing/problem/special_nodes.cuh @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2023-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ @@ -28,6 +28,8 @@ class special_nodes_t { node_infos(0, handle_ptr->get_stream()), earliest_time(0, handle_ptr->get_stream()), latest_time(0, handle_ptr->get_stream()), + distance_min(0, handle_ptr->get_stream()), + distance_max(0, handle_ptr->get_stream()), break_loc_to_idx(0, handle_ptr->get_stream()) { } @@ -46,8 +48,6 @@ class special_nodes_t { { view_t v; v.num_vehicles = num_vehicles; - // v.num_break_dimensions = num_break_dimensions; - // v.nodes_per_dimension_per_vehicle = nodes_per_dimension_per_vehicle; i_t break_offset = num_breaks_offset[vehicle_id] + break_dim; i_t offset = break_nodes_offset[break_offset]; @@ -55,6 +55,10 @@ class special_nodes_t { v.node_infos = raft::device_span>(node_infos.data() + offset, sz); v.earliest_time = raft::device_span(earliest_time.data() + offset, sz); v.latest_time = raft::device_span(latest_time.data() + offset, sz); + if (!distance_min.empty()) { + v.distance_min = raft::device_span(distance_min.data() + offset, sz); + v.distance_max = raft::device_span(distance_max.data() + offset, sz); + } return v; } @@ -67,13 +71,14 @@ class special_nodes_t { i_t num_vehicles{0}; i_t num_max_break_dimensions{0}; - // i_t num_break_dimensions{0}; - // i_t nodes_per_dimension_per_vehicle{0}; raft::device_span num_breaks_offset; raft::device_span break_nodes_offset; raft::device_span> node_infos; raft::device_span earliest_time; raft::device_span latest_time; + // populated only when distance-based breaks are present + raft::device_span distance_min; + raft::device_span distance_max; raft::device_span break_loc_to_idx; }; @@ -82,15 +87,17 @@ class special_nodes_t { view_t v; v.num_vehicles = num_vehicles; v.num_max_break_dimensions = num_max_break_dimensions; - // v.num_break_dimensions = num_break_dimensions; - // v.nodes_per_dimension_per_vehicle = nodes_per_dimension_per_vehicle; v.num_breaks_offset = cuopt::make_span(num_breaks_offset); v.break_nodes_offset = cuopt::make_span(break_nodes_offset); v.node_infos = cuopt::make_span(node_infos); v.earliest_time = cuopt::make_span(earliest_time); v.latest_time = cuopt::make_span(latest_time); - v.break_loc_to_idx = cuopt::make_span(break_loc_to_idx); + if (!distance_min.is_empty()) { + v.distance_min = cuopt::make_span(distance_min); + v.distance_max = cuopt::make_span(distance_max); + } + v.break_loc_to_idx = cuopt::make_span(break_loc_to_idx); return v; } @@ -104,6 +111,7 @@ class special_nodes_t { i_t num_vehicles{0}; i_t num_max_break_dimensions{0}; + bool has_distance_break{false}; // FIXME:: Use mdarray rmm::device_uvector num_breaks_offset; @@ -111,6 +119,8 @@ class special_nodes_t { rmm::device_uvector> node_infos; rmm::device_uvector earliest_time; rmm::device_uvector latest_time; + rmm::device_uvector distance_min; + rmm::device_uvector distance_max; rmm::device_uvector break_loc_to_idx; }; } // namespace detail diff --git a/cpp/src/routing/route/distance_route.cuh b/cpp/src/routing/route/distance_route.cuh index a5f98c13ce..5f651334d3 100644 --- a/cpp/src/routing/route/distance_route.cuh +++ b/cpp/src/routing/route/distance_route.cuh @@ -30,7 +30,15 @@ class distance_route_t { : dim_info(dim_info_), distance_forward(0, sol_handle_->get_stream()), distance_backward(0, sol_handle_->get_stream()), - reverse_distance(0, sol_handle_->get_stream()) + reverse_distance(0, sol_handle_->get_stream()), + distance_window_forward(0, sol_handle_->get_stream()), + distance_window_backward(0, sol_handle_->get_stream()), + distance_window_backward_min(0, sol_handle_->get_stream()), + window_start(0, sol_handle_->get_stream()), + window_end(0, sol_handle_->get_stream()), + excess_forward(0, sol_handle_->get_stream()), + excess_backward(0, sol_handle_->get_stream()), + distance_break_cost_forward(0, sol_handle_->get_stream()) { raft::common::nvtx::range fun_scope("zero distance_route_t copy_ctr"); } @@ -40,7 +48,17 @@ class distance_route_t { : dim_info(distance_route.dim_info), distance_forward(distance_route.distance_forward, sol_handle_->get_stream()), distance_backward(distance_route.distance_backward, sol_handle_->get_stream()), - reverse_distance(distance_route.reverse_distance, sol_handle_->get_stream()) + reverse_distance(distance_route.reverse_distance, sol_handle_->get_stream()), + distance_window_forward(distance_route.distance_window_forward, sol_handle_->get_stream()), + distance_window_backward(distance_route.distance_window_backward, sol_handle_->get_stream()), + distance_window_backward_min(distance_route.distance_window_backward_min, + sol_handle_->get_stream()), + window_start(distance_route.window_start, sol_handle_->get_stream()), + window_end(distance_route.window_end, sol_handle_->get_stream()), + excess_forward(distance_route.excess_forward, sol_handle_->get_stream()), + excess_backward(distance_route.excess_backward, sol_handle_->get_stream()), + distance_break_cost_forward(distance_route.distance_break_cost_forward, + sol_handle_->get_stream()) { raft::common::nvtx::range fun_scope("distance route copy_ctr"); } @@ -52,15 +70,40 @@ class distance_route_t { distance_forward.resize(max_nodes_per_route, stream); distance_backward.resize(max_nodes_per_route, stream); reverse_distance.resize(max_nodes_per_route, stream); + if (dim_info.has_distance_window) { + distance_window_forward.resize(max_nodes_per_route, stream); + distance_window_backward.resize(max_nodes_per_route, stream); + window_start.resize(max_nodes_per_route, stream); + window_end.resize(max_nodes_per_route, stream); + excess_forward.resize(max_nodes_per_route, stream); + excess_backward.resize(max_nodes_per_route, stream); + if (dim_info.has_distance_break_cost) { + distance_window_backward_min.resize(max_nodes_per_route, stream); + distance_break_cost_forward.resize(max_nodes_per_route, stream); + } + } } struct view_t { bool is_empty() const { return distance_forward.empty(); } + DI distance_node_t get_node(i_t idx) const { distance_node_t distance_node; distance_node.distance_forward = distance_forward[idx]; distance_node.distance_backward = distance_backward[idx]; + if (dim_info.has_distance_window) { + distance_node.distance_window_forward = distance_window_forward[idx]; + distance_node.distance_window_backward = distance_window_backward[idx]; + distance_node.window_start = window_start[idx]; + distance_node.window_end = window_end[idx]; + distance_node.excess_forward = excess_forward[idx]; + distance_node.excess_backward = excess_backward[idx]; + if (dim_info.has_distance_break_cost) { + distance_node.distance_window_backward_min = distance_window_backward_min[idx]; + distance_node.distance_break_cost_forward = distance_break_cost_forward[idx]; + } + } return distance_node; } @@ -68,16 +111,34 @@ class distance_route_t { { set_forward_data(idx, node); set_backward_data(idx, node); + if (dim_info.has_distance_window) { + window_start[idx] = node.window_start; + window_end[idx] = node.window_end; + } } DI void set_forward_data(i_t idx, const distance_node_t& node) { distance_forward[idx] = node.distance_forward; + if (dim_info.has_distance_window) { + distance_window_forward[idx] = node.distance_window_forward; + excess_forward[idx] = node.excess_forward; + if (dim_info.has_distance_break_cost) { + distance_break_cost_forward[idx] = node.distance_break_cost_forward; + } + } } DI void set_backward_data(i_t idx, const distance_node_t& node) { distance_backward[idx] = node.distance_backward; + if (dim_info.has_distance_window) { + distance_window_backward[idx] = node.distance_window_backward; + excess_backward[idx] = node.excess_backward; + if (dim_info.has_distance_break_cost) { + distance_window_backward_min[idx] = node.distance_window_backward_min; + } + } } DI void copy_forward_data(const view_t& orig_route, i_t start_idx, i_t end_idx, i_t write_start) @@ -86,6 +147,18 @@ class distance_route_t { block_copy(distance_forward.subspan(write_start), orig_route.distance_forward.subspan(start_idx), size); + if (dim_info.has_distance_window) { + block_copy(distance_window_forward.subspan(write_start), + orig_route.distance_window_forward.subspan(start_idx), + size); + block_copy( + excess_forward.subspan(write_start), orig_route.excess_forward.subspan(start_idx), size); + if (dim_info.has_distance_break_cost) { + block_copy(distance_break_cost_forward.subspan(write_start), + orig_route.distance_break_cost_forward.subspan(start_idx), + size); + } + } } DI void copy_backward_data(const view_t& orig_route, @@ -97,6 +170,19 @@ class distance_route_t { block_copy(distance_backward.subspan(write_start), orig_route.distance_backward.subspan(start_idx), size); + if (dim_info.has_distance_window) { + block_copy(distance_window_backward.subspan(write_start), + orig_route.distance_window_backward.subspan(start_idx), + size); + block_copy(excess_backward.subspan(write_start), + orig_route.excess_backward.subspan(start_idx), + size); + if (dim_info.has_distance_break_cost) { + block_copy(distance_window_backward_min.subspan(write_start), + orig_route.distance_window_backward_min.subspan(start_idx), + size); + } + } } DI void copy_fixed_route_data(const view_t& orig_route, @@ -104,7 +190,12 @@ class distance_route_t { i_t to_idx, i_t write_start) { - // there is no fixed route data associated with distance + if (dim_info.has_distance_window) { + auto size = to_idx - from_idx; + block_copy( + window_start.subspan(write_start), orig_route.window_start.subspan(from_idx), size); + block_copy(window_end.subspan(write_start), orig_route.window_end.subspan(from_idx), size); + } } DI void compute_cost(const VehicleInfo& vehicle_info, @@ -112,14 +203,16 @@ class distance_route_t { objective_cost_t& obj_cost, infeasible_cost_t& inf_cost) const noexcept { - double objective_cost = distance_forward[n_nodes_route]; - double infeasibility_cost = 0.; - if (dim_info.has_max_constraint) { - infeasibility_cost = max(0., distance_forward[n_nodes_route] - vehicle_info.max_cost); + obj_cost[objective_t::COST] = distance_forward[n_nodes_route]; + if (dim_info.has_distance_window && dim_info.has_distance_break_cost) { + obj_cost[objective_t::DISTANCE_BREAK_COST] = distance_break_cost_forward[n_nodes_route]; } - obj_cost[objective_t::COST] = objective_cost; - inf_cost[dim_t::DIST] = infeasibility_cost; + inf_cost[dim_t::DIST] = 0.; + if (dim_info.has_max_constraint) { + inf_cost[dim_t::DIST] = max(0., distance_forward[n_nodes_route] - vehicle_info.max_cost); + } + if (dim_info.has_distance_window) { inf_cost[dim_t::DIST] += excess_forward[n_nodes_route]; } } static DI thrust::tuple create_shared_route(i_t* shmem, @@ -127,12 +220,25 @@ class distance_route_t { i_t n_nodes_route) { view_t v; - v.dim_info = dim_info; - v.distance_forward = raft::device_span{(double*)shmem, (size_t)n_nodes_route + 1}; - v.distance_backward = raft::device_span{ - (double*)&v.distance_forward.data()[n_nodes_route + 1], (size_t)n_nodes_route + 1}; + size_t sz = n_nodes_route + 1; + i_t* sh_ptr = shmem; + v.dim_info = dim_info; + thrust::tie(v.distance_forward, sh_ptr) = wrap_ptr_as_span(sh_ptr, sz); + thrust::tie(v.distance_backward, sh_ptr) = wrap_ptr_as_span(sh_ptr, sz); + if (dim_info.has_distance_window) { + thrust::tie(v.distance_window_forward, sh_ptr) = wrap_ptr_as_span(sh_ptr, sz); + thrust::tie(v.distance_window_backward, sh_ptr) = wrap_ptr_as_span(sh_ptr, sz); + thrust::tie(v.window_start, sh_ptr) = wrap_ptr_as_span(sh_ptr, sz); + thrust::tie(v.window_end, sh_ptr) = wrap_ptr_as_span(sh_ptr, sz); + thrust::tie(v.excess_forward, sh_ptr) = wrap_ptr_as_span(sh_ptr, sz); + thrust::tie(v.excess_backward, sh_ptr) = wrap_ptr_as_span(sh_ptr, sz); + if (dim_info.has_distance_break_cost) { + thrust::tie(v.distance_window_backward_min, sh_ptr) = + wrap_ptr_as_span(sh_ptr, sz); + thrust::tie(v.distance_break_cost_forward, sh_ptr) = wrap_ptr_as_span(sh_ptr, sz); + } + } - i_t* sh_ptr = (i_t*)&v.distance_backward.data()[n_nodes_route + 1]; return thrust::make_tuple(v, sh_ptr); } @@ -140,6 +246,14 @@ class distance_route_t { raft::device_span distance_forward; raft::device_span distance_backward; raft::device_span reverse_distance; + raft::device_span distance_window_forward; + raft::device_span distance_window_backward; + raft::device_span distance_window_backward_min; + raft::device_span window_start; + raft::device_span window_end; + raft::device_span excess_forward; + raft::device_span excess_backward; + raft::device_span distance_break_cost_forward; }; view_t view() @@ -152,6 +266,22 @@ class distance_route_t { raft::device_span{distance_backward.data(), distance_backward.size()}; v.reverse_distance = raft::device_span{reverse_distance.data(), reverse_distance.size()}; + if (dim_info.has_distance_window) { + v.distance_window_forward = + raft::device_span{distance_window_forward.data(), distance_window_forward.size()}; + v.distance_window_backward = + raft::device_span{distance_window_backward.data(), distance_window_backward.size()}; + v.window_start = raft::device_span{window_start.data(), window_start.size()}; + v.window_end = raft::device_span{window_end.data(), window_end.size()}; + v.excess_forward = raft::device_span{excess_forward.data(), excess_forward.size()}; + v.excess_backward = raft::device_span{excess_backward.data(), excess_backward.size()}; + if (dim_info.has_distance_break_cost) { + v.distance_window_backward_min = raft::device_span{ + distance_window_backward_min.data(), distance_window_backward_min.size()}; + v.distance_break_cost_forward = raft::device_span{ + distance_break_cost_forward.data(), distance_break_cost_forward.size()}; + } + } return v; } @@ -165,19 +295,27 @@ class distance_route_t { [[maybe_unused]] cost_dimension_info_t dim_info, [[maybe_unused]] bool is_tsp = false) { - // forward, backward - return 2 * route_size * sizeof(double); + return (2 + 6 * dim_info.has_distance_window + + 2 * (dim_info.has_distance_window && dim_info.has_distance_break_cost)) * + route_size * sizeof(double); } cost_dimension_info_t dim_info; - // forward data rmm::device_uvector distance_forward; - // backward data rmm::device_uvector distance_backward; - // The info is not updated with the other dimension buffers. - // It is only used for cvrp/tsp and populated in global memory. + // Only used for cvrp/tsp and populated in global memory. rmm::device_uvector reverse_distance; + // Allocated only when has_distance_window. + rmm::device_uvector distance_window_forward; + rmm::device_uvector distance_window_backward; + // Allocated only when has_distance_break_cost. + rmm::device_uvector distance_window_backward_min; + rmm::device_uvector window_start; + rmm::device_uvector window_end; + rmm::device_uvector excess_forward; + rmm::device_uvector excess_backward; + rmm::device_uvector distance_break_cost_forward; }; } // namespace detail diff --git a/cpp/src/routing/solution/solution.cuh b/cpp/src/routing/solution/solution.cuh index db00d09147..74e8f2d71b 100644 --- a/cpp/src/routing/solution/solution.cuh +++ b/cpp/src/routing/solution/solution.cuh @@ -300,6 +300,11 @@ DI node_t create_break_node( node.time_dim.window_start = special_nodes.earliest_time[index]; node.time_dim.window_end = special_nodes.latest_time[index]; + if (!special_nodes.distance_min.empty()) { + node.distance_dim.window_start = static_cast(special_nodes.distance_min[index]); + node.distance_dim.window_end = static_cast(special_nodes.distance_max[index]); + } + // FIXME:: setting the prize to zero for now. // When we support breaks through prize collection mechanism, this will change node.prize_dim.prize = 0.; diff --git a/cpp/src/routing/util_kernels/set_nodes_data.cuh b/cpp/src/routing/util_kernels/set_nodes_data.cuh index 91458efe1d..4d2483c951 100644 --- a/cpp/src/routing/util_kernels/set_nodes_data.cuh +++ b/cpp/src/routing/util_kernels/set_nodes_data.cuh @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2024-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ @@ -54,8 +54,19 @@ __device__ void set_route_data(typename problem_t::view_t const& probl route.template get_dim().excess_backward[0]) < 0.0001, "Backward forward mismatch!"); } - route.template get_dim().distance_backward[n_nodes_route] = 0.f; - route.template get_dim().distance_forward[0] = 0.f; + auto& dist_route = route.template get_dim(); + dist_route.distance_forward[0] = 0.f; + dist_route.distance_backward[n_nodes_route] = 0.f; + if (dist_route.dim_info.has_distance_window) { + dist_route.distance_window_forward[0] = 0.; + dist_route.distance_window_backward[n_nodes_route] = DISTANCE_WINDOW_INFINITY; + dist_route.excess_forward[0] = 0.; + dist_route.excess_backward[n_nodes_route] = 0.; + if (dist_route.dim_info.has_distance_break_cost) { + dist_route.distance_window_backward_min[n_nodes_route] = 0.; + dist_route.distance_break_cost_forward[0] = 0.; + } + } if (problem.dimensions_info.has_dimension(dim_t::CAP)) { route.template get_dim().max_to_node[0] = 0; route.template get_dim().gathered[0] = 0; diff --git a/cpp/tests/routing/CMakeLists.txt b/cpp/tests/routing/CMakeLists.txt index a7d1072b0d..2b642b8845 100644 --- a/cpp/tests/routing/CMakeLists.txt +++ b/cpp/tests/routing/CMakeLists.txt @@ -42,6 +42,7 @@ ConfigureTest(ROUTING_UNIT_TEST ${CUOPT_TEST_DIR}/internal/main.cu ${CMAKE_CURRENT_SOURCE_DIR}/unit_tests/vehicle_types.cu ${CMAKE_CURRENT_SOURCE_DIR}/unit_tests/breaks.cu + ${CMAKE_CURRENT_SOURCE_DIR}/unit_tests/distance_breaks.cu ${CMAKE_CURRENT_SOURCE_DIR}/unit_tests/heterogenous_breaks.cu ${CMAKE_CURRENT_SOURCE_DIR}/unit_tests/vehicle_fixed_costs.cu ${CMAKE_CURRENT_SOURCE_DIR}/unit_tests/vehicle_order_match.cu diff --git a/cpp/tests/routing/grpc/CMakeLists.txt b/cpp/tests/routing/grpc/CMakeLists.txt index e7b3613675..6ca249eaa8 100644 --- a/cpp/tests/routing/grpc/CMakeLists.txt +++ b/cpp/tests/routing/grpc/CMakeLists.txt @@ -33,6 +33,42 @@ endif() add_dependencies(GRPC_VRP_TEST_DRIVER cuopt_grpc_server) +# Mapper round-trip (no running server). Covers time-window and distance +# per-vehicle breaks with the same proto grouping (vehicle_id + repeated breaks). +add_executable(GRPC_ROUTING_PROBLEM_MAPPER_TEST + ${CMAKE_CURRENT_SOURCE_DIR}/grpc_routing_problem_mapper_test.cpp +) + +target_include_directories(GRPC_ROUTING_PROBLEM_MAPPER_TEST + PRIVATE + "${CUOPT_SOURCE_DIR}/include" + "${CUOPT_SOURCE_DIR}/src/grpc" + "${CUOPT_SOURCE_DIR}/src/grpc/codegen/generated" + "${CMAKE_BINARY_DIR}" +) + +target_link_libraries(GRPC_ROUTING_PROBLEM_MAPPER_TEST + PRIVATE + cuopt + GTest::gtest + GTest::gtest_main + protobuf::libprotobuf +) + +if(NOT DEFINED INSTALL_TARGET OR "${INSTALL_TARGET}" STREQUAL "") + target_link_options(GRPC_ROUTING_PROBLEM_MAPPER_TEST PRIVATE -Wl,--enable-new-dtags) +endif() + +add_test(NAME GRPC_ROUTING_PROBLEM_MAPPER_TEST COMMAND GRPC_ROUTING_PROBLEM_MAPPER_TEST) +set_tests_properties(GRPC_ROUTING_PROBLEM_MAPPER_TEST PROPERTIES LABELS "routing") + +install( + TARGETS GRPC_ROUTING_PROBLEM_MAPPER_TEST + COMPONENT testing + DESTINATION bin/gtests/libcuopt + EXCLUDE_FROM_ALL +) + install( TARGETS GRPC_VRP_TEST_DRIVER COMPONENT testing diff --git a/cpp/tests/routing/grpc/grpc_routing_problem_mapper_test.cpp b/cpp/tests/routing/grpc/grpc_routing_problem_mapper_test.cpp new file mode 100644 index 0000000000..651f2b983e --- /dev/null +++ b/cpp/tests/routing/grpc/grpc_routing_problem_mapper_test.cpp @@ -0,0 +1,134 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include "routing/grpc_routing_problem_mapper.hpp" + +#include +#include + +#include + +#include + +namespace { + +cuopt::routing::cpu_routing_problem_t make_base_problem() +{ + cuopt::routing::cpu_routing_problem_t p; + p.num_locations = 5; + p.fleet_size = 2; + p.num_orders = 5; + return p; +} + +} // namespace + +TEST(RoutingProblemMapper, VehicleBreaksRoundTrip) +{ + auto p = make_base_problem(); + + cuopt::routing::cpu_vehicle_break_t b0; + b0.earliest = 10; + b0.latest = 20; + b0.duration = 5; + b0.locations = {3, 6}; + p.vehicle_breaks[0].push_back(b0); + + cuopt::routing::cpu_vehicle_break_t b1a; + b1a.earliest = 30; + b1a.latest = 40; + b1a.duration = 5; + p.vehicle_breaks[1].push_back(b1a); + + cuopt::routing::cpu_vehicle_break_t b1b; + b1b.earliest = 60; + b1b.latest = 70; + b1b.duration = 5; + b1b.locations = {1, 4}; + p.vehicle_breaks[1].push_back(b1b); + + cuopt::remote::RoutingProblem pb; + cuopt::routing::map_routing_problem_to_proto(p, &pb); + ASSERT_EQ(pb.vehicle_breaks_size(), 2); + + cuopt::routing::cpu_routing_problem_t back; + cuopt::routing::map_proto_to_routing_problem(pb, back); + ASSERT_EQ(back.vehicle_breaks.size(), 2u); + ASSERT_EQ(back.vehicle_distance_breaks.size(), 0u); + + ASSERT_EQ(back.vehicle_breaks[0].size(), 1u); + EXPECT_EQ(back.vehicle_breaks[0][0].earliest, 10); + EXPECT_EQ(back.vehicle_breaks[0][0].latest, 20); + EXPECT_EQ(back.vehicle_breaks[0][0].duration, 5); + EXPECT_EQ(back.vehicle_breaks[0][0].locations, (std::vector{3, 6})); + + ASSERT_EQ(back.vehicle_breaks[1].size(), 2u); + EXPECT_EQ(back.vehicle_breaks[1][0].earliest, 30); + EXPECT_EQ(back.vehicle_breaks[1][0].latest, 40); + EXPECT_TRUE(back.vehicle_breaks[1][0].locations.empty()); + EXPECT_EQ(back.vehicle_breaks[1][1].earliest, 60); + EXPECT_EQ(back.vehicle_breaks[1][1].latest, 70); + EXPECT_EQ(back.vehicle_breaks[1][1].locations, (std::vector{1, 4})); +} + +TEST(RoutingProblemMapper, VehicleDistanceBreaksRoundTrip) +{ + auto p = make_base_problem(); + + cuopt::routing::cpu_vehicle_distance_break_t b0; + b0.distance_min = 120.f; + b0.distance_max = 150.f; + b0.duration = 10; + b0.locations = {3, 6}; + p.vehicle_distance_breaks[0].push_back(b0); + + cuopt::routing::cpu_vehicle_distance_break_t b1a; + b1a.distance_min = 0.f; + b1a.distance_max = 200.f; + b1a.duration = 10; + p.vehicle_distance_breaks[1].push_back(b1a); + + cuopt::routing::cpu_vehicle_distance_break_t b1b; + b1b.distance_min = 270.f; + b1b.distance_max = 300.f; + b1b.duration = 10; + b1b.locations = {1, 4}; + p.vehicle_distance_breaks[1].push_back(b1b); + + cuopt::remote::RoutingProblem pb; + cuopt::routing::map_routing_problem_to_proto(p, &pb); + ASSERT_EQ(pb.vehicle_distance_breaks_size(), 2); + ASSERT_EQ(pb.vehicle_breaks_size(), 0); + + auto const& proto_v0 = pb.vehicle_distance_breaks(0).vehicle_id() == 0 + ? pb.vehicle_distance_breaks(0) + : pb.vehicle_distance_breaks(1); + ASSERT_EQ(proto_v0.breaks_size(), 1); + EXPECT_FLOAT_EQ(proto_v0.breaks(0).distance_min(), 120.f); + EXPECT_FLOAT_EQ(proto_v0.breaks(0).distance_max(), 150.f); + EXPECT_EQ(proto_v0.breaks(0).duration(), 10); + ASSERT_EQ(proto_v0.breaks(0).locations_size(), 2); + + cuopt::routing::cpu_routing_problem_t back; + cuopt::routing::map_proto_to_routing_problem(pb, back); + ASSERT_EQ(back.vehicle_distance_breaks.size(), 2u); + ASSERT_EQ(back.vehicle_breaks.size(), 0u); + + ASSERT_EQ(back.vehicle_distance_breaks[0].size(), 1u); + EXPECT_FLOAT_EQ(back.vehicle_distance_breaks[0][0].distance_min, 120.f); + EXPECT_FLOAT_EQ(back.vehicle_distance_breaks[0][0].distance_max, 150.f); + EXPECT_EQ(back.vehicle_distance_breaks[0][0].duration, 10); + EXPECT_EQ(back.vehicle_distance_breaks[0][0].locations, (std::vector{3, 6})); + + ASSERT_EQ(back.vehicle_distance_breaks[1].size(), 2u); + EXPECT_FLOAT_EQ(back.vehicle_distance_breaks[1][0].distance_min, 0.f); + EXPECT_FLOAT_EQ(back.vehicle_distance_breaks[1][0].distance_max, 200.f); + EXPECT_TRUE(back.vehicle_distance_breaks[1][0].locations.empty()); + EXPECT_FLOAT_EQ(back.vehicle_distance_breaks[1][1].distance_min, 270.f); + EXPECT_FLOAT_EQ(back.vehicle_distance_breaks[1][1].distance_max, 300.f); + EXPECT_EQ(back.vehicle_distance_breaks[1][1].locations, (std::vector{1, 4})); +} diff --git a/cpp/tests/routing/grpc/grpc_vrp_test_driver.cpp b/cpp/tests/routing/grpc/grpc_vrp_test_driver.cpp index 6d9df24a15..4c651d3549 100644 --- a/cpp/tests/routing/grpc/grpc_vrp_test_driver.cpp +++ b/cpp/tests/routing/grpc/grpc_vrp_test_driver.cpp @@ -143,6 +143,36 @@ cuopt::routing::cpu_routing_problem_t load_cuopt_json(std::string const& path) p.order_service_times[-1] = std::move(times); } + if (fleet.contains("vehicle_breaks") && !fleet["vehicle_breaks"].is_null()) { + for (auto const& entry : fleet.at("vehicle_breaks")) { + cuopt::routing::cpu_vehicle_break_t brk; + brk.earliest = entry.at("earliest").get(); + brk.latest = entry.at("latest").get(); + brk.duration = entry.at("duration").get(); + if (entry.contains("locations") && !entry["locations"].is_null()) { + for (auto const& loc : entry.at("locations")) { + brk.locations.push_back(loc.get()); + } + } + p.vehicle_breaks[entry.at("vehicle_id").get()].push_back(std::move(brk)); + } + } + + if (fleet.contains("vehicle_distance_breaks") && !fleet["vehicle_distance_breaks"].is_null()) { + for (auto const& entry : fleet.at("vehicle_distance_breaks")) { + cuopt::routing::cpu_vehicle_distance_break_t brk; + brk.distance_min = entry.at("distance_min").get(); + brk.distance_max = entry.at("distance_max").get(); + brk.duration = entry.at("duration").get(); + if (entry.contains("locations") && !entry["locations"].is_null()) { + for (auto const& loc : entry.at("locations")) { + brk.locations.push_back(loc.get()); + } + } + p.vehicle_distance_breaks[entry.at("vehicle_id").get()].push_back(std::move(brk)); + } + } + return p; } @@ -212,7 +242,9 @@ int main(int argc, char** argv) << " vehicles, " << (problem.num_orders < 0 ? problem.num_locations : problem.num_orders) << " orders, " << problem.capacity_dimensions.size() << " capacity dims, " - << problem.cost_matrices.size() << " cost matrices\n"; + << problem.cost_matrices.size() << " cost matrices, " << problem.vehicle_breaks.size() + << " vehicles with time breaks, " << problem.vehicle_distance_breaks.size() + << " vehicles with distance breaks\n"; cuopt::remote::SubmitJobRequest submit_req; auto* vrp = submit_req.mutable_vrp_request(); diff --git a/cpp/tests/routing/level1/l1_routing_test.cu b/cpp/tests/routing/level1/l1_routing_test.cu index 2d918e7b8d..186c491a4d 100644 --- a/cpp/tests/routing/level1/l1_routing_test.cu +++ b/cpp/tests/routing/level1/l1_routing_test.cu @@ -24,6 +24,11 @@ TEST_P(regression_routing_test_50_t, CVRPTW_50) { test_cvrptw(); } TEST_P(regression_routing_test_100_t, CVRPTW_100) { test_cvrptw(); } TEST_P(float_regression_test_t, CVRPTW) { test_cvrptw(); } TEST_P(regression_routing_test_pickup_t, PICKUP) { test_cvrptw(); } +// Solomon-25 CVRPTW regression with one distance break per vehicle. +TEST_P(regression_routing_test_distance_breaks_t, CVRPTW_DISTANCE_BREAKS) +{ + test_cvrptw_distance_breaks(/*min_range=*/5.f, /*max_range=*/120.f, /*duration=*/0); +} INSTANTIATE_TEST_SUITE_P( l1_tsp, @@ -53,6 +58,10 @@ INSTANTIATE_TEST_SUITE_P( l1_pickup, regression_routing_test_pickup_t, ::testing::ValuesIn(parse_tests(cuopt::test::read_tests("datasets/ref/l1_pickup.txt")))); +INSTANTIATE_TEST_SUITE_P( + l1_distance_breaks, + regression_routing_test_distance_breaks_t, + ::testing::ValuesIn(parse_tests(cuopt::test::read_tests("datasets/ref/l1_25.txt")))); } // namespace test } // namespace routing diff --git a/cpp/tests/routing/routing_test.cuh b/cpp/tests/routing/routing_test.cuh index cdafbbf1f7..a855803b00 100644 --- a/cpp/tests/routing/routing_test.cuh +++ b/cpp/tests/routing/routing_test.cuh @@ -588,6 +588,47 @@ class base_test_t { } } + /** + * @brief Verifies that every Break node in the assignment is taken no later than its + * configured cumulative-distance upper bound @p max_range, reset at the depot of each route. + * + * The lower bound (min_range) is intentionally not checked here. Early arrival is a soft + * objective controlled by objective_t::DISTANCE_BREAK_COST, so a solution may legitimately + * place a break before min_range when that objective is explicitly disabled or outweighed by + * other costs. + */ + void check_distance_break_windows(host_assignment_t const& h_routing_solution, f_t max_range) + { + auto const& truck_id = h_routing_solution.truck_id; + auto const& locations = h_routing_solution.locations; + auto const& node_types = h_routing_solution.node_types; + + auto cost_matrix_h = matrices_h.get_cost_matrix(0); + i_t prev_loc = -1; + i_t curr_truck = -1; + f_t cumulative = 0.f; + size_t break_count = 0; + + for (size_t i = 0; i < truck_id.size(); ++i) { + if (truck_id[i] != curr_truck) { + curr_truck = truck_id[i]; + cumulative = 0.f; + prev_loc = locations[i]; + continue; + } + i_t loc = locations[i]; + cumulative += cost_matrix_h[prev_loc * n_locations + loc]; + prev_loc = loc; + if (static_cast(node_types[i]) == node_type_t::BREAK) { + ++break_count; + ASSERT_LE(cumulative, max_range + 1e-3f) + << "break at cumulative distance " << cumulative << " exceeds max_range " << max_range; + } + } + ASSERT_GT(break_count, 0u) + << "expected at least one BREAK node in the solution, none were emitted"; + } + void check_vehicle_breaks(host_assignment_t const& h_routing_solution) { auto truck_id = h_routing_solution.truck_id; @@ -992,6 +1033,40 @@ class routing_test_t : public base_test_t { } } + /** + * @brief Regression test for the distance-break feature on a CVRPTW benchmark. + * + * Builds the standard CVRPTW data model and additionally attaches one distance + * break per vehicle with a soft target of min_range and a hard deadline of max_range. + * Validates routes, capacities, and that every Break node meets its hard deadline. + */ + void test_cvrptw_distance_breaks(f_t min_range, f_t max_range, i_t duration = 0) + { + auto start_vehicle = this->n_vehicles; + cuopt::routing::data_model_view_t data_model( + &this->handle_, this->n_locations, start_vehicle, this->n_orders); + + data_model.add_cost_matrix(this->cost_matrix_d.data()); + data_model.add_capacity_dimension("weight", this->demand_d.data(), this->capacity_d.data()); + data_model.set_order_time_windows(this->earliest_time_d.data(), this->latest_time_d.data()); + data_model.set_order_service_times(this->service_time_d.data()); + + for (i_t vid = 0; vid < start_vehicle; ++vid) { + data_model.add_vehicle_distance_break(vid, min_range, max_range, duration, nullptr, 0); + } + + cuopt::routing::solver_settings_t settings; + settings.set_time_limit(this->n_orders / 5); + + auto routing_solution = this->solve(data_model, settings); + ASSERT_EQ(routing_solution.get_status(), cuopt::routing::solution_status_t::SUCCESS); + + host_assignment_t h_routing_solution(routing_solution); + check_route(data_model, h_routing_solution); + this->check_capacity(h_routing_solution, this->demand_h, this->capacity_h, this->demand_d); + this->check_distance_break_windows(h_routing_solution, max_range); + } + protected: std::string input_file_; }; @@ -1070,6 +1145,13 @@ class regression_routing_test_pickup_t : public float_regression_test_t { regression_routing_test_pickup_t() : float_regression_test_t(110E-2, 23) {} }; +/// Regression fixture for the distance-break feature: CVRPTW + a single distance break +/// per vehicle. Inherits the 25-customer Solomon limit; ref cost/vn unused. +class regression_routing_test_distance_breaks_t : public float_regression_test_t { + public: + regression_routing_test_distance_breaks_t() : float_regression_test_t(1E-2, 2, 26) {} +}; + class regression_routing_test_dummy : public float_regression_test_t { public: regression_routing_test_dummy() : float_regression_test_t(1E-1, 2, 201) {} diff --git a/cpp/tests/routing/unit_tests/distance_breaks.cu b/cpp/tests/routing/unit_tests/distance_breaks.cu new file mode 100644 index 0000000000..376edc0961 --- /dev/null +++ b/cpp/tests/routing/unit_tests/distance_breaks.cu @@ -0,0 +1,657 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace cuopt { +namespace routing { +namespace test { + +namespace { + +using distance_node = detail::distance_node_t; +using distance_route = detail::distance_route_t; +constexpr auto DISTANCE_INF = detail::DISTANCE_WINDOW_INFINITY; + +template +auto copy_array_to_device(std::array const& values, rmm::cuda_stream_view stream) +{ + rmm::device_uvector result(values.size(), stream); + raft::copy(result.data(), values.data(), values.size(), stream); + return result; +} + +__global__ void compute_distance_route_cost(distance_route::view_t route, double* result) +{ + detail::objective_cost_t objective_cost; + detail::infeasible_cost_t infeasible_cost; + detail::VehicleInfo vehicle_info; + route.compute_cost(vehicle_info, 0, objective_cost, infeasible_cost); + result[0] = objective_cost[objective_t::DISTANCE_BREAK_COST]; +} + +struct test_route { + std::vector nodes; + std::vector arcs; + detail::VehicleInfo vehicle_info{}; + + void run_passes() + { + auto n_arcs = static_cast(arcs.size()); + for (int i = 0; i < n_arcs; ++i) { + nodes[i].calculate_forward(nodes[i + 1], arcs[i]); + } + for (int i = n_arcs; i > 0; --i) { + nodes[i].calculate_backward(nodes[i - 1], arcs[i - 1]); + } + } +}; + +// windows.size() must equal arcs.size() + 1; +// {0, DISTANCE_INF} means unconstrained. +test_route make_route(std::vector arcs, + std::vector> windows, + float max_cost = std::numeric_limits::max()) +{ + test_route r; + r.arcs = std::move(arcs); + auto n_nodes = r.arcs.size() + 1; + r.vehicle_info.max_cost = max_cost; + r.nodes.resize(n_nodes); + for (size_t i = 0; i < n_nodes; ++i) { + r.nodes[i].window_start = windows[i].first; + r.nodes[i].window_end = windows[i].second; + } + r.nodes.back().distance_window_backward = DISTANCE_INF; + return r; +} + +} // namespace + +// Forward sweep clamps cumulative distance at hard upper bounds and accumulates excess. +TEST(distance_node, forward_propagation) +{ + auto r = make_route({80.f, 600.f, 400.f}, + {{0., DISTANCE_INF}, {0., 60.}, {0., DISTANCE_INF}, {0., DISTANCE_INF}}); + r.run_passes(); + + EXPECT_DOUBLE_EQ(r.nodes[0].distance_forward, 0.); + EXPECT_DOUBLE_EQ(r.nodes[1].distance_forward, 80.); + EXPECT_DOUBLE_EQ(r.nodes[2].distance_forward, 680.); + EXPECT_DOUBLE_EQ(r.nodes[3].distance_forward, 1080.); + + EXPECT_DOUBLE_EQ(r.nodes[0].distance_window_forward, 0.); + EXPECT_DOUBLE_EQ(r.nodes[1].distance_window_forward, 60.); + EXPECT_DOUBLE_EQ(r.nodes[2].distance_window_forward, 660.); + EXPECT_DOUBLE_EQ(r.nodes[3].distance_window_forward, 1060.); + + EXPECT_DOUBLE_EQ(r.nodes[0].excess_forward, 0.); + EXPECT_DOUBLE_EQ(r.nodes[1].excess_forward, 20.); + EXPECT_DOUBLE_EQ(r.nodes[2].excess_forward, 20.); + EXPECT_DOUBLE_EQ(r.nodes[3].excess_forward, 20.); +} + +// Multiple early breaks contribute the route's maximum shortfall, not a sum of hinges. +TEST(distance_node, early_arrival_cost_is_maximum_per_route) +{ + auto r = make_route({10.f, 10.f, 0.f}, + {{0., DISTANCE_INF}, {50., 100.}, {80., 100.}, {0., DISTANCE_INF}}, + /*max_cost=*/1000.f); + r.run_passes(); + + EXPECT_DOUBLE_EQ(r.nodes[1].distance_break_cost_forward, 40.); + EXPECT_DOUBLE_EQ(r.nodes[2].distance_break_cost_forward, 60.); + EXPECT_DOUBLE_EQ(r.nodes[3].distance_break_cost_forward, 60.); + + detail::cost_dimension_info_t dim_info; + dim_info.has_distance_window = true; + dim_info.has_distance_break_cost = true; + + for (size_t k = 0; k + 1 < r.nodes.size(); ++k) { + auto next_copy = r.nodes[k + 1]; + r.nodes[k].calculate_forward(next_copy, r.arcs[k]); + + detail::objective_cost_t obj_cost; + detail::infeasible_cost_t inf_cost; + next_copy.get_cost(r.nodes[k], r.vehicle_info, dim_info, obj_cost, inf_cost); + + EXPECT_DOUBLE_EQ(obj_cost[objective_t::DISTANCE_BREAK_COST], 60.) + << "split (" << k << ", " << (k + 1) << ")"; + EXPECT_DOUBLE_EQ(inf_cost[detail::dim_t::DIST], 0.); + } +} + +// A soft lower-bound correction must not shift the independently propagated hard upper state. +TEST(distance_node, early_arrival_does_not_create_later_upper_excess) +{ + auto r = make_route({0.f, 40.f, 0.f}, + {{0., DISTANCE_INF}, {100., 200.}, {0., 30.}, {0., DISTANCE_INF}}, + /*max_cost=*/1000.f); + r.run_passes(); + + EXPECT_DOUBLE_EQ(r.nodes[1].distance_forward, 0.); + EXPECT_DOUBLE_EQ(r.nodes[2].distance_forward, 40.); + EXPECT_DOUBLE_EQ(r.nodes[2].distance_window_forward, 30.); + EXPECT_DOUBLE_EQ(r.nodes[2].distance_break_cost_forward, 100.); + EXPECT_DOUBLE_EQ(r.nodes[2].excess_forward, 10.); + + detail::cost_dimension_info_t dim_info; + dim_info.has_distance_window = true; + dim_info.has_distance_break_cost = true; + + for (size_t k = 0; k + 1 < r.nodes.size(); ++k) { + auto next_copy = r.nodes[k + 1]; + r.nodes[k].calculate_forward(next_copy, r.arcs[k]); + + detail::objective_cost_t obj_cost; + detail::infeasible_cost_t inf_cost; + next_copy.get_cost(r.nodes[k], r.vehicle_info, dim_info, obj_cost, inf_cost); + + EXPECT_DOUBLE_EQ(obj_cost[objective_t::DISTANCE_BREAK_COST], 100.) + << "split (" << k << ", " << (k + 1) << ")"; + EXPECT_DOUBLE_EQ(inf_cost[detail::dim_t::DIST], 10.) + << "split (" << k << ", " << (k + 1) << ")"; + EXPECT_DOUBLE_EQ(distance_node::combine(r.nodes[k], r.nodes[k + 1], r.vehicle_info, r.arcs[k]), + 10.) + << "split (" << k << ", " << (k + 1) << ")"; + } +} + +// Backward sweep propagates the latest cumulative distance allowed by upper bounds. +TEST(distance_node, backward_propagation) +{ + auto r = make_route({80.f, 600.f, 400.f}, + {{0., DISTANCE_INF}, {0., 60.}, {0., DISTANCE_INF}, {0., DISTANCE_INF}}, + /*max_cost=*/800.f); + r.run_passes(); + + EXPECT_DOUBLE_EQ(r.nodes[3].distance_backward, 0.); + EXPECT_DOUBLE_EQ(r.nodes[2].distance_backward, 400.); + EXPECT_DOUBLE_EQ(r.nodes[1].distance_backward, 1000.); + EXPECT_DOUBLE_EQ(r.nodes[0].distance_backward, 1080.); + + EXPECT_DOUBLE_EQ(r.nodes[1].distance_window_backward, 60.); + EXPECT_DOUBLE_EQ(r.nodes[1].excess_backward, 0.); + EXPECT_DOUBLE_EQ(r.nodes[0].distance_window_backward, 0.); + EXPECT_DOUBLE_EQ(r.nodes[0].excess_backward, 20.); + EXPECT_DOUBLE_EQ(r.nodes[0].backward_excess(r.vehicle_info), 300.); +} + +// combine() returns 0 at every split point of a window-feasible route. +TEST(distance_node, combine_invariant_feasible) +{ + auto r = make_route({50.f, 55.f, 100.f}, + {{0., DISTANCE_INF}, {0., 60.}, {0., DISTANCE_INF}, {0., DISTANCE_INF}}, + /*max_cost=*/1000.f); + r.run_passes(); + + for (size_t k = 0; k + 1 < r.nodes.size(); ++k) { + double c = distance_node::combine(r.nodes[k], r.nodes[k + 1], r.vehicle_info, r.arcs[k]); + EXPECT_DOUBLE_EQ(c, 0.) << "split (" << k << ", " << (k + 1) << ") got " << c; + } +} + +// combine() reports the same window-violation excess at every split point. +TEST(distance_node, combine_invariant_window_violation) +{ + auto r = make_route({80.f, 600.f, 400.f}, + {{0., DISTANCE_INF}, {0., 60.}, {0., DISTANCE_INF}, {0., DISTANCE_INF}}, + /*max_cost=*/800.f); + r.run_passes(); + + double reference = distance_node::combine(r.nodes[0], r.nodes[1], r.vehicle_info, r.arcs[0]); + EXPECT_GT(reference, 0.); + for (size_t k = 1; k + 1 < r.nodes.size(); ++k) { + double c = distance_node::combine(r.nodes[k], r.nodes[k + 1], r.vehicle_info, r.arcs[k]); + EXPECT_DOUBLE_EQ(c, reference) << "split (" << k << ", " << (k + 1) << ") = " << c + << " differs from reference " << reference; + } +} + +// combine() reports the max_cost overage at every split point of a window-free route. +TEST(distance_node, combine_invariant_max_cost_only) +{ + auto r = + make_route({400.f, 300.f, 400.f}, + {{0., DISTANCE_INF}, {0., DISTANCE_INF}, {0., DISTANCE_INF}, {0., DISTANCE_INF}}, + /*max_cost=*/1000.f); + r.run_passes(); + + double reference = distance_node::combine(r.nodes[0], r.nodes[1], r.vehicle_info, r.arcs[0]); + EXPECT_DOUBLE_EQ(reference, 100.); // total 1100, max_cost 1000. + for (size_t k = 1; k + 1 < r.nodes.size(); ++k) { + double c = distance_node::combine(r.nodes[k], r.nodes[k + 1], r.vehicle_info, r.arcs[k]); + EXPECT_DOUBLE_EQ(c, reference); + } +} + +// End-of-route boundary plus max_cost overage matches combine() at the first split. +TEST(distance_node, compute_cost_combine_consistency) +{ + auto r = make_route({80.f, 600.f, 400.f}, + {{0., DISTANCE_INF}, {0., 60.}, {0., DISTANCE_INF}, {0., DISTANCE_INF}}, + /*max_cost=*/800.f); + r.run_passes(); + + auto const& end_node = r.nodes.back(); + double boundary = + std::max(0., end_node.distance_window_forward - end_node.distance_window_backward); + double total_distance = end_node.distance_forward; + double max_cost_excess = + std::max(0., total_distance - static_cast(r.vehicle_info.max_cost)); + double total = end_node.excess_forward + boundary + max_cost_excess; + + double combine_at_first = + distance_node::combine(r.nodes[0], r.nodes[1], r.vehicle_info, r.arcs[0]); + + EXPECT_DOUBLE_EQ(total, combine_at_first); +} + +// compute_cost must not read the soft-cost span unless distance windows are enabled. +TEST(distance_route, distance_break_cost_requires_distance_window) +{ + raft::handle_t handle; + auto stream = handle.get_stream(); + + auto distance_forward = cuopt::device_copy(std::vector{0.}, stream); + rmm::device_uvector result(1, stream); + + distance_route::view_t route; + route.dim_info.has_distance_window = false; + route.dim_info.has_distance_break_cost = true; + route.distance_forward = + raft::device_span{distance_forward.data(), distance_forward.size()}; + ASSERT_TRUE(route.distance_break_cost_forward.empty()); + EXPECT_EQ(distance_route::get_shared_size(1, route.dim_info), 2 * sizeof(double)); + + compute_distance_route_cost<<<1, 1, 0, stream>>>(route, result.data()); + RAFT_CUDA_TRY(cudaGetLastError()); + + auto host_result = cuopt::host_copy(result, stream); + EXPECT_DOUBLE_EQ(host_result[0], 0.); +} + +// get_cost() agrees with combine() at every split point of a route. +TEST(distance_node, get_cost_combine_consistency) +{ + auto r = make_route({80.f, 600.f, 400.f}, + {{0., DISTANCE_INF}, {0., 60.}, {0., DISTANCE_INF}, {0., DISTANCE_INF}}, + /*max_cost=*/800.f); + r.run_passes(); + + detail::cost_dimension_info_t dim_info; + dim_info.has_max_constraint = true; + dim_info.has_distance_window = true; + dim_info.has_distance_break_cost = true; + + for (size_t k = 0; k + 1 < r.nodes.size(); ++k) { + auto next_copy = r.nodes[k + 1]; + r.nodes[k].calculate_forward(next_copy, r.arcs[k]); + + detail::objective_cost_t obj_cost; + detail::infeasible_cost_t inf_cost; + next_copy.get_cost(r.nodes[k], r.vehicle_info, dim_info, obj_cost, inf_cost); + double get_cost_total = inf_cost[detail::dim_t::DIST]; + + double combine_value = + distance_node::combine(r.nodes[k], r.nodes[k + 1], r.vehicle_info, r.arcs[k]); + + EXPECT_DOUBLE_EQ(get_cost_total, combine_value) + << "split (" << k << ", " << (k + 1) << "): get_cost = " << get_cost_total + << ", combine = " << combine_value; + EXPECT_DOUBLE_EQ(obj_cost[objective_t::DISTANCE_BREAK_COST], 0.); + } +} + +// combine() = break-window excess + max_cost overage (additive accounting). +TEST(distance_node, combine_additive_break_and_max_cost) +{ + // Arc 100 to break B with window [0, 50] (cumulative 100 → excess 50), then arcs 20 and 10. + // Route total = 130, max_cost = 120, so max_cost overage = 10. + // Expected combine value at every split = 50 (break) + 10 (max_cost) = 60. + auto r = make_route({100.f, 20.f, 10.f}, + {{0., DISTANCE_INF}, {0., 50.}, {0., DISTANCE_INF}, {0., DISTANCE_INF}}, + /*max_cost=*/120.f); + r.run_passes(); + + double reference = distance_node::combine(r.nodes[0], r.nodes[1], r.vehicle_info, r.arcs[0]); + EXPECT_DOUBLE_EQ(reference, 60.); + for (size_t k = 1; k + 1 < r.nodes.size(); ++k) { + double c = distance_node::combine(r.nodes[k], r.nodes[k + 1], r.vehicle_info, r.arcs[k]); + EXPECT_DOUBLE_EQ(c, reference) << "split (" << k << ", " << (k + 1) << ") = " << c; + } +} + +// depot=0, orders=1-2, optional break locations=3-4 (used in 5x5 tests) +// clang-format off +constexpr std::array cost_matrix_3x3 = { + 0, 1, 1, + 1, 0, 1, + 1, 1, 0, +}; +constexpr std::array cost_matrix_5x5 = { + 0, 1, 1, 1, 1, + 1, 0, 1, 1, 1, + 1, 1, 0, 1, 1, + 1, 1, 1, 0, 1, + 1, 1, 1, 1, 0, +}; +// clang-format on + +// End-to-end smoke test: distance breaks solve on a trivial 3x3 matrix without break locations. +TEST(distance_breaks, default_case) +{ + raft::handle_t handle; + auto stream = handle.get_stream(); + + auto v_cost_matrix = copy_array_to_device(cost_matrix_3x3, stream); + cuopt::routing::data_model_view_t data_model(&handle, 3, 2); + data_model.add_cost_matrix(v_cost_matrix.data()); + data_model.add_vehicle_distance_break(0, 0.f, 2.f, 1, nullptr, 0); + data_model.add_vehicle_distance_break(1, 0.f, 2.f, 1, nullptr, 0); + data_model.set_min_vehicles(2); + + auto routing_solution = cuopt::routing::solve(data_model); + handle.sync_stream(); + + ASSERT_EQ(routing_solution.get_status(), cuopt::routing::solution_status_t::SUCCESS); + host_assignment_t h_routing_solution(routing_solution); + check_route(data_model, h_routing_solution); +} + +// Distance-break cost defaults to weight 1, including when another objective is configured; +// an explicit zero disables it. +TEST(distance_breaks, default_objective_weight) +{ + enum class objective_mode { DEFAULTS, OMIT_DISTANCE_BREAK_COST, DISABLE_DISTANCE_BREAK_COST }; + for (auto mode : {objective_mode::DEFAULTS, + objective_mode::OMIT_DISTANCE_BREAK_COST, + objective_mode::DISABLE_DISTANCE_BREAK_COST}) { + raft::handle_t handle; + auto stream = handle.get_stream(); + + std::vector cost_matrix = {0.f, 1.f, 1.f, 1.f, 0.f, 1.f, 1.f, 1.f, 0.f}; + std::vector order_locations = {1}; + std::vector break_locations = {2}; + std::vector objectives = {objective_t::COST}; + std::vector weights = {mode == objective_mode::OMIT_DISTANCE_BREAK_COST ? 2.f : 1.f}; + if (mode == objective_mode::DISABLE_DISTANCE_BREAK_COST) { + objectives.push_back(objective_t::DISTANCE_BREAK_COST); + weights.push_back(0.f); + } + + auto v_cost_matrix = cuopt::device_copy(cost_matrix, stream); + auto v_order_locations = cuopt::device_copy(order_locations, stream); + auto v_break_locations = cuopt::device_copy(break_locations, stream); + auto v_objectives = cuopt::device_copy(objectives, stream); + auto v_weights = cuopt::device_copy(weights, stream); + + cuopt::routing::data_model_view_t data_model(&handle, 3, 1, 1); + data_model.add_cost_matrix(v_cost_matrix.data()); + data_model.set_order_locations(v_order_locations.data()); + data_model.add_vehicle_distance_break(0, 10.f, 100.f, 0, v_break_locations.data(), 1); + + if (mode != objective_mode::DEFAULTS) { + data_model.set_objective_function(v_objectives.data(), v_weights.data(), weights.size()); + } + + auto settings = cuopt::routing::solver_settings_t{}; + settings.set_time_limit(10); + + auto solution = cuopt::routing::solve(data_model, settings); + handle.sync_stream(); + + ASSERT_EQ(solution.get_status(), cuopt::routing::solution_status_t::SUCCESS); + auto const& objective_values = solution.get_objectives(); + EXPECT_DOUBLE_EQ(objective_values.at(objective_t::COST), 3.); + if (mode == objective_mode::DISABLE_DISTANCE_BREAK_COST) { + EXPECT_EQ(objective_values.count(objective_t::DISTANCE_BREAK_COST), 0u); + EXPECT_DOUBLE_EQ(solution.get_total_objective(), 3.); + } else { + EXPECT_DOUBLE_EQ(objective_values.at(objective_t::DISTANCE_BREAK_COST), 8.); + EXPECT_DOUBLE_EQ(solution.get_total_objective(), + mode == objective_mode::OMIT_DISTANCE_BREAK_COST ? 14. : 11.); + } + } +} + +// Break locations restrict where the break can be inserted. +TEST(distance_breaks, with_break_locations) +{ + raft::handle_t handle; + auto stream = handle.get_stream(); + + std::vector order_locations = {1, 2}; + std::vector break_locations = {3, 4}; + + auto v_cost_matrix = copy_array_to_device(cost_matrix_5x5, stream); + auto v_order_locations = cuopt::device_copy(order_locations, stream); + auto v_break_locations = cuopt::device_copy(break_locations, stream); + + cuopt::routing::data_model_view_t data_model(&handle, 5, 2, 2); + data_model.add_cost_matrix(v_cost_matrix.data()); + data_model.set_order_locations(v_order_locations.data()); + data_model.add_vehicle_distance_break( + 0, 0.f, 2.f, 1, v_break_locations.data(), (int)v_break_locations.size()); + data_model.add_vehicle_distance_break( + 1, 0.f, 2.f, 1, v_break_locations.data(), (int)v_break_locations.size()); + data_model.set_min_vehicles(2); + + auto settings = cuopt::routing::solver_settings_t{}; + settings.set_time_limit(10); + + auto routing_solution = cuopt::routing::solve(data_model, settings); + handle.sync_stream(); + + ASSERT_EQ(routing_solution.get_status(), cuopt::routing::solution_status_t::SUCCESS); + host_assignment_t h_routing_solution(routing_solution); + check_route(data_model, h_routing_solution); + + for (size_t i = 0; i < h_routing_solution.node_types.size(); ++i) { + if ((node_type_t)h_routing_solution.node_types[i] == node_type_t::BREAK) { + auto loc = h_routing_solution.locations[i]; + ASSERT_TRUE(loc == 3 || loc == 4); + } + } +} + +// Stacking add_vehicle_distance_break calls produces one break per cycle per vehicle. +TEST(distance_breaks, multi_cycle) +{ + raft::handle_t handle; + auto stream = handle.get_stream(); + + // Two vehicles, each with two charge cycles: [0, 2) and [2, 4). + std::vector order_locations = {1, 2}; + + auto v_cost_matrix = copy_array_to_device(cost_matrix_5x5, stream); + auto v_order_locations = cuopt::device_copy(order_locations, stream); + + cuopt::routing::data_model_view_t data_model(&handle, 5, 2, 2); + data_model.add_cost_matrix(v_cost_matrix.data()); + data_model.set_order_locations(v_order_locations.data()); + + for (int vid = 0; vid < 2; ++vid) { + data_model.add_vehicle_distance_break(vid, 0.f, 2.f, 1, nullptr, 0); + data_model.add_vehicle_distance_break(vid, 2.f, 4.f, 1, nullptr, 0); + } + data_model.set_min_vehicles(2); + + auto settings = cuopt::routing::solver_settings_t{}; + settings.set_time_limit(10); + + auto routing_solution = cuopt::routing::solve(data_model, settings); + handle.sync_stream(); + + ASSERT_EQ(routing_solution.get_status(), cuopt::routing::solution_status_t::SUCCESS); + host_assignment_t h_routing_solution(routing_solution); + check_route(data_model, h_routing_solution); + + // Every vehicle that appears in the solution must carry exactly 2 breaks + std::unordered_map break_count; + for (size_t i = 0; i < h_routing_solution.node_types.size(); ++i) { + if ((node_type_t)h_routing_solution.node_types[i] == node_type_t::BREAK) { + break_count[h_routing_solution.truck_id[i]]++; + } + } + for (auto const& [vid, cnt] : break_count) { + ASSERT_EQ(cnt, 2); + } +} + +// Solver chooses the longer route so the break lands inside [0, d_max]. +TEST(distance_breaks, break_distance_window_enforced) +{ + raft::handle_t handle; + auto stream = handle.get_stream(); + + // clang-format off + std::vector cost_matrix_3 = { + 0, 100, 50, + 100, 0, 5, + 1, 55, 0, + }; + // clang-format on + std::vector order_locations = {1}; + std::vector break_locations = {2}; + + auto v_cost_matrix = cuopt::device_copy(cost_matrix_3, stream); + auto v_order_locations = cuopt::device_copy(order_locations, stream); + auto v_break_locations = cuopt::device_copy(break_locations, stream); + + cuopt::routing::data_model_view_t data_model(&handle, 3, 1, 1); + data_model.add_cost_matrix(v_cost_matrix.data()); + data_model.set_order_locations(v_order_locations.data()); + data_model.add_vehicle_distance_break(0, 0.f, 60.f, 0, v_break_locations.data(), 1); + + auto settings = cuopt::routing::solver_settings_t{}; + settings.set_time_limit(10); + + auto routing_solution = cuopt::routing::solve(data_model, settings); + handle.sync_stream(); + + ASSERT_EQ(routing_solution.get_status(), cuopt::routing::solution_status_t::SUCCESS); + host_assignment_t h(routing_solution); + + float cumulative = 0.f; + int prev_loc = 0; + bool found_break = false; + for (size_t i = 0; i < h.locations.size(); ++i) { + int loc = h.locations[i]; + cumulative += cost_matrix_3[prev_loc * 3 + loc]; + if (static_cast(h.node_types[i]) == node_type_t::BREAK) { + found_break = true; + EXPECT_LE(cumulative, 60.f) << "break at cumulative distance " << cumulative + << " exceeds d_max=60"; + } + prev_loc = loc; + } + EXPECT_TRUE(found_break) << "no break found in solution"; +} + +// A configured objective weight makes the solver prefer a route that reaches distance_min. +TEST(distance_breaks, early_arrival_objective) +{ + raft::handle_t handle; + auto stream = handle.get_stream(); + + // clang-format off + std::vector cost_matrix_4 = { + 0, 50, 50, 1, + 50, 0, 10, 60, + 50, 10, 0, 60, + 1, 60, 60, 0, + }; + // clang-format on + std::vector order_locations = {1, 2}; + std::vector break_locations = {3}; + std::vector objectives = {objective_t::COST, objective_t::DISTANCE_BREAK_COST}; + std::vector objective_weights = {1.f, 100.f}; + + auto v_cost_matrix = cuopt::device_copy(cost_matrix_4, stream); + auto v_order_locations = cuopt::device_copy(order_locations, stream); + auto v_break_locations = cuopt::device_copy(break_locations, stream); + auto v_objectives = cuopt::device_copy(objectives, stream); + auto v_objective_weights = cuopt::device_copy(objective_weights, stream); + + cuopt::routing::data_model_view_t data_model(&handle, 4, 1, 2); + data_model.add_cost_matrix(v_cost_matrix.data()); + data_model.set_order_locations(v_order_locations.data()); + data_model.add_vehicle_distance_break(0, 40.f, 200.f, 0, v_break_locations.data(), 1); + data_model.set_objective_function( + v_objectives.data(), v_objective_weights.data(), v_objective_weights.size()); + + auto settings = cuopt::routing::solver_settings_t{}; + settings.set_time_limit(10); + + auto routing_solution = cuopt::routing::solve(data_model, settings); + handle.sync_stream(); + + ASSERT_EQ(routing_solution.get_status(), cuopt::routing::solution_status_t::SUCCESS); + EXPECT_DOUBLE_EQ(routing_solution.get_objectives().at(objective_t::DISTANCE_BREAK_COST), 0.); + + host_assignment_t h(routing_solution); + float cumulative = 0.f; + int prev_loc = 0; + bool found_break = false; + for (size_t i = 0; i < h.locations.size(); ++i) { + int loc = h.locations[i]; + cumulative += cost_matrix_4[prev_loc * 4 + loc]; + if (static_cast(h.node_types[i]) == node_type_t::BREAK) { + found_break = true; + EXPECT_GE(cumulative, 40.f - 1e-3f); + EXPECT_LE(cumulative, 200.f + 1e-3f); + } + prev_loc = loc; + } + EXPECT_TRUE(found_break); +} + +// Only vehicles configured with a distance break receive break nodes. +TEST(distance_breaks, mixed_fleet) +{ + raft::handle_t handle; + auto stream = handle.get_stream(); + + auto v_cost_matrix = copy_array_to_device(cost_matrix_3x3, stream); + cuopt::routing::data_model_view_t data_model(&handle, 3, 2); + data_model.add_cost_matrix(v_cost_matrix.data()); + data_model.add_vehicle_distance_break(0, 0.f, 2.f, 1, nullptr, 0); + data_model.set_min_vehicles(2); + + auto settings = cuopt::routing::solver_settings_t{}; + settings.set_time_limit(10); + + auto routing_solution = cuopt::routing::solve(data_model, settings); + handle.sync_stream(); + + ASSERT_EQ(routing_solution.get_status(), cuopt::routing::solution_status_t::SUCCESS); + host_assignment_t h_routing_solution(routing_solution); + check_route(data_model, h_routing_solution); + + for (size_t i = 0; i < h_routing_solution.node_types.size(); ++i) { + if ((node_type_t)h_routing_solution.node_types[i] == node_type_t::BREAK) { + ASSERT_EQ(h_routing_solution.truck_id[i], 0); + } + } +} + +} // namespace test +} // namespace routing +} // namespace cuopt diff --git a/docs/cuopt/source/cuopt-python/routing/examples/distance_break_example.py b/docs/cuopt/source/cuopt-python/routing/examples/distance_break_example.py new file mode 100644 index 0000000000..f34bd64c26 --- /dev/null +++ b/docs/cuopt/source/cuopt-python/routing/examples/distance_break_example.py @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Distance break example: a vehicle must take a mandatory break at a +# break location before its cumulative route distance exceeds 75 km. + +# distance_max is a hard feasibility constraint, while distance_min is a soft +# target. routing.Objective.DISTANCE_BREAK_COST guides the search toward that +# target and sums the maximum lower-bound shortfall on each route. A break +# after distance_max makes the route infeasible. The objective's default weight +# is 1.0; configure another positive weight to change the tradeoff, or set it +# to 0.0 to disable the early-break penalty. + +import cudf +import numpy as np + +from cuopt import routing + +# 2-D coordinates (km): depot, 2 customers, 1 break location. +# Geometry forces the route depot -> customer 1 -> break -> customer 2 -> depot: +# customer 2 is 63 km from the depot, so any route that does not break +# between the two customers either lands the break past its 75 km window +# or backtracks far enough to be strictly more expensive. +COORDS = np.array( + [ + [0.0, 0.0], # 0 depot + [40.0, 0.0], # 1 customer 1 + [60.0, 20.0], # 2 customer 2 + [60.0, 0.0], # 3 break location + ], + dtype=np.float32, +) + + +def build_cost_matrix(coords): + diff = coords[:, np.newaxis] - coords[np.newaxis, :] + return cudf.DataFrame(np.linalg.norm(diff, axis=-1).astype(np.float32)) + + +def main(): + data_model = routing.DataModel(n_locations=4, n_fleet=1, n_orders=2) + data_model.add_cost_matrix(build_cost_matrix(COORDS)) + data_model.set_order_locations(cudf.Series([1, 2], dtype=np.int32)) + + data_model.add_vehicle_distance_break( + 0, 25.0, 75.0, 10, cudf.Series([3], dtype=np.int32) + ) + data_model.set_objective_function( + cudf.Series( + [routing.Objective.COST, routing.Objective.DISTANCE_BREAK_COST] + ), + cudf.Series([1.0, 10.0], dtype=np.float32), + ) + + settings = routing.SolverSettings() + settings.set_time_limit(5.0) + + solution = routing.Solve(data_model, settings) + if solution.get_status() != 0: + print(f"No solution found (status={solution.get_status()})") + return + + labels = {0: "depot", 1: "customer 1", 2: "customer 2", 3: "break"} + route = solution.get_route().to_pandas() + print(f"Weighted objective: {solution.get_total_objective():.1f}\n") + for _, stop in route.iterrows(): + print(f" {stop['type']:<10} {labels[stop['location']]}") + + +if __name__ == "__main__": + main() diff --git a/docs/cuopt/source/cuopt-python/routing/routing-examples.rst b/docs/cuopt/source/cuopt-python/routing/routing-examples.rst index c506312678..72a680cb65 100644 --- a/docs/cuopt/source/cuopt-python/routing/routing-examples.rst +++ b/docs/cuopt/source/cuopt-python/routing/routing-examples.rst @@ -66,3 +66,30 @@ Sample output: - All problems in the batch use the **same** :class:`cuopt.routing.SolverSettings` (e.g., time limit, solver options). - Callbacks are not supported in batch mode. - For best practices when batching many instances, see the *Add best practices for batch solving* note in the release documentation. + +Distance-Based Breaks +--------------------- + +:meth:`cuopt.routing.DataModel.add_vehicle_distance_break` specifies a +distance-based break for a given vehicle the same way +:meth:`cuopt.routing.DataModel.add_vehicle_break` specifies a time-windowed +break: ``(vehicle_id, distance_min, distance_max, duration, locations)``. +Call again with later windows to require additional stops. + +:download:`distance_break_example.py ` + +.. literalinclude:: examples/distance_break_example.py + :language: python + :linenos: + +Sample output: + +.. code-block:: text + + Weighted objective: 143.2 + + Depot depot + Delivery customer 1 + Break break + Delivery customer 2 + Depot depot diff --git a/docs/cuopt/source/routing-features.rst b/docs/cuopt/source/routing-features.rst index 7442bbd213..63a2697efa 100644 --- a/docs/cuopt/source/routing-features.rst +++ b/docs/cuopt/source/routing-features.rst @@ -60,6 +60,25 @@ There are two types of breaks, Only one of the type of breaks can be used at a time. +Distance-Based Breaks +--------------------- + +``add_vehicle_distance_break`` specifies a distance-based break for a given +vehicle the same way ``add_vehicle_break`` specifies a time-windowed break: +call it once per break, and call it again for additional breaks on the same +vehicle. + +The solver inserts one mandatory stop no later than the hard +cumulative-distance limit ``distance_max``. ``distance_min`` is the soft +target for that stop. ``DISTANCE_BREAK_COST`` guides the search toward the +soft target. A break after ``distance_max`` makes the route infeasible. The +objective value is the sum of the maximum lower-bound shortfall on each +route. Add ``Objective.DISTANCE_BREAK_COST`` with a positive objective +weight to penalize early breaks more strongly. Its default weight is +``1.0``; explicitly set it to ``0.0`` to disable the early-break penalty. + +Pass ``locations`` to restrict the eligible break locations; if omitted, any +location is eligible. Prize Collection ------------------------ diff --git a/python/cuopt/cuopt/grpc/client/grpc_client.pxd b/python/cuopt/cuopt/grpc/client/grpc_client.pxd index 2e90dea3b3..f24a67917a 100644 --- a/python/cuopt/cuopt/grpc/client/grpc_client.pxd +++ b/python/cuopt/cuopt/grpc/client/grpc_client.pxd @@ -44,6 +44,13 @@ cdef extern from "cuopt/routing/cpu_routing_problem.hpp" namespace "cuopt::routi int32_t duration vector[int32_t] locations + cdef cppclass cpu_vehicle_distance_break_t: + cpu_vehicle_distance_break_t() except + + float distance_min + float distance_max + int32_t duration + vector[int32_t] locations + cdef cppclass cpu_initial_solution_t: cpu_initial_solution_t() except + vector[int32_t] vehicle_ids @@ -78,6 +85,7 @@ cdef extern from "cuopt/routing/cpu_routing_problem.hpp" namespace "cuopt::routi vector[int32_t] break_locations vector[cpu_uniform_break_t] uniform_breaks cpp_map[int32_t, vector[cpu_vehicle_break_t]] vehicle_breaks + cpp_map[int32_t, vector[cpu_vehicle_distance_break_t]] vehicle_distance_breaks cpp_map[int32_t, vector[int32_t]] vehicle_order_match cpp_map[int32_t, vector[int32_t]] order_vehicle_match cpp_map[int32_t, vector[int32_t]] order_precedence diff --git a/python/cuopt/cuopt/grpc/client/grpc_client.pyx b/python/cuopt/cuopt/grpc/client/grpc_client.pyx index 6e1909fc2f..c7d7b896b5 100644 --- a/python/cuopt/cuopt/grpc/client/grpc_client.pyx +++ b/python/cuopt/cuopt/grpc/client/grpc_client.pyx @@ -27,6 +27,7 @@ from cuopt.grpc.client.grpc_client cimport ( cpu_routing_solution_t, cpu_uniform_break_t, cpu_vehicle_break_t, + cpu_vehicle_distance_break_t, grpc_incumbents_result_t, grpc_job_status_t, grpc_logs_result_t, @@ -704,6 +705,7 @@ HANDLED_SETTERS = frozenset({ "add_order_precedence", "add_break_dimension", "add_vehicle_break", + "add_vehicle_distance_break", "set_objective_function", "add_initial_solutions", "set_min_vehicles", @@ -802,6 +804,7 @@ cdef void _populate(cpu_routing_problem_t& p, data_model) except *: cdef cpu_capacity_dimension_t cap cdef cpu_uniform_break_t ub cdef cpu_vehicle_break_t vb + cdef cpu_vehicle_distance_break_t vdb cdef int32_t vid for name, args, _ in data_model._calls: @@ -862,6 +865,15 @@ cdef void _populate(cpu_routing_problem_t& p, data_model) except *: if len(args) > 4 and args[4] is not None: _fill_i32(vb.locations, args[4]) p.vehicle_breaks[vid].push_back(vb) + elif name == "add_vehicle_distance_break": + vid = int(args[0]) + vdb = cpu_vehicle_distance_break_t() + vdb.distance_min = float(args[1]) + vdb.distance_max = float(args[2]) + vdb.duration = int(args[3]) + if len(args) > 4 and args[4] is not None: + _fill_i32(vdb.locations, args[4]) + p.vehicle_distance_breaks[vid].push_back(vdb) elif name == "set_objective_function": _fill_i32(p.objectives, args[0]) _fill_f32(p.objective_weights, args[1]) @@ -933,6 +945,7 @@ def problem_summary(data_model): "break_locations": p.break_locations.size(), "uniform_breaks": p.uniform_breaks.size(), "vehicle_breaks": p.vehicle_breaks.size(), + "vehicle_distance_breaks": p.vehicle_distance_breaks.size(), "vehicle_order_match": p.vehicle_order_match.size(), "order_vehicle_match": p.order_vehicle_match.size(), "order_precedence": p.order_precedence.size(), diff --git a/python/cuopt/cuopt/routing/_deferred.py b/python/cuopt/cuopt/routing/_deferred.py index ff8ed1ded9..6035db0c8d 100644 --- a/python/cuopt/cuopt/routing/_deferred.py +++ b/python/cuopt/cuopt/routing/_deferred.py @@ -51,6 +51,7 @@ "add_order_vehicle_match", "add_transit_time_matrix", "add_vehicle_break", + "add_vehicle_distance_break", "add_vehicle_order_match", "set_break_locations", "set_drop_return_trips", diff --git a/python/cuopt/cuopt/routing/structure/routing_utilities.pxd b/python/cuopt/cuopt/routing/structure/routing_utilities.pxd index 1b833a23dc..b70614a1c6 100644 --- a/python/cuopt/cuopt/routing/structure/routing_utilities.pxd +++ b/python/cuopt/cuopt/routing/structure/routing_utilities.pxd @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # noqa +# SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # cython: profile=False @@ -41,6 +41,7 @@ cdef extern from "cuopt/routing/routing_structures.hpp" namespace "cuopt::routin VARIANCE_ROUTE_SERVICE_TIME "cuopt::routing::objective_t::VARIANCE_ROUTE_SERVICE_TIME" # noqa PRIZE "cuopt::routing::objective_t::PRIZE" VEHICLE_FIXED_COST "cuopt::routing::objective_t::VEHICLE_FIXED_COST" + DISTANCE_BREAK_COST "cuopt::routing::objective_t::DISTANCE_BREAK_COST" cdef extern from "cuopt/routing/cython/generator.hpp" namespace "cuopt::routing::generator": # noqa diff --git a/python/cuopt/cuopt/routing/vehicle_routing.pxd b/python/cuopt/cuopt/routing/vehicle_routing.pxd index 7f89d33ff8..2adf7b52dd 100644 --- a/python/cuopt/cuopt/routing/vehicle_routing.pxd +++ b/python/cuopt/cuopt/routing/vehicle_routing.pxd @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # noqa +# SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 @@ -25,6 +25,7 @@ cdef extern from "cuopt/routing/solve.hpp" namespace "cuopt::routing": VARIANCE_ROUTE_SERVICE_TIME "cuopt::routing::objective_t::VARIANCE_ROUTE_SERVICE_TIME" # noqa PRIZE "cuopt::routing::objective_t::PRIZE" VEHICLE_FIXED_COST "cuopt::routing::objective_t::VEHICLE_FIXED_COST" + DISTANCE_BREAK_COST "cuopt::routing::objective_t::DISTANCE_BREAK_COST" ctypedef enum node_type_t "cuopt::routing::node_type_t": DEPOT "cuopt::routing::node_type_t::DEPOT" @@ -99,6 +100,14 @@ cdef extern from "cuopt/routing/solve.hpp" namespace "cuopt::routing": const i_t *break_locations, const int n_break_locations ) except + + void add_vehicle_distance_break( + const int vehicle_id, + const float distance_min, + const float distance_max, + const int duration, + const i_t *break_locations, + const int n_break_locations + ) except + void add_capacity_dimension( const string &name, const i_t *demand, const i_t *capacity ) except + diff --git a/python/cuopt/cuopt/routing/vehicle_routing.py b/python/cuopt/cuopt/routing/vehicle_routing.py index bff3aefc22..55077bd4e5 100644 --- a/python/cuopt/cuopt/routing/vehicle_routing.py +++ b/python/cuopt/cuopt/routing/vehicle_routing.py @@ -277,7 +277,7 @@ def set_break_locations(self, break_locations): >>> data_model.set_break_locations(cudf.Series([1, 3])) """ validate_range( - break_locations, "break_locations", 0, self.get_num_locations() + break_locations, "break_locations", 0, self.get_num_locations() - 1 ) super().set_break_locations(break_locations) @@ -405,23 +405,89 @@ def add_vehicle_break( """ if locations is None: locations = cudf.Series() - validate_range(vehicle_id, "vehicle id", 0, self.get_fleet_size()) + validate_range(vehicle_id, "vehicle id", 0, self.get_fleet_size() - 1) if len(locations) > 0: validate_range( - locations, "break locations", 0, self.get_num_locations() + locations, "break locations", 0, self.get_num_locations() - 1 ) super().add_vehicle_break( vehicle_id, earliest, latest, duration, locations ) + @catch_cuopt_exception + def add_vehicle_distance_break( + self, vehicle_id, distance_min, distance_max, duration, locations=None + ): + """ + Specify a distance-based break for a given vehicle. Use this api the + same way as :meth:`add_vehicle_break`: call it once per break, and + call it again for additional breaks on the same vehicle. + + The solver inserts one mandatory stop no later than the hard + cumulative-distance limit ``distance_max``. ``distance_min`` is the + soft target for that stop. Arriving before ``distance_min`` + contributes to ``Objective.DISTANCE_BREAK_COST``. Its default weight + is ``1.0``; use :meth:`set_objective_function` to tune it or + explicitly set it to ``0.0`` to disable the early-break penalty. + + ``distance_min`` and ``distance_max`` are expressed in the same units + as the primary cost matrix. + + Note: This function cannot be used in conjunction with + add_break_dimension + + Parameters + ---------- + vehicle_id: integer + Vehicle Id for which the break is being specified + distance_min: float + Soft lower bound on cumulative distance at the break + distance_max: float + Latest cumulative distance by which the vehicle must take the + break. Must be strictly greater than ``distance_min``. + duration: integer + Time spent at the break location + locations: cudf.Series dtype - int32 + List of locations where this break can be taken. By default + any location can be used + + Examples + -------- + >>> from cuopt import routing + >>> vehicle_num = 2 + >>> d = routing.DataModel(nodes, vehicle_num) + >>> d.add_vehicle_distance_break(0, 120, 150, 10, cudf.Series([3, 6])) + >>> d.add_vehicle_distance_break(0, 270, 300, 10, cudf.Series([3, 6])) + >>> d.add_vehicle_distance_break(1, 0, 200, 10) + """ + if locations is None: + locations = cudf.Series() + validate_range(vehicle_id, "vehicle id", 0, self.get_fleet_size() - 1) + validate_non_negative(distance_min, "distance min") + validate_positive(distance_max, "distance max") + validate_non_negative(duration, "duration") + if distance_min >= distance_max: + raise ValueError("distance_min must be smaller than distance_max") + if len(locations) > 0: + validate_range( + locations, "break locations", 0, self.get_num_locations() - 1 + ) + + super().add_vehicle_distance_break( + vehicle_id, distance_min, distance_max, duration, locations + ) + @catch_cuopt_exception def set_objective_function(self, objectives, objective_weights): """ The objective function can be defined as a linear combination of the different objectives. Solver optimizes for vehicle - count first and then the total objective. The default value of - 1 is used for COST objective weight and 0 for other objective weights + count first and then the total objective. ``COST`` defaults to weight + ``1.0``. ``PRIZE``, ``VEHICLE_FIXED_COST``, and + ``DISTANCE_BREAK_COST`` also default to ``1.0`` when their associated + model data is configured; explicitly pass ``0.0`` to disable them. + Other objective weights default to ``0.0``. Parameters ---------- @@ -491,7 +557,7 @@ def set_order_locations(self, order_locations): "number of orders", ) validate_range( - order_locations, "order locations", 0, self.get_num_locations() + order_locations, "order locations", 0, self.get_num_locations() - 1 ) super().set_order_locations(order_locations) @@ -903,7 +969,7 @@ def add_vehicle_order_match(self, vehicle_id, orders): >>> d.add_vehicle_order_match(2, cudf.Series([3])) >>> cuopt_solution = routing.Solve(d) """ - validate_range(orders, "orders served", 0, self.get_num_orders()) + validate_range(orders, "orders served", 0, self.get_num_orders() - 1) validate_range( len(orders), "number of orders served", 0, self.get_num_orders() ) @@ -948,13 +1014,13 @@ def add_order_vehicle_match(self, order_id, vehicles): >>> cuopt_solution = routing.Solve(d) """ validate_range( - len(vehicles), "Number of vehicles", 0, self.get_fleet_size() + 1 + len(vehicles), "Number of vehicles", 0, self.get_fleet_size() ) validate_range( vehicles, "vehicles that can fulfill the order", 0, - self.get_fleet_size(), + self.get_fleet_size() - 1, ) super().add_order_vehicle_match(order_id, vehicles) diff --git a/python/cuopt/cuopt/routing/vehicle_routing_wrapper.pyx b/python/cuopt/cuopt/routing/vehicle_routing_wrapper.pyx index a290132d50..8ea5ea0f86 100644 --- a/python/cuopt/cuopt/routing/vehicle_routing_wrapper.pyx +++ b/python/cuopt/cuopt/routing/vehicle_routing_wrapper.pyx @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # noqa +# SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 @@ -181,7 +181,14 @@ class Objective(IntEnum): PRIZE - Models with respect to prizes collected by the serviced orders - VEHICLE_FIXED_COST - Models cost per vehicle. Enabled when set_vehicle_fixed_costs is used. + VEHICLE_FIXED_COST - Models cost per vehicle. Enabled when + set_vehicle_fixed_costs is used. + + DISTANCE_BREAK_COST - Models each route's maximum distance shortfall below + the soft lower bounds of distance-based breaks. The + solution value sums those route maxima. Its default + weight is 1.0 when distance breaks are configured; + an explicit zero disables it. """ COST = objective_t.COST @@ -190,6 +197,7 @@ class Objective(IntEnum): VARIANCE_ROUTE_SERVICE_TIME = objective_t.VARIANCE_ROUTE_SERVICE_TIME PRIZE = objective_t.PRIZE VEHICLE_FIXED_COST = objective_t.VEHICLE_FIXED_COST + DISTANCE_BREAK_COST = objective_t.DISTANCE_BREAK_COST class NodeType(IntEnum): @@ -511,32 +519,57 @@ cdef class DataModel: def add_vehicle_break( self, vehicle_id, earliest, latest, duration, locations ): - dim = 0 - if vehicle_id in self.non_uniform_breaks: - dim = len(self.non_uniform_breaks[vehicle_id]) + breaks = self.non_uniform_breaks.setdefault(vehicle_id, {}) + dim = len(breaks) - if dim == 0: - self.non_uniform_breaks[vehicle_id] = {} + casted_locations = type_cast( + locations, np.int32, "breaklocations" + ) - self.non_uniform_breaks[vehicle_id][dim] = { + breaks[dim] = { "earliest": earliest, "latest": latest, "duration": duration, - "locations": type_cast( - locations, np.int32, "breaklocations" - ) + "locations": casted_locations, } - current_breaks = self.non_uniform_breaks[vehicle_id][dim]["locations"] - cdef uintptr_t c_locations_ptr = ( - current_breaks.__cuda_array_interface__['data'][0] + casted_locations.__cuda_array_interface__['data'][0] ) self.c_data_model_view.get().add_vehicle_break( vehicle_id, earliest, latest, duration, c_locations_ptr, len(locations)) + def add_vehicle_distance_break( + self, vehicle_id, distance_min, distance_max, duration, locations + ): + breaks = self.non_uniform_breaks.setdefault(vehicle_id, {}) + dim = len(breaks) + + casted_locations = type_cast( + locations, np.int32, "breaklocations" + ) + + breaks[dim] = { + "distance_min": distance_min, + "distance_max": distance_max, + "duration": duration, + "locations": casted_locations, + } + + cdef uintptr_t c_locations_ptr = ( + casted_locations.__cuda_array_interface__['data'][0] + ) + + self.c_data_model_view.get().add_vehicle_distance_break( + vehicle_id, + distance_min, + distance_max, + duration, + c_locations_ptr, + len(locations)) + def add_capacity_dimension(self, name, demand, capacity): self.demand_name.append(name) self.demand.append(type_cast(demand, np.int32, "demand")) diff --git a/python/cuopt/cuopt/tests/routing/API_COVERAGE.md b/python/cuopt/cuopt/tests/routing/API_COVERAGE.md index 061688fc66..c470203ddb 100644 --- a/python/cuopt/cuopt/tests/routing/API_COVERAGE.md +++ b/python/cuopt/cuopt/tests/routing/API_COVERAGE.md @@ -35,6 +35,7 @@ Summary of which APIs from `assignment.py` and `vehicle_routing.py` are exercise | `set_break_locations()` | Yes | test_vehicle_properties (test_empty_routes_with_breaks) | | `add_break_dimension()` | Yes | test_vehicle_properties (test_empty_routes_with_breaks), test_solver, test_initial_solutions | | `add_vehicle_break()` | Yes | test_vehicle_properties (test_heterogenous_breaks) | +| `add_vehicle_distance_break()` | Yes | test_distance_breaks, test_routing_grpc_serialization | | `set_objective_function()` | Yes | test_data_model, test_initial_solutions | | `add_initial_solutions()` | Yes | test_initial_solutions | | `set_order_locations()` | Yes | test_vehicle_properties, test_solver, test_initial_solutions, test_warnings, etc. | diff --git a/python/cuopt/cuopt/tests/routing/test_data_model.py b/python/cuopt/cuopt/tests/routing/test_data_model.py index ad9f0b53a2..8d70e4a079 100644 --- a/python/cuopt/cuopt/tests/routing/test_data_model.py +++ b/python/cuopt/cuopt/tests/routing/test_data_model.py @@ -58,8 +58,10 @@ def test_objective_function(): d = utils.create_data_model(filename, run_nodes=10) obj = routing.Objective - objectives = cudf.Series([obj.COST, obj.VARIANCE_ROUTE_SIZE]) - objective_weights = cudf.Series([1, 10]).astype(np.float32) + objectives = cudf.Series( + [obj.COST, obj.VARIANCE_ROUTE_SIZE, obj.DISTANCE_BREAK_COST] + ) + objective_weights = cudf.Series([1, 10, 2]).astype(np.float32) d.set_objective_function(objectives, objective_weights) ret_objectives, ret_objective_weights = d.get_objective_function() diff --git a/python/cuopt/cuopt/tests/routing/test_distance_breaks.py b/python/cuopt/cuopt/tests/routing/test_distance_breaks.py new file mode 100644 index 0000000000..5d07b3a59d --- /dev/null +++ b/python/cuopt/cuopt/tests/routing/test_distance_breaks.py @@ -0,0 +1,451 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import pytest + +import cudf +import numpy as np + +from cuopt import routing + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _small_data_model(n_vehicles=3): + """Minimal DataModel for API-level tests (no solver needed).""" + n = 6 + rows = [ + [0, 10, 20, 15, 25, 30], + [10, 0, 15, 20, 30, 25], + [20, 15, 0, 10, 20, 15], + [15, 20, 10, 0, 15, 20], + [25, 30, 20, 15, 0, 10], + [30, 25, 15, 20, 10, 0], + ] + d = routing.DataModel(n, n_vehicles) + d.add_cost_matrix(cudf.DataFrame(rows, dtype="float32")) + return d + + +# --------------------------------------------------------------------------- +# API / data-model tests (no solve) +# --------------------------------------------------------------------------- + + +def test_distance_break_api_single_cycle_defaults(): + """Single cycle with distance_min=0: distance window is [0, distance_max].""" + d = _small_data_model() + d.add_vehicle_distance_break(0, 0.0, 100.0, 15) + + breaks = d.get_non_uniform_breaks() + assert 0 in breaks + assert len(breaks[0]) == 1 + assert breaks[0][0]["distance_min"] == 0.0 + assert breaks[0][0]["distance_max"] == 100.0 + assert breaks[0][0]["duration"] == 15 + + +def test_distance_break_api_int_vehicle_id(): + """Numpy integer scalars are accepted as vehicle_id.""" + d = _small_data_model() + d.add_vehicle_distance_break(np.int32(0), 0.0, 100.0, 15) + + breaks = d.get_non_uniform_breaks() + assert 0 in breaks + assert breaks[0][0]["distance_max"] == 100.0 + + +def test_distance_break_api_distance_min(): + """distance_min sets the first cycle's soft cumulative-distance target.""" + d = _small_data_model() + d.add_vehicle_distance_break(0, 30.0, 100.0, 10) + + breaks = d.get_non_uniform_breaks() + assert breaks[0][0]["distance_min"] == 30.0 + assert breaks[0][0]["distance_max"] == 100.0 + + +def test_distance_break_api_multi_cycle(): + """Successive calls add successive soft-target/hard-deadline pairs.""" + windows = [(20.0, 100.0), (120.0, 200.0), (220.0, 300.0)] + + d = _small_data_model() + for distance_min, distance_max in windows: + d.add_vehicle_distance_break(0, distance_min, distance_max, 15) + + breaks = d.get_non_uniform_breaks() + assert len(breaks[0]) == len(windows) + for k, (distance_min, distance_max) in enumerate(windows): + assert breaks[0][k]["distance_min"] == distance_min + assert breaks[0][k]["distance_max"] == distance_max + + +def test_distance_break_api_multiple_vehicles(): + """Each vehicle is configured with its own add_vehicle_distance_break call.""" + d = _small_data_model(n_vehicles=4) + vehicle_ids = [0, 1, 2] + + for vid in vehicle_ids: + d.add_vehicle_distance_break(vid, 0.0, 100.0, 15) + + breaks = d.get_non_uniform_breaks() + for vid in vehicle_ids: + assert vid in breaks + assert len(breaks[vid]) == 1 + + assert 3 not in breaks + + +def test_distance_break_api_locations_stored(): + """Specified locations are stored in the non_uniform_breaks dict.""" + d = _small_data_model() + break_locs = cudf.Series([1, 2, 3], dtype="int32") + + d.add_vehicle_distance_break(0, 0.0, 100.0, 15, break_locs) + + breaks = d.get_non_uniform_breaks() + stored_locs = breaks[0][0]["locations"].to_arrow().to_pylist() + assert stored_locs == [1, 2, 3] + + +def test_distance_break_api_stacked_calls(): + """Two separate add_vehicle_distance_break calls on the same vehicle accumulate breaks.""" + d = _small_data_model() + d.add_vehicle_distance_break(0, 0.0, 100.0, 15) + d.add_vehicle_distance_break(0, 0.0, 100.0, 15) + + breaks = d.get_non_uniform_breaks() + assert len(breaks[0]) == 2 + + +# --------------------------------------------------------------------------- +# Validation tests +# --------------------------------------------------------------------------- + + +@pytest.fixture +def model(): + return _small_data_model(n_vehicles=3) + + +# fleet_size=3 → validate_range(vid, "vehicle id", 0, 3): fails for vid < 0 or vid > 2 +@pytest.mark.parametrize("vid", [-1, 3, 100]) +def test_distance_break_invalid_vehicle_id(model, vid): + """Out-of-range vehicle id raises ValueError.""" + with pytest.raises(ValueError, match="vehicle id"): + model.add_vehicle_distance_break(vid, 0.0, 100.0, 15) + + +@pytest.mark.parametrize("distance_max", [0, -1, -100.0]) +def test_distance_max_must_be_positive(model, distance_max): + """distance_max must be strictly positive.""" + with pytest.raises(ValueError, match="distance max"): + model.add_vehicle_distance_break(0, 0.0, distance_max, 10) + + +def test_negative_distance_min_rejected(model): + """distance_min must be non-negative.""" + with pytest.raises(ValueError, match="distance min"): + model.add_vehicle_distance_break(0, -1.0, 100.0, 10) + + +@pytest.mark.parametrize( + "distance_min, distance_max", + [ + (100.0, 100.0), + (150.0, 100.0), + ], +) +def test_distance_min_must_be_less_than_distance_max( + model, distance_min, distance_max +): + """distance_min >= distance_max raises ValueError.""" + with pytest.raises(ValueError, match="distance_min must be smaller"): + model.add_vehicle_distance_break(0, distance_min, distance_max, 10) + + +def test_negative_duration_rejected(model): + """Duration must be non-negative.""" + with pytest.raises(ValueError, match="duration"): + model.add_vehicle_distance_break(0, 0.0, 100.0, -1) + + +def test_locations_out_of_range(model): + """Break location indices must be within [0, num_locations).""" + bad_locs = cudf.Series([999], dtype="int32") + with pytest.raises(ValueError, match="break locations"): + model.add_vehicle_distance_break(0, 0.0, 100.0, 10, bad_locs) + + +# --------------------------------------------------------------------------- +# Solver tests (require GPU / actual solve) +# --------------------------------------------------------------------------- + +_COST_3X3 = [[0, 1, 1], [1, 0, 1], [1, 1, 0]] + +# depot=0, orders=1-2, break locations=3-4 +_COST_5X5 = [ + [0, 1, 1, 1, 1], + [1, 0, 1, 1, 1], + [1, 1, 0, 1, 1], + [1, 1, 1, 0, 1], + [1, 1, 1, 1, 0], +] + + +def _solve(dm, time_limit=10): + s = routing.SolverSettings() + s.set_time_limit(time_limit) + return routing.Solve(dm, s) + + +def test_solve_basic_break_assigned(): + """Each vehicle with a distance break receives exactly one break in the solution.""" + dm = routing.DataModel(3, 2) + dm.add_cost_matrix(cudf.DataFrame(_COST_3X3, dtype="float32")) + dm.add_vehicle_distance_break(0, 0.0, 2.0, 1) + dm.add_vehicle_distance_break(1, 0.0, 2.0, 1) + dm.set_min_vehicles(2) + + sol = _solve(dm) + assert sol.get_status() == 0 + + routes = sol.get_route().to_pandas() + breaks_per_vehicle = {} + for i in range(routes.shape[0]): + if routes["type"][i] == "Break": + vid = routes["truck_id"][i] + breaks_per_vehicle[vid] = breaks_per_vehicle.get(vid, 0) + 1 + + assert 0 in breaks_per_vehicle + assert 1 in breaks_per_vehicle + assert breaks_per_vehicle[0] == 1 + assert breaks_per_vehicle[1] == 1 + + +@pytest.mark.parametrize("objective_mode", ["defaults", "omit", "disable"]) +def test_default_distance_break_cost_weight(objective_mode): + """Distance-break cost defaults to 1 when omitted; an explicit zero disables it.""" + dm = routing.DataModel(3, 1, 1) + dm.add_cost_matrix(cudf.DataFrame(_COST_3X3, dtype="float32")) + dm.set_order_locations(cudf.Series([1], dtype="int32")) + dm.add_vehicle_distance_break( + 0, 10.0, 100.0, 0, cudf.Series([2], dtype="int32") + ) + if objective_mode != "defaults": + objectives = [routing.Objective.COST] + weights = [2.0 if objective_mode == "omit" else 1.0] + if objective_mode == "disable": + objectives.append(routing.Objective.DISTANCE_BREAK_COST) + weights.append(0.0) + dm.set_objective_function( + cudf.Series(objectives), + cudf.Series(weights, dtype="float32"), + ) + + sol = _solve(dm) + assert sol.get_status() == 0 + objectives = sol.get_objective_values() + assert objectives[routing.Objective.COST] == 3.0 + if objective_mode == "disable": + assert routing.Objective.DISTANCE_BREAK_COST not in objectives + assert sol.get_total_objective() == 3.0 + else: + assert objectives[routing.Objective.DISTANCE_BREAK_COST] == 8.0 + assert sol.get_total_objective() == ( + 14.0 if objective_mode == "omit" else 11.0 + ) + + +def test_solve_break_at_break_location(): + """When break locations are specified, every break node lands at one of them.""" + order_locations = cudf.Series([1, 2], dtype="int32") + locations = cudf.Series([3, 4], dtype="int32") + + dm = routing.DataModel(5, 2, 2) + dm.add_cost_matrix(cudf.DataFrame(_COST_5X5, dtype="float32")) + dm.set_order_locations(order_locations) + dm.add_vehicle_distance_break(0, 0.0, 2.0, 1, locations) + dm.add_vehicle_distance_break(1, 0.0, 2.0, 1, locations) + dm.set_min_vehicles(2) + + sol = _solve(dm) + assert sol.get_status() == 0 + + routes = sol.get_route().to_pandas() + break_loc_set = {3, 4} + vehicles_with_breaks = set() + for i in range(routes.shape[0]): + if routes["type"][i] == "Break": + vehicles_with_breaks.add(int(routes["truck_id"][i])) + assert routes["location"][i] in break_loc_set + assert vehicles_with_breaks == {0, 1}, ( + f"expected breaks on vehicles {{0, 1}}, got {vehicles_with_breaks}" + ) + + +def test_solve_multi_cycle_break_count(): + """Each used vehicle with 2 cycles receives exactly 2 break nodes.""" + order_locations = cudf.Series([1, 2], dtype="int32") + + dm = routing.DataModel(5, 2, 2) + dm.add_cost_matrix(cudf.DataFrame(_COST_5X5, dtype="float32")) + dm.set_order_locations(order_locations) + for vid in [0, 1]: + dm.add_vehicle_distance_break(vid, 0.0, 2.0, 1) + dm.add_vehicle_distance_break(vid, 2.0, 4.0, 1) + dm.set_min_vehicles(2) + + sol = _solve(dm) + assert sol.get_status() == 0 + + routes = sol.get_route().to_pandas() + breaks_per_vehicle = {} + for i in range(routes.shape[0]): + if routes["type"][i] == "Break": + vid = int(routes["truck_id"][i]) + breaks_per_vehicle[vid] = breaks_per_vehicle.get(vid, 0) + 1 + + assert set(breaks_per_vehicle) == {0, 1}, ( + f"expected breaks on vehicles {{0, 1}}, got {set(breaks_per_vehicle)}" + ) + for vid, cnt in breaks_per_vehicle.items(): + assert cnt == 2 + + +def test_solve_break_distance_window_enforced(): + """Solver picks a longer route so the break lands inside [0, d_max=60].""" + cost_asym = [ + [0, 100, 50], + [100, 0, 5], + [1, 55, 0], + ] + order_locations = cudf.Series([1], dtype="int32") + locations = cudf.Series([2], dtype="int32") + + dm = routing.DataModel(3, 1, 1) + dm.add_cost_matrix(cudf.DataFrame(cost_asym, dtype="float32")) + dm.set_order_locations(order_locations) + dm.add_vehicle_distance_break(0, 0.0, 60.0, 0, locations) + + sol = _solve(dm) + assert sol.get_status() == 0 + + routes = sol.get_route().to_pandas() + cost_flat = [c for row in cost_asym for c in row] + cumulative = 0.0 + prev_loc = 0 + found_break = False + for i in range(routes.shape[0]): + loc = int(routes["location"][i]) + cumulative += cost_flat[prev_loc * 3 + loc] + if routes["type"][i] == "Break": + found_break = True + assert cumulative <= 60.0, ( + f"break at cumulative distance {cumulative} exceeds d_max=60" + ) + prev_loc = loc + + assert found_break, "no break found in solution" + + +def test_solve_full_feature_api(): + """Exercises every add_vehicle_distance_break parameter at non-default values. + + Two cycle targets of 10 and 30, with hard limits of 20 and 40 and a high + early-break penalty, make the solver prefer distinct break locations for + each cycle on a 5-location unit-cost grid (arc 10 between distinct locations). + """ + # depot(0), customers(1, 2), break locations(3, 4); arc 10 between distinct locations. + cost = [[0 if i == j else 10 for j in range(5)] for i in range(5)] + order_locations = cudf.Series([1, 2], dtype="int32") + locations = cudf.Series([3, 4], dtype="int32") + cycle_windows = [(10.0, 20.0), (30.0, 40.0)] + duration = 1 + + dm = routing.DataModel(5, 2, 2) + dm.add_cost_matrix(cudf.DataFrame(cost, dtype="float32")) + dm.set_order_locations(order_locations) + for vid in (0, 1): + for distance_min, distance_max in cycle_windows: + dm.add_vehicle_distance_break( + vid, distance_min, distance_max, duration, locations + ) + dm.set_objective_function( + cudf.Series( + [routing.Objective.COST, routing.Objective.DISTANCE_BREAK_COST] + ), + cudf.Series([1.0, 100.0], dtype="float32"), + ) + dm.set_min_vehicles(2) + + sol = _solve(dm) + assert sol.get_status() == 0 + + routes = sol.get_route().to_pandas() + cost_flat = [c for row in cost for c in row] + break_loc_set = {int(s) for s in locations.to_arrow().to_pylist()} + + breaks_per_vehicle: dict[int, list[float]] = {} + cumulative_per_vehicle: dict[int, float] = {} + prev_loc_per_vehicle: dict[int, int] = {} + n_loc = 5 + + for i in range(routes.shape[0]): + vid = int(routes["truck_id"][i]) + loc = int(routes["location"][i]) + prev_loc = prev_loc_per_vehicle.get(vid, 0) + cumulative_per_vehicle[vid] = ( + cumulative_per_vehicle.get(vid, 0.0) + + cost_flat[prev_loc * n_loc + loc] + ) + prev_loc_per_vehicle[vid] = loc + + if routes["type"][i] == "Break": + breaks_per_vehicle.setdefault(vid, []).append( + cumulative_per_vehicle[vid] + ) + assert loc in break_loc_set, ( + f"vehicle {vid} break at location {loc} not in break locations " + f"{break_loc_set}" + ) + + assert set(breaks_per_vehicle) == {0, 1}, ( + f"expected breaks on vehicles {{0, 1}}, got {set(breaks_per_vehicle)}" + ) + for vid, break_distances in breaks_per_vehicle.items(): + assert len(break_distances) == len(cycle_windows), ( + f"vehicle {vid} has {len(break_distances)} breaks, " + f"expected {len(cycle_windows)}" + ) + for k, d in enumerate(break_distances): + lo, hi = cycle_windows[k] + assert lo - 1e-6 <= d <= hi + 1e-6, ( + f"vehicle {vid} cycle {k} break at cumulative {d} outside window " + f"[{lo}, {hi}]" + ) + + +def test_solve_mixed_fleet_break_assignment(): + """Only the vehicle with a distance break configured receives break nodes.""" + dm = routing.DataModel(3, 2) + dm.add_cost_matrix(cudf.DataFrame(_COST_3X3, dtype="float32")) + dm.add_vehicle_distance_break(0, 0.0, 2.0, 1) + dm.set_min_vehicles(2) + + sol = _solve(dm) + assert sol.get_status() == 0 + + routes = sol.get_route().to_pandas() + found_break_v0 = False + for i in range(routes.shape[0]): + if routes["type"][i] == "Break": + assert routes["truck_id"][i] == 0, ( + f"vehicle {routes['truck_id'][i]} should not have a distance break" + ) + found_break_v0 = True + assert found_break_v0, ( + "vehicle 0 has a distance break configured but received none" + ) diff --git a/python/cuopt/cuopt/tests/routing/test_routing_grpc_serialization.py b/python/cuopt/cuopt/tests/routing/test_routing_grpc_serialization.py index fa43287ee2..bbf5d5b124 100644 --- a/python/cuopt/cuopt/tests/routing/test_routing_grpc_serialization.py +++ b/python/cuopt/cuopt/tests/routing/test_routing_grpc_serialization.py @@ -115,6 +115,28 @@ def test_populate_breaks(): assert s["uniform_breaks"] == 1 +def test_populate_vehicle_breaks(): + dm = routing.DataModel(5, 2) + cost = np.ones((5, 5), dtype=np.float32) + np.fill_diagonal(cost, 0) + dm.add_cost_matrix(cost) + dm.add_vehicle_break(0, 10, 20, 5, np.array([1, 2], np.int32)) + dm.add_vehicle_break(1, 30, 40, 4) + s = problem_summary(dm) + assert s["vehicle_breaks"] == 2 + + +def test_populate_distance_breaks(): + dm = routing.DataModel(5, 2) + cost = np.ones((5, 5), dtype=np.float32) + np.fill_diagonal(cost, 0) + dm.add_cost_matrix(cost) + dm.add_vehicle_distance_break(0, 2.0, 10.0, 3, np.array([1, 2], np.int32)) + dm.add_vehicle_distance_break(1, 0.0, 20.0, 4) + s = problem_summary(dm) + assert s["vehicle_distance_breaks"] == 2 + + def test_populate_handles_pandas_host_inputs(): """Pandas (host) inputs map identically to numpy. diff --git a/python/cuopt/cuopt/tests/routing/test_warnings_exceptions.py b/python/cuopt/cuopt/tests/routing/test_warnings_exceptions.py index 067d9d8f04..fec8978006 100644 --- a/python/cuopt/cuopt/tests/routing/test_warnings_exceptions.py +++ b/python/cuopt/cuopt/tests/routing/test_warnings_exceptions.py @@ -139,7 +139,7 @@ def test_range(): dm.set_order_locations(order_locations) assert ( str(exc_info.value) - == "All values in order locations must be less than or equal to 3" + == "All values in order locations must be less than or equal to 2" ) diff --git a/python/cuopt_server/cuopt_server/tests/test_server.py b/python/cuopt_server/cuopt_server/tests/test_server.py index a141053f49..a36b8e125a 100644 --- a/python/cuopt_server/cuopt_server/tests/test_server.py +++ b/python/cuopt_server/cuopt_server/tests/test_server.py @@ -1,7 +1,8 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 import pandas as pd +import pytest from cuopt_server.tests.utils.utils import cuoptproc # noqa from cuopt_server.tests.utils.utils import ( @@ -869,6 +870,52 @@ def test_vehicle_fixed_costs(cuoptproc): # noqa ) +@pytest.mark.parametrize( + "objectives, expected_cost", + [ + pytest.param(None, 11.0, id="default-weight"), + pytest.param({"distance_break_cost": 0}, 3.0, id="disabled"), + ], +) +@pytest.mark.usefixtures("cuoptproc") +def test_distance_break_cost_default_and_disabled(objectives, expected_cost): + cost_matrix = {0: [[0, 1, 1], [1, 0, 1], [1, 1, 0]]} + distance_breaks = [ + { + "vehicle_id": 0, + "distance_min": 10.0, + "distance_max": 100.0, + "duration": 0, + "locations": [2], + } + ] + + res = get_routes( + client, + cost_matrix=cost_matrix, + vehicle_locations=[[0, 0]], + vehicle_distance_breaks=distance_breaks, + task_locations=[1], + objectives=objectives, + time_limit=10, + ) + + assert res.status_code == 200 + solver_response = res.json()["response"]["solver_response"] + expected_objectives = {"cost": 3.0} + if objectives is None: + expected_objectives["distance_break_cost"] = 8.0 + validate_solver_sol( + solver_response, + expected_status=0, + expected_cost=expected_cost, + expected_vehicle_count=1, + expected_objective_values=expected_objectives, + ) + if objectives is not None: + assert "distance_break_cost" not in solver_response["objective_values"] + + def test_cost_matrix_solution(cuoptproc): # noqa cost_matrix = { 0: [ diff --git a/python/cuopt_server/cuopt_server/tests/test_set_solver_config.py b/python/cuopt_server/cuopt_server/tests/test_set_solver_config.py index d45179ffc2..56b0e73bf5 100644 --- a/python/cuopt_server/cuopt_server/tests/test_set_solver_config.py +++ b/python/cuopt_server/cuopt_server/tests/test_set_solver_config.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 import copy @@ -50,6 +50,7 @@ "travel_time": 200, "variance_route_size": 10, "variance_route_service_time": 50, + "distance_break_cost": 25, }, "config_file": "config.yaml", }, diff --git a/python/cuopt_server/cuopt_server/tests/utils/utils.py b/python/cuopt_server/cuopt_server/tests/utils/utils.py index 496794b5a4..c089fafc2a 100644 --- a/python/cuopt_server/cuopt_server/tests/utils/utils.py +++ b/python/cuopt_server/cuopt_server/tests/utils/utils.py @@ -61,6 +61,7 @@ def get_routes( capacities: Optional[List[List[int]]] = None, vehicle_time_windows: Optional[List[List[float]]] = None, vehicle_breaks: Optional[List[Dict]] = None, + vehicle_distance_breaks: Optional[List[Dict]] = None, vehicle_break_time_windows: Optional[List[List[List[int]]]] = None, vehicle_break_durations: Optional[List[List[int]]] = None, vehicle_break_locations: Optional[List[int]] = None, @@ -113,6 +114,7 @@ def get_routes( capacities=capacities, vehicle_time_windows=vehicle_time_windows, vehicle_breaks=vehicle_breaks, + vehicle_distance_breaks=vehicle_distance_breaks, vehicle_break_time_windows=vehicle_break_time_windows, vehicle_break_durations=vehicle_break_durations, vehicle_break_locations=vehicle_break_locations, @@ -179,6 +181,7 @@ def cuopt_service_sync( capacities: Optional[List[List[int]]] = None, vehicle_time_windows: Optional[List[List[float]]] = None, vehicle_breaks: Optional[List[Dict]] = None, + vehicle_distance_breaks: Optional[List[Dict]] = None, vehicle_break_time_windows: Optional[List[List[List[int]]]] = None, vehicle_break_durations: Optional[List[List[int]]] = None, vehicle_break_locations: Optional[List[int]] = None, @@ -225,6 +228,7 @@ def cuopt_service_sync( capacities=capacities, vehicle_time_windows=vehicle_time_windows, vehicle_breaks=vehicle_breaks, + vehicle_distance_breaks=vehicle_distance_breaks, vehicle_break_time_windows=vehicle_break_time_windows, vehicle_break_durations=vehicle_break_durations, vehicle_break_locations=vehicle_break_locations, diff --git a/python/cuopt_server/cuopt_server/utils/routing/data_definition.py b/python/cuopt_server/cuopt_server/utils/routing/data_definition.py index ba1b5e4e52..6cef634af3 100644 --- a/python/cuopt_server/cuopt_server/utils/routing/data_definition.py +++ b/python/cuopt_server/cuopt_server/utils/routing/data_definition.py @@ -84,6 +84,18 @@ class Objective(StrictModel): "The weight assigned to the accumulated fixed costs of each vehicle used in solution" # noqa ), ) + distance_break_cost: Optional[float] = Field( + default=None, + examples=[1], + description=( + "dtype: float32." + " \n\n " + "The weight assigned to each route's maximum shortfall below the " + "soft lower bounds of distance-based breaks. The solution value " + "sums those route maxima. It defaults to 1 when distance breaks " + "are configured; explicitly set it to 0 to disable the penalty." + ), + ) class VehicleBreak(StrictModel): @@ -119,6 +131,48 @@ class VehicleBreak(StrictModel): ) +class VehicleDistanceBreak(StrictModel): + vehicle_id: int = Field( + ..., + description=( + "dtype: int32, vehicle_id >= 0." + " \n\n " + "Vehicle id as an integer denoting the vehicle index for which the break is added" # noqa + ), + ) + distance_min: float = Field( + ..., + description=( + "dtype: float32, distance_min >= 0." + " \n\n " + "Soft lower bound on cumulative route distance at this break" + ), + ) + distance_max: float = Field( + ..., + description=( + "dtype: float32, distance_max > distance_min." + " \n\n " + "Latest cumulative route distance by which the vehicle must take" + " the break" + ), + ) + duration: int = Field( + ..., + description=( + "dtype: int32, duration >= 0. \n\n Duration of the break time" + ), + ) + locations: Optional[List[int]] = Field( + ..., + description=( + "dtype: int32, location_id >= 0." + " \n\n " + "Location ids where this break can be taken." + ), + ) + + class VehicleOrderMatch(StrictModel): vehicle_id: int = Field( ..., @@ -364,6 +418,40 @@ class FleetData(StrictModel): "be used." ), ) + vehicle_distance_breaks: Optional[List[VehicleDistanceBreak]] = Field( + default=None, + examples=[ + [ + { + "vehicle_id": 0, + "distance_min": 0.0, + "distance_max": 100.0, + "duration": 15, + "locations": [3, 4], + }, + { + "vehicle_id": 1, + "distance_min": 50.0, + "distance_max": 80.0, + "duration": 10, + "locations": [2], + }, + { + "vehicle_id": 1, + "distance_min": 150.0, + "distance_max": 200.0, + "duration": 10, + }, + ] + ], + description=( + "A list of vehicle distance breaks where a vehicle can take a" + " break between distance_min and distance_max for specified" + " duration in the specified locations. By default any location" + " can be used. Add multiple entries for the same vehicle to" + " require additional stops." + ), + ) vehicle_types: Optional[List[int]] = Field( default=None, examples=[[1, 2]], diff --git a/python/cuopt_server/cuopt_server/utils/routing/optimization_data_model.py b/python/cuopt_server/cuopt_server/utils/routing/optimization_data_model.py index 95f42fdb28..8bf599c095 100644 --- a/python/cuopt_server/cuopt_server/utils/routing/optimization_data_model.py +++ b/python/cuopt_server/cuopt_server/utils/routing/optimization_data_model.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 import numpy as np @@ -47,6 +47,9 @@ def get_objectives_as_lists(objectives): if objectives.vehicle_fixed_cost is not None: cuopt_objectives.append(routing.Objective.VEHICLE_FIXED_COST) objective_weights.append(objectives.vehicle_fixed_cost) + if objectives.distance_break_cost is not None: + cuopt_objectives.append(routing.Objective.DISTANCE_BREAK_COST) + objective_weights.append(objectives.distance_break_cost) return cuopt_objectives, objective_weights @@ -58,6 +61,7 @@ def get_objectives_as_lists(objectives): routing.Objective.VARIANCE_ROUTE_SERVICE_TIME: "variance_route_service_time", # noqa routing.Objective.PRIZE: "prize", routing.Objective.VEHICLE_FIXED_COST: "vehicle_fixed_cost", + routing.Objective.DISTANCE_BREAK_COST: "distance_break_cost", } @@ -93,6 +97,7 @@ def reset_fleet_data(self): "vehicle_break_durations": None, "vehicle_break_locations": None, "vehicle_breaks": None, + "vehicle_distance_breaks": None, "vehicle_types": None, "vehicle_order_match": None, "skip_first_trips": None, @@ -210,6 +215,9 @@ def get_fleet_data(self): if self.fleet_data["vehicle_break_locations"] is not None else None, "vehicle_breaks": self.fleet_data["vehicle_breaks"], + "vehicle_distance_breaks": self.fleet_data[ + "vehicle_distance_breaks" + ], "vehicle_order_match": self.fleet_data["vehicle_order_match"], "skip_first_trips": self.fleet_data["skip_first_trips"] .to_arrow() @@ -484,6 +492,7 @@ def set_fleet_data( vehicle_max_costs, vehicle_max_times, vehicle_fixed_costs, + vehicle_distance_breaks=None, ): if not self.is_route_detail_set: return ( @@ -519,6 +528,9 @@ def set_fleet_data( vehicle_order_match = get_none_for_empty_list(vehicle_order_match) skip_first_trips = get_none_for_empty_list(skip_first_trips) drop_return_trips = get_none_for_empty_list(drop_return_trips) + vehicle_distance_breaks = get_none_for_empty_list( + vehicle_distance_breaks + ) is_valid = validate_fleet_data( vehicle_ids, @@ -540,6 +552,7 @@ def set_fleet_data( vehicle_fixed_costs, updating=False, comparison_locations=None, + vehicle_distance_breaks=vehicle_distance_breaks, ) if is_valid[0]: @@ -608,6 +621,17 @@ def set_fleet_data( } for data in vehicle_breaks ] + if vehicle_distance_breaks is not None: + self.fleet_data["vehicle_distance_breaks"] = [ + { + "vehicle_id": data.vehicle_id, + "distance_min": data.distance_min, + "distance_max": data.distance_max, + "duration": data.duration, + "locations": data.locations, + } + for data in vehicle_distance_breaks + ] if vehicle_order_match is not None: self.fleet_data["vehicle_order_match"] = [ { @@ -647,6 +671,7 @@ def update_fleet_data( vehicle_max_costs, vehicle_max_times, vehicle_fixed_costs, + vehicle_distance_breaks=None, ): if not self.is_route_detail_set: return ( @@ -685,6 +710,9 @@ def update_fleet_data( vehicle_order_match = get_none_for_empty_list(vehicle_order_match) skip_first_trips = get_none_for_empty_list(skip_first_trips) drop_return_trips = get_none_for_empty_list(drop_return_trips) + vehicle_distance_breaks = get_none_for_empty_list( + vehicle_distance_breaks + ) is_valid = validate_fleet_data( vehicle_ids, @@ -703,8 +731,10 @@ def update_fleet_data( min_vehicles, vehicle_max_costs, vehicle_max_times, + vehicle_fixed_costs, updating=True, comparison_locations=self.fleet_data["vehicle_locations"], + vehicle_distance_breaks=vehicle_distance_breaks, ) if is_valid[0]: @@ -766,6 +796,17 @@ def update_fleet_data( } for data in vehicle_order_match ] + if vehicle_distance_breaks is not None: + self.fleet_data["vehicle_distance_breaks"] = [ + { + "vehicle_id": data.vehicle_id, + "distance_min": data.distance_min, + "distance_max": data.distance_max, + "duration": data.duration, + "locations": data.locations, + } + for data in vehicle_distance_breaks + ] if skip_first_trips: self.fleet_data["skip_first_trips"] = cudf.Series( skip_first_trips, dtype=bool diff --git a/python/cuopt_server/cuopt_server/utils/routing/solver.py b/python/cuopt_server/cuopt_server/utils/routing/solver.py index 2281488d30..441aff8540 100644 --- a/python/cuopt_server/cuopt_server/utils/routing/solver.py +++ b/python/cuopt_server/cuopt_server/utils/routing/solver.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 import time @@ -226,6 +226,27 @@ def create_data_model( cudf.Series(data["locations"]), ) + if optimization_data.fleet_data["vehicle_distance_breaks"] is not None: + for data in optimization_data.fleet_data["vehicle_distance_breaks"]: + if data["locations"] is not None: + if len(optimization_data.locations) > 0: + break_locations = locations.loc[data["locations"]].astype( + "int32" + ) + else: + break_locations = cudf.Series( + data["locations"], dtype="int32" + ) + else: + break_locations = None + data_model.add_vehicle_distance_break( + data["vehicle_id"], + data["distance_min"], + data["distance_max"], + data["duration"], + break_locations, + ) + if optimization_data.fleet_data["vehicle_order_match"] is not None: for data in optimization_data.fleet_data["vehicle_order_match"]: data_model.add_vehicle_order_match( @@ -400,6 +421,14 @@ def prep_optimization_data(optimization_data): "vehicle_break_locations" ].to_numpy(), ) + if optimization_data.fleet_data["vehicle_distance_breaks"] is not None: + for d in optimization_data.fleet_data["vehicle_distance_breaks"]: + break_locs = d.get("locations") + if break_locs is not None and len(break_locs) > 0: + optimization_data.locations = np.append( + optimization_data.locations, + np.asarray(break_locs), + ) optimization_data.locations = np.unique(optimization_data.locations) for v_type, graph in optimization_data.waypoint_graph.items(): diff --git a/python/cuopt_server/cuopt_server/utils/routing/validation_fleet_data.py b/python/cuopt_server/cuopt_server/utils/routing/validation_fleet_data.py index ff94ccfa79..f92ba4788a 100644 --- a/python/cuopt_server/cuopt_server/utils/routing/validation_fleet_data.py +++ b/python/cuopt_server/cuopt_server/utils/routing/validation_fleet_data.py @@ -47,6 +47,7 @@ def validate_fleet_data( vehicle_fixed_costs, updating=False, comparison_locations=None, + vehicle_distance_breaks=None, ): if vehicle_locations is not None: for loc in vehicle_locations: @@ -181,6 +182,42 @@ def validate_fleet_data( "Vehicle break location must be greater than or equal to 0", ) + if vehicle_distance_breaks is not None: + for entry in vehicle_distance_breaks: + if entry.vehicle_id < 0 or entry.vehicle_id >= n_vehicles: + return ( + False, + "vehicle_distance_breaks: vehicle_id must be within" + " [0, n_vehicles)", + ) + if entry.distance_min < 0: + return ( + False, + "vehicle_distance_breaks: distance_min must be >= 0", + ) + if entry.distance_max <= 0: + return ( + False, + "vehicle_distance_breaks: distance_max must be > 0", + ) + if entry.duration < 0: + return ( + False, + "vehicle_distance_breaks: duration must be >= 0", + ) + if entry.distance_min >= entry.distance_max: + return ( + False, + "vehicle_distance_breaks: distance_min must be <" + " distance_max", + ) + if entry.locations is not None: + if any(loc < 0 for loc in entry.locations): + return ( + False, + "vehicle_distance_breaks: locations must be >= 0", + ) + if vehicle_types is not None: unique_vehicle_types = set(vehicle_types) for matrix_type, vehicle_ids in vehicle_types_dict.items(): diff --git a/python/cuopt_server/cuopt_server/utils/solver.py b/python/cuopt_server/cuopt_server/utils/solver.py index 7f4e7896ff..c5e4124563 100644 --- a/python/cuopt_server/cuopt_server/utils/solver.py +++ b/python/cuopt_server/cuopt_server/utils/solver.py @@ -220,6 +220,7 @@ def populate_optimization_data( fleet_data.vehicle_max_costs, fleet_data.vehicle_max_times, fleet_data.vehicle_fixed_costs, + vehicle_distance_breaks=fleet_data.vehicle_distance_breaks, ) )