diff --git a/.gitignore b/.gitignore index c9e16fcc5d..0a3cac3582 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ python/**/*.cpp # datasets datasets/** !datasets/ref +!datasets/ref/fsmvrptwsc_small.txt !datasets/get_test_data.sh !datasets/distance_engine !datasets/sat/get_test_data.sh diff --git a/cpp/include/cuopt/routing/cpu_routing_problem.hpp b/cpp/include/cuopt/routing/cpu_routing_problem.hpp index bfbe3193ea..d2cb0acf1f 100644 --- a/cpp/include/cuopt/routing/cpu_routing_problem.hpp +++ b/cpp/include/cuopt/routing/cpu_routing_problem.hpp @@ -79,6 +79,7 @@ class cpu_routing_problem_t { std::vector cost_matrices; std::vector transit_time_matrices; + std::vector distance_matrices; std::vector vehicle_start_locations; std::vector vehicle_return_locations; @@ -88,8 +89,13 @@ class cpu_routing_problem_t { std::vector drop_return_trips; // 0/1 (avoid vector) std::vector skip_first_trips; // 0/1 std::vector vehicle_max_costs; + std::vector vehicle_max_distances; std::vector vehicle_max_times; std::vector vehicle_fixed_costs; + std::vector distance_tier_thresholds; + std::vector distance_tier_fixed_costs; + std::vector distance_tier_costs_per_unit; + std::vector distance_tier_offsets; std::vector order_locations; std::vector order_tw_earliest; diff --git a/cpp/include/cuopt/routing/data_model_view.hpp b/cpp/include/cuopt/routing/data_model_view.hpp index 2c44b2eeeb..add4c30f18 100644 --- a/cpp/include/cuopt/routing/data_model_view.hpp +++ b/cpp/include/cuopt/routing/data_model_view.hpp @@ -55,18 +55,28 @@ class data_model_view_t { i_t const fleet_size, i_t num_orders = -1); + /** + * @brief Set a distance matrix used by distance constraints and tiered pricing. + * + * + * @param[in] matrix Device memory pointer to a floating point square matrix of + * size + * num_locations_. cuOpt does not own or copy this data. + * @param[in] vehicle_type Identifier + * of the vehicle. + */ + void add_distance_matrix(f_t const* matrix, uint8_t vehicle_type = 0); + /** * @brief Set a cost matrix for all locations (depot included) at - * once. A cost matrix is defined a square matrix containing the - * costs, taken pairwise, between all locations. Entries are non-negative - * real numbers. Diagonal elements - * should be 0. Users should pre-compute costs between each pair of - * locations with their own technique before calling this function. Entries in - * this matrix could represent time, miles, meters or any metric that can be - * stored as a real number and satisfy the property above. - * The user can call add_cost_matrix multiple times. Setting the - * vehicle type will enable heterogenous fleet. It can model traveling - * costs for different vehicles (bicyces, bikes, trucks). + * once. A cost matrix + * is defined a square matrix containing the costs, taken pairwise, between all locations. Entries + * are non-negative real numbers. Diagonal elements should be 0. Users should pre-compute costs + * between each pair of locations with their own technique before calling this function. Entries + * in this matrix could represent time, miles, meters or any metric that can be stored as a real + * number and satisfy the property above. The user can call add_cost_matrix multiple times. + * Setting the vehicle type will enable heterogenous fleet. It can model traveling costs for + * different vehicles (bicyces, bikes, trucks). * * * @throws cuopt::logic_error when an error occurs. @@ -408,9 +418,17 @@ class data_model_view_t { */ void set_min_vehicles(i_t min_vehicles); + /** + * @brief Limits the distance matrix values accumulated along each route. + * @param[in] + * vehicle_max_distances Upper bound for each vehicle's route distance. + */ + void set_vehicle_max_distances(f_t const* vehicle_max_distances); + /** * @brief Limits the primary matrix cost cumulated along a route. - * @param[in] vehicle_max_costs Upper bound for route cost. + * @param[in] + * vehicle_max_costs Upper bound for route cost. */ void set_vehicle_max_costs(f_t const* vehicle_max_costs); @@ -430,6 +448,32 @@ class data_model_view_t { */ void set_vehicle_max_times(f_t const* vehicle_max_times); + /** + * @brief Set distance-based tiered pricing for vehicles. + * Each vehicle can have multiple tiers with different cost structures based on total route + * distance. Tier costs are accumulated by distance band in ascending threshold order. + * + * @param[in] thresholds Device memory pointer to distance thresholds for all tiers (flattened + * array) + * @param[in] fixed_costs Device memory pointer to fixed costs for all tiers (flattened array) + * @param[in] costs_per_unit Device memory pointer to cost per unit for all tiers (flattened + * array) + * @param[in] tier_offsets Device memory pointer to offsets array (size = fleet_size + 1) + * tier_offsets[i] indicates where vehicle i's tiers start in the flattened arrays + * @param[in] total_tiers Total number of tiers across all vehicles + */ + void set_vehicle_distance_tiers(f_t const* thresholds, + f_t const* fixed_costs, + f_t const* costs_per_unit, + i_t const* tier_offsets, + i_t total_tiers); + + /** + * @brief Get distance matrix + * @return Distance matrix pointer + */ + f_t const* get_distance_matrix(uint8_t vehicle_type = 0) const noexcept; + /** * @brief Get cost matrix * @return Matrix pointer @@ -442,10 +486,17 @@ class data_model_view_t { */ f_t const* get_transit_time_matrix(uint8_t vehicle_type = 0) const noexcept; + /** + * @brief Get all distance matrices as a map + * @return map of vehicle type to distance + * matrix + */ + std::unordered_map get_distance_matrices() const noexcept; + /** * @brief Get all cost matrices as a map * @return map of vehicle type to cost matrix - */ + */ std::unordered_map get_cost_matrices() const noexcept; /** @@ -623,6 +674,12 @@ class data_model_view_t { */ i_t get_min_vehicles() const noexcept; + /** + * @brief Return max distance allowed per vehicle + * @return max distance per route + */ + raft::device_span get_vehicle_max_distances() const noexcept; + /** * @brief Return max cost allowed per vehicle * @return max cost per route @@ -641,6 +698,13 @@ class data_model_view_t { */ raft::device_span get_vehicle_fixed_costs() const noexcept; + /** + * @brief Get distance tiers configuration for all vehicles + * @return Tuple of (thresholds, fixed_costs, costs_per_unit, tier_offsets, total_tiers) + */ + std::tuple get_vehicle_distance_tiers() + const noexcept; + /** * @brief Get raft handle object containing GPU resource objects * @return Handle object @@ -661,6 +725,7 @@ class data_model_view_t { i_t n_requests_{}; raft::device_span vehicle_types_; std::unordered_map cost_matrices_{}; + std::unordered_map distance_matrices_{}; std::unordered_map transit_time_matrices_{}; i_t const* order_locations_{nullptr}; i_t const* break_locations_{nullptr}; @@ -687,10 +752,18 @@ class data_model_view_t { std::unordered_map> precedence_{}; i_t min_num_vehicles_{0}; + raft::device_span vehicle_max_distances_{}; raft::device_span vehicle_max_costs_{}; raft::device_span vehicle_max_times_{}; raft::device_span vehicle_fixed_costs_{}; + // Distance tiers for tiered pricing + f_t const* distance_tier_thresholds_{nullptr}; + f_t const* distance_tier_fixed_costs_{nullptr}; + f_t const* distance_tier_costs_per_unit_{nullptr}; + i_t const* distance_tier_offsets_{nullptr}; + i_t total_distance_tiers_{0}; + raft::device_span initial_vehicle_ids_{}; raft::device_span initial_routes_{}; raft::device_span initial_types_{}; diff --git a/cpp/src/grpc/routing/cuopt_routing.proto b/cpp/src/grpc/routing/cuopt_routing.proto index 7121298aa5..aea142cf99 100644 --- a/cpp/src/grpc/routing/cuopt_routing.proto +++ b/cpp/src/grpc/routing/cuopt_routing.proto @@ -77,6 +77,13 @@ message InitialSolution { repeated int32 sol_offsets = 4; } +message VehicleDistanceTiers { + repeated float thresholds = 1; + repeated float fixed_costs = 2; + repeated float costs_per_unit = 3; + repeated int32 offsets = 4; +} + // --- Main problem --- message RoutingProblem { @@ -86,6 +93,7 @@ message RoutingProblem { repeated CostMatrix cost_matrices = 10; repeated CostMatrix transit_time_matrices = 11; + repeated CostMatrix distance_matrices = 12; // Vehicle arrays (size = fleet_size) repeated int32 vehicle_start_locations = 20; @@ -98,6 +106,8 @@ message RoutingProblem { repeated float vehicle_max_costs = 27; repeated float vehicle_max_times = 28; repeated float vehicle_fixed_costs = 29; + repeated float vehicle_max_distances = 35; + VehicleDistanceTiers vehicle_distance_tiers = 36; // Order arrays (size = num_orders) repeated int32 order_locations = 30; diff --git a/cpp/src/grpc/routing/grpc_routing_mapper_utils.hpp b/cpp/src/grpc/routing/grpc_routing_mapper_utils.hpp index 5cfbfd5685..a443220047 100644 --- a/cpp/src/grpc/routing/grpc_routing_mapper_utils.hpp +++ b/cpp/src/grpc/routing/grpc_routing_mapper_utils.hpp @@ -10,6 +10,8 @@ #include #include +#include +#include #include namespace cuopt { @@ -40,6 +42,9 @@ inline void copy_u32_to_u8(const google::protobuf::RepeatedField& src, dst.clear(); dst.reserve(static_cast(src.size())); for (auto v : src) { + if (v > std::numeric_limits::max()) { + throw std::invalid_argument("vehicle type must be within [0, 255]"); + } dst.push_back(static_cast(v)); } } diff --git a/cpp/src/grpc/routing/grpc_routing_problem_mapper.cpp b/cpp/src/grpc/routing/grpc_routing_problem_mapper.cpp index 95ea1e2d7b..f456c447fd 100644 --- a/cpp/src/grpc/routing/grpc_routing_problem_mapper.cpp +++ b/cpp/src/grpc/routing/grpc_routing_problem_mapper.cpp @@ -10,6 +10,8 @@ #include "grpc_routing_mapper_utils.hpp" #include +#include +#include #include namespace cuopt { @@ -29,17 +31,32 @@ void map_proto_to_routing_problem(const cuopt::remote::RoutingProblem& pb, p.num_orders = pb.num_orders(); for (auto const& cm : pb.cost_matrices()) { + if (cm.vehicle_type() > std::numeric_limits::max()) { + throw std::invalid_argument("cost matrix vehicle_type must be within [0, 255]"); + } cuopt::routing::cpu_cost_matrix_t out; out.vehicle_type = static_cast(cm.vehicle_type()); copy_repeated_to_vector(cm.values(), out.matrix); p.cost_matrices.push_back(std::move(out)); } for (auto const& tm : pb.transit_time_matrices()) { + if (tm.vehicle_type() > std::numeric_limits::max()) { + throw std::invalid_argument("transit time matrix vehicle_type must be within [0, 255]"); + } cuopt::routing::cpu_cost_matrix_t out; out.vehicle_type = static_cast(tm.vehicle_type()); copy_repeated_to_vector(tm.values(), out.matrix); p.transit_time_matrices.push_back(std::move(out)); } + for (auto const& dm : pb.distance_matrices()) { + if (dm.vehicle_type() > std::numeric_limits::max()) { + throw std::invalid_argument("distance matrix vehicle_type must be within [0, 255]"); + } + cuopt::routing::cpu_cost_matrix_t out; + out.vehicle_type = static_cast(dm.vehicle_type()); + copy_repeated_to_vector(dm.values(), out.matrix); + p.distance_matrices.push_back(std::move(out)); + } copy_repeated_to_vector(pb.vehicle_start_locations(), p.vehicle_start_locations); copy_repeated_to_vector(pb.vehicle_return_locations(), p.vehicle_return_locations); @@ -49,8 +66,16 @@ void map_proto_to_routing_problem(const cuopt::remote::RoutingProblem& pb, copy_bool_to_u8(pb.drop_return_trips(), p.drop_return_trips); copy_bool_to_u8(pb.skip_first_trips(), p.skip_first_trips); copy_repeated_to_vector(pb.vehicle_max_costs(), p.vehicle_max_costs); + copy_repeated_to_vector(pb.vehicle_max_distances(), p.vehicle_max_distances); copy_repeated_to_vector(pb.vehicle_max_times(), p.vehicle_max_times); copy_repeated_to_vector(pb.vehicle_fixed_costs(), p.vehicle_fixed_costs); + if (pb.has_vehicle_distance_tiers()) { + auto const& tiers = pb.vehicle_distance_tiers(); + copy_repeated_to_vector(tiers.thresholds(), p.distance_tier_thresholds); + copy_repeated_to_vector(tiers.fixed_costs(), p.distance_tier_fixed_costs); + copy_repeated_to_vector(tiers.costs_per_unit(), p.distance_tier_costs_per_unit); + copy_repeated_to_vector(tiers.offsets(), p.distance_tier_offsets); + } copy_repeated_to_vector(pb.order_locations(), p.order_locations); copy_repeated_to_vector(pb.order_tw_earliest(), p.order_tw_earliest); @@ -155,6 +180,11 @@ void map_routing_problem_to_proto(const cuopt::routing::cpu_routing_problem_t& p out->set_vehicle_type(tm.vehicle_type); copy_vector_to_repeated(tm.matrix, out->mutable_values()); } + for (auto const& dm : p.distance_matrices) { + auto* out = pb->add_distance_matrices(); + out->set_vehicle_type(dm.vehicle_type); + copy_vector_to_repeated(dm.matrix, out->mutable_values()); + } copy_vector_to_repeated(p.vehicle_start_locations, pb->mutable_vehicle_start_locations()); copy_vector_to_repeated(p.vehicle_return_locations, pb->mutable_vehicle_return_locations()); @@ -170,8 +200,17 @@ void map_routing_problem_to_proto(const cuopt::routing::cpu_routing_problem_t& p pb->add_skip_first_trips(v != 0); } copy_vector_to_repeated(p.vehicle_max_costs, pb->mutable_vehicle_max_costs()); + copy_vector_to_repeated(p.vehicle_max_distances, pb->mutable_vehicle_max_distances()); copy_vector_to_repeated(p.vehicle_max_times, pb->mutable_vehicle_max_times()); copy_vector_to_repeated(p.vehicle_fixed_costs, pb->mutable_vehicle_fixed_costs()); + if (!p.distance_tier_thresholds.empty() || !p.distance_tier_fixed_costs.empty() || + !p.distance_tier_costs_per_unit.empty() || !p.distance_tier_offsets.empty()) { + auto* tiers = pb->mutable_vehicle_distance_tiers(); + copy_vector_to_repeated(p.distance_tier_thresholds, tiers->mutable_thresholds()); + copy_vector_to_repeated(p.distance_tier_fixed_costs, tiers->mutable_fixed_costs()); + copy_vector_to_repeated(p.distance_tier_costs_per_unit, tiers->mutable_costs_per_unit()); + copy_vector_to_repeated(p.distance_tier_offsets, tiers->mutable_offsets()); + } copy_vector_to_repeated(p.order_locations, pb->mutable_order_locations()); copy_vector_to_repeated(p.order_tw_earliest, pb->mutable_order_tw_earliest()); diff --git a/cpp/src/grpc/server/grpc_worker.cpp b/cpp/src/grpc/server/grpc_worker.cpp index a915eebf68..474b002209 100644 --- a/cpp/src/grpc/server/grpc_worker.cpp +++ b/cpp/src/grpc/server/grpc_worker.cpp @@ -119,6 +119,7 @@ struct DeserializedJob { bool enable_set_incumbent = false; bool is_vrp = false; bool success = false; + std::string error_message; }; struct SolveResult { @@ -321,91 +322,98 @@ static DeserializedJob read_problem_from_pipe(int worker_id, const JobQueueEntry auto pipe_recv_t0 = std::chrono::steady_clock::now(); - if (is_chunked_job) { - // Chunked path: LP/MIP only for now (VRP is unary-only in this POC). - if (job.problem_category == cuopt::remote::VRP) { - SERVER_LOG_ERROR("[Worker] Chunked VRP upload is not supported"); - return dj; - } - // Chunked path: the server wrote a ChunkedProblemHeader followed by - // a set of raw typed arrays (constraint matrix, bounds, etc.). - // This avoids a single giant protobuf allocation for large problems. - cuopt::remote::ChunkedProblemHeader chunked_header; - std::map> arrays; - std::map> - container_arrays; - if (!read_chunked_request_from_pipe(read_fd, chunked_header, arrays, container_arrays)) { - return dj; - } - - if (config.verbose) { - int64_t total_bytes = 0; - for (const auto& [fid, data] : arrays) { - total_bytes += data.size(); + try { + if (is_chunked_job) { + // Chunked path: LP/MIP only for now (VRP is unary-only in this POC). + if (job.problem_category == cuopt::remote::VRP) { + SERVER_LOG_ERROR("[Worker] Chunked VRP upload is not supported"); + return dj; } - int64_t container_total_bytes = 0; - for (const auto& [key, data] : container_arrays) { - container_total_bytes += data.size(); + // Chunked path: the server wrote a ChunkedProblemHeader followed by + // a set of raw typed arrays (constraint matrix, bounds, etc.). + // This avoids a single giant protobuf allocation for large problems. + cuopt::remote::ChunkedProblemHeader chunked_header; + std::map> arrays; + std::map> + container_arrays; + if (!read_chunked_request_from_pipe(read_fd, chunked_header, arrays, container_arrays)) { + return dj; } - log_pipe_throughput("pipe_job_recv", total_bytes + container_total_bytes, pipe_recv_t0); - SERVER_LOG_INFO( - "[Worker] IPC path: CHUNKED (%zu top-level arrays, %ld bytes; %zu container " - "arrays, %ld bytes)", - arrays.size(), - total_bytes, - container_arrays.size(), - container_total_bytes); - } - if (chunked_header.has_lp_settings()) { - map_proto_to_pdlp_settings(chunked_header.lp_settings(), dj.lp_settings); - } - if (chunked_header.has_mip_settings()) { - map_proto_to_mip_settings(chunked_header.mip_settings(), dj.mip_settings); - } - dj.enable_incumbents = chunked_header.enable_incumbents(); - dj.enable_set_incumbent = chunked_header.enable_set_incumbent(); - cuopt::mathematical_optimization::map_chunked_arrays_to_problem( - chunked_header, arrays, container_arrays, dj.problem); - } else { - // Unary path: the entire SubmitJobRequest was serialized as a single - // protobuf blob. Simpler but copies more memory for large problems. - std::vector request_data; - if (!recv_job_data_pipe(read_fd, job.data_size, request_data)) { return dj; } - - if (config.verbose) { - log_pipe_throughput("pipe_job_recv", static_cast(request_data.size()), pipe_recv_t0); - } - cuopt::remote::SubmitJobRequest submit_request; - if (!submit_request.ParseFromArray(request_data.data(), - static_cast(request_data.size())) || - (!submit_request.has_lp_request() && !submit_request.has_mip_request() && - !submit_request.has_vrp_request())) { - return dj; - } - if (submit_request.has_lp_request()) { - const auto& req = submit_request.lp_request(); - SERVER_LOG_INFO("[Worker] IPC path: UNARY LP (%zu bytes)", request_data.size()); - map_proto_to_problem(req.problem(), dj.problem); - map_proto_to_pdlp_settings(req.settings(), dj.lp_settings); - } else if (submit_request.has_mip_request()) { - const auto& req = submit_request.mip_request(); - SERVER_LOG_INFO("[Worker] IPC path: UNARY MIP (%zu bytes)", request_data.size()); - map_proto_to_problem(req.problem(), dj.problem); - map_proto_to_mip_settings(req.settings(), dj.mip_settings); - dj.enable_incumbents = req.has_enable_incumbents() ? req.enable_incumbents() : true; - dj.enable_set_incumbent = req.has_enable_set_incumbent() ? req.enable_set_incumbent() : false; + if (config.verbose) { + int64_t total_bytes = 0; + for (const auto& [fid, data] : arrays) { + total_bytes += data.size(); + } + int64_t container_total_bytes = 0; + for (const auto& [key, data] : container_arrays) { + container_total_bytes += data.size(); + } + log_pipe_throughput("pipe_job_recv", total_bytes + container_total_bytes, pipe_recv_t0); + SERVER_LOG_INFO( + "[Worker] IPC path: CHUNKED (%zu top-level arrays, %ld bytes; %zu container " + "arrays, %ld bytes)", + arrays.size(), + total_bytes, + container_arrays.size(), + container_total_bytes); + } + if (chunked_header.has_lp_settings()) { + map_proto_to_pdlp_settings(chunked_header.lp_settings(), dj.lp_settings); + } + if (chunked_header.has_mip_settings()) { + map_proto_to_mip_settings(chunked_header.mip_settings(), dj.mip_settings); + } + dj.enable_incumbents = chunked_header.enable_incumbents(); + dj.enable_set_incumbent = chunked_header.enable_set_incumbent(); + cuopt::mathematical_optimization::map_chunked_arrays_to_problem( + chunked_header, arrays, container_arrays, dj.problem); } else { + // Unary path: the entire SubmitJobRequest was serialized as a single + // protobuf blob. Simpler but copies more memory for large problems. + std::vector request_data; + if (!recv_job_data_pipe(read_fd, job.data_size, request_data)) { return dj; } + + if (config.verbose) { + log_pipe_throughput( + "pipe_job_recv", static_cast(request_data.size()), pipe_recv_t0); + } + cuopt::remote::SubmitJobRequest submit_request; + if (!submit_request.ParseFromArray(request_data.data(), + static_cast(request_data.size())) || + (!submit_request.has_lp_request() && !submit_request.has_mip_request() && + !submit_request.has_vrp_request())) { + return dj; + } + if (submit_request.has_lp_request()) { + const auto& req = submit_request.lp_request(); + SERVER_LOG_INFO("[Worker] IPC path: UNARY LP (%zu bytes)", request_data.size()); + map_proto_to_problem(req.problem(), dj.problem); + map_proto_to_pdlp_settings(req.settings(), dj.lp_settings); + } else if (submit_request.has_mip_request()) { + const auto& req = submit_request.mip_request(); + SERVER_LOG_INFO("[Worker] IPC path: UNARY MIP (%zu bytes)", request_data.size()); + map_proto_to_problem(req.problem(), dj.problem); + map_proto_to_mip_settings(req.settings(), dj.mip_settings); + dj.enable_incumbents = req.has_enable_incumbents() ? req.enable_incumbents() : true; + dj.enable_set_incumbent = + req.has_enable_set_incumbent() ? req.enable_set_incumbent() : false; + } else { #ifdef CUOPT_ENABLE_GRPC_ROUTING - const auto& req = submit_request.vrp_request(); - SERVER_LOG_INFO("[Worker] IPC path: UNARY VRP (%zu bytes)", request_data.size()); - map_proto_to_routing_problem(req.problem(), dj.routing_problem); - map_proto_to_routing_settings(req.settings(), dj.routing_settings); - dj.is_vrp = true; + const auto& req = submit_request.vrp_request(); + SERVER_LOG_INFO("[Worker] IPC path: UNARY VRP (%zu bytes)", request_data.size()); + map_proto_to_routing_problem(req.problem(), dj.routing_problem); + map_proto_to_routing_settings(req.settings(), dj.routing_settings); + dj.is_vrp = true; #else - SERVER_LOG_ERROR("[Worker] VRP request received but this build has no routing support"); - return dj; + SERVER_LOG_ERROR("[Worker] VRP request received but this build has no routing support"); + return dj; #endif + } } + } catch (const std::exception& e) { + dj.error_message = e.what(); + SERVER_LOG_ERROR("[Worker %d] Failed to deserialize problem: %s", worker_id, e.what()); + return dj; } dj.success = true; @@ -748,7 +756,10 @@ void worker_process(int worker_id) auto deserialized = read_problem_from_pipe(worker_id, job); if (!deserialized.success) { - SERVER_LOG_ERROR("[Worker %d] Failed to read job data from pipe", worker_id); + const auto error_message = deserialized.error_message.empty() + ? "Failed to read job data" + : deserialized.error_message.c_str(); + SERVER_LOG_ERROR("[Worker %d] %s", worker_id, error_message); store_simple_result(job_id, worker_id, RESULT_ERROR, "Failed to read job data"); reset_job_slot(job); continue; diff --git a/cpp/src/routing/arc_value.hpp b/cpp/src/routing/arc_value.hpp index f01e6f3c98..9a48bf05c2 100644 --- a/cpp/src/routing/arc_value.hpp +++ b/cpp/src/routing/arc_value.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include @@ -58,6 +59,18 @@ static constexpr double get_arc_cost(const NodeInfo& l1, return lookup_matrix_value(matrix, l1.location(), l2.location(), vehicle_info.matrices.extent[3]); } +template +static constexpr double get_travel_distance(const NodeInfo& l1, + const NodeInfo& l2, + const VehicleInfo& vehicle_info) +{ + if (!vehicle_info.uses_travel_distance()) { return 0.; } + if (vehicle_info.skip_first_trip && l1.node_type() == node_type_t::DEPOT) { return 0.f; } + if (vehicle_info.drop_return_trip && l2.node_type() == node_type_t::DEPOT) { return 0.f; } + auto matrix = vehicle_info.matrices.get_distance_matrix(vehicle_info.type); + return lookup_matrix_value(matrix, l1.location(), l2.location(), vehicle_info.matrices.extent[3]); +} + // All values pre-loaded overload template static constexpr double get_transit_time(const NodeInfo& l1, diff --git a/cpp/src/routing/cpu_routing_problem.cu b/cpp/src/routing/cpu_routing_problem.cu index 03bf7e5569..c3243d7010 100644 --- a/cpp/src/routing/cpu_routing_problem.cu +++ b/cpp/src/routing/cpu_routing_problem.cu @@ -15,6 +15,8 @@ #include #include +#include +#include #include #include @@ -24,6 +26,7 @@ namespace routing { struct cpu_routing_problem_t::device_data_t { std::vector>> cost_matrices; std::vector>> transit_time_matrices; + std::vector>> distance_matrices; std::unique_ptr> vehicle_start_locations; std::unique_ptr> vehicle_return_locations; @@ -33,8 +36,13 @@ struct cpu_routing_problem_t::device_data_t { std::unique_ptr> drop_return_trips; std::unique_ptr> skip_first_trips; std::unique_ptr> vehicle_max_costs; + std::unique_ptr> vehicle_max_distances; std::unique_ptr> vehicle_max_times; std::unique_ptr> vehicle_fixed_costs; + std::unique_ptr> distance_tier_thresholds; + std::unique_ptr> distance_tier_fixed_costs; + std::unique_ptr> distance_tier_costs_per_unit; + std::unique_ptr> distance_tier_offsets; std::unique_ptr> order_locations; std::unique_ptr> order_tw_earliest; @@ -115,10 +123,15 @@ cpu_routing_problem_t::to_device(raft::handle_t* handle) const auto stream = handle->get_stream(); device_data_ptr data(new device_data_t()); - int32_t orders = (num_orders < 0) ? num_locations : num_orders; + int32_t orders = (num_orders < 0) ? num_locations : num_orders; + const auto matrix_size = static_cast(num_locations) * num_locations; data_model_view_t view(handle, num_locations, fleet_size, orders); for (auto const& cm : cost_matrices) { + if (cm.matrix.size() != matrix_size) { + throw std::invalid_argument( + "cpu_routing_problem_t::to_device: cost matrix size must equal num_locations squared"); + } auto d = copy_vector(cm.matrix, stream); if (!d) { throw std::invalid_argument("cpu_routing_problem_t::to_device: empty cost matrix"); } view.add_cost_matrix(d->data(), cm.vehicle_type); @@ -126,6 +139,11 @@ cpu_routing_problem_t::to_device(raft::handle_t* handle) const } for (auto const& tm : transit_time_matrices) { + if (tm.matrix.size() != matrix_size) { + throw std::invalid_argument( + "cpu_routing_problem_t::to_device: transit time matrix size must equal num_locations " + "squared"); + } auto d = copy_vector(tm.matrix, stream); if (!d) { throw std::invalid_argument("cpu_routing_problem_t::to_device: empty transit time matrix"); @@ -134,6 +152,26 @@ cpu_routing_problem_t::to_device(raft::handle_t* handle) const data->transit_time_matrices.push_back(std::move(d)); } + for (auto const& dm : distance_matrices) { + if (dm.matrix.size() != matrix_size) { + throw std::invalid_argument( + "cpu_routing_problem_t::to_device: distance matrix size must equal num_locations squared"); + } + if (std::any_of(dm.matrix.begin(), dm.matrix.end(), [](float value) { + return std::isnan(value) || value < 0.f; + })) { + throw std::invalid_argument( + "cpu_routing_problem_t::to_device: distance matrix values must be non-negative and not " + "NaN"); + } + auto d = copy_vector(dm.matrix, stream); + if (!d) { + throw std::invalid_argument("cpu_routing_problem_t::to_device: empty distance matrix"); + } + view.add_distance_matrix(d->data(), dm.vehicle_type); + data->distance_matrices.push_back(std::move(d)); + } + if (!vehicle_start_locations.empty() && !vehicle_return_locations.empty()) { data->vehicle_start_locations = copy_vector(vehicle_start_locations, stream); data->vehicle_return_locations = copy_vector(vehicle_return_locations, stream); @@ -164,10 +202,31 @@ cpu_routing_problem_t::to_device(raft::handle_t* handle) const } if (!vehicle_max_costs.empty()) { + if (vehicle_max_costs.size() != static_cast(fleet_size) || + std::any_of(vehicle_max_costs.begin(), vehicle_max_costs.end(), [](float value) { + return !std::isfinite(value) || value < 0.f; + })) { + throw std::invalid_argument( + "cpu_routing_problem_t::to_device: vehicle max costs must contain one finite, " + "non-negative value per vehicle"); + } data->vehicle_max_costs = copy_vector(vehicle_max_costs, stream); view.set_vehicle_max_costs(data->vehicle_max_costs->data()); } + if (!vehicle_max_distances.empty()) { + if (vehicle_max_distances.size() != static_cast(fleet_size) || + std::any_of(vehicle_max_distances.begin(), vehicle_max_distances.end(), [](float value) { + return !std::isfinite(value) || value < 0.f; + })) { + throw std::invalid_argument( + "cpu_routing_problem_t::to_device: vehicle max distances must contain one finite, " + "non-negative value per vehicle"); + } + data->vehicle_max_distances = copy_vector(vehicle_max_distances, stream); + view.set_vehicle_max_distances(data->vehicle_max_distances->data()); + } + if (!vehicle_max_times.empty()) { data->vehicle_max_times = copy_vector(vehicle_max_times, stream); view.set_vehicle_max_times(data->vehicle_max_times->data()); @@ -178,6 +237,55 @@ cpu_routing_problem_t::to_device(raft::handle_t* handle) const view.set_vehicle_fixed_costs(data->vehicle_fixed_costs->data()); } + if (!distance_tier_thresholds.empty() || !distance_tier_fixed_costs.empty() || + !distance_tier_costs_per_unit.empty() || !distance_tier_offsets.empty()) { + if (distance_tier_fixed_costs.size() != distance_tier_thresholds.size() || + distance_tier_costs_per_unit.size() != distance_tier_thresholds.size() || + distance_tier_offsets.size() != static_cast(fleet_size + 1) || + distance_tier_offsets.front() != 0 || + distance_tier_offsets.back() != static_cast(distance_tier_thresholds.size()) || + !std::is_sorted(distance_tier_offsets.begin(), distance_tier_offsets.end())) { + throw std::invalid_argument( + "cpu_routing_problem_t::to_device: invalid vehicle distance tiers"); + } + for (int32_t vehicle_id = 0; vehicle_id < fleet_size; ++vehicle_id) { + const auto tier_begin = distance_tier_offsets[vehicle_id]; + const auto tier_end = distance_tier_offsets[vehicle_id + 1]; + if (tier_begin >= tier_end) { + throw std::invalid_argument( + "cpu_routing_problem_t::to_device: each vehicle must have at least one distance tier"); + } + for (auto tier = tier_begin; tier < tier_end; ++tier) { + if (!std::isfinite(distance_tier_thresholds[tier]) || + distance_tier_thresholds[tier] < 0.f || + !std::isfinite(distance_tier_fixed_costs[tier]) || + distance_tier_fixed_costs[tier] < 0.f || + !std::isfinite(distance_tier_costs_per_unit[tier]) || + distance_tier_costs_per_unit[tier] < 0.f || + (tier > tier_begin && + distance_tier_thresholds[tier - 1] >= distance_tier_thresholds[tier])) { + throw std::invalid_argument( + "cpu_routing_problem_t::to_device: distance tiers must have finite, non-negative " + "values and strictly increasing thresholds"); + } + } + if (distance_tier_thresholds[tier_end - 1] != std::numeric_limits::max()) { + throw std::invalid_argument( + "cpu_routing_problem_t::to_device: the last distance tier threshold for each vehicle " + "must be float32 max"); + } + } + data->distance_tier_thresholds = copy_vector(distance_tier_thresholds, stream); + data->distance_tier_fixed_costs = copy_vector(distance_tier_fixed_costs, stream); + data->distance_tier_costs_per_unit = copy_vector(distance_tier_costs_per_unit, stream); + data->distance_tier_offsets = copy_vector(distance_tier_offsets, stream); + view.set_vehicle_distance_tiers(data->distance_tier_thresholds->data(), + data->distance_tier_fixed_costs->data(), + data->distance_tier_costs_per_unit->data(), + data->distance_tier_offsets->data(), + static_cast(distance_tier_thresholds.size())); + } + if (!order_locations.empty()) { data->order_locations = copy_vector(order_locations, stream); view.set_order_locations(data->order_locations->data()); diff --git a/cpp/src/routing/data_model_view.cu b/cpp/src/routing/data_model_view.cu index 40b82a2f09..3733b753a8 100644 --- a/cpp/src/routing/data_model_view.cu +++ b/cpp/src/routing/data_model_view.cu @@ -76,6 +76,13 @@ data_model_view_t::data_model_view_t(raft::handle_t* handle_ptr, "Number of nodes should be lower than 65535"); } +template +void data_model_view_t::add_distance_matrix(f_t const* matrix, uint8_t vehicle_type) +{ + cuopt_expects(matrix != nullptr, error_type_t::ValidationError, "Matrix input cannot be null"); + distance_matrices_[vehicle_type] = matrix; +} + template void data_model_view_t::add_cost_matrix(f_t const* matrix, uint8_t vehicle_type) { @@ -545,6 +552,15 @@ void data_model_view_t::set_min_vehicles(i_t min_vehicles) min_num_vehicles_ = min_vehicles; } +template +void data_model_view_t::set_vehicle_max_distances(f_t const* vehicle_max_distances) +{ + cuopt_expects(vehicle_max_distances != nullptr, + error_type_t::ValidationError, + "vehicle_max_distances cannot be null"); + vehicle_max_distances_ = raft::device_span(vehicle_max_distances, fleet_size_); +} + template void data_model_view_t::set_vehicle_max_costs(f_t const* vehicle_max_costs) { @@ -572,6 +588,41 @@ void data_model_view_t::set_vehicle_fixed_costs(f_t const* vehicle_fix vehicle_fixed_costs_ = raft::device_span(vehicle_fixed_costs, fleet_size_); } +template +void data_model_view_t::set_vehicle_distance_tiers(f_t const* thresholds, + f_t const* fixed_costs, + f_t const* costs_per_unit, + i_t const* tier_offsets, + i_t total_tiers) +{ + cuopt_expects(thresholds != nullptr, + error_type_t::ValidationError, + "distance tier thresholds cannot be null"); + cuopt_expects(fixed_costs != nullptr, + error_type_t::ValidationError, + "distance tier fixed_costs cannot be null"); + cuopt_expects(costs_per_unit != nullptr, + error_type_t::ValidationError, + "distance tier costs_per_unit cannot be null"); + cuopt_expects( + tier_offsets != nullptr, error_type_t::ValidationError, "distance tier offsets cannot be null"); + cuopt_expects(total_tiers > 0, error_type_t::ValidationError, "total_tiers must be positive"); + + distance_tier_thresholds_ = thresholds; + distance_tier_fixed_costs_ = fixed_costs; + distance_tier_costs_per_unit_ = costs_per_unit; + distance_tier_offsets_ = tier_offsets; + total_distance_tiers_ = total_tiers; +} + +template +f_t const* data_model_view_t::get_distance_matrix(uint8_t vehicle_type) const noexcept +{ + if (distance_matrices_.find(vehicle_type) != distance_matrices_.end()) + return distance_matrices_.at(vehicle_type); + return nullptr; +} + template f_t const* data_model_view_t::get_cost_matrix(uint8_t vehicle_type) const noexcept { @@ -588,6 +639,13 @@ f_t const* data_model_view_t::get_transit_time_matrix(uint8_t vehicle_ return nullptr; } +template +std::unordered_map data_model_view_t::get_distance_matrices() + const noexcept +{ + return distance_matrices_; +} + template std::unordered_map data_model_view_t::get_cost_matrices() const noexcept @@ -775,6 +833,12 @@ i_t data_model_view_t::get_min_vehicles() const noexcept return min_num_vehicles_; } +template +raft::device_span data_model_view_t::get_vehicle_max_distances() const noexcept +{ + return vehicle_max_distances_; +} + template raft::device_span data_model_view_t::get_vehicle_max_costs() const noexcept { @@ -805,6 +869,17 @@ raft::handle_t const* data_model_view_t::get_handle_ptr() const noexce return handle_ptr_; } +template +std::tuple +data_model_view_t::get_vehicle_distance_tiers() const noexcept +{ + return std::make_tuple(distance_tier_thresholds_, + distance_tier_fixed_costs_, + distance_tier_costs_per_unit_, + distance_tier_offsets_, + total_distance_tiers_); +} + template class CUOPT_EXPORT data_model_view_t; } // namespace routing } // namespace cuopt diff --git a/cpp/src/routing/fleet_info.cu b/cpp/src/routing/fleet_info.cu index 71997db103..e83102c56f 100644 --- a/cpp/src/routing/fleet_info.cu +++ b/cpp/src/routing/fleet_info.cu @@ -8,6 +8,9 @@ #include #include +#include +#include + namespace cuopt { namespace routing { namespace detail { @@ -26,9 +29,16 @@ void populate_matrices(data_model_view_t const& data_model, d_mdarray_ // Check for consistency of cost matrices const auto& cost_matrices = data_model.get_cost_matrices(); + const auto& distance_matrices = data_model.get_distance_matrices(); const auto& transit_time_matrices = data_model.get_transit_time_matrices(); + const auto total_tiers = std::get<4>(data_model.get_vehicle_distance_tiers()); + const bool requires_distance = !data_model.get_vehicle_max_distances().empty() || total_tiers > 0; if (cost_matrices.empty()) { EXE_CUOPT_FAIL("Cost matrix (or matrices) must be specified!"); } + cuopt_expects(!requires_distance || !distance_matrices.empty(), + error_type_t::ValidationError, + "A distance matrix must be set when using vehicle distance tiers or maximum " + "distances"); for (auto& [vtype, time_matrix] : transit_time_matrices) { if (!cost_matrices.count(vtype)) { @@ -50,6 +60,27 @@ void populate_matrices(data_model_view_t const& data_model, d_mdarray_ } } + if (!distance_matrices.empty()) { + auto vehicle_types_map = get_unique_vehicle_types(data_model.get_vehicle_types(), stream_view_); + for (auto const& vehicle_type_mapping : vehicle_types_map) { + cuopt_expects(distance_matrices.count(vehicle_type_mapping.first) > 0, + error_type_t::ValidationError, + "All vehicle distance matrices should be set"); + } + + const size_t matrix_size = static_cast(nlocations) * static_cast(nlocations); + for (auto const& distance_matrix_entry : distance_matrices) { + const bool valid = + thrust::all_of(handle_ptr_->get_thrust_policy(), + distance_matrix_entry.second, + distance_matrix_entry.second + matrix_size, + [] __device__(f_t value) { return value == value && value >= f_t{0}; }); + cuopt_expects(valid, + error_type_t::ValidationError, + "Distance matrix values must be non-negative and not NaN"); + } + } + auto n_matrix_types = detail::get_cost_matrix_type_dim(data_model); matrices_ = detail::create_device_mdarray(nlocations, n_vehicle_types, n_matrix_types, stream_view_); @@ -227,9 +258,38 @@ void populate_fleet_info(data_model_view_t const& data_model, } populate_matrices(data_model, fleet_info_.matrices_); populate_fleet_order_constraints(data_model, fleet_info_.fleet_order_constraints_, is_homogenous); + const bool has_separate_distance_matrix = detail::has_distance_matrix(data_model); // max constraints + if (auto vehicle_max_distances = data_model.get_vehicle_max_distances(); + !vehicle_max_distances.empty()) { + cuopt_expects(has_separate_distance_matrix, + error_type_t::ValidationError, + "vehicle_max_distances requires add_distance_matrix() to be set"); + auto host_max_distances = cuopt::host_copy(vehicle_max_distances, stream_view); + const bool valid = + std::all_of(host_max_distances.begin(), host_max_distances.end(), [](f_t value) { + return std::isfinite(value) && value >= f_t{0}; + }); + cuopt_expects(valid, + error_type_t::ValidationError, + "Vehicle maximum distances must be finite and non-negative"); + fleet_info_.v_max_distances_.resize(fleet_size, stream_view); + raft::copy( + fleet_info_.v_max_distances_.data(), vehicle_max_distances.data(), fleet_size, stream_view); + is_homogenous = + is_homogenous && + all_entries_are_equal(handle_ptr_, fleet_info_.v_max_distances_.data(), fleet_size); + } + if (auto vehicle_max_costs = data_model.get_vehicle_max_costs(); !vehicle_max_costs.empty()) { + auto host_max_costs = cuopt::host_copy(vehicle_max_costs, stream_view); + const bool valid = std::all_of(host_max_costs.begin(), host_max_costs.end(), [](f_t value) { + return std::isfinite(value) && value >= f_t{0}; + }); + cuopt_expects(valid, + error_type_t::ValidationError, + "Vehicle maximum costs must be finite and non-negative"); fleet_info_.v_max_costs_.resize(fleet_size, stream_view); raft::copy(fleet_info_.v_max_costs_.data(), vehicle_max_costs.data(), fleet_size, stream_view); is_homogenous = is_homogenous && @@ -255,6 +315,84 @@ void populate_fleet_info(data_model_view_t const& data_model, fleet_info_.v_fixed_costs_.end(), -1.f); } + + // Copy distance tiers from data_model to fleet_info + auto [thresholds, fixed_costs, costs_per_unit, tier_offsets, total_tiers] = + data_model.get_vehicle_distance_tiers(); + + if (thresholds != nullptr && total_tiers > 0) { + cuopt_expects(has_separate_distance_matrix, + error_type_t::ValidationError, + "vehicle_distance_tiers requires add_distance_matrix() to be set"); + // Resize and copy the flattened tiers data + fleet_info_.v_distance_tiers_.resize(total_tiers, stream_view); + fleet_info_.v_tier_offsets_.resize(fleet_size + 1, stream_view); + + std::vector h_tier_offsets(fleet_size + 1); + + // Copy tier offsets + raft::copy(fleet_info_.v_tier_offsets_.data(), tier_offsets, fleet_size + 1, stream_view); + raft::copy(h_tier_offsets.data(), tier_offsets, fleet_size + 1, stream_view); + + // Copy tier data (thresholds, fixed_costs, costs_per_unit) into distance_tier_t structs + std::vector> h_tiers(total_tiers); + std::vector h_thresholds(total_tiers); + std::vector h_fixed_costs(total_tiers); + std::vector h_costs_per_unit(total_tiers); + + raft::copy(h_thresholds.data(), thresholds, total_tiers, stream_view); + raft::copy(h_fixed_costs.data(), fixed_costs, total_tiers, stream_view); + raft::copy(h_costs_per_unit.data(), costs_per_unit, total_tiers, stream_view); + handle_ptr_->sync_stream(); + + cuopt_expects(h_tier_offsets.front() == 0 && h_tier_offsets.back() == total_tiers && + std::is_sorted(h_tier_offsets.begin(), h_tier_offsets.end()), + error_type_t::ValidationError, + "Invalid distance tier offsets"); + for (i_t vehicle_id = 0; vehicle_id < fleet_size; ++vehicle_id) { + const auto tier_begin = h_tier_offsets[vehicle_id]; + const auto tier_end = h_tier_offsets[vehicle_id + 1]; + cuopt_expects(tier_begin < tier_end, + error_type_t::ValidationError, + "Each vehicle must have at least one distance tier"); + for (i_t tier = tier_begin; tier < tier_end; ++tier) { + cuopt_expects(std::isfinite(h_thresholds[tier]) && h_thresholds[tier] >= 0.f && + std::isfinite(h_fixed_costs[tier]) && h_fixed_costs[tier] >= 0.f && + std::isfinite(h_costs_per_unit[tier]) && h_costs_per_unit[tier] >= 0.f, + error_type_t::ValidationError, + "Distance tier values must be finite and non-negative"); + if (tier > tier_begin) { + cuopt_expects(h_thresholds[tier - 1] < h_thresholds[tier], + error_type_t::ValidationError, + "Distance tier thresholds must be strictly increasing"); + } + } + cuopt_expects(h_thresholds[tier_end - 1] == std::numeric_limits::max(), + error_type_t::ValidationError, + "The last distance tier threshold for each vehicle must be float32 max"); + } + + // Pack into distance_tier_t structs + for (i_t i = 0; i < total_tiers; ++i) { + h_tiers[i].threshold = h_thresholds[i]; + h_tiers[i].fixed_cost = h_fixed_costs[i]; + h_tiers[i].cost_per_unit = h_costs_per_unit[i]; + } + + for (i_t vehicle_id = 1; vehicle_id < fleet_size && is_homogenous; ++vehicle_id) { + const auto first_begin = h_tier_offsets[0]; + const auto first_end = h_tier_offsets[1]; + const auto tier_begin = h_tier_offsets[vehicle_id]; + const auto tier_end = h_tier_offsets[vehicle_id + 1]; + is_homogenous = + first_end - first_begin == tier_end - tier_begin && + std::equal( + h_tiers.begin() + first_begin, h_tiers.begin() + first_end, h_tiers.begin() + tier_begin); + } + + raft::copy(fleet_info_.v_distance_tiers_.data(), h_tiers.data(), total_tiers, stream_view); + } + fleet_info_.is_homogenous_ = is_homogenous; } diff --git a/cpp/src/routing/fleet_info.hpp b/cpp/src/routing/fleet_info.hpp index a40fefc04e..5db9abb36b 100644 --- a/cpp/src/routing/fleet_info.hpp +++ b/cpp/src/routing/fleet_info.hpp @@ -39,11 +39,14 @@ class fleet_info_t { v_vehicle_infos_(num_vehicles, handle_ptr_->get_stream()), matrices_(handle_ptr_->get_stream()), fleet_order_constraints_(handle_ptr, 0, 0), + v_max_distances_(0, handle_ptr_->get_stream()), v_max_costs_(0, handle_ptr_->get_stream()), v_max_times_(0, handle_ptr_->get_stream()), v_fixed_costs_(0, handle_ptr_->get_stream()), v_buckets_(0, handle_ptr_->get_stream()), v_vehicle_availability_(0, handle_ptr_->get_stream()), + v_distance_tiers_(0, handle_ptr_->get_stream()), + v_tier_offsets_(0, handle_ptr_->get_stream()), is_homogenous_(true) { } @@ -52,7 +55,10 @@ class fleet_info_t { auto constexpr get_num_vehicles() const { return v_earliest_time_.size(); } - constexpr bool has_time_matrix() const { return matrices_.extent[1] > 1; } + constexpr bool has_time_matrix() const + { + return matrices_.time_matrix_index != matrices_.cost_matrix_index; + } constexpr bool is_homogenous() const { return is_homogenous_; } @@ -85,14 +91,20 @@ class fleet_info_t { h.drop_return_trip = host_copy(v_drop_return_trip_, stream); h.skip_first_trip = host_copy(v_skip_first_trip_, stream); h.capacities = host_copy(v_capacities_, stream); + h.max_distances = host_copy(v_max_distances_, stream); h.max_costs = host_copy(v_max_costs_, stream); h.max_times = host_copy(v_max_times_, stream); h.fixed_costs = host_copy(v_fixed_costs_, stream); h.fleet_order_constraints = fleet_order_constraints_.to_host(stream); h.types = host_copy(v_types_, stream); h.buckets = host_copy(v_buckets_, stream); + h.distance_tiers = host_copy(v_distance_tiers_, stream); + h.tier_offsets = host_copy(v_tier_offsets_, stream); h.matrices = detail::create_host_mdarray( matrices_.extent[2], matrices_.extent[0], matrices_.extent[1]); + h.matrices.cost_matrix_index = matrices_.cost_matrix_index; + h.matrices.distance_matrix_index = matrices_.distance_matrix_index; + h.matrices.time_matrix_index = matrices_.time_matrix_index; raft::copy(h.matrices.buffer.data(), matrices_.buffer.data(), matrices_.buffer.size(), stream); return h; } @@ -123,6 +135,8 @@ class fleet_info_t { info.skip_first_trip = skip_first_trip[vehicle_id]; info.type = types[vehicle_id]; + if (!max_distances.empty()) { info.max_distance = max_distances[vehicle_id]; } + if (!max_costs.empty()) { info.max_cost = max_costs[vehicle_id]; } if (!max_times.empty()) { info.max_time = max_times[vehicle_id]; } @@ -142,6 +156,19 @@ class fleet_info_t { info.latest = latest_time[vehicle_id]; info.start = start_locations[vehicle_id]; info.end = return_locations[vehicle_id]; + + // Assign distance tiers for this vehicle + if (!tier_offsets.empty() && !distance_tiers.empty() && + vehicle_id < static_cast(tier_offsets.size()) - 1) { + i_t tier_start = tier_offsets[vehicle_id]; + i_t tier_end = tier_offsets[vehicle_id + 1]; + i_t num_tiers = tier_end - tier_start; + if (num_tiers > 0) { + info.distance_tiers = raft::span const, is_device>( + distance_tiers.data() + tier_start, num_tiers); + } + } + return info; } @@ -159,10 +186,13 @@ class fleet_info_t { typename fleet_order_constraints_t::host_t fleet_order_constraints; std::vector drop_return_trip; std::vector skip_first_trip; + std::vector max_distances; std::vector max_costs; std::vector max_times; std::vector fixed_costs; std::vector vehicle_availability; + std::vector> distance_tiers; + std::vector tier_offsets; h_mdarray_t matrices; }; @@ -178,7 +208,10 @@ class fleet_info_t { constexpr i_t is_homogenous_fleet() const { return is_homogenous; } - constexpr bool has_time_matrix() const { return matrices.extent[1] > 1; } + constexpr bool has_time_matrix() const + { + return matrices.time_matrix_index != matrices.cost_matrix_index; + } i_t num_vehicles = 0; mdarray_view_t matrices{}; const i_t* break_offset{nullptr}; @@ -197,6 +230,7 @@ class fleet_info_t { const bool* drop_return_trip{nullptr}; const bool* skip_first_trip{nullptr}; + raft::device_span max_distances{}; raft::device_span max_costs{}; raft::device_span max_times{}; raft::device_span fixed_costs{}; @@ -226,6 +260,8 @@ class fleet_info_t { raft::device_span>(v_vehicle_infos_.data(), v_vehicle_infos_.size()); v.fleet_order_constraints = fleet_order_constraints_.view(); + v.max_distances = + raft::device_span(v_max_distances_.data(), v_max_distances_.size()); v.max_costs = raft::device_span(v_max_costs_.data(), v_max_costs_.size()); v.max_times = raft::device_span(v_max_times_.data(), v_max_times_.size()); v.fixed_costs = raft::device_span(v_fixed_costs_.data(), v_fixed_costs_.size()); @@ -265,6 +301,10 @@ class fleet_info_t { info.skip_first_trip = v_skip_first_trip_.element(vehicle_id, handle_ptr_->get_stream()); info.type = v_types_.element(vehicle_id, handle_ptr_->get_stream()); + if (!v_max_distances_.is_empty()) { + info.max_distance = v_max_distances_.element(vehicle_id, handle_ptr_->get_stream()); + } + if (!v_max_costs_.is_empty()) { info.max_cost = v_max_costs_.element(vehicle_id, handle_ptr_->get_stream()); } @@ -290,6 +330,18 @@ class fleet_info_t { info.latest = v_latest_time_.element(vehicle_id, handle_ptr_->get_stream()); info.start = v_start_locations_.element(vehicle_id, handle_ptr_->get_stream()); info.end = v_return_locations_.element(vehicle_id, handle_ptr_->get_stream()); + + // Assign distance tiers for this vehicle + if (!v_tier_offsets_.is_empty() && !v_distance_tiers_.is_empty()) { + i_t tier_start = v_tier_offsets_.element(vehicle_id, handle_ptr_->get_stream()); + i_t tier_end = v_tier_offsets_.element(vehicle_id + 1, handle_ptr_->get_stream()); + i_t num_tiers = tier_end - tier_start; + if (num_tiers > 0) { + info.distance_tiers = raft::span const, true>( + v_distance_tiers_.data() + tier_start, num_tiers); + } + } + return info; } @@ -309,11 +361,14 @@ class fleet_info_t { rmm::device_uvector v_drop_return_trip_; rmm::device_uvector v_skip_first_trip_; fleet_order_constraints_t fleet_order_constraints_; + rmm::device_uvector v_max_distances_; rmm::device_uvector v_max_costs_; rmm::device_uvector v_max_times_; rmm::device_uvector v_fixed_costs_; rmm::device_uvector v_buckets_; rmm::device_uvector v_vehicle_availability_; + rmm::device_uvector> v_distance_tiers_; // Flattened array of all tiers + rmm::device_uvector v_tier_offsets_; // Offsets per vehicle (size = fleet_size + 1) bool is_homogenous_; }; diff --git a/cpp/src/routing/generator/generator.cu b/cpp/src/routing/generator/generator.cu index d9042e19cd..77b20365e9 100644 --- a/cpp/src/routing/generator/generator.cu +++ b/cpp/src/routing/generator/generator.cu @@ -239,6 +239,7 @@ d_mdarray_t generate_matrices(raft::handle_t& handle, auto seed = params.seed; auto matrices = detail::create_device_mdarray( params.n_locations, params.n_vehicle_types, params.n_matrix_types, handle.get_stream()); + if (params.n_matrix_types > 1) { matrices.time_matrix_index = 1; } for (auto vehicle_type = 0; vehicle_type < params.n_vehicle_types; ++vehicle_type) { for (auto matrix_type = 0; matrix_type < params.n_matrix_types; ++matrix_type) { diff --git a/cpp/src/routing/ges/lexicographic_search/node_stack.cuh b/cpp/src/routing/ges/lexicographic_search/node_stack.cuh index 90003ac337..2b0225690a 100644 --- a/cpp/src/routing/ges/lexicographic_search/node_stack.cuh +++ b/cpp/src/routing/ges/lexicographic_search/node_stack.cuh @@ -30,6 +30,7 @@ DI static void copy_forward_data(dst_t& dst, const src_t& src) if constexpr (is_src_a_node && !is_dst_a_node) { dst.cost_forward = src.cost_dim.cost_forward; + dst.distance_forward = src.cost_dim.distance_forward; dst.transit_time_forward = src.time_dim.transit_time_forward; dst.latest_arrival_forward = src.time_dim.latest_arrival_forward; dst.unavoidable_wait_forward = src.time_dim.unavoidable_wait_forward; @@ -43,6 +44,7 @@ DI static void copy_forward_data(dst_t& dst, const src_t& src) }); } else if constexpr (is_dst_a_node && !is_src_a_node) { dst.cost_dim.cost_forward = src.cost_forward; + dst.cost_dim.distance_forward = src.distance_forward; dst.time_dim.transit_time_forward = src.transit_time_forward; dst.time_dim.latest_arrival_forward = src.latest_arrival_forward; dst.time_dim.unavoidable_wait_forward = src.unavoidable_wait_forward; @@ -56,6 +58,7 @@ DI static void copy_forward_data(dst_t& dst, const src_t& src) }); } else if constexpr (!is_src_a_node && !is_dst_a_node) { dst.cost_forward = src.cost_forward; + dst.distance_forward = src.distance_forward; dst.transit_time_forward = src.transit_time_forward; dst.latest_arrival_forward = src.latest_arrival_forward; dst.unavoidable_wait_forward = src.unavoidable_wait_forward; @@ -67,6 +70,7 @@ DI static void copy_forward_data(dst_t& dst, const src_t& src) } } else { dst.cost_dim.cost_forward = src.cost_dim.cost_forward; + dst.cost_dim.distance_forward = src.cost_dim.distance_forward; dst.time_dim.transit_time_forward = src.time_dim.transit_time_forward; dst.time_dim.latest_arrival_forward = src.time_dim.latest_arrival_forward; dst.time_dim.unavoidable_wait_forward = src.time_dim.unavoidable_wait_forward; @@ -120,6 +124,7 @@ struct node_stack_t { // this will be in shared memory for each thread struct __align__(32ul) item_t { double cost_forward; + double distance_forward; double transit_time_forward; double latest_arrival_forward; double unavoidable_wait_forward; @@ -405,6 +410,32 @@ struct node_stack_t { return get_dim_between(intra_idx_1, intra_idx_2); } + DI f_t get_travel_distance_between(i_t intra_idx_1, i_t intra_idx_2) const + { + return get_travel_distance_between(s_route.get_node(intra_idx_1).node_info(), + s_route.get_node(intra_idx_2).node_info(), + s_route.vehicle_info()); + } + + static DI f_t get_travel_distance_between(NodeInfo const& from, + NodeInfo const& to, + VehicleInfo const& vehicle_info) + { + return detail::get_travel_distance(from, to, vehicle_info); + } + + DI f_t get_travel_distance_to_delivery(i_t intra_idx) const + { + return detail::get_travel_distance( + s_route.get_node(intra_idx).node_info(), delivery_node.node_info(), s_route.vehicle_info()); + } + + DI f_t get_travel_distance_from_delivery(i_t intra_idx) const + { + return detail::get_travel_distance( + delivery_node.node_info(), s_route.get_node(intra_idx).node_info(), s_route.vehicle_info()); + } + DI const enabled_dimensions_t& dim_info() const { return delivery_node.dimensions_info; } DI void calculate_forward_between(const i_t from_idx, @@ -415,8 +446,15 @@ struct node_stack_t { copy_forward_data(d_node, top()); loop_over_dimensions(dim_info(), [&] __device__(auto I) { if (get_dimension_of(dim_info()).has_constraints()) { - auto dim_between = get_dim_between(from_idx, to_idx); - get_dimension_of(d_node).calculate_forward(get_dimension_of(node), dim_between); + if constexpr (decltype(I)::value == (size_t)dim_t::COST) { + auto cost_between = get_dim_between(from_idx, to_idx); + auto travel_between = get_travel_distance_between(from_idx, to_idx); + get_dimension_of(d_node).calculate_forward( + get_dimension_of(node), cost_between, travel_between); + } else { + auto dim_between = get_dim_between(from_idx, to_idx); + get_dimension_of(d_node).calculate_forward(get_dimension_of(node), dim_between); + } } }); } @@ -457,9 +495,16 @@ struct node_stack_t { { loop_over_dimensions(dim_info(), [&] __device__(auto I) { if (get_dimension_of(dim_info()).has_constraints()) { - auto dim_to_delivery = get_dim_to_delivery(idx); - get_dimension_of(node).calculate_forward(get_dimension_of(delivery_node), - dim_to_delivery); + if constexpr (decltype(I)::value == (size_t)dim_t::COST) { + auto cost_to_delivery = get_dim_to_delivery(idx); + auto travel_to_delivery = get_travel_distance_to_delivery(idx); + get_dimension_of(node).calculate_forward( + get_dimension_of(delivery_node), cost_to_delivery, travel_to_delivery); + } else { + auto dim_to_delivery = get_dim_to_delivery(idx); + get_dimension_of(node).calculate_forward(get_dimension_of(delivery_node), + dim_to_delivery); + } } }); } @@ -480,9 +525,16 @@ struct node_stack_t { { loop_over_dimensions(dim_info(), [&] __device__(auto I) { if (get_dimension_of(dim_info()).has_constraints()) { - auto dim_from_delivery = get_dim_from_delivery(idx); - get_dimension_of(delivery_node) - .calculate_forward(get_dimension_of(node), dim_from_delivery); + if constexpr (decltype(I)::value == (size_t)dim_t::COST) { + auto cost_from_delivery = get_dim_from_delivery(idx); + auto travel_from_delivery = get_travel_distance_from_delivery(idx); + get_dimension_of(delivery_node) + .calculate_forward(get_dimension_of(node), cost_from_delivery, travel_from_delivery); + } else { + auto dim_from_delivery = get_dim_from_delivery(idx); + get_dimension_of(delivery_node) + .calculate_forward(get_dimension_of(node), dim_from_delivery); + } } }); } @@ -694,9 +746,16 @@ struct node_stack_t { "dim buffer mismatch"); loop_over_dimensions(beginning_of_hole.dimensions_info, [&] __device__(auto I) { if (get_dimension_of(beginning_of_hole.dimensions_info).has_constraints()) { - auto dim_between = get_dim_between(i - size_of_hole, i + 1); - get_dimension_of(beginning_of_hole) - .calculate_forward(get_dimension_of(next_node), dim_between); + if constexpr (decltype(I)::value == (size_t)dim_t::COST) { + auto cost_between = get_dim_between(i - size_of_hole, i + 1); + auto travel_between = get_travel_distance_between(i - size_of_hole, i + 1); + get_dimension_of(beginning_of_hole) + .calculate_forward(get_dimension_of(next_node), cost_between, travel_between); + } else { + auto dim_between = get_dim_between(i - size_of_hole, i + 1); + get_dimension_of(beginning_of_hole) + .calculate_forward(get_dimension_of(next_node), dim_between); + } } }); @@ -724,9 +783,16 @@ struct node_stack_t { cuopt_assert(check_dim_between(i, i + 1, iter_node, next_node), "dim buffer mismatch"); loop_over_dimensions(iter_node.dimensions_info, [&] __device__(auto I) { if (get_dimension_of(iter_node.dimensions_info).has_constraints()) { - auto dim_between = get_dim_between(i, i + 1); - get_dimension_of(iter_node).calculate_forward(get_dimension_of(next_node), - dim_between); + if constexpr (decltype(I)::value == (size_t)dim_t::COST) { + auto cost_between = get_dim_between(i, i + 1); + auto travel_between = get_travel_distance_between(i, i + 1); + get_dimension_of(iter_node).calculate_forward( + get_dimension_of(next_node), cost_between, travel_between); + } else { + auto dim_between = get_dim_between(i, i + 1); + get_dimension_of(iter_node).calculate_forward(get_dimension_of(next_node), + dim_between); + } } }); if (!advance) { @@ -797,9 +863,17 @@ struct node_stack_t { cuopt_assert(check_dim_from_delivery(i + 1, next_node), "dim buffer mismatch"); loop_over_dimensions(beginning_of_hole.dimensions_info, [&] __device__(auto I) { if (get_dimension_of(beginning_of_hole.dimensions_info).has_constraints()) { - auto dim_between = get_dim_from_delivery(i + 1); - get_dimension_of(beginning_of_hole) - .calculate_forward(get_dimension_of(next_node), dim_between); + if constexpr (decltype(I)::value == (size_t)dim_t::COST) { + auto cost_between = get_dim_from_delivery(i + 1); + auto travel_between = get_travel_distance_from_delivery(i + 1); + get_dimension_of(beginning_of_hole) + .calculate_forward( + get_dimension_of(next_node), cost_between, travel_between); + } else { + auto dim_between = get_dim_from_delivery(i + 1); + get_dimension_of(beginning_of_hole) + .calculate_forward(get_dimension_of(next_node), dim_between); + } } }); } else { @@ -807,9 +881,17 @@ struct node_stack_t { "dim buffer mismatch"); loop_over_dimensions(beginning_of_hole.dimensions_info, [&] __device__(auto I) { if (get_dimension_of(beginning_of_hole.dimensions_info).has_constraints()) { - auto dim_between = get_dim_between(i - size_of_hole, i + 1); - get_dimension_of(beginning_of_hole) - .calculate_forward(get_dimension_of(next_node), dim_between); + if constexpr (decltype(I)::value == (size_t)dim_t::COST) { + auto cost_between = get_dim_between(i - size_of_hole, i + 1); + auto travel_between = get_travel_distance_between(i - size_of_hole, i + 1); + get_dimension_of(beginning_of_hole) + .calculate_forward( + get_dimension_of(next_node), cost_between, travel_between); + } else { + auto dim_between = get_dim_between(i - size_of_hole, i + 1); + get_dimension_of(beginning_of_hole) + .calculate_forward(get_dimension_of(next_node), dim_between); + } } }); } @@ -839,9 +921,16 @@ struct node_stack_t { cuopt_assert(check_dim_between(i, i + 1, iter_node, next_node), "dim buffer mismatch"); loop_over_dimensions(iter_node.dimensions_info, [&] __device__(auto I) { if (get_dimension_of(iter_node.dimensions_info).has_constraints()) { - auto dim_between = get_dim_between(i, i + 1); - get_dimension_of(iter_node).calculate_forward(get_dimension_of(next_node), - dim_between); + if constexpr (decltype(I)::value == (size_t)dim_t::COST) { + auto cost_between = get_dim_between(i, i + 1); + auto travel_between = get_travel_distance_between(i, i + 1); + get_dimension_of(iter_node).calculate_forward( + get_dimension_of(next_node), cost_between, travel_between); + } else { + auto dim_between = get_dim_between(i, i + 1); + get_dimension_of(iter_node).calculate_forward(get_dimension_of(next_node), + dim_between); + } } }); if (!advance) { diff --git a/cpp/src/routing/local_search/compute_compatible.cu b/cpp/src/routing/local_search/compute_compatible.cu index 4953c683e4..f719c6d664 100644 --- a/cpp/src/routing/local_search/compute_compatible.cu +++ b/cpp/src/routing/local_search/compute_compatible.cu @@ -477,7 +477,7 @@ void problem_t::sort_viable_matrix(rmm::device_uvector& viable_fr i_t segment = idx / l_n_requests; return segment; }); - // sort according to cost + // sort according to the same per-arc score used by tiered route costs thrust::stable_sort( handle_ptr->get_thrust_policy(), thrust::make_zip_iterator(viable_from_matrix.begin(), segments.begin()), @@ -489,19 +489,19 @@ void problem_t::sort_viable_matrix(rmm::device_uvector& viable_fr i_t from_node_2 = thrust::get<1>(second); if (to_node_1 == -1) return false; if (to_node_2 == -1) return true; - auto cost_between_1 = get_arc_cost( - NodeInfo( - from_node_1, order_info_view.get_order_location(from_node_1), node_type_t::PICKUP), - NodeInfo( - to_node_1, order_info_view.get_order_location(to_node_1), node_type_t::PICKUP), - l_vehicle_info); - auto cost_between_2 = get_arc_cost( - NodeInfo( - from_node_2, order_info_view.get_order_location(from_node_2), node_type_t::PICKUP), - NodeInfo( - to_node_2, order_info_view.get_order_location(to_node_2), node_type_t::PICKUP), - l_vehicle_info); - return cost_between_1 < cost_between_2; + const auto from_info_1 = NodeInfo( + from_node_1, order_info_view.get_order_location(from_node_1), node_type_t::PICKUP); + const auto to_info_1 = NodeInfo( + to_node_1, order_info_view.get_order_location(to_node_1), node_type_t::PICKUP); + const auto from_info_2 = NodeInfo( + from_node_2, order_info_view.get_order_location(from_node_2), node_type_t::PICKUP); + const auto to_info_2 = NodeInfo( + to_node_2, order_info_view.get_order_location(to_node_2), node_type_t::PICKUP); + const auto score_1 = + problem_t::compute_viable_neighbor_score(from_info_1, to_info_1, l_vehicle_info); + const auto score_2 = + problem_t::compute_viable_neighbor_score(from_info_2, to_info_2, l_vehicle_info); + return score_1 < score_2; }); // sort the segments thrust::stable_sort(handle_ptr->get_thrust_policy(), @@ -521,7 +521,7 @@ void problem_t::sort_viable_matrix(rmm::device_uvector& viable_fr i_t segment = idx / l_n_requests; return segment; }); - // sort according to cost + // sort according to the same per-arc score used by tiered route costs thrust::stable_sort( handle_ptr->get_thrust_policy(), thrust::make_zip_iterator(viable_to_matrix.begin(), segments.begin()), @@ -533,19 +533,19 @@ void problem_t::sort_viable_matrix(rmm::device_uvector& viable_fr i_t to_node_2 = thrust::get<1>(second); if (from_node_1 == -1) return false; if (from_node_2 == -1) return true; - auto cost_between_1 = get_arc_cost( - NodeInfo( - from_node_1, order_info_view.get_order_location(from_node_1), node_type_t::PICKUP), - NodeInfo( - to_node_1, order_info_view.get_order_location(to_node_1), node_type_t::PICKUP), - l_vehicle_info); - auto cost_between_2 = get_arc_cost( - NodeInfo( - from_node_2, order_info_view.get_order_location(from_node_2), node_type_t::PICKUP), - NodeInfo( - to_node_2, order_info_view.get_order_location(to_node_2), node_type_t::PICKUP), - l_vehicle_info); - return cost_between_1 < cost_between_2; + const auto from_info_1 = NodeInfo( + from_node_1, order_info_view.get_order_location(from_node_1), node_type_t::PICKUP); + const auto to_info_1 = NodeInfo( + to_node_1, order_info_view.get_order_location(to_node_1), node_type_t::PICKUP); + const auto from_info_2 = NodeInfo( + from_node_2, order_info_view.get_order_location(from_node_2), node_type_t::PICKUP); + const auto to_info_2 = NodeInfo( + to_node_2, order_info_view.get_order_location(to_node_2), node_type_t::PICKUP); + const auto score_1 = + problem_t::compute_viable_neighbor_score(from_info_1, to_info_1, l_vehicle_info); + const auto score_2 = + problem_t::compute_viable_neighbor_score(from_info_2, to_info_2, l_vehicle_info); + return score_1 < score_2; }); // sort the segments again to get back the segmented sorted thrust::stable_sort(handle_ptr->get_thrust_policy(), diff --git a/cpp/src/routing/local_search/permutation_helper.cuh b/cpp/src/routing/local_search/permutation_helper.cuh index a0e918acc7..55fe8bcc04 100644 --- a/cpp/src/routing/local_search/permutation_helper.cuh +++ b/cpp/src/routing/local_search/permutation_helper.cuh @@ -235,17 +235,22 @@ DI bool forward_fragment_update_cvrp(const node_t& curr_node, const typename route_t::view_t& s_route, node_t* fragment, i_t fragment_size, - f_t fragment_cost, + f_t fragment_cost_distance, + f_t fragment_travel_distance, f_t fragment_demand, const infeasible_cost_t& weights, double excess_limit) { cuopt_assert(fragment_size != 0, "Fragment size cannot be zero!"); - f_t arc_value = get_arc_of_dimension( - curr_node.request.info, fragment[0].request.info, s_route.vehicle_info()); + f_t arc_cost_distance = + get_arc_cost(curr_node.request.info, fragment[0].request.info, s_route.vehicle_info()); + f_t arc_travel_distance = + get_travel_distance(curr_node.request.info, fragment[0].request.info, s_route.vehicle_info()); fragment[fragment_size - 1].cost_dim.cost_forward = - curr_node.cost_dim.cost_forward + arc_value + fragment_cost; + curr_node.cost_dim.cost_forward + arc_cost_distance + fragment_cost_distance; + fragment[fragment_size - 1].cost_dim.distance_forward = + curr_node.cost_dim.distance_forward + arc_travel_distance + fragment_travel_distance; fragment[fragment_size - 1].capacity_dim.gathered[0] = curr_node.capacity_dim.gathered[0] + fragment_demand; fragment[fragment_size - 1].capacity_dim.max_to_node[0] = @@ -290,14 +295,20 @@ DI bool backward_fragment_update_cvrp(const node_t& curr_node const typename route_t::view_t& s_route, node_t* fragment, i_t fragment_size, - f_t fragment_cost, + f_t fragment_cost_distance, + f_t fragment_travel_distance, f_t fragment_demand, const infeasible_cost_t& weights, double excess_limit) { - f_t arc_value = get_arc_of_dimension( + f_t arc_cost_distance = get_arc_cost( fragment[fragment_size - 1].request.info, curr_node.request.info, s_route.vehicle_info()); - fragment[0].cost_dim.cost_backward = curr_node.cost_dim.cost_backward + arc_value + fragment_cost; + f_t arc_travel_distance = get_travel_distance( + fragment[fragment_size - 1].request.info, curr_node.request.info, s_route.vehicle_info()); + fragment[0].cost_dim.cost_backward = + curr_node.cost_dim.cost_backward + arc_cost_distance + fragment_cost_distance; + fragment[0].cost_dim.distance_backward = + curr_node.cost_dim.distance_backward + arc_travel_distance + fragment_travel_distance; fragment[0].capacity_dim.max_after[0] = curr_node.capacity_dim.max_after[0] + fragment_demand; diff --git a/cpp/src/routing/local_search/sliding_tsp.cu b/cpp/src/routing/local_search/sliding_tsp.cu index 90f42a1303..1f56a833ff 100644 --- a/cpp/src/routing/local_search/sliding_tsp.cu +++ b/cpp/src/routing/local_search/sliding_tsp.cu @@ -9,6 +9,7 @@ #include "../solution/solution.cuh" #include "../utilities/cuopt_utils.cuh" #include "local_search.cuh" +#include "vrp/fragment_kernels.cuh" #include #include @@ -26,6 +27,7 @@ DI thrust::pair eval_move( typename move_candidates_t::view_t& move_candidates, const typename route_t::view_t& s_route, raft::device_span sh_reverse_cost, + raft::device_span sh_reverse_distance, i_t intra_idx, i_t insertion_pos, i_t window_size, @@ -38,46 +40,87 @@ DI thrust::pair eval_move( auto new_window_cost = reverse ? sh_reverse_cost[route_max_window_size - 1] - sh_reverse_cost[route_max_window_size - window_size] : original_window_cost; + auto original_window_distance = + s_route.dimensions.cost_dim.distance_forward[intra_idx + window_size - 1] - + s_route.dimensions.cost_dim.distance_forward[intra_idx]; + auto new_window_distance = reverse ? sh_reverse_distance[route_max_window_size - 1] - + sh_reverse_distance[route_max_window_size - window_size] + : original_window_distance; auto original_previous_intra_frag_next = s_route.dimensions.cost_dim.cost_forward[intra_idx + window_size] - s_route.dimensions.cost_dim.cost_forward[intra_idx - 1]; - - auto frag_begin = reverse ? intra_idx + window_size - 1 : intra_idx; - auto frag_end = reverse ? intra_idx : intra_idx + window_size - 1; - auto insertion_pos_frag_begin = - get_arc_of_dimension(s_route.get_node(insertion_pos).node_info(), - s_route.get_node(frag_begin).node_info(), - s_route.vehicle_info()); + auto original_previous_intra_frag_next_distance = + s_route.dimensions.cost_dim.distance_forward[intra_idx + window_size] - + s_route.dimensions.cost_dim.distance_forward[intra_idx - 1]; + + auto frag_begin = reverse ? intra_idx + window_size - 1 : intra_idx; + auto frag_end = reverse ? intra_idx : intra_idx + window_size - 1; + auto insertion_pos_frag_begin_cost = get_arc_cost(s_route.get_node(insertion_pos).node_info(), + s_route.get_node(frag_begin).node_info(), + s_route.vehicle_info()); + auto insertion_pos_frag_begin_distance = + get_travel_distance(s_route.get_node(insertion_pos).node_info(), + s_route.get_node(frag_begin).node_info(), + s_route.vehicle_info()); // in-place if (insertion_pos == intra_idx - 1) { - auto frag_end_frag_next = get_arc_of_dimension( - s_route.get_node(frag_end).node_info(), - s_route.get_node(intra_idx + window_size).node_info(), - s_route.vehicle_info()); - auto delta = insertion_pos_frag_begin + new_window_cost + frag_end_frag_next - - original_previous_intra_frag_next; - return {delta, delta}; + auto frag_end_frag_next_cost = + get_arc_cost(s_route.get_node(frag_end).node_info(), + s_route.get_node(intra_idx + window_size).node_info(), + s_route.vehicle_info()); + auto frag_end_frag_next_distance = + get_travel_distance(s_route.get_node(frag_end).node_info(), + s_route.get_node(intra_idx + window_size).node_info(), + s_route.vehicle_info()); + auto new_total_cost = s_route.get_node(s_route.get_num_nodes()).cost_dim.cost_forward + + (insertion_pos_frag_begin_cost + new_window_cost + + frag_end_frag_next_cost - original_previous_intra_frag_next); + auto new_total_distance = + s_route.get_node(s_route.get_num_nodes()).cost_dim.distance_forward + + (insertion_pos_frag_begin_distance + new_window_distance + frag_end_frag_next_distance - + original_previous_intra_frag_next_distance); + return compute_distance_delta_from_totals( + move_candidates, s_route, new_total_cost, new_total_distance); } - auto frag_end_insertion_pos_next = - get_arc_of_dimension(s_route.get_node(frag_end).node_info(), - s_route.get_node(insertion_pos + 1).node_info(), - s_route.vehicle_info()); - - auto previous_intra_frag_next = get_arc_of_dimension( - s_route.get_node(intra_idx - 1).node_info(), - s_route.get_node(intra_idx + window_size).node_info(), - s_route.vehicle_info()); - auto insertion_pos_insertion_pos_next = - get_arc_of_dimension(s_route.get_node(insertion_pos).node_info(), - s_route.get_node(insertion_pos + 1).node_info(), - s_route.vehicle_info()); - auto delta = previous_intra_frag_next + insertion_pos_frag_begin + new_window_cost + - frag_end_insertion_pos_next - insertion_pos_insertion_pos_next - - original_previous_intra_frag_next; - return {delta, delta}; + auto frag_end_insertion_pos_next_cost = + get_arc_cost(s_route.get_node(frag_end).node_info(), + s_route.get_node(insertion_pos + 1).node_info(), + s_route.vehicle_info()); + auto frag_end_insertion_pos_next_distance = + get_travel_distance(s_route.get_node(frag_end).node_info(), + s_route.get_node(insertion_pos + 1).node_info(), + s_route.vehicle_info()); + + auto previous_intra_frag_next_cost = + get_arc_cost(s_route.get_node(intra_idx - 1).node_info(), + s_route.get_node(intra_idx + window_size).node_info(), + s_route.vehicle_info()); + auto previous_intra_frag_next_distance = + get_travel_distance(s_route.get_node(intra_idx - 1).node_info(), + s_route.get_node(intra_idx + window_size).node_info(), + s_route.vehicle_info()); + auto insertion_pos_insertion_pos_next_cost = + get_arc_cost(s_route.get_node(insertion_pos).node_info(), + s_route.get_node(insertion_pos + 1).node_info(), + s_route.vehicle_info()); + auto insertion_pos_insertion_pos_next_distance = + get_travel_distance(s_route.get_node(insertion_pos).node_info(), + s_route.get_node(insertion_pos + 1).node_info(), + s_route.vehicle_info()); + auto new_total_cost = s_route.get_node(s_route.get_num_nodes()).cost_dim.cost_forward + + (previous_intra_frag_next_cost + insertion_pos_frag_begin_cost + + new_window_cost + frag_end_insertion_pos_next_cost - + insertion_pos_insertion_pos_next_cost - original_previous_intra_frag_next); + auto new_total_distance = + s_route.get_node(s_route.get_num_nodes()).cost_dim.distance_forward + + (previous_intra_frag_next_distance + insertion_pos_frag_begin_distance + new_window_distance + + frag_end_insertion_pos_next_distance - insertion_pos_insertion_pos_next_distance - + original_previous_intra_frag_next_distance); + return compute_distance_delta_from_totals( + move_candidates, s_route, new_total_cost, new_total_distance); } template @@ -129,6 +172,8 @@ __global__ void find_sliding_moves_tsp( auto sh_reverse_cost = raft::device_span( reinterpret_cast(raft::alignTo(s_route.shared_end_address(), sizeof(double))), route_max_window_size); + auto sh_reverse_distance = + raft::device_span(&sh_reverse_cost[route_max_window_size], route_max_window_size); s_route.copy_from(route); __syncthreads(); @@ -137,6 +182,9 @@ __global__ void find_sliding_moves_tsp( sh_reverse_cost[tid] = route.dimensions.cost_dim .reverse_cost[route.get_num_nodes() - intra_idx - (route_max_window_size - 1) + tid]; + sh_reverse_distance[tid] = + route.dimensions.cost_dim + .reverse_distance[route.get_num_nodes() - intra_idx - (route_max_window_size - 1) + tid]; } __syncthreads(); @@ -188,6 +236,7 @@ __global__ void find_sliding_moves_tsp( move_candidates, s_route, sh_reverse_cost, + sh_reverse_distance, intra_idx, insertion_pos, window_size, @@ -390,27 +439,33 @@ __global__ void execute_sliding_moves_tsp( template __global__ void fill_reverse_costs_kernel(typename solution_t::view_t sol) { - auto route = sol.routes[0]; - auto n_nodes = route.get_num_nodes(); - auto reverse_costs = route.dimensions.cost_dim.reverse_cost; + auto route = sol.routes[0]; + auto n_nodes = route.get_num_nodes(); + auto reverse_costs = route.dimensions.cost_dim.reverse_cost; + auto reverse_distances = route.dimensions.cost_dim.reverse_distance; for (i_t tid = blockIdx.x * blockDim.x + threadIdx.x; tid < n_nodes; tid += blockDim.x * gridDim.x) { - reverse_costs[tid] = - get_arc_of_dimension(route.get_node(n_nodes - tid).node_info(), - route.get_node(n_nodes - 1 - tid).node_info(), - route.vehicle_info()); + reverse_costs[tid] = get_arc_cost(route.get_node(n_nodes - tid).node_info(), + route.get_node(n_nodes - 1 - tid).node_info(), + route.vehicle_info()); + reverse_distances[tid] = get_travel_distance(route.get_node(n_nodes - tid).node_info(), + route.get_node(n_nodes - 1 - tid).node_info(), + route.vehicle_info()); } } template __global__ void fill_forward_costs_kernel(typename solution_t::view_t sol) { - auto route = sol.routes[0]; - auto n_nodes = route.get_num_nodes(); - auto forward_costs = route.dimensions.cost_dim.cost_forward; + auto route = sol.routes[0]; + auto n_nodes = route.get_num_nodes(); + auto forward_costs = route.dimensions.cost_dim.cost_forward; + auto forward_distances = route.dimensions.cost_dim.distance_forward; for (i_t tid = blockIdx.x * blockDim.x + threadIdx.x; tid < n_nodes; tid += blockDim.x * gridDim.x) { - forward_costs[tid] = get_arc_of_dimension( + forward_costs[tid] = get_arc_cost( + route.get_node(tid).node_info(), route.get_node(tid + 1).node_info(), route.vehicle_info()); + forward_distances[tid] = get_travel_distance( route.get_node(tid).node_info(), route.get_node(tid + 1).node_info(), route.vehicle_info()); } } @@ -443,6 +498,8 @@ void compute_cumulative_costs(solution_t& sol, { auto costs_ptr = reverse ? sol.get_route(0).dimensions.cost_dim.reverse_cost.data() : sol.get_route(0).dimensions.cost_dim.cost_forward.data(); + auto distances_ptr = reverse ? sol.get_route(0).dimensions.cost_dim.reverse_distance.data() + : sol.get_route(0).dimensions.cost_dim.distance_forward.data(); auto n_fill_blocks = (sol.get_num_orders() + n_threads - 1) / n_threads; if (reverse) { fill_reverse_costs_kernel @@ -474,6 +531,12 @@ void compute_cumulative_costs(solution_t& sol, costs_ptr, n_nodes + 2, sol.sol_handle->get_stream().get()); + cub::DeviceScan::ExclusiveSum(move_candidates.temp_storage.data(), + temp_storage_bytes, + distances_ptr, + distances_ptr, + n_nodes + 2, + sol.sol_handle->get_stream().get()); } template @@ -505,7 +568,7 @@ bool local_search_t::perform_sliding_tsp( sol.sol_handle->get_stream()); auto sh_size = - raft::alignTo(shared_route_size, sizeof(double)) + max_window_size * sizeof(double); + raft::alignTo(shared_route_size, sizeof(double)) + 2 * max_window_size * sizeof(double); if (!set_shmem_of_kernel(find_sliding_moves_tsp, sh_size)) { return false; } diff --git a/cpp/src/routing/local_search/sliding_window.cu b/cpp/src/routing/local_search/sliding_window.cu index 7b545a0021..c8f33c2a82 100644 --- a/cpp/src/routing/local_search/sliding_window.cu +++ b/cpp/src/routing/local_search/sliding_window.cu @@ -153,11 +153,21 @@ __device__ void try_permutations( auto next_node = s_route.get_node(window_start_idx + window_size); loop_over_constrained_dimensions(dimensions_info, [&] __device__(auto I) { - get_dimension_of(nodes[window_size - 1]) - .calculate_forward( - get_dimension_of(next_node), - get_arc_of_dimension( - nodes[window_size - 1].request.info, next_node.request.info, s_route.vehicle_info())); + if constexpr (decltype(I)::value == (size_t)dim_t::COST) { + get_dimension_of(nodes[window_size - 1]) + .calculate_forward( + get_dimension_of(next_node), + get_arc_cost( + nodes[window_size - 1].request.info, next_node.request.info, s_route.vehicle_info()), + get_travel_distance( + nodes[window_size - 1].request.info, next_node.request.info, s_route.vehicle_info())); + } else { + get_dimension_of(nodes[window_size - 1]) + .calculate_forward( + get_dimension_of(next_node), + get_arc_of_dimension( + nodes[window_size - 1].request.info, next_node.request.info, s_route.vehicle_info())); + } }); bool valid = true; @@ -311,11 +321,23 @@ __device__ void try_permutations( auto next_node = s_route.get_node(i + 1); loop_over_constrained_dimensions(dimensions_info, [&] __device__(auto I) { - get_dimension_of(nodes[window_size - 1]) - .calculate_forward( - get_dimension_of(next_node), - get_arc_of_dimension( - nodes[window_size - 1].request.info, next_node.request.info, s_route.vehicle_info())); + if constexpr (decltype(I)::value == (size_t)dim_t::COST) { + get_dimension_of(nodes[window_size - 1]) + .calculate_forward(get_dimension_of(next_node), + get_arc_cost(nodes[window_size - 1].request.info, + next_node.request.info, + s_route.vehicle_info()), + get_travel_distance(nodes[window_size - 1].request.info, + next_node.request.info, + s_route.vehicle_info())); + } else { + get_dimension_of(nodes[window_size - 1]) + .calculate_forward( + get_dimension_of(next_node), + get_arc_of_dimension(nodes[window_size - 1].request.info, + next_node.request.info, + s_route.vehicle_info())); + } }); bool valid = true; @@ -425,11 +447,14 @@ __device__ void try_permutations_cvrp( nodes, window_start_idx, solution, s_route.get_num_nodes()); // pre-compute fragment cost - f_t fragment_cost = 0.; - f_t fragment_demand = nodes[0].capacity_dim.demand[0]; + f_t fragment_cost_distance = 0.; + f_t fragment_travel_distance = 0.; + f_t fragment_demand = nodes[0].capacity_dim.demand[0]; for (int i = 1; i < window_size; ++i) { - fragment_cost += get_arc_of_dimension( - nodes[i - 1].request.info, nodes[i].request.info, s_route.vehicle_info()); + fragment_cost_distance += + get_arc_cost(nodes[i - 1].request.info, nodes[i].request.info, s_route.vehicle_info()); + fragment_travel_distance += + get_travel_distance(nodes[i - 1].request.info, nodes[i].request.info, s_route.vehicle_info()); fragment_demand += nodes[i].capacity_dim.demand[0]; } // printf("start_idx: %i, end_idx: %i\n", start_idx, end_idx); @@ -446,7 +471,8 @@ __device__ void try_permutations_cvrp( s_route, nodes.data(), window_size, - fragment_cost, + fragment_cost_distance, + fragment_travel_distance, fragment_demand, move_candidates.weights, excess_limit)) { @@ -502,7 +528,8 @@ __device__ void try_permutations_cvrp( s_route, nodes.data(), window_size, - fragment_cost, + fragment_cost_distance, + fragment_travel_distance, fragment_demand, move_candidates.weights, excess_limit)) { @@ -572,7 +599,8 @@ __device__ void try_permutations_cvrp( s_route, nodes.data(), window_size, - fragment_cost, + fragment_cost_distance, + fragment_travel_distance, fragment_demand, move_candidates.weights, excess_limit)) { diff --git a/cpp/src/routing/local_search/two_opt.cu b/cpp/src/routing/local_search/two_opt.cu index 764a2da39f..1f3b4a9225 100644 --- a/cpp/src/routing/local_search/two_opt.cu +++ b/cpp/src/routing/local_search/two_opt.cu @@ -50,22 +50,38 @@ DI thrust::pair evaluate_two_opt_cvrp_move( i_t first, i_t second) { - auto n_nodes = route.get_num_nodes(); - double frag_backward = reverse_route.cost_dim.cost_forward[n_nodes - (first + 1)] - - reverse_route.cost_dim.cost_forward[n_nodes - second]; - double forward_sum = + auto n_nodes = route.get_num_nodes(); + double frag_backward_cost = reverse_route.cost_dim.cost_forward[n_nodes - (first + 1)] - + reverse_route.cost_dim.cost_forward[n_nodes - second]; + double frag_backward_distance = reverse_route.cost_dim.distance_forward[n_nodes - (first + 1)] - + reverse_route.cost_dim.distance_forward[n_nodes - second]; + double forward_cost = route.get_node(second + 1).cost_dim.cost_forward - route.get_node(first).cost_dim.cost_forward; + double forward_distance = route.get_node(second + 1).cost_dim.distance_forward - + route.get_node(first).cost_dim.distance_forward; - double first_second = get_arc_of_dimension( + double first_second_cost = get_arc_cost( + route.get_node(first).node_info(), route.get_node(second).node_info(), route.vehicle_info()); + double first_second_distance = get_travel_distance( route.get_node(first).node_info(), route.get_node(second).node_info(), route.vehicle_info()); - double first_next_second_next = - get_arc_of_dimension(route.get_node(first + 1).node_info(), - route.get_node(second + 1).node_info(), - route.vehicle_info()); - - double delta = (first_second + frag_backward + first_next_second_next) - forward_sum; - return {delta, delta}; + double first_next_second_next_cost = get_arc_cost(route.get_node(first + 1).node_info(), + route.get_node(second + 1).node_info(), + route.vehicle_info()); + double first_next_second_next_distance = + get_travel_distance(route.get_node(first + 1).node_info(), + route.get_node(second + 1).node_info(), + route.vehicle_info()); + + auto new_total_cost = + route.get_node(n_nodes).cost_dim.cost_forward + + ((first_second_cost + frag_backward_cost + first_next_second_next_cost) - forward_cost); + auto new_total_distance = + route.get_node(n_nodes).cost_dim.distance_forward + + ((first_second_distance + frag_backward_distance + first_next_second_next_distance) - + forward_distance); + return compute_distance_delta_from_totals( + move_candidates, route, new_total_cost, new_total_distance); } template diff --git a/cpp/src/routing/local_search/vrp/fragment_kernels.cuh b/cpp/src/routing/local_search/vrp/fragment_kernels.cuh index de42497c1b..08abb5957b 100644 --- a/cpp/src/routing/local_search/vrp/fragment_kernels.cuh +++ b/cpp/src/routing/local_search/vrp/fragment_kernels.cuh @@ -15,6 +15,52 @@ namespace cuopt { namespace routing { namespace detail { +template +DI thrust::pair compute_distance_delta_from_totals( + typename move_candidates_t::view_t const& move_candidates, + const typename route_t::view_t& route, + double new_total_cost, + double new_total_distance) +{ + auto new_obj_cost = route.get_objective_cost(); + auto new_inf_cost = route.get_infeasibility_cost(); + + if (route.vehicle_info().has_distance_tiers()) { + const auto old_total_cost = route.get_node(route.get_num_nodes()).cost_dim.cost_forward; + const auto old_total_distance = route.get_node(route.get_num_nodes()).cost_dim.distance_forward; + new_obj_cost[objective_t::COST] = route.vehicle_info().compute_distance_cost_from_delta( + old_total_distance, + old_total_cost, + route.get_objective_cost()[objective_t::COST], + new_total_distance, + new_total_cost, + route.get_active_distance_tier()); + } else { + new_obj_cost[objective_t::COST] = new_total_cost; + } + new_inf_cost[dim_t::COST] = + route.template get_dim().dim_info.has_max_constraint + ? route.vehicle_info().compute_distance_excess(new_total_distance) + + max(0., new_obj_cost[objective_t::COST] - route.vehicle_info().max_cost) + : route.vehicle_info().compute_distance_excess(new_total_distance); + + double delta = infeasible_cost_t::dot( + move_candidates.weights, + infeasible_cost_t::nominal_diff(new_inf_cost, route.get_infeasibility_cost())); + double selection_delta = infeasible_cost_t::dot( + move_candidates.selection_weights, + infeasible_cost_t::nominal_diff(new_inf_cost, route.get_infeasibility_cost())); + + if (move_candidates.include_objective) { + auto obj_weights = route.dimensions_info().objective_weights; + delta += objective_cost_t::dot(obj_weights, new_obj_cost - route.get_objective_cost()); + selection_delta += + objective_cost_t::dot(obj_weights, new_obj_cost - route.get_objective_cost()); + } + + return {delta, selection_delta}; +} + template DI thrust::pair evaluate_cap_infeasibility( typename solution_t::view_t& solution, @@ -73,48 +119,86 @@ DI thrust::pair evaluate_fragment( return {std::numeric_limits::max(), std::numeric_limits::max()}; } - if (!move_candidates.include_objective) { return {0, 0}; } - // cost check - double obj_delta = 0.; - double all_forward_1 = route_1.get_node(start_idx_1 + 1 + frag_size_1).cost_dim.cost_forward - - route_1.get_node(start_idx_1).cost_dim.cost_forward; + double cost_delta = 0.; + double distance_delta = 0.; + double all_forward_cost = route_1.get_node(start_idx_1 + 1 + frag_size_1).cost_dim.cost_forward - + route_1.get_node(start_idx_1).cost_dim.cost_forward; + double all_forward_distance = + route_1.get_node(start_idx_1 + 1 + frag_size_1).cost_dim.distance_forward - + route_1.get_node(start_idx_1).cost_dim.distance_forward; if (frag_size_2 == 0) { - auto direct = get_arc_of_dimension( - route_1.get_node(start_idx_1).node_info(), - route_1.get_node(start_idx_1 + 1 + frag_size_1).node_info(), - route_1.vehicle_info()); - return {direct - all_forward_1, direct - all_forward_1}; + auto direct_cost = get_arc_cost(route_1.get_node(start_idx_1).node_info(), + route_1.get_node(start_idx_1 + 1 + frag_size_1).node_info(), + route_1.vehicle_info()); + auto direct_distance = + get_travel_distance(route_1.get_node(start_idx_1).node_info(), + route_1.get_node(start_idx_1 + 1 + frag_size_1).node_info(), + route_1.vehicle_info()); + auto new_total_cost = route_1.get_node(route_1.get_num_nodes()).cost_dim.cost_forward + + (direct_cost - all_forward_cost); + auto new_total_distance = route_1.get_node(route_1.get_num_nodes()).cost_dim.distance_forward + + (direct_distance - all_forward_distance); + return compute_distance_delta_from_totals( + move_candidates, route_1, new_total_cost, new_total_distance); } if (!reverse) { - double sd1_sd2_1 = - get_arc_of_dimension(route_1.get_node(start_idx_1).node_info(), - route_2.get_node(start_idx_2 + 1).node_info(), - route_1.vehicle_info()); - - double end_node_2_end_node_1 = get_arc_of_dimension( - route_2.get_node(start_idx_2 + frag_size_2).node_info(), - route_1.get_node(start_idx_1 + frag_size_1 + 1).node_info(), - route_1.vehicle_info()); - double frag_dist = route_2.get_node(start_idx_2 + frag_size_2).cost_dim.cost_forward - + double sd1_sd2_1_cost = get_arc_cost(route_1.get_node(start_idx_1).node_info(), + route_2.get_node(start_idx_2 + 1).node_info(), + route_1.vehicle_info()); + double sd1_sd2_1_distance = get_travel_distance(route_1.get_node(start_idx_1).node_info(), + route_2.get_node(start_idx_2 + 1).node_info(), + route_1.vehicle_info()); + + double end_node_2_end_node_1_cost = + get_arc_cost(route_2.get_node(start_idx_2 + frag_size_2).node_info(), + route_1.get_node(start_idx_1 + frag_size_1 + 1).node_info(), + route_1.vehicle_info()); + double end_node_2_end_node_1_distance = + get_travel_distance(route_2.get_node(start_idx_2 + frag_size_2).node_info(), + route_1.get_node(start_idx_1 + frag_size_1 + 1).node_info(), + route_1.vehicle_info()); + double frag_cost = route_2.get_node(start_idx_2 + frag_size_2).cost_dim.cost_forward - route_2.get_node(start_idx_2 + 1).cost_dim.cost_forward; - obj_delta = sd1_sd2_1 + frag_dist + end_node_2_end_node_1 - all_forward_1; + double frag_distance = route_2.get_node(start_idx_2 + frag_size_2).cost_dim.distance_forward - + route_2.get_node(start_idx_2 + 1).cost_dim.distance_forward; + cost_delta = sd1_sd2_1_cost + frag_cost + end_node_2_end_node_1_cost - all_forward_cost; + distance_delta = + sd1_sd2_1_distance + frag_distance + end_node_2_end_node_1_distance - all_forward_distance; } else { - double sd1_end_frag_2 = get_arc_of_dimension( - route_1.get_node(start_idx_1).node_info(), - route_2.get_node(start_idx_2 + frag_size_2).node_info(), - route_1.vehicle_info()); - - double sd2_1_end_node_1 = get_arc_of_dimension( - route_2.get_node(start_idx_2 + 1).node_info(), - route_1.get_node(start_idx_1 + frag_size_1 + 1).node_info(), - route_1.vehicle_info()); - double frag_dist = route_2.dimensions.cost_dim.reverse_cost[(start_idx_2 + 1)] - + double sd1_end_frag_2_cost = + get_arc_cost(route_1.get_node(start_idx_1).node_info(), + route_2.get_node(start_idx_2 + frag_size_2).node_info(), + route_1.vehicle_info()); + double sd1_end_frag_2_distance = + get_travel_distance(route_1.get_node(start_idx_1).node_info(), + route_2.get_node(start_idx_2 + frag_size_2).node_info(), + route_1.vehicle_info()); + + double sd2_1_end_node_1_cost = + get_arc_cost(route_2.get_node(start_idx_2 + 1).node_info(), + route_1.get_node(start_idx_1 + frag_size_1 + 1).node_info(), + route_1.vehicle_info()); + double sd2_1_end_node_1_distance = + get_travel_distance(route_2.get_node(start_idx_2 + 1).node_info(), + route_1.get_node(start_idx_1 + frag_size_1 + 1).node_info(), + route_1.vehicle_info()); + double frag_cost = route_2.dimensions.cost_dim.reverse_cost[(start_idx_2 + 1)] - route_2.dimensions.cost_dim.reverse_cost[(start_idx_2 + frag_size_2)]; - obj_delta = sd1_end_frag_2 + frag_dist + sd2_1_end_node_1 - all_forward_1; + double frag_distance = + route_2.dimensions.cost_dim.reverse_distance[(start_idx_2 + 1)] - + route_2.dimensions.cost_dim.reverse_distance[(start_idx_2 + frag_size_2)]; + cost_delta = sd1_end_frag_2_cost + frag_cost + sd2_1_end_node_1_cost - all_forward_cost; + distance_delta = + sd1_end_frag_2_distance + frag_distance + sd2_1_end_node_1_distance - all_forward_distance; } - return {obj_delta, obj_delta}; + auto new_total_cost = + route_1.get_node(route_1.get_num_nodes()).cost_dim.cost_forward + cost_delta; + auto new_total_distance = + route_1.get_node(route_1.get_num_nodes()).cost_dim.distance_forward + distance_delta; + return compute_distance_delta_from_totals( + move_candidates, route_1, new_total_cost, new_total_distance); } template diff --git a/cpp/src/routing/local_search/vrp/vrp_search.cu b/cpp/src/routing/local_search/vrp/vrp_search.cu index 56f66a3572..7029308bf2 100644 --- a/cpp/src/routing/local_search/vrp/vrp_search.cu +++ b/cpp/src/routing/local_search/vrp/vrp_search.cu @@ -29,12 +29,17 @@ __global__ void compute_reverse_costs(typename solution_t::vi auto route_id = route.get_id(); auto n_nodes = route.get_num_nodes(); - route.dimensions.cost_dim.reverse_cost[n_nodes] = 0.; + route.dimensions.cost_dim.reverse_cost[n_nodes] = 0.; + route.dimensions.cost_dim.reverse_distance[n_nodes] = 0.; for (int i = n_nodes - 1; i >= 0; i--) { - double cost = get_arc_of_dimension( + double cost = get_arc_cost( + route.get_node(i + 1).node_info(), route.get_node(i).node_info(), route.vehicle_info()); + double distance = get_travel_distance( route.get_node(i + 1).node_info(), route.get_node(i).node_info(), route.vehicle_info()); route.dimensions.cost_dim.reverse_cost[i] = cost + route.dimensions.cost_dim.reverse_cost[i + 1]; + route.dimensions.cost_dim.reverse_distance[i] = + distance + route.dimensions.cost_dim.reverse_distance[i + 1]; } } } diff --git a/cpp/src/routing/node/cost_node.cuh b/cpp/src/routing/node/cost_node.cuh index a6201de0cf..562bfbd3bb 100644 --- a/cpp/src/routing/node/cost_node.cuh +++ b/cpp/src/routing/node/cost_node.cuh @@ -32,6 +32,10 @@ class cost_node_t { double cost_forward = 0.0; //! Cost gathered after node double cost_backward = 0.0; + //! Physical travel distance gathered to node + double distance_forward = 0.0; + //! Physical travel 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 @@ -48,9 +52,12 @@ class cost_node_t { double distance_break_cost_forward = 0.0; /*! \brief { Calculate next node forward gathered cost data based on actual node} */ - void HDI calculate_forward(cost_node_t& next, double cost_between) const noexcept + void HDI calculate_forward(cost_node_t& next, + double cost_between, + double distance_between) const noexcept { - next.cost_forward = cost_forward + cost_between; + next.cost_forward = cost_forward + cost_between; + next.distance_forward = distance_forward + distance_between; next.distance_window_forward = distance_window_forward + cost_between; next.excess_forward = excess_forward; @@ -64,9 +71,12 @@ class cost_node_t { } /*! \brief { Calculate prev node gathered cost backward data based on actual node} */ - void HDI calculate_backward(cost_node_t& prev, double cost_between) const noexcept + void HDI calculate_backward(cost_node_t& prev, + double cost_between, + double distance_between) const noexcept { - prev.cost_backward = cost_backward + cost_between; + prev.cost_backward = cost_backward + cost_between; + prev.distance_backward = distance_backward + distance_between; prev.distance_window_backward = distance_window_backward - cost_between; prev.excess_backward = excess_backward; @@ -85,12 +95,18 @@ class cost_node_t { HDI double forward_excess(const VehicleInfo& vehicle_info) const noexcept { - return excess_forward + max(0., cost_forward - vehicle_info.max_cost); + const double objective_cost = + vehicle_info.compute_distance_cost(distance_forward, cost_forward); + return excess_forward + vehicle_info.compute_distance_excess(distance_forward) + + max(0., objective_cost - vehicle_info.max_cost); } HDI double backward_excess(const VehicleInfo& vehicle_info) const noexcept { - return excess_backward + max(0., cost_backward - vehicle_info.max_cost); + const double objective_cost = + vehicle_info.compute_distance_cost(distance_backward, cost_backward); + return excess_backward + vehicle_info.compute_distance_excess(distance_backward) + + max(0., objective_cost - vehicle_info.max_cost); } HDI bool forward_feasible(const VehicleInfo& vehicle_info, @@ -102,16 +118,21 @@ class cost_node_t { /*! \brief { Combine information from begining and ending fragments.} \return { Cost excess of route represented by nodes prev and next }*/ + template static HDI double combine(const cost_node_t& prev, const cost_node_t& next, - const VehicleInfo& vehicle_info, - f_t cost_between) noexcept + const VehicleInfo& vehicle_info, + f_t cost_between, + f_t distance_between) noexcept { - double total_cost = prev.cost_forward + next.cost_backward + cost_between; - double arrival_f = prev.distance_window_forward + cost_between; + double total_cost = prev.cost_forward + next.cost_backward + cost_between; + double total_distance = prev.distance_forward + next.distance_backward + distance_between; + double objective_cost = vehicle_info.compute_distance_cost(total_distance, total_cost); + double arrival_f = prev.distance_window_forward + cost_between; return prev.excess_forward + next.excess_backward + max(0., arrival_f - next.distance_window_backward) + - max(0., total_cost - vehicle_info.max_cost); + vehicle_info.compute_distance_excess(total_distance) + + max(0., objective_cost - vehicle_info.max_cost); } HDI bool backward_feasible(const VehicleInfo& vehicle_info, @@ -129,16 +150,17 @@ class cost_node_t { infeasible_cost_t& inf_cost) const noexcept { double total_cost = cost_forward + cost_backward; - obj_cost[objective_t::COST] = total_cost; + double total_distance = distance_forward + distance_backward; + obj_cost[objective_t::COST] = vehicle_info.compute_distance_cost(total_distance, total_cost); 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 - cost_forward); } - inf_cost[dim_t::COST] = 0.; + inf_cost[dim_t::COST] = vehicle_info.compute_distance_excess(total_distance); if (dim_info.has_max_constraint) { - inf_cost[dim_t::COST] = max(0., total_cost - vehicle_info.max_cost); + inf_cost[dim_t::COST] += max(0., obj_cost[objective_t::COST] - vehicle_info.max_cost); } if (dim_info.has_distance_window) { inf_cost[dim_t::COST] += excess_forward + excess_backward + diff --git a/cpp/src/routing/node/node.cuh b/cpp/src/routing/node/node.cuh index 859f0b1d4e..0df5f637a7 100644 --- a/cpp/src/routing/node/node.cuh +++ b/cpp/src/routing/node/node.cuh @@ -66,9 +66,17 @@ class node_t { const VehicleInfo& vehicle_info) const { loop_over_dimensions(dimensions_info, [&](auto I) { - double arc_value = get_arc_of_dimension( - request.info, next_node.request.info, vehicle_info); - get_dimension().calculate_forward(next_node.get_dimension(), arc_value); + if constexpr (decltype(I)::value == (size_t)dim_t::COST) { + auto arc_cost_distance = get_arc_cost(request.info, next_node.request.info, vehicle_info); + auto arc_travel_distance = + get_travel_distance(request.info, next_node.request.info, vehicle_info); + get_dimension().calculate_forward( + next_node.get_dimension(), arc_cost_distance, arc_travel_distance); + } else { + double arc_value = get_arc_of_dimension( + request.info, next_node.request.info, vehicle_info); + get_dimension().calculate_forward(next_node.get_dimension(), arc_value); + } }); } @@ -106,9 +114,17 @@ class node_t { DI void calculate_backward_all(node_t& prev_node, const VehicleInfo& vehicle_info) const { loop_over_dimensions(dimensions_info, [&](auto I) { - double arc_value = - get_arc_of_dimension(prev_node.request.info, request.info, vehicle_info); - get_dimension().calculate_backward(prev_node.get_dimension(), arc_value); + if constexpr (decltype(I)::value == (size_t)dim_t::COST) { + auto arc_cost_distance = get_arc_cost(prev_node.request.info, request.info, vehicle_info); + auto arc_travel_distance = + get_travel_distance(prev_node.request.info, request.info, vehicle_info); + get_dimension().calculate_backward( + prev_node.get_dimension(), arc_cost_distance, arc_travel_distance); + } else { + double arc_value = + get_arc_of_dimension(prev_node.request.info, request.info, vehicle_info); + get_dimension().calculate_backward(prev_node.get_dimension(), arc_value); + } }); } @@ -160,11 +176,23 @@ class node_t { loop_over_dimensions(prev.dimensions_info, [&] __device__(auto I) { // time dimension is already included if constexpr (I != (size_t)dim_t::TIME) { - double arc_value = - get_arc_of_dimension(prev.request.info, next.request.info, vehicle_info); auto& dim_node = prev.get_dimension(); - double dim_excess = std::decay_t::combine( - prev.get_dimension(), next.get_dimension(), vehicle_info, arc_value); + double dim_excess = 0.; + if constexpr (decltype(I)::value == (size_t)dim_t::COST) { + auto arc_cost_distance = get_arc_cost(prev.request.info, next.request.info, vehicle_info); + auto arc_travel_distance = + get_travel_distance(prev.request.info, next.request.info, vehicle_info); + dim_excess = std::decay_t::combine(prev.get_dimension(), + next.get_dimension(), + vehicle_info, + arc_cost_distance, + arc_travel_distance); + } else { + double arc_value = + get_arc_of_dimension(prev.request.info, next.request.info, vehicle_info); + dim_excess = std::decay_t::combine( + prev.get_dimension(), next.get_dimension(), vehicle_info, arc_value); + } total_excess += dim_excess * weights[I]; } }); diff --git a/cpp/src/routing/problem/problem.cu b/cpp/src/routing/problem/problem.cu index e57c86dca9..55734ac3c5 100644 --- a/cpp/src/routing/problem/problem.cu +++ b/cpp/src/routing/problem/problem.cu @@ -55,14 +55,21 @@ problem_t::problem_t(const data_model_view_t& data_model_vie pair_indices_h.size(), handle_ptr->get_stream()); - vehicle_types_h = cuopt::host_copy(fleet_info.v_types_, handle_ptr->get_stream()); + vehicle_types_h = cuopt::host_copy(fleet_info.v_types_, handle_ptr->get_stream()); + const size_t matrix_size = static_cast(n_locations) * static_cast(n_locations); for (auto& vtype : vehicle_types_h) { if (!cost_matrices_h.count(vtype)) { - auto cost_matrix = fleet_info.matrices_.get_cost_matrix(vtype); - auto cost_matrix_h = - cuopt::host_copy(cost_matrix, n_locations * n_locations, handle_ptr->get_stream()); + auto cost_matrix = fleet_info.matrices_.get_cost_matrix(vtype); + auto cost_matrix_h = cuopt::host_copy(cost_matrix, matrix_size, handle_ptr->get_stream()); cost_matrices_h.emplace(vtype, cost_matrix_h); } + if (fleet_info.matrices_.distance_matrix_index != fleet_info.matrices_.cost_matrix_index && + !travel_distance_matrices_h.count(vtype)) { + auto travel_distance_matrix = fleet_info.matrices_.get_distance_matrix(vtype); + auto travel_distance_matrix_h = + cuopt::host_copy(travel_distance_matrix, matrix_size, handle_ptr->get_stream()); + travel_distance_matrices_h.emplace(vtype, travel_distance_matrix_h); + } } handle_ptr->sync_stream(); @@ -262,9 +269,16 @@ void problem_t::populate_dimensions_info() dimensions_info.enable_objective(objective_t::COST, cost_obj_weight); auto& cost_dim_info = dimensions_info.cost_dim; + if (auto vehicle_max_distances = data_view_ptr->get_vehicle_max_distances(); + !vehicle_max_distances.empty()) { + cost_dim_info.has_max_constraint = true; + } if (auto vehicle_max_costs = data_view_ptr->get_vehicle_max_costs(); !vehicle_max_costs.empty()) { cost_dim_info.has_max_constraint = true; } + if (std::get<4>(data_view_ptr->get_vehicle_distance_tiers()) > 0) { + 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)) { @@ -375,7 +389,11 @@ void problem_t::populate_dimensions_info() } } - if (data_view_ptr->get_fleet_size() == 1) { + const auto total_tiers = std::get<4>(data_view_ptr->get_vehicle_distance_tiers()); + const bool has_max_distance = !data_view_ptr->get_vehicle_max_distances().empty(); + has_non_additive_cost_ = total_tiers > 0; + + if (data_view_ptr->get_fleet_size() == 1 && !has_non_additive_cost_ && !has_max_distance) { is_tsp = true; loop_over_dimensions(dimensions_info, [&](auto I) { if constexpr (I != (size_t)dim_t::COST) { is_tsp = false; } @@ -384,7 +402,8 @@ void problem_t::populate_dimensions_info() dimensions_info.is_tsp = is_tsp; if (!is_tsp) { - is_cvrp_ = !is_pdp() && (data_view_ptr->get_cost_matrices().size() == 1); + is_cvrp_ = + !has_non_additive_cost_ && !is_pdp() && (data_view_ptr->get_cost_matrices().size() == 1); if (is_cvrp_) { loop_over_dimensions(dimensions_info, [&](auto I) { if (I != (int)dim_t::COST && I != (int)dim_t::CAP) { is_cvrp_ = false; } @@ -486,6 +505,26 @@ double problem_t::cost_between(const NodeInfo<>& node_1, return cost_matrices_h.at(vehicle_type)[node_1.location() * n_locations + node_2.location()]; } +template +double problem_t::distance_between(const NodeInfo<>& node_1, + const NodeInfo<>& node_2, + const int& vehicle_id) const +{ + auto n_locations = data_view_ptr->get_num_locations(); + cuopt_assert(vehicle_id < (int)vehicle_types_h.size(), "vehicle id should be in range!"); + i_t vehicle_type = vehicle_types_h[vehicle_id]; + if (!travel_distance_matrices_h.count(vehicle_type)) { return 0.; } + + if (node_1.is_depot() && skip_first_trip_h[vehicle_id]) { + return 0.; + } else if (node_2.is_depot() && drop_return_trip_h[vehicle_id]) { + return 0.; + } + + return travel_distance_matrices_h.at( + vehicle_type)[node_1.location() * n_locations + node_2.location()]; +} + template i_t problem_t::get_num_orders() const { @@ -815,7 +854,7 @@ bool problem_t::is_pdp() const template bool problem_t::is_cvrp_intra() const { - return !is_pdp() && !dimensions_info.has_dimension(dim_t::TIME) && + return !has_non_additive_cost_ && !is_pdp() && !dimensions_info.has_dimension(dim_t::TIME) && !dimensions_info.has_dimension(dim_t::BREAK); } diff --git a/cpp/src/routing/problem/problem.cuh b/cpp/src/routing/problem/problem.cuh index 563d2789ef..78907e4efe 100644 --- a/cpp/src/routing/problem/problem.cuh +++ b/cpp/src/routing/problem/problem.cuh @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -166,6 +167,16 @@ struct viables_t { template class problem_t { public: + template + static HDI double compute_viable_neighbor_score(const NodeInfo& from_node, + const NodeInfo& to_node, + const VehicleInfo& vehicle_info) + { + const auto arc_cost_distance = get_arc_cost(from_node, to_node, vehicle_info); + const auto arc_travel_distance = get_travel_distance(from_node, to_node, vehicle_info); + return vehicle_info.compute_distance_cost(arc_travel_distance, arc_cost_distance); + } + problem_t() = delete; problem_t(problem_t&&) = default; problem_t(const data_model_view_t& data_model_view_, @@ -197,6 +208,10 @@ class problem_t { const NodeInfo<>& node_2, const int& vehicle_id) const; + double distance_between(const NodeInfo<>& node_1, + const NodeInfo<>& node_2, + const int& vehicle_id) const; + struct view_t { DI NodeInfo<> get_start_depot_node_info(const i_t vehicle_id) const { @@ -220,7 +235,8 @@ class problem_t { DI bool has_non_uniform_breaks() const { return non_uniform_breaks; } DI bool is_cvrp_intra() const { - return !order_info.is_pdp() && !dimensions_info.has_dimension(dim_t::TIME) && + return !has_non_additive_cost && !order_info.is_pdp() && + !dimensions_info.has_dimension(dim_t::TIME) && !dimensions_info.has_dimension(dim_t::BREAK); } DI bool is_cvrp() const { return is_cvrp_; } @@ -237,6 +253,7 @@ class problem_t { typename special_nodes_t::view_t special_nodes; bool non_uniform_breaks{false}; bool is_cvrp_{false}; + bool has_non_additive_cost{false}; }; view_t view() const @@ -261,6 +278,7 @@ class problem_t { v.special_nodes = special_nodes.view(); v.non_uniform_breaks = has_non_uniform_breaks(); v.is_cvrp_ = is_cvrp(); + v.has_non_additive_cost = has_non_additive_cost_; return v; } @@ -315,6 +333,7 @@ class problem_t { // appropriate host functions in order_info_, fleet_info_ classes and call // them directly std::map> cost_matrices_h; + std::map> travel_distance_matrices_h; std::vector pair_indices_h; std::vector is_pickup_h; std::vector order_locations_h; @@ -333,6 +352,7 @@ class problem_t { special_nodes_t special_nodes; bool is_tsp{false}; bool is_cvrp_{false}; + bool has_non_additive_cost_{false}; bool non_uniform_breaks_{false}; }; diff --git a/cpp/src/routing/route/cost_route.cuh b/cpp/src/routing/route/cost_route.cuh index 23748bdea4..4c93d9ca26 100644 --- a/cpp/src/routing/route/cost_route.cuh +++ b/cpp/src/routing/route/cost_route.cuh @@ -31,7 +31,10 @@ class cost_route_t { : dim_info(dim_info_), cost_forward(0, sol_handle_->get_stream()), cost_backward(0, sol_handle_->get_stream()), + distance_forward(0, sol_handle_->get_stream()), + distance_backward(0, sol_handle_->get_stream()), reverse_cost(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()), @@ -48,7 +51,10 @@ class cost_route_t { : dim_info(cost_route.dim_info), cost_forward(cost_route.cost_forward, sol_handle_->get_stream()), cost_backward(cost_route.cost_backward, sol_handle_->get_stream()), + distance_forward(cost_route.distance_forward, sol_handle_->get_stream()), + distance_backward(cost_route.distance_backward, sol_handle_->get_stream()), reverse_cost(cost_route.reverse_cost, sol_handle_->get_stream()), + reverse_distance(cost_route.reverse_distance, sol_handle_->get_stream()), distance_window_forward(cost_route.distance_window_forward, sol_handle_->get_stream()), distance_window_backward(cost_route.distance_window_backward, sol_handle_->get_stream()), distance_window_backward_min(cost_route.distance_window_backward_min, @@ -68,7 +74,10 @@ class cost_route_t { { cost_forward.resize(max_nodes_per_route, stream); cost_backward.resize(max_nodes_per_route, stream); + distance_forward.resize(max_nodes_per_route, stream); + distance_backward.resize(max_nodes_per_route, stream); reverse_cost.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); @@ -88,8 +97,10 @@ class cost_route_t { DI cost_node_t get_node(i_t idx) const { cost_node_t cost_node; - cost_node.cost_forward = cost_forward[idx]; - cost_node.cost_backward = cost_backward[idx]; + cost_node.cost_forward = cost_forward[idx]; + cost_node.cost_backward = cost_backward[idx]; + cost_node.distance_forward = distance_forward[idx]; + cost_node.distance_backward = distance_backward[idx]; if (dim_info.has_distance_window) { cost_node.distance_window_forward = distance_window_forward[idx]; cost_node.distance_window_backward = distance_window_backward[idx]; @@ -117,7 +128,8 @@ class cost_route_t { DI void set_forward_data(i_t idx, const cost_node_t& node) { - cost_forward[idx] = node.cost_forward; + cost_forward[idx] = node.cost_forward; + 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; @@ -129,7 +141,8 @@ class cost_route_t { DI void set_backward_data(i_t idx, const cost_node_t& node) { - cost_backward[idx] = node.cost_backward; + cost_backward[idx] = node.cost_backward; + 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; @@ -144,6 +157,9 @@ class cost_route_t { i_t size = end_idx - start_idx; block_copy( cost_forward.subspan(write_start), orig_route.cost_forward.subspan(start_idx), size); + 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), @@ -166,6 +182,9 @@ class cost_route_t { i_t size = end_idx - start_idx; block_copy( cost_backward.subspan(write_start), orig_route.cost_backward.subspan(start_idx), size); + 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), @@ -199,14 +218,16 @@ class cost_route_t { objective_cost_t& obj_cost, infeasible_cost_t& inf_cost) const noexcept { - obj_cost[objective_t::COST] = cost_forward[n_nodes_route]; + double total_cost = cost_forward[n_nodes_route]; + double total_distance = distance_forward[n_nodes_route]; + obj_cost[objective_t::COST] = vehicle_info.compute_distance_cost(total_distance, total_cost); 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]; } - inf_cost[dim_t::COST] = 0.; + inf_cost[dim_t::COST] = vehicle_info.compute_distance_excess(total_distance); if (dim_info.has_max_constraint) { - inf_cost[dim_t::COST] = max(0., cost_forward[n_nodes_route] - vehicle_info.max_cost); + inf_cost[dim_t::COST] += max(0., obj_cost[objective_t::COST] - vehicle_info.max_cost); } if (dim_info.has_distance_window) { inf_cost[dim_t::COST] += excess_forward[n_nodes_route]; } } @@ -216,11 +237,13 @@ class cost_route_t { i_t n_nodes_route) { view_t v; - size_t sz = n_nodes_route + 1; - i_t* sh_ptr = shmem; - v.dim_info = dim_info; - thrust::tie(v.cost_forward, sh_ptr) = wrap_ptr_as_span(sh_ptr, sz); - thrust::tie(v.cost_backward, sh_ptr) = wrap_ptr_as_span(sh_ptr, sz); + size_t sz = n_nodes_route + 1; + i_t* sh_ptr = shmem; + v.dim_info = dim_info; + thrust::tie(v.cost_forward, sh_ptr) = wrap_ptr_as_span(sh_ptr, sz); + thrust::tie(v.cost_backward, sh_ptr) = wrap_ptr_as_span(sh_ptr, sz); + 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); @@ -240,7 +263,10 @@ class cost_route_t { cost_dimension_info_t dim_info; raft::device_span cost_forward; raft::device_span cost_backward; + raft::device_span distance_forward; + raft::device_span distance_backward; raft::device_span reverse_cost; + raft::device_span reverse_distance; raft::device_span distance_window_forward; raft::device_span distance_window_backward; raft::device_span distance_window_backward_min; @@ -257,7 +283,13 @@ class cost_route_t { v.dim_info = dim_info; v.cost_forward = raft::device_span{cost_forward.data(), cost_forward.size()}; v.cost_backward = raft::device_span{cost_backward.data(), cost_backward.size()}; - v.reverse_cost = raft::device_span{reverse_cost.data(), reverse_cost.size()}; + v.distance_forward = + raft::device_span{distance_forward.data(), distance_forward.size()}; + v.distance_backward = + raft::device_span{distance_backward.data(), distance_backward.size()}; + v.reverse_cost = raft::device_span{reverse_cost.data(), reverse_cost.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()}; @@ -287,7 +319,7 @@ class cost_route_t { [[maybe_unused]] cost_dimension_info_t dim_info, [[maybe_unused]] bool is_tsp = false) { - return (2 + 6 * dim_info.has_distance_window + + return (4 + 6 * dim_info.has_distance_window + 2 * (dim_info.has_distance_window && dim_info.has_distance_break_cost)) * route_size * sizeof(double); } @@ -298,9 +330,14 @@ class cost_route_t { rmm::device_uvector cost_forward; // backward data rmm::device_uvector cost_backward; + // physical distance forward data + rmm::device_uvector distance_forward; + // physical distance 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. rmm::device_uvector reverse_cost; + rmm::device_uvector reverse_distance; // Allocated only when has_distance_window. rmm::device_uvector distance_window_forward; rmm::device_uvector distance_window_backward; diff --git a/cpp/src/routing/route/route.cuh b/cpp/src/routing/route/route.cuh index b624acb903..3f87ea1617 100644 --- a/cpp/src/routing/route/route.cuh +++ b/cpp/src/routing/route/route.cuh @@ -30,16 +30,19 @@ class route_t { dimensions(sol_handle_, dimensions_info_), route_id(route_id_, sol_handle_->get_stream()), vehicle_id(vehicle_id_, sol_handle_->get_stream()), + n_nodes(sol_handle_->get_stream()), infeasibility_cost(sol_handle_->get_stream()), objective_cost(sol_handle_->get_stream()), - n_nodes(sol_handle_->get_stream()), + active_distance_tier(sol_handle_->get_stream()), fleet_info_ptr(fleet_info_ptr_) { raft::common::nvtx::range fun_scope("zero route_t copy_ctr"); infeasible_cost_t zero_inf; objective_cost_t zero_obj; + i_t inactive_distance_tier = -1; infeasibility_cost.set_value_async(zero_inf, sol_handle->get_stream()); objective_cost.set_value_async(zero_obj, sol_handle->get_stream()); + active_distance_tier.set_value_async(inactive_distance_tier, sol_handle->get_stream()); } void print() const @@ -57,6 +60,7 @@ class route_t { n_nodes(route.n_nodes, route.sol_handle->get_stream()), infeasibility_cost(route.infeasibility_cost, route.sol_handle->get_stream()), objective_cost(route.objective_cost, route.sol_handle->get_stream()), + active_distance_tier(route.active_distance_tier, route.sol_handle->get_stream()), fleet_info_ptr(route.fleet_info_ptr) { raft::common::nvtx::range fun_scope("route copy_ctr"); @@ -122,15 +126,17 @@ class route_t { i_t* vehicle_id_, infeasible_cost_t* infeasibility_cost_, objective_cost_t* objective_cost_, + i_t* active_distance_tier_, typename fleet_info_t::view_t fleet_info_) { view_t v; - v.n_nodes = num_nodes_; - v.route_id = route_id_; - v.vehicle_id = vehicle_id_; - v.infeasibility_cost = infeasibility_cost_; - v.objective_cost = objective_cost_; - v.fleet_info = fleet_info_; + v.n_nodes = num_nodes_; + v.route_id = route_id_; + v.vehicle_id = vehicle_id_; + v.infeasibility_cost = infeasibility_cost_; + v.objective_cost = objective_cost_; + v.active_distance_tier = active_distance_tier_; + v.fleet_info = fleet_info_; return v; } DI auto& requests() const { return dimensions.requests; } @@ -559,6 +565,7 @@ class route_t { copy_from(orig_route, 0, *orig_route.n_nodes + 1, 0); block_copy(infeasibility_cost, orig_route.infeasibility_cost, 1); block_copy(objective_cost, orig_route.objective_cost, 1); + block_copy(active_distance_tier, orig_route.active_distance_tier, 1); block_copy(n_nodes, orig_route.n_nodes, 1); block_copy(route_id, orig_route.route_id, 1); block_copy(vehicle_id, orig_route.vehicle_id, 1); @@ -662,6 +669,8 @@ class route_t { get_dimension_of(dimensions) .compute_cost(this->vehicle_info(), *n_nodes, objective_cost[0], infeasibility_cost[0]); }); + active_distance_tier[0] = + this->vehicle_info().find_distance_tier(dimensions.cost_dim.distance_forward[*n_nodes]); return thrust::make_tuple(objective_cost[0], infeasibility_cost[0]); } @@ -684,6 +693,7 @@ class route_t { DI infeasible_cost_t get_infeasibility_cost() const { return *infeasibility_cost; } DI objective_cost_t get_objective_cost() const { return *objective_cost; } + DI i_t get_active_distance_tier() const { return *active_distance_tier; } // extend for other things later DI i_t max_nodes_per_route() const noexcept { return requests().node_info.size(); } @@ -717,16 +727,18 @@ class route_t { dimensions_route_t::view_t::create_shared_route( sh_ptr, orig_route.dimensions_info(), n_nodes_route, is_tsp); - v.n_nodes = (i_t*)sh_ptr; - v.route_id = (i_t*)&v.n_nodes[1]; - v.vehicle_id = (i_t*)&v.route_id[1]; + v.n_nodes = (i_t*)sh_ptr; + v.route_id = (i_t*)&v.n_nodes[1]; + v.vehicle_id = (i_t*)&v.route_id[1]; + v.active_distance_tier = (i_t*)&v.vehicle_id[1]; // vehicle info will still be in global memory v.fleet_info = orig_route.fleet_info; if (threadIdx.x == 0) { - *v.n_nodes = n_nodes_route; - *v.route_id = *orig_route.route_id; - *v.vehicle_id = *orig_route.vehicle_id; + *v.n_nodes = n_nodes_route; + *v.route_id = *orig_route.route_id; + *v.vehicle_id = *orig_route.vehicle_id; + *v.active_distance_tier = *orig_route.active_distance_tier; } return v; } @@ -734,7 +746,7 @@ class route_t { DI unsigned long shared_end_address() { // address of last item - return reinterpret_cast(&vehicle_id[1]); + return reinterpret_cast(&active_distance_tier[1]); } static DI void compute_forward_in_between(view_t& curr_route, i_t start, i_t end) @@ -847,6 +859,7 @@ class route_t { i_t* vehicle_id{nullptr}; infeasible_cost_t* infeasibility_cost{nullptr}; objective_cost_t* objective_cost{nullptr}; + i_t* active_distance_tier{nullptr}; typename fleet_info_t::view_t fleet_info; }; @@ -857,6 +870,7 @@ class route_t { vehicle_id.data(), infeasibility_cost.data(), objective_cost.data(), + active_distance_tier.data(), fleet_info_ptr->view()); v.dimensions = dimensions.view(); @@ -874,7 +888,8 @@ class route_t { // everything that is stored in rmm::device_scalar should be stored in shared size_t sz = 3 * sizeof(i_t) // route_id, vehicle_id, n_nodes + sizeof(infeasible_cost_t) + - sizeof(objective_cost_t); // infeasibility cost, objective cost + sizeof(objective_cost_t) + // infeasibility cost, objective cost + sizeof(i_t); // active distance tier sz += dimensions_route_t::get_shared_size(route_size, dimensions_info); return sz; } @@ -898,6 +913,8 @@ class route_t { rmm::device_scalar objective_cost; + rmm::device_scalar active_distance_tier; + // fleet info const fleet_info_t* fleet_info_ptr; }; diff --git a/cpp/src/routing/solver.cu b/cpp/src/routing/solver.cu index b160a2d140..7c2004534a 100644 --- a/cpp/src/routing/solver.cu +++ b/cpp/src/routing/solver.cu @@ -43,8 +43,8 @@ solver_t::solver_t(data_model_view_t const& data_model, solver_settings_t const& settings) : handle_ptr_(data_model.get_handle_ptr()), settings_(settings) { - auto n_matrix_types = detail::get_cost_matrix_type_dim(data_model); - if (n_matrix_types == 1 && !data_model.get_vehicle_max_times().empty()) { + if (!detail::has_transit_time_matrix(data_model) && + !data_model.get_vehicle_max_times().empty()) { cuopt_expects(false, error_type_t::ValidationError, "Time matrix should be set in order to use vehicle max time constraints"); diff --git a/cpp/src/routing/util_kernels/set_nodes_data.cuh b/cpp/src/routing/util_kernels/set_nodes_data.cuh index a871bb4652..8c7ee6164d 100644 --- a/cpp/src/routing/util_kernels/set_nodes_data.cuh +++ b/cpp/src/routing/util_kernels/set_nodes_data.cuh @@ -67,6 +67,8 @@ __device__ void set_route_data(typename problem_t::view_t const& probl cost_route.distance_break_cost_forward[0] = 0.; } } + cost_route.distance_backward[n_nodes_route] = 0.f; + cost_route.distance_forward[0] = 0.f; 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/src/routing/utilities/md_utils.hpp b/cpp/src/routing/utilities/md_utils.hpp index 7e59dae362..bea7148201 100644 --- a/cpp/src/routing/utilities/md_utils.hpp +++ b/cpp/src/routing/utilities/md_utils.hpp @@ -36,17 +36,31 @@ struct mdarray_view_t { constexpr auto get_cost_matrix(uint8_t vehicle_type) const { - return get_cost_matrix(vehicle_type, 0); + return get_cost_matrix(vehicle_type, cost_matrix_index); + } + + constexpr auto get_distance_matrix(uint8_t vehicle_type) const + { + return get_cost_matrix(vehicle_type, distance_matrix_index); } constexpr auto get_time_matrix(uint8_t vehicle_type) const { - return get_cost_matrix(vehicle_type, extent[1] - 1); + if (time_matrix_index != cost_matrix_index) { + return get_cost_matrix(vehicle_type, time_matrix_index); + } + if (extent[1] == 2 && distance_matrix_index == cost_matrix_index) { + return get_cost_matrix(vehicle_type, static_cast(extent[1] - 1)); + } + return get_cost_matrix(vehicle_type, cost_matrix_index); } f_t const* buffer_ptr{nullptr}; // dim4(n_vehicle_types, n_matrix_types, n_loc, n_loc) size_t extent[NCON_DIMS]; + uint8_t cost_matrix_index{0}; + uint8_t distance_matrix_index{0}; + uint8_t time_matrix_index{0}; }; template @@ -74,17 +88,28 @@ struct h_mdarray_t { return vehicle_type_matrices + (extent[3] * extent[2] * matrix_type); } - constexpr auto get_cost_matrix(uint8_t vehicle_type) { return get_cost_matrix(vehicle_type, 0); } + constexpr auto get_cost_matrix(uint8_t vehicle_type) + { + return get_cost_matrix(vehicle_type, cost_matrix_index); + } + + constexpr auto get_distance_matrix(uint8_t vehicle_type) + { + return get_cost_matrix(vehicle_type, distance_matrix_index); + } constexpr auto get_time_matrix(uint8_t vehicle_type) { - return get_cost_matrix(vehicle_type, extent[1] - 1); + return get_cost_matrix(vehicle_type, time_matrix_index); } auto view() const { mdarray_view_t view; - view.buffer_ptr = buffer.data(); + view.buffer_ptr = buffer.data(); + view.cost_matrix_index = cost_matrix_index; + view.distance_matrix_index = distance_matrix_index; + view.time_matrix_index = time_matrix_index; for (size_t i = 0; i < NCON_DIMS; ++i) { view.extent[i] = extent[i]; } @@ -92,6 +117,9 @@ struct h_mdarray_t { } size_t extent[NCON_DIMS]; std::vector buffer; + uint8_t cost_matrix_index{0}; + uint8_t distance_matrix_index{0}; + uint8_t time_matrix_index{0}; }; template @@ -120,17 +148,28 @@ struct d_mdarray_t { return vehicle_type_matrices + (extent[3] * extent[2] * matrix_type); } - constexpr auto get_cost_matrix(uint8_t vehicle_type) { return get_cost_matrix(vehicle_type, 0); } + constexpr auto get_cost_matrix(uint8_t vehicle_type) + { + return get_cost_matrix(vehicle_type, cost_matrix_index); + } + + constexpr auto get_distance_matrix(uint8_t vehicle_type) + { + return get_cost_matrix(vehicle_type, distance_matrix_index); + } constexpr auto get_time_matrix(uint8_t vehicle_type) { - return get_cost_matrix(vehicle_type, extent[1] - 1); + return get_cost_matrix(vehicle_type, time_matrix_index); } auto view() const { mdarray_view_t view; - view.buffer_ptr = buffer.data(); + view.buffer_ptr = buffer.data(); + view.cost_matrix_index = cost_matrix_index; + view.distance_matrix_index = distance_matrix_index; + view.time_matrix_index = time_matrix_index; for (size_t i = 0; i < NCON_DIMS; ++i) { view.extent[i] = extent[i]; } @@ -140,6 +179,9 @@ struct d_mdarray_t { size_t extent[NCON_DIMS]; rmm::device_uvector buffer; cuda::stream_ref stream; + uint8_t cost_matrix_index{0}; + uint8_t distance_matrix_index{0}; + uint8_t time_matrix_index{0}; }; namespace detail { @@ -147,8 +189,8 @@ namespace detail { template bool limit_matrix_entries(f_t* matrix, i_t width, raft::handle_t const* handle_ptr) { - i_t mat_size = width * width; - f_t max_value = 1.0e+30; + size_t mat_size = static_cast(width) * static_cast(width); + f_t max_value = 1.0e+30; bool exceeds_max = thrust::any_of(handle_ptr->get_thrust_policy(), @@ -172,14 +214,13 @@ void fill_data_model_matrices(data_model_view_t& data_model, d_mdarray { i_t n_vehicle_types = matrices.extent[0]; - i_t n_matrix_types = matrices.extent[1]; for (auto vehicle_type = 0; vehicle_type < n_vehicle_types; ++vehicle_type) { - for (auto matrix_type = 0; matrix_type < n_matrix_types; ++matrix_type) { - auto const matrix = matrices.get_cost_matrix(vehicle_type, matrix_type); - if (matrix_type == 0) - data_model.add_cost_matrix(matrix, vehicle_type); - else - data_model.add_transit_time_matrix(matrix, vehicle_type); + data_model.add_cost_matrix(matrices.get_cost_matrix(vehicle_type), vehicle_type); + if (matrices.distance_matrix_index != matrices.cost_matrix_index) { + data_model.add_distance_matrix(matrices.get_distance_matrix(vehicle_type), vehicle_type); + } + if (matrices.time_matrix_index != matrices.cost_matrix_index) { + data_model.add_transit_time_matrix(matrices.get_time_matrix(vehicle_type), vehicle_type); } } } @@ -189,6 +230,8 @@ auto create_host_mdarray(size_t nlocations, uint8_t n_vehicle_types, uint8_t n_m { std::vector full_matrix_extent{n_vehicle_types, n_matrix_types, nlocations, nlocations}; h_mdarray_t matrices{full_matrix_extent}; + // Legacy builders store transit time in the last slot; explicit layouts override this index. + matrices.time_matrix_index = n_matrix_types > 1 ? n_matrix_types - 1 : matrices.cost_matrix_index; return matrices; } @@ -200,6 +243,7 @@ auto create_device_mdarray(size_t nlocations, { std::vector full_matrix_extent{n_vehicle_types, n_matrix_types, nlocations, nlocations}; d_mdarray_t matrices{full_matrix_extent, stream}; + matrices.time_matrix_index = n_matrix_types > 1 ? n_matrix_types - 1 : matrices.cost_matrix_index; return matrices; } @@ -222,55 +266,109 @@ inline auto get_unique_vehicle_types(const raft::device_span& veh } template -auto get_cost_matrix_type_dim(data_model_view_t const& data_model) +bool has_distance_matrix(data_model_view_t const& data_model) { - auto n_matrix_types = 1; auto vehicle_types_map = get_unique_vehicle_types(data_model.get_vehicle_types(), data_model.get_handle_ptr()->get_stream()); + bool has_distance = false; for (auto& [old_type, new_type] : vehicle_types_map) { - if (data_model.get_transit_time_matrix(old_type)) { - ++n_matrix_types; - break; + const bool current_has_distance = data_model.get_distance_matrix(old_type) != nullptr; + if (has_distance && !current_has_distance) { + cuopt_expects( + false, error_type_t::ValidationError, "All vehicle distance matrices should be set"); } + if (current_has_distance && !has_distance && old_type != vehicle_types_map.begin()->first) { + cuopt_expects( + false, error_type_t::ValidationError, "All vehicle distance matrices should be set"); + } + has_distance = has_distance || current_has_distance; } + return has_distance; +} + +template +bool requires_distance_matrix(data_model_view_t const& data_model) +{ + const auto total_tiers = std::get<4>(data_model.get_vehicle_distance_tiers()); + return !data_model.get_vehicle_max_distances().empty() || total_tiers > 0; +} + +template +bool has_transit_time_matrix(data_model_view_t const& data_model) +{ + auto vehicle_types_map = get_unique_vehicle_types(data_model.get_vehicle_types(), + data_model.get_handle_ptr()->get_stream()); + for (auto& [old_type, new_type] : vehicle_types_map) { + if (data_model.get_transit_time_matrix(old_type)) { return true; } + } + return false; +} + +template +auto get_cost_matrix_type_dim(data_model_view_t const& data_model) +{ + auto n_matrix_types = 1; + if (requires_distance_matrix(data_model)) { ++n_matrix_types; } + if (has_transit_time_matrix(data_model)) { ++n_matrix_types; } return n_matrix_types; } template -std::tuple get_vehicle_matrices( +std::tuple get_vehicle_matrices( data_model_view_t const& data_model, uint8_t vehicle_type) { auto cost_matrix = data_model.get_cost_matrix(vehicle_type); if (!cost_matrix) cuopt_expects( false, error_type_t::ValidationError, "Set vehicle types when using multiple matrices"); - auto time_matrix = data_model.get_transit_time_matrix(vehicle_type); + auto distance_matrix = data_model.get_distance_matrix(vehicle_type); + auto time_matrix = data_model.get_transit_time_matrix(vehicle_type); if (!time_matrix) time_matrix = cost_matrix; - return std::make_tuple(cost_matrix, time_matrix); + return std::make_tuple(cost_matrix, distance_matrix, time_matrix); } template void fill_mdarray_from_data_model(d_mdarray_t& matrices, data_model_view_t const& data_model) { - auto stream = data_model.get_handle_ptr()->get_stream(); - auto vehicle_types = data_model.get_vehicle_types(); - auto nlocations = data_model.get_num_locations(); - auto vehicle_types_map = get_unique_vehicle_types(vehicle_types, stream); + auto stream = data_model.get_handle_ptr()->get_stream(); + auto vehicle_types = data_model.get_vehicle_types(); + auto nlocations = data_model.get_num_locations(); + auto vehicle_types_map = get_unique_vehicle_types(vehicle_types, stream); + const bool has_distance = requires_distance_matrix(data_model); + const bool has_time = has_transit_time_matrix(data_model); + const size_t matrix_size = static_cast(nlocations) * static_cast(nlocations); + + matrices.cost_matrix_index = 0; + uint8_t next_index = 1; + matrices.distance_matrix_index = has_distance ? next_index++ : matrices.cost_matrix_index; + matrices.time_matrix_index = has_time ? next_index++ : matrices.cost_matrix_index; for (auto& [old_type, new_type] : vehicle_types_map) { - auto [cost_matrix, time_matrix] = get_vehicle_matrices(data_model, old_type); - auto cost_matrix_span = matrices.get_cost_matrix(new_type); - auto time_matrix_span = matrices.get_time_matrix(new_type); - raft::copy(cost_matrix_span, cost_matrix, nlocations * nlocations, stream); + auto [cost_matrix, distance_matrix, time_matrix] = + get_vehicle_matrices(data_model, old_type); + auto cost_matrix_span = matrices.get_cost_matrix(new_type, matrices.cost_matrix_index); + raft::copy(cost_matrix_span, cost_matrix, matrix_size, stream); if (limit_matrix_entries(cost_matrix_span, nlocations, data_model.get_handle_ptr())) { std::cout << "\nMax cost matrix value overriden to 1.0e+30"; } - raft::copy(time_matrix_span, time_matrix, nlocations * nlocations, stream); - if (limit_matrix_entries(time_matrix_span, nlocations, data_model.get_handle_ptr())) { - std::cout << "\nMax time matrix value overriden to 1.0e+30"; + if (has_distance) { + auto distance_matrix_span = + matrices.get_cost_matrix(new_type, matrices.distance_matrix_index); + raft::copy(distance_matrix_span, distance_matrix, matrix_size, stream); + if (limit_matrix_entries(distance_matrix_span, nlocations, data_model.get_handle_ptr())) { + std::cout << "\nMax distance matrix value overriden to 1.0e+30"; + } + } + + if (matrices.time_matrix_index != matrices.cost_matrix_index) { + auto time_matrix_span = matrices.get_cost_matrix(new_type, matrices.time_matrix_index); + raft::copy(time_matrix_span, time_matrix, matrix_size, stream); + if (limit_matrix_entries(time_matrix_span, nlocations, data_model.get_handle_ptr())) { + std::cout << "\nMax time matrix value overriden to 1.0e+30"; + } } } } diff --git a/cpp/src/routing/vehicle_info.hpp b/cpp/src/routing/vehicle_info.hpp index d7b4e049a7..afac5a6677 100644 --- a/cpp/src/routing/vehicle_info.hpp +++ b/cpp/src/routing/vehicle_info.hpp @@ -16,34 +16,172 @@ namespace cuopt { namespace routing { namespace detail { +/** + * @brief Represents a distance tier with threshold and cost structure + * + * Example: + * - Tier 1: threshold=100, fixed_cost=X, cost_per_unit=0 + * - Tier 2: threshold=200, fixed_cost=0, cost_per_unit=0.1 + * - Tier 3: threshold=max, fixed_cost=0, cost_per_unit=0.5 + */ +template +struct distance_tier_t { + f_t threshold{0.0}; // Distance threshold (e.g., 100, 200) + f_t fixed_cost{0.0}; // Fixed cost for this tier + f_t cost_per_unit{0.0}; // Cost per km/unit for this tier + + bool operator==(distance_tier_t const& rhs) const + { + return threshold == rhs.threshold && fixed_cost == rhs.fixed_cost && + cost_per_unit == rhs.cost_per_unit; + } +}; + template struct VehicleInfo { - constexpr bool has_time_matrix() const { return matrices.extent[1] > 1; } + constexpr bool has_time_matrix() const + { + if (matrices.time_matrix_index != matrices.cost_matrix_index) { return true; } + return matrices.extent[1] >= 2 && matrices.distance_matrix_index == matrices.cost_matrix_index; + } + + HDI bool has_distance_tiers() const { return !distance_tiers.empty(); } + + HDI bool has_max_distance_constraint() const + { + return max_distance < std::numeric_limits::max(); + } + + HDI bool uses_travel_distance() const + { + return has_distance_tiers() || has_max_distance_constraint(); + } + + HDI double compute_distance_excess(double travel_distance) const + { + constexpr double unreachable_distance = 1.0e30; + if (travel_distance >= unreachable_distance) { return travel_distance; } + return max(0., travel_distance - max_distance); + } + + HDI double compute_distance_cost(double travel_distance, double fallback_cost_distance) const + { + if (!has_distance_tiers()) { return fallback_cost_distance; } + + double tier_cost = 0.0; + double prev_threshold = 0.0; + for (size_t i = 0; i < distance_tiers.size(); ++i) { + const auto& tier = distance_tiers[i]; + if (travel_distance <= prev_threshold) { break; } + const double upper = tier.threshold; + const double in_band = min(travel_distance, upper) - prev_threshold; + if (in_band > 0.0) { + if (tier.fixed_cost > 0.0) { tier_cost += tier.fixed_cost; } + tier_cost += in_band * tier.cost_per_unit; + } + prev_threshold = upper; + if (travel_distance <= upper) { break; } + } + + return fallback_cost_distance + tier_cost; + } + + HDI int find_distance_tier(double travel_distance) const + { + if (!has_distance_tiers()) { return -1; } + + double prev_threshold = 0.0; + for (size_t i = 0; i < distance_tiers.size(); ++i) { + const double upper = distance_tiers[i].threshold; + if (travel_distance > prev_threshold && travel_distance <= upper) { + return static_cast(i); + } + if (travel_distance <= upper) { break; } + prev_threshold = upper; + } + + return -1; + } + + HDI double compute_distance_cost_from_delta(double old_travel_distance, + double old_fallback_cost_distance, + double old_distance_cost, + double new_travel_distance, + double new_fallback_cost_distance, + int old_distance_tier) const + { + if (!has_distance_tiers()) { return new_fallback_cost_distance; } + + if (old_distance_tier >= 0 && old_distance_tier < static_cast(distance_tiers.size())) { + const auto& tier = distance_tiers[old_distance_tier]; + const double upper = tier.threshold; + const double prev_threshold = + old_distance_tier == 0 ? 0.0 : distance_tiers[old_distance_tier - 1].threshold; + const bool old_in_tier = old_travel_distance > prev_threshold && old_travel_distance <= upper; + const bool new_in_tier = new_travel_distance > prev_threshold && new_travel_distance <= upper; + + if (old_in_tier && new_in_tier) { + return old_distance_cost + (new_fallback_cost_distance - old_fallback_cost_distance) + + (new_travel_distance - old_travel_distance) * tier.cost_per_unit; + } + } + + return compute_distance_cost(new_travel_distance, new_fallback_cost_distance); + } bool operator==(VehicleInfo const& rhs) const { + if (distance_tiers.size() != rhs.distance_tiers.size()) { return false; } + for (size_t i = 0; i < distance_tiers.size(); ++i) { + if (!(distance_tiers[i] == rhs.distance_tiers[i])) { return false; } + } + return drop_return_trip == rhs.drop_return_trip && skip_first_trip == rhs.skip_first_trip && type == rhs.type && order_service_times == rhs.order_service_times && order_match == rhs.order_match && capacities == rhs.capacities && break_durations == rhs.break_durations && break_earliest == rhs.break_earliest && break_latest == rhs.break_latest && earliest == rhs.earliest && latest == rhs.latest && start == rhs.start && end == rhs.end && max_cost == rhs.max_cost && - max_time == rhs.max_time && fixed_cost == rhs.fixed_cost && priority == rhs.priority; + max_distance == rhs.max_distance && max_time == rhs.max_time && + fixed_cost == rhs.fixed_cost && priority == rhs.priority; } HDI int num_breaks() const { return break_durations.size(); } + double get_average_distance() const + { + auto matrix = matrices.get_distance_matrix(type); + auto width = matrices.extent[3]; + double sum = 0.; + size_t count = 0; + + for (size_t i = 0; i < width * width; ++i) { + if (matrix[i] < f_t{1.0e30}) { + sum += matrix[i]; + ++count; + } + } + + return count > 0 ? (sum / static_cast(count)) : 0.0; + } + double get_average_cost() const { - auto matrix = matrices.get_cost_matrix(type); - auto width = matrices.extent[3]; - double avg_cost = 0.; + auto matrix = matrices.get_cost_matrix(type); + auto width = matrices.extent[3]; + double sum = 0.; + size_t count = 0; for (size_t i = 0; i < width * width; ++i) { - if (matrix[i] != std::numeric_limits::max()) { avg_cost += matrix[i]; } + if (matrix[i] != std::numeric_limits::max()) { + sum += matrix[i]; + ++count; + } } - return avg_cost / (width * width); + if (distance_tiers.empty()) { return sum / (width * width); } + const double average_matrix_cost = count > 0 ? (sum / static_cast(count)) : 0.0; + return compute_distance_cost(get_average_distance(), average_matrix_cost); } bool drop_return_trip = false; @@ -60,10 +198,16 @@ struct VehicleInfo { int latest{}; int start{}; int end{}; - f_t max_cost = std::numeric_limits::max(); - f_t max_time = std::numeric_limits::max(); + f_t max_cost = std::numeric_limits::max(); + f_t max_distance = std::numeric_limits::max(); + f_t max_time = std::numeric_limits::max(); f_t fixed_cost{}; int priority{}; + + // Distance tiers for tiered pricing based on total route distance + // Tiers should be sorted by threshold in ascending order + // Example: [{100, X, 0}, {200, 0, 0.1}, {INF, 0, 0.5}] + raft::span const, is_device> distance_tiers{}; }; } // namespace detail } // namespace routing diff --git a/cpp/tests/routing/CMakeLists.txt b/cpp/tests/routing/CMakeLists.txt index 4beb6c3315..ce050bc073 100644 --- a/cpp/tests/routing/CMakeLists.txt +++ b/cpp/tests/routing/CMakeLists.txt @@ -56,6 +56,8 @@ ConfigureTest(ROUTING_UNIT_TEST ${CMAKE_CURRENT_SOURCE_DIR}/unit_tests/batch_tsp.cu ${CMAKE_CURRENT_SOURCE_DIR}/unit_tests/cost_boundary_initialization.cu ${CMAKE_CURRENT_SOURCE_DIR}/unit_tests/set_shmem_of_kernel.cu + ${CMAKE_CURRENT_SOURCE_DIR}/unit_tests/distance_tiers_separate_distance.cu + ${CMAKE_CURRENT_SOURCE_DIR}/fsmvrptwsc/fsmvrptwsc_test.cu LABELS routing STATIC_LIB) diff --git a/cpp/tests/routing/fsmvrptwsc/fsmvrptwsc_parser.hpp b/cpp/tests/routing/fsmvrptwsc/fsmvrptwsc_parser.hpp new file mode 100644 index 0000000000..6d3cef2fbf --- /dev/null +++ b/cpp/tests/routing/fsmvrptwsc/fsmvrptwsc_parser.hpp @@ -0,0 +1,182 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cuopt { +namespace routing { +namespace test { + +struct fsmvrptwsc_instance_t { + std::string name; + int n_clients{}; + int n_vehicle_types{}; + int n_distance_ranges{}; + int n_vehicles{}; + std::vector distance_matrix; + std::vector transit_time_matrix; + std::vector order_locations; + std::vector earliest; + std::vector latest; + std::vector service_times; + std::vector demand; + std::vector vehicle_earliest; + std::vector vehicle_latest; + std::vector capacities; + std::vector vehicle_types; + std::vector tier_thresholds; + std::vector tier_fixed_costs; + std::vector tier_costs_per_unit; + std::vector tier_offsets; +}; + +inline std::string normalize_token(std::string token) +{ + token.erase(std::remove(token.begin(), token.end(), ','), token.end()); + token.erase(std::remove(token.begin(), token.end(), '\r'), token.end()); + token.erase(std::remove_if(token.begin(), + token.end(), + [](unsigned char c) { return c == 0xef || c == 0xbb || c == 0xbf; }), + token.end()); + return token; +} + +template +value_t read_value(std::istream& in) +{ + std::string token; + if (!(in >> token)) { throw std::runtime_error("Unexpected end of FSMVRPTWSC file"); } + token = normalize_token(token); + try { + auto const value = std::stof(token); + if constexpr (std::is_integral_v) { + return static_cast(value); + } else { + return static_cast(value); + } + } catch (std::exception const& e) { + throw std::runtime_error("Invalid numeric token in FSMVRPTWSC file: '" + token + "'"); + } +} + +inline fsmvrptwsc_instance_t read_one_instance(std::istream& in) +{ + fsmvrptwsc_instance_t instance; + in >> instance.name; + instance.name = normalize_token(instance.name); + instance.n_clients = read_value(in); + instance.n_vehicle_types = read_value(in); + instance.n_distance_ranges = read_value(in); + + auto const matrix_size = (instance.n_clients + 1) * (instance.n_clients + 1); + instance.distance_matrix.resize(matrix_size); + instance.transit_time_matrix.resize(matrix_size); + for (auto& value : instance.distance_matrix) { + value = read_value(in); + } + for (auto& value : instance.transit_time_matrix) { + value = read_value(in); + } + + for (int i = 0; i <= instance.n_clients; ++i) { + auto const earliest = read_value(in); + auto const latest = read_value(in); + auto const service = read_value(in); + auto const demand = read_value(in); + if (i > 0) { + instance.order_locations.push_back(i); + instance.earliest.push_back(static_cast(earliest)); + instance.latest.push_back(static_cast(latest)); + instance.service_times.push_back(static_cast(service)); + instance.demand.push_back(static_cast(demand)); + } else { + instance.vehicle_earliest.push_back(static_cast(earliest)); + instance.vehicle_latest.push_back(static_cast(latest)); + } + } + + std::vector type_capacities(instance.n_vehicle_types); + for (auto& capacity : type_capacities) { + capacity = read_value(in); + } + + std::vector range_starts(instance.n_distance_ranges); + for (auto& range_start : range_starts) { + range_start = read_value(in); + } + + std::vector> type_costs(instance.n_vehicle_types, + std::vector(instance.n_distance_ranges)); + for (auto& costs : type_costs) { + for (auto& cost : costs) { + cost = read_value(in); + } + } + + // FSMVRPTWSC has an unrestricted fleet mix. Model that by making each vehicle + // type available up to one route per client. + instance.n_vehicles = instance.n_clients * instance.n_vehicle_types; + instance.vehicle_earliest.resize(instance.n_vehicles, instance.vehicle_earliest.front()); + instance.vehicle_latest.resize(instance.n_vehicles, instance.vehicle_latest.front()); + instance.tier_offsets.push_back(0); + for (int type = 0; type < instance.n_vehicle_types; ++type) { + for (int copy = 0; copy < instance.n_clients; ++copy) { + instance.vehicle_types.push_back(static_cast(type)); + instance.capacities.push_back(type_capacities[type]); + + auto const& costs = type_costs[type]; + for (int tier = 0; tier < instance.n_distance_ranges; ++tier) { + if (tier + 1 < instance.n_distance_ranges) { + // Dataset ranges include their lower bound; cuOpt thresholds include the upper bound. + instance.tier_thresholds.push_back( + std::nextafter(range_starts[tier + 1], -std::numeric_limits::infinity())); + auto const previous = tier == 0 ? 0.0f : costs[tier - 1]; + instance.tier_fixed_costs.push_back(costs[tier] - previous); + instance.tier_costs_per_unit.push_back(0.0f); + } else { + instance.tier_thresholds.push_back(std::numeric_limits::max()); + instance.tier_fixed_costs.push_back(0.0f); + instance.tier_costs_per_unit.push_back(costs[tier]); + } + } + instance.tier_offsets.push_back(static_cast(instance.tier_thresholds.size())); + } + } + + return instance; +} + +inline fsmvrptwsc_instance_t load_small_instance(std::string const& path, + std::string const& instance_name) +{ + std::ifstream input(path); + if (!input.is_open()) { + throw std::runtime_error("FSMVRPTWSC Small.txt cannot be opened: " + path); + } + + auto const n_instances = read_value(input); + for (int i = 0; i < n_instances; ++i) { + auto instance = read_one_instance(input); + if (instance.name == instance_name) { return instance; } + } + + throw std::runtime_error("FSMVRPTWSC instance not found: " + instance_name + " in " + path); +} + +} // namespace test +} // namespace routing +} // namespace cuopt diff --git a/cpp/tests/routing/fsmvrptwsc/fsmvrptwsc_test.cu b/cpp/tests/routing/fsmvrptwsc/fsmvrptwsc_test.cu new file mode 100644 index 0000000000..b7e326f7ae --- /dev/null +++ b/cpp/tests/routing/fsmvrptwsc/fsmvrptwsc_test.cu @@ -0,0 +1,198 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include "fsmvrptwsc_parser.hpp" + +#include +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace cuopt { +namespace routing { +namespace test { + +namespace { + +struct fsmvrptwsc_params_t { + std::string small_file; + std::string instance_name; + float reference_cost{}; + float max_relative_gap{}; +}; + +bool is_absolute_path(std::string const& path) +{ + return !path.empty() && + (path[0] == '/' || path[0] == '\\' || (path.size() > 1 && path[1] == ':')); +} + +std::string join_path(std::string const& base, std::string const& path) +{ + if (base.empty()) { return path; } + if (base.back() == '/' || base.back() == '\\') { return base + path; } + return base + "/" + path; +} + +std::string resolve_ref_path(std::string const& ref_file) +{ + if (is_absolute_path(ref_file)) { return ref_file; } + auto const cuopt_home = cuopt::test::get_cuopt_home(); + return cuopt_home.empty() ? ref_file : join_path(cuopt_home, ref_file); +} + +std::string resolve_dataset_path(std::string const& dataset_file) +{ + if (is_absolute_path(dataset_file)) { return dataset_file; } + + auto dataset_root = cuopt::test::get_rapids_dataset_root_dir(); + auto const cuopt_home = cuopt::test::get_cuopt_home(); + if (!is_absolute_path(dataset_root) && !cuopt_home.empty()) { + dataset_root = join_path(cuopt_home, dataset_root); + } + + return join_path(dataset_root, dataset_file); +} + +std::vector read_fsmvrptwsc_tests(std::string const& ref_file) +{ + std::ifstream infile(resolve_ref_path(ref_file)); + if (!infile.is_open()) { throw std::runtime_error("Ref file cannot be opened: " + ref_file); } + + std::vector params; + for (std::string line; getline(infile, line);) { + if (line.empty()) { continue; } + auto tokens = cuopt::test::split(line, ','); + if (tokens.size() != 4) { throw std::runtime_error("Invalid FSMVRPTWSC ref line: " + line); } + params.push_back( + {resolve_dataset_path(tokens[0]), tokens[1], std::stof(tokens[2]), std::stof(tokens[3])}); + } + return params; +} + +} // namespace + +class fsmvrptwsc_small_test_t : public ::testing::TestWithParam {}; + +TEST(fsmvrptwsc_parser, range_starts_enter_the_next_tier) +{ + std::istringstream input( + "boundary 1 1 3 " + "0 1 1 0 " // Distance matrix. + "0 1 1 0 " // Transit-time matrix. + "0 100 0 0 0 100 0 1 " // Depot and order windows, service times and demands. + "10 0 40 70 50 58 2"); // Capacity, range starts and costs. + auto instance = read_one_instance(input); + ASSERT_EQ(instance.tier_thresholds.size(), 3); + std::vector> tiers; + for (size_t i = 0; i < instance.tier_thresholds.size(); ++i) { + tiers.push_back( + {instance.tier_thresholds[i], instance.tier_fixed_costs[i], instance.tier_costs_per_unit[i]}); + } + detail::VehicleInfo vehicle; + vehicle.distance_tiers = + raft::span const, false>(tiers.data(), tiers.size()); + EXPECT_DOUBLE_EQ(vehicle.compute_distance_cost(instance.tier_thresholds[0], 0.), 50.); + EXPECT_DOUBLE_EQ(vehicle.compute_distance_cost(40., 0.), 58.); + EXPECT_EQ(vehicle.find_distance_tier(40.), 1); + EXPECT_EQ(vehicle.find_distance_tier(70.), 2); + EXPECT_FLOAT_EQ(instance.tier_thresholds.back(), std::numeric_limits::max()); + EXPECT_NEAR(vehicle.compute_distance_cost(75., 0.), 68., 1.e-4); +} + +TEST(fsmvrptwsc_parser, missing_instance_throws) +{ + auto const params = read_fsmvrptwsc_tests("datasets/ref/fsmvrptwsc_small.txt"); + ASSERT_FALSE(params.empty()); + EXPECT_THROW(load_small_instance(params.front().small_file, "missing-instance"), + std::runtime_error); +} + +TEST_P(fsmvrptwsc_small_test_t, solves_small_step_cost_instance) +{ + auto const param = GetParam(); + auto instance = load_small_instance(param.small_file, param.instance_name); + + raft::handle_t handle; + auto stream = handle.get_stream(); + + auto zero_cost_matrix = std::vector(instance.distance_matrix.size(), 0.0f); + + auto d_cost_matrix = cuopt::device_copy(zero_cost_matrix, stream); + auto d_distance_matrix = cuopt::device_copy(instance.distance_matrix, stream); + auto d_transit_time_matrix = cuopt::device_copy(instance.transit_time_matrix, stream); + auto d_order_locations = cuopt::device_copy(instance.order_locations, stream); + auto d_earliest = cuopt::device_copy(instance.earliest, stream); + auto d_latest = cuopt::device_copy(instance.latest, stream); + auto d_service_times = cuopt::device_copy(instance.service_times, stream); + auto d_demands = cuopt::device_copy(instance.demand, stream); + auto d_vehicle_earliest = cuopt::device_copy(instance.vehicle_earliest, stream); + auto d_vehicle_latest = cuopt::device_copy(instance.vehicle_latest, stream); + auto d_capacities = cuopt::device_copy(instance.capacities, stream); + auto d_vehicle_types = cuopt::device_copy(instance.vehicle_types, stream); + auto d_tier_thresholds = cuopt::device_copy(instance.tier_thresholds, stream); + auto d_tier_fixed_costs = cuopt::device_copy(instance.tier_fixed_costs, stream); + auto d_tier_costs_per_unit = cuopt::device_copy(instance.tier_costs_per_unit, stream); + auto d_tier_offsets = cuopt::device_copy(instance.tier_offsets, stream); + handle.sync_stream(); + + cuopt::routing::data_model_view_t data_model( + &handle, instance.n_clients + 1, instance.n_vehicles, instance.n_clients); + for (int type = 0; type < instance.n_vehicle_types; ++type) { + data_model.add_cost_matrix(d_cost_matrix.data(), static_cast(type)); + data_model.add_distance_matrix(d_distance_matrix.data(), static_cast(type)); + data_model.add_transit_time_matrix(d_transit_time_matrix.data(), static_cast(type)); + } + data_model.set_order_locations(d_order_locations.data()); + data_model.set_order_time_windows(d_earliest.data(), d_latest.data(), false); + data_model.set_order_service_times(d_service_times.data(), -1, false); + data_model.set_vehicle_time_windows(d_vehicle_earliest.data(), d_vehicle_latest.data(), false); + data_model.set_vehicle_types(d_vehicle_types.data(), false); + data_model.add_capacity_dimension("demand", d_demands.data(), d_capacities.data(), false); + data_model.set_vehicle_distance_tiers(d_tier_thresholds.data(), + d_tier_fixed_costs.data(), + d_tier_costs_per_unit.data(), + d_tier_offsets.data(), + static_cast(instance.tier_thresholds.size())); + + cuopt::routing::solver_settings_t settings; + // Use longer time limit for larger real instances. + auto time_limit = (instance.n_clients > 50) ? 300.0f : 5.0f; + settings.set_time_limit(time_limit); + + auto routing_solution = cuopt::routing::solve(data_model, settings); + handle.sync_stream(); + + ASSERT_EQ(routing_solution.get_status(), cuopt::routing::solution_status_t::SUCCESS); + auto host_route = cuopt::routing::host_assignment_t(routing_solution); + check_route(data_model, host_route); + + auto const objective = routing_solution.get_total_objective(); + auto const max_cost = param.reference_cost * (1.0f + param.max_relative_gap); + + EXPECT_LE(objective, max_cost) << "FSMVRPTWSC gap exceeded for " << param.instance_name; +} + +INSTANTIATE_TEST_SUITE_P( + small, + fsmvrptwsc_small_test_t, + ::testing::ValuesIn(read_fsmvrptwsc_tests("datasets/ref/fsmvrptwsc_small.txt"))); + +} // namespace test +} // namespace routing +} // namespace cuopt diff --git a/cpp/tests/routing/grpc/grpc_routing_problem_mapper_test.cpp b/cpp/tests/routing/grpc/grpc_routing_problem_mapper_test.cpp index 651f2b983e..187642d5e6 100644 --- a/cpp/tests/routing/grpc/grpc_routing_problem_mapper_test.cpp +++ b/cpp/tests/routing/grpc/grpc_routing_problem_mapper_test.cpp @@ -12,6 +12,8 @@ #include +#include +#include #include namespace { @@ -132,3 +134,66 @@ TEST(RoutingProblemMapper, VehicleDistanceBreaksRoundTrip) 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})); } + +TEST(RoutingProblemMapper, VehicleDistanceTiersRoundTrip) +{ + auto p = make_base_problem(); + p.distance_matrices = {{1, {0.f, 2.f, 2.f, 0.f}}}; + p.vehicle_max_distances = {10.f, 20.f}; + p.distance_tier_thresholds = { + 5.f, std::numeric_limits::max(), 7.f, std::numeric_limits::max()}; + p.distance_tier_fixed_costs = {3.f, 0.f, 4.f, 0.f}; + p.distance_tier_costs_per_unit = {0.f, 2.f, 0.f, 3.f}; + p.distance_tier_offsets = {0, 2, 4}; + + cuopt::remote::RoutingProblem pb; + cuopt::routing::map_routing_problem_to_proto(p, &pb); + + ASSERT_EQ(pb.distance_matrices_size(), 1); + ASSERT_TRUE(pb.has_vehicle_distance_tiers()); + ASSERT_EQ(pb.vehicle_distance_tiers().thresholds_size(), 4); + + cuopt::routing::cpu_routing_problem_t back; + cuopt::routing::map_proto_to_routing_problem(pb, back); + ASSERT_EQ(back.distance_matrices.size(), 1u); + EXPECT_EQ(back.distance_matrices[0].vehicle_type, 1); + EXPECT_EQ(back.distance_matrices[0].matrix, (std::vector{0.f, 2.f, 2.f, 0.f})); + EXPECT_EQ(back.vehicle_max_distances, (std::vector{10.f, 20.f})); + EXPECT_EQ(back.distance_tier_thresholds, + (std::vector{ + 5.f, std::numeric_limits::max(), 7.f, std::numeric_limits::max()})); + EXPECT_EQ(back.distance_tier_fixed_costs, (std::vector{3.f, 0.f, 4.f, 0.f})); + EXPECT_EQ(back.distance_tier_costs_per_unit, (std::vector{0.f, 2.f, 0.f, 3.f})); + EXPECT_EQ(back.distance_tier_offsets, (std::vector{0, 2, 4})); +} + +TEST(RoutingProblemMapper, PreservesPartialDistanceTiersForValidation) +{ + auto p = make_base_problem(); + p.distance_tier_fixed_costs = {3.f}; + p.distance_tier_costs_per_unit = {2.f}; + p.distance_tier_offsets = {0, 1, 1}; + + cuopt::remote::RoutingProblem pb; + cuopt::routing::map_routing_problem_to_proto(p, &pb); + + ASSERT_TRUE(pb.has_vehicle_distance_tiers()); + EXPECT_EQ(pb.vehicle_distance_tiers().thresholds_size(), 0); + EXPECT_EQ(pb.vehicle_distance_tiers().fixed_costs_size(), 1); + + cuopt::routing::cpu_routing_problem_t back; + cuopt::routing::map_proto_to_routing_problem(pb, back); + EXPECT_TRUE(back.distance_tier_thresholds.empty()); + EXPECT_EQ(back.distance_tier_fixed_costs, (std::vector{3.f})); + EXPECT_EQ(back.distance_tier_costs_per_unit, (std::vector{2.f})); + EXPECT_EQ(back.distance_tier_offsets, (std::vector{0, 1, 1})); +} + +TEST(RoutingProblemMapper, RejectsOutOfRangeVehicleType) +{ + cuopt::remote::RoutingProblem pb; + pb.add_vehicle_types(256); + + cuopt::routing::cpu_routing_problem_t problem; + EXPECT_THROW(cuopt::routing::map_proto_to_routing_problem(pb, problem), std::invalid_argument); +} diff --git a/cpp/tests/routing/level0/l0_ges_test.cu b/cpp/tests/routing/level0/l0_ges_test.cu index 47944c478d..e4d282aeb1 100644 --- a/cpp/tests/routing/level0/l0_ges_test.cu +++ b/cpp/tests/routing/level0/l0_ges_test.cu @@ -7,8 +7,12 @@ #include +#include #include +#include +#include #include +#include #include #include @@ -17,6 +21,115 @@ namespace cuopt { namespace routing { namespace test { +namespace { + +__global__ void copy_ges_distance_forward_kernel(detail::enabled_dimensions_t dimensions, + double* copied_distances) +{ + using node_t = detail::node_t; + using node_stack_t = detail::node_stack_t; + + node_t source(dimensions); + node_t node_destination(dimensions); + node_t second_node_destination(dimensions); + typename node_stack_t::item_t item{}; + typename node_stack_t::item_t second_item{}; + + source.request = + detail::request_info_t(detail::NodeInfo{0, 0, node_type_t::DEPOT}); + source.cost_dim.distance_forward = 37.0; + + item.intra_idx = 0; + item.from_idx = 0; + second_item.intra_idx = 0; + second_item.from_idx = 0; + + item = source; + copied_distances[0] = item.distance_forward; + second_item = item; + copied_distances[1] = second_item.distance_forward; + detail::copy_forward_data(node_destination, second_item); + copied_distances[2] = node_destination.cost_dim.distance_forward; + detail::copy_forward_data(second_node_destination, source); + copied_distances[3] = second_node_destination.cost_dim.distance_forward; +} + +__global__ void get_ges_direct_distance_kernel(float const* matrices, double* distances) +{ + if (threadIdx.x == 0) { + mdarray_view_t matrix_view; + matrix_view.buffer_ptr = matrices; + matrix_view.extent[0] = 1; + matrix_view.extent[1] = 2; + matrix_view.extent[2] = 4; + matrix_view.extent[3] = 4; + matrix_view.cost_matrix_index = 0; + matrix_view.distance_matrix_index = 1; + + detail::VehicleInfo vehicle_info; + vehicle_info.matrices = matrix_view; + vehicle_info.max_distance = 10000.f; + + const detail::NodeInfo from{0, 0, node_type_t::DEPOT}; + const detail::NodeInfo via{1, 1, node_type_t::PICKUP}; + const detail::NodeInfo to{2, 2, node_type_t::PICKUP}; + distances[0] = detail::node_stack_t::get_travel_distance_between( + from, to, vehicle_info); + distances[1] = detail::get_travel_distance(from, to, vehicle_info); + distances[2] = detail::get_travel_distance(from, via, vehicle_info) + + detail::get_travel_distance(via, to, vehicle_info); + } +} + +TEST(ges_node_stack, copies_distance_forward_in_all_directions) +{ + raft::handle_t handle; + auto stream = handle.get_stream(); + detail::enabled_dimensions_t dimensions; + dimensions.enable_dimension(detail::dim_t::COST); + rmm::device_uvector copied_distances(4, stream); + + copy_ges_distance_forward_kernel<<<1, 1, 0, stream.get()>>>(dimensions, copied_distances.data()); + RAFT_CHECK_CUDA(stream.get()); + auto host_distances = cuopt::host_copy(copied_distances, stream); + + EXPECT_EQ(host_distances, (std::vector{37.0, 37.0, 37.0, 37.0})); +} + +TEST(ges_node_stack, uses_direct_arc_from_separate_distance_matrix) +{ + constexpr int n_locations = 4; + constexpr int n_threads = 32; + + std::vector cost_matrix(n_locations * n_locations, 1.f); + std::vector distance_matrix(n_locations * n_locations); + for (int from = 0; from < n_locations; ++from) { + for (int to = 0; to < n_locations; ++to) { + const auto index = from * n_locations + to; + cost_matrix[index] = from == to ? 0.f : 1.f; + distance_matrix[index] = from == to ? 0.f : 10.f * from + to + 1.f; + } + } + + std::vector matrices = cost_matrix; + matrices.insert(matrices.end(), distance_matrix.begin(), distance_matrix.end()); + + raft::handle_t handle; + auto stream = handle.get_stream(); + auto d_matrices = cuopt::device_copy(matrices, stream); + rmm::device_uvector distances(3, stream); + + get_ges_direct_distance_kernel<<<1, n_threads, 0, stream.get()>>>(d_matrices.data(), + distances.data()); + RAFT_CHECK_CUDA(stream.get()); + auto host_distances = cuopt::host_copy(distances, stream); + + EXPECT_DOUBLE_EQ(host_distances[0], host_distances[1]); + EXPECT_NE(host_distances[1], host_distances[2]); +} + +} // namespace + template class routing_ges_test_t : public ::testing::TestWithParam>, public base_test_t { diff --git a/cpp/tests/routing/unit_tests/distance_breaks.cu b/cpp/tests/routing/unit_tests/distance_breaks.cu index b2a239a344..445341d160 100644 --- a/cpp/tests/routing/unit_tests/distance_breaks.cu +++ b/cpp/tests/routing/unit_tests/distance_breaks.cu @@ -56,10 +56,10 @@ struct test_route { { auto n_arcs = static_cast(arcs.size()); for (int i = 0; i < n_arcs; ++i) { - nodes[i].calculate_forward(nodes[i + 1], arcs[i]); + nodes[i].calculate_forward(nodes[i + 1], arcs[i], arcs[i]); } for (int i = n_arcs; i > 0; --i) { - nodes[i].calculate_backward(nodes[i - 1], arcs[i - 1]); + nodes[i].calculate_backward(nodes[i - 1], arcs[i - 1], arcs[i - 1]); } } }; @@ -126,7 +126,7 @@ TEST(cost_node, early_arrival_cost_is_maximum_per_route) 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]); + r.nodes[k].calculate_forward(next_copy, r.arcs[k], r.arcs[k]); detail::objective_cost_t obj_cost; detail::infeasible_cost_t inf_cost; @@ -158,7 +158,7 @@ TEST(cost_node, early_arrival_does_not_create_later_upper_excess) 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]); + r.nodes[k].calculate_forward(next_copy, r.arcs[k], r.arcs[k]); detail::objective_cost_t obj_cost; detail::infeasible_cost_t inf_cost; @@ -168,7 +168,8 @@ TEST(cost_node, early_arrival_does_not_create_later_upper_excess) << "split (" << k << ", " << (k + 1) << ")"; EXPECT_DOUBLE_EQ(inf_cost[detail::dim_t::COST], 10.) << "split (" << k << ", " << (k + 1) << ")"; - EXPECT_DOUBLE_EQ(cost_node::combine(r.nodes[k], r.nodes[k + 1], r.vehicle_info, r.arcs[k]), 10.) + EXPECT_DOUBLE_EQ( + cost_node::combine(r.nodes[k], r.nodes[k + 1], r.vehicle_info, r.arcs[k], r.arcs[k]), 10.) << "split (" << k << ", " << (k + 1) << ")"; } } @@ -202,7 +203,7 @@ TEST(cost_node, combine_invariant_feasible) r.run_passes(); for (size_t k = 0; k + 1 < r.nodes.size(); ++k) { - double c = cost_node::combine(r.nodes[k], r.nodes[k + 1], r.vehicle_info, r.arcs[k]); + double c = cost_node::combine(r.nodes[k], r.nodes[k + 1], r.vehicle_info, r.arcs[k], r.arcs[k]); EXPECT_DOUBLE_EQ(c, 0.) << "split (" << k << ", " << (k + 1) << ") got " << c; } } @@ -215,10 +216,11 @@ TEST(cost_node, combine_invariant_window_violation) /*max_cost=*/800.f); r.run_passes(); - double reference = cost_node::combine(r.nodes[0], r.nodes[1], r.vehicle_info, r.arcs[0]); + double reference = + cost_node::combine(r.nodes[0], r.nodes[1], r.vehicle_info, r.arcs[0], r.arcs[0]); EXPECT_GT(reference, 0.); for (size_t k = 1; k + 1 < r.nodes.size(); ++k) { - double c = cost_node::combine(r.nodes[k], r.nodes[k + 1], r.vehicle_info, r.arcs[k]); + double c = cost_node::combine(r.nodes[k], r.nodes[k + 1], r.vehicle_info, r.arcs[k], r.arcs[k]); EXPECT_DOUBLE_EQ(c, reference) << "split (" << k << ", " << (k + 1) << ") = " << c << " differs from reference " << reference; } @@ -233,10 +235,11 @@ TEST(cost_node, combine_invariant_max_cost_only) /*max_cost=*/1000.f); r.run_passes(); - double reference = cost_node::combine(r.nodes[0], r.nodes[1], r.vehicle_info, r.arcs[0]); + double reference = + cost_node::combine(r.nodes[0], r.nodes[1], r.vehicle_info, r.arcs[0], 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 = cost_node::combine(r.nodes[k], r.nodes[k + 1], r.vehicle_info, r.arcs[k]); + double c = cost_node::combine(r.nodes[k], r.nodes[k + 1], r.vehicle_info, r.arcs[k], r.arcs[k]); EXPECT_DOUBLE_EQ(c, reference); } } @@ -257,7 +260,8 @@ TEST(cost_node, compute_cost_combine_consistency) 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 = cost_node::combine(r.nodes[0], r.nodes[1], r.vehicle_info, r.arcs[0]); + double combine_at_first = + cost_node::combine(r.nodes[0], r.nodes[1], r.vehicle_info, r.arcs[0], r.arcs[0]); EXPECT_DOUBLE_EQ(total, combine_at_first); } @@ -268,15 +272,18 @@ TEST(cost_route, distance_break_cost_requires_distance_window) raft::handle_t handle; auto stream = handle.get_stream(); - auto cost_forward = cuopt::device_copy(std::vector{0.}, stream); + auto cost_forward = cuopt::device_copy(std::vector{0.}, stream); + auto distance_forward = cuopt::device_copy(std::vector{0.}, stream); rmm::device_uvector result(1, stream); cost_route::view_t route; route.dim_info.has_distance_window = false; route.dim_info.has_distance_break_cost = true; route.cost_forward = raft::device_span{cost_forward.data(), cost_forward.size()}; + route.distance_forward = + raft::device_span{distance_forward.data(), distance_forward.size()}; ASSERT_TRUE(route.distance_break_cost_forward.empty()); - EXPECT_EQ(cost_route::get_shared_size(1, route.dim_info), 2 * sizeof(double)); + EXPECT_EQ(cost_route::get_shared_size(1, route.dim_info), 4 * sizeof(double)); compute_cost_route_cost<<<1, 1, 0, stream.get()>>>(route, result.data()); RAFT_CUDA_TRY(cudaGetLastError()); @@ -300,7 +307,7 @@ TEST(cost_node, get_cost_combine_consistency) 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]); + r.nodes[k].calculate_forward(next_copy, r.arcs[k], r.arcs[k]); detail::objective_cost_t obj_cost; detail::infeasible_cost_t inf_cost; @@ -308,7 +315,7 @@ TEST(cost_node, get_cost_combine_consistency) double get_cost_total = inf_cost[detail::dim_t::COST]; double combine_value = - cost_node::combine(r.nodes[k], r.nodes[k + 1], r.vehicle_info, r.arcs[k]); + cost_node::combine(r.nodes[k], r.nodes[k + 1], r.vehicle_info, r.arcs[k], r.arcs[k]); EXPECT_DOUBLE_EQ(get_cost_total, combine_value) << "split (" << k << ", " << (k + 1) << "): get_cost = " << get_cost_total @@ -328,10 +335,11 @@ TEST(cost_node, combine_additive_break_and_max_cost) /*max_cost=*/120.f); r.run_passes(); - double reference = cost_node::combine(r.nodes[0], r.nodes[1], r.vehicle_info, r.arcs[0]); + double reference = + cost_node::combine(r.nodes[0], r.nodes[1], r.vehicle_info, r.arcs[0], r.arcs[0]); EXPECT_DOUBLE_EQ(reference, 60.); for (size_t k = 1; k + 1 < r.nodes.size(); ++k) { - double c = cost_node::combine(r.nodes[k], r.nodes[k + 1], r.vehicle_info, r.arcs[k]); + double c = cost_node::combine(r.nodes[k], r.nodes[k + 1], r.vehicle_info, r.arcs[k], r.arcs[k]); EXPECT_DOUBLE_EQ(c, reference) << "split (" << k << ", " << (k + 1) << ") = " << c; } } diff --git a/cpp/tests/routing/unit_tests/distance_tiers_separate_distance.cu b/cpp/tests/routing/unit_tests/distance_tiers_separate_distance.cu new file mode 100644 index 0000000000..fbe7d4a2be --- /dev/null +++ b/cpp/tests/routing/unit_tests/distance_tiers_separate_distance.cu @@ -0,0 +1,903 @@ +/* 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 { + +struct tier_buffers_t { + rmm::device_uvector thresholds; + rmm::device_uvector fixed_costs; + rmm::device_uvector costs_per_unit; + rmm::device_uvector offsets; + + explicit tier_buffers_t(rmm::cuda_stream_view stream) + : thresholds(0, stream), fixed_costs(0, stream), costs_per_unit(0, stream), offsets(0, stream) + { + } +}; + +tier_buffers_t make_tier_buffers(rmm::cuda_stream_view stream, + std::vector const& thresholds, + std::vector const& fixed_costs, + std::vector const& costs_per_unit, + std::vector const& tier_offsets) +{ + tier_buffers_t buffers(stream); + buffers.thresholds = cuopt::device_copy(thresholds, stream); + buffers.fixed_costs = cuopt::device_copy(fixed_costs, stream); + buffers.costs_per_unit = cuopt::device_copy(costs_per_unit, stream); + buffers.offsets = cuopt::device_copy(tier_offsets, stream); + return buffers; +} + +tier_buffers_t make_uniform_two_band_tiers(rmm::cuda_stream_view stream, + int nvehicles, + float threshold, + float overflow_cost_per_unit) +{ + std::vector thresholds; + std::vector fixed_costs; + std::vector costs_per_unit; + std::vector tier_offsets{0}; + + thresholds.reserve(2 * nvehicles); + fixed_costs.reserve(2 * nvehicles); + costs_per_unit.reserve(2 * nvehicles); + + for (int vehicle_id = 0; vehicle_id < nvehicles; ++vehicle_id) { + thresholds.push_back(threshold); + fixed_costs.push_back(0.f); + costs_per_unit.push_back(0.f); + + thresholds.push_back(std::numeric_limits::max()); + fixed_costs.push_back(0.f); + costs_per_unit.push_back(overflow_cost_per_unit); + + tier_offsets.push_back(static_cast(thresholds.size())); + } + + return make_tier_buffers(stream, thresholds, fixed_costs, costs_per_unit, tier_offsets); +} + +void set_vehicle_distance_tiers(cuopt::routing::data_model_view_t& data_model, + tier_buffers_t const& buffers) +{ + data_model.set_vehicle_distance_tiers(buffers.thresholds.data(), + buffers.fixed_costs.data(), + buffers.costs_per_unit.data(), + buffers.offsets.data(), + static_cast(buffers.thresholds.size())); +} + +} // namespace + +TEST(distance_tiers_separate_distance, host_matrix_builder_preserves_time_slot) +{ + auto cost_only = detail::create_host_mdarray(2, 1, 1); + EXPECT_EQ(cost_only.get_time_matrix(0), cost_only.get_cost_matrix(0)); + + auto matrices = detail::create_host_mdarray(2, 1, 2); + matrices.buffer = {0.f, 3.f, 5.f, 0.f, 0.f, 11.f, 17.f, 0.f}; + EXPECT_EQ(matrices.time_matrix_index, 1); + EXPECT_EQ(matrices.get_time_matrix(0), matrices.buffer.data() + 4); + EXPECT_FLOAT_EQ(matrices.get_time_matrix(0)[1], 11.f); + EXPECT_FLOAT_EQ(matrices.view().get_time_matrix(0)[1], 11.f); +} + +TEST(distance_tiers_separate_distance, device_matrix_builder_registers_separate_time_matrix) +{ + raft::handle_t handle; + auto stream = handle.get_stream(); + auto matrices = detail::create_device_mdarray(2, 1, 2, stream); + matrices.buffer = + cuopt::device_copy(std::vector{0.f, 3.f, 5.f, 0.f, 0.f, 11.f, 17.f, 0.f}, stream); + data_model_view_t data_model(&handle, 2, 1, 1); + detail::fill_data_model_matrices(data_model, matrices); + EXPECT_EQ(matrices.time_matrix_index, 1); + EXPECT_EQ(data_model.get_cost_matrix(0), matrices.buffer.data()); + EXPECT_EQ(data_model.get_transit_time_matrix(0), matrices.buffer.data() + 4); + EXPECT_TRUE((detail::has_transit_time_matrix(data_model))); + EXPECT_EQ(data_model.get_distance_matrix(0), nullptr); + auto time_matrix = cuopt::host_copy(matrices.get_time_matrix(0), 4, stream); + handle.sync_stream(); + EXPECT_EQ(time_matrix, (std::vector{0.f, 11.f, 17.f, 0.f})); +} + +TEST(distance_tiers_separate_distance, solver_uses_separate_distance_matrix_for_tiered_costs) +{ + constexpr int nlocations = 2; + constexpr int norders = 1; + constexpr int nvehicles = 1; + + std::vector cost_matrix = { + 0.f, + 1.f, + 1.f, + 0.f, + }; + std::vector distance_matrix = { + 0.f, + 5.f, + 5.f, + 0.f, + }; + std::vector order_locations = {1}; + std::vector demands = {1}; + std::vector capacities = {1}; + + raft::handle_t handle; + auto stream = handle.get_stream(); + + auto d_cost_matrix = cuopt::device_copy(cost_matrix, stream); + auto d_distance_matrix = cuopt::device_copy(distance_matrix, stream); + auto d_order_locations = cuopt::device_copy(order_locations, stream); + auto d_demands = cuopt::device_copy(demands, stream); + auto d_capacities = cuopt::device_copy(capacities, stream); + auto tier_buffers = make_uniform_two_band_tiers(stream, nvehicles, 8.0f, 3.0f); + + cuopt::routing::data_model_view_t data_model(&handle, nlocations, nvehicles, norders); + data_model.add_cost_matrix(d_cost_matrix.data()); + data_model.add_distance_matrix(d_distance_matrix.data()); + data_model.set_order_locations(d_order_locations.data()); + data_model.add_capacity_dimension("demand", d_demands.data(), d_capacities.data()); + set_vehicle_distance_tiers(data_model, tier_buffers); + + cuopt::routing::solver_settings_t settings; + cuopt::routing::detail::problem_t problem(data_model, settings); + ASSERT_FALSE(problem.is_cvrp()); + ASSERT_FALSE(problem.is_cvrp_intra()); + ASSERT_TRUE(problem.dimensions_info.cost_dim.has_constraints()); + + auto routing_solution = cuopt::routing::solve(data_model); + handle.sync_stream(); + ASSERT_EQ(routing_solution.get_status(), cuopt::routing::solution_status_t::SUCCESS); + ASSERT_EQ(routing_solution.get_vehicle_count(), 1); + ASSERT_NEAR(routing_solution.get_total_objective(), 8.0f, 1e-5); +} + +TEST(distance_tiers_separate_distance, solver_uses_tier_fixed_cost_in_objective) +{ + constexpr int nlocations = 2; + constexpr int norders = 1; + constexpr int nvehicles = 1; + + std::vector cost_matrix = { + 0.f, + 1.f, + 1.f, + 0.f, + }; + std::vector distance_matrix = { + 0.f, + 5.f, + 5.f, + 0.f, + }; + std::vector order_locations = {1}; + std::vector demands = {1}; + std::vector capacities = {1}; + std::vector thresholds = {8.f, std::numeric_limits::max()}; + std::vector fixed_costs = {0.f, 7.f}; + std::vector costs_per_unit = {0.f, 3.f}; + std::vector tier_offsets = {0, 2}; + + raft::handle_t handle; + auto stream = handle.get_stream(); + + auto d_cost_matrix = cuopt::device_copy(cost_matrix, stream); + auto d_distance_matrix = cuopt::device_copy(distance_matrix, stream); + auto d_order_locations = cuopt::device_copy(order_locations, stream); + auto d_demands = cuopt::device_copy(demands, stream); + auto d_capacities = cuopt::device_copy(capacities, stream); + auto tier_buffers = + make_tier_buffers(stream, thresholds, fixed_costs, costs_per_unit, tier_offsets); + + cuopt::routing::data_model_view_t data_model(&handle, nlocations, nvehicles, norders); + data_model.add_cost_matrix(d_cost_matrix.data()); + data_model.add_distance_matrix(d_distance_matrix.data()); + data_model.set_order_locations(d_order_locations.data()); + data_model.add_capacity_dimension("demand", d_demands.data(), d_capacities.data()); + set_vehicle_distance_tiers(data_model, tier_buffers); + + auto routing_solution = cuopt::routing::solve(data_model); + handle.sync_stream(); + ASSERT_EQ(routing_solution.get_status(), cuopt::routing::solution_status_t::SUCCESS); + ASSERT_NEAR(routing_solution.get_total_objective(), 15.0f, 1e-5); +} + +TEST(distance_tiers_separate_distance, + compute_distance_cost_treats_threshold_as_inclusive_upper_bound) +{ + using vehicle_info_t = cuopt::routing::detail::VehicleInfo; + using distance_tier_t = cuopt::routing::detail::distance_tier_t; + + std::vector tiers = {{10.f, 0.f, 0.f}, {1.0e9f, 5.f, 3.f}}; + vehicle_info_t vehicle_info{}; + vehicle_info.distance_tiers = + raft::span(tiers.data(), tiers.size()); + + ASSERT_NEAR(vehicle_info.compute_distance_cost(10.f, 2.f), 2.f, 1e-5); + ASSERT_NEAR(vehicle_info.compute_distance_cost(11.f, 2.f), 10.f, 1e-5); +} + +TEST(distance_tiers_separate_distance, compute_distance_cost_accumulates_fixed_costs_across_tiers) +{ + using vehicle_info_t = cuopt::routing::detail::VehicleInfo; + using distance_tier_t = cuopt::routing::detail::distance_tier_t; + + std::vector tiers = {{5.f, 4.f, 0.f}, {10.f, 7.f, 0.f}, {1.0e9f, 0.f, 2.f}}; + vehicle_info_t vehicle_info{}; + vehicle_info.distance_tiers = + raft::span(tiers.data(), tiers.size()); + + ASSERT_NEAR(vehicle_info.compute_distance_cost(12.f, 3.f), 18.f, 1e-5); +} + +TEST(distance_tiers_separate_distance, compute_distance_cost_keeps_flat_fixed_tier_exact) +{ + using vehicle_info_t = cuopt::routing::detail::VehicleInfo; + using distance_tier_t = cuopt::routing::detail::distance_tier_t; + + std::vector tiers = {{100.f, 50.f, 0.f}}; + vehicle_info_t vehicle_info{}; + vehicle_info.distance_tiers = + raft::span(tiers.data(), tiers.size()); + + const double short_route_cost = vehicle_info.compute_distance_cost(10.f, 0.f); + const double long_route_cost = vehicle_info.compute_distance_cost(20.f, 0.f); + const int old_tier = vehicle_info.find_distance_tier(10.f); + + ASSERT_NEAR(short_route_cost, 50.f, 1e-5); + ASSERT_NEAR(long_route_cost, 50.f, 1e-5); + ASSERT_NEAR( + vehicle_info.compute_distance_cost_from_delta(10.f, 0.f, short_route_cost, 20.f, 0.f, old_tier), + long_route_cost, + 1e-5); +} + +TEST(distance_tiers_separate_distance, compute_distance_cost_from_delta_matches_full_cost) +{ + using vehicle_info_t = cuopt::routing::detail::VehicleInfo; + using distance_tier_t = cuopt::routing::detail::distance_tier_t; + + std::vector tiers = {{5.f, 4.f, 2.f}, {10.f, 7.f, 3.f}, {1.0e9f, 0.f, 5.f}}; + vehicle_info_t vehicle_info{}; + vehicle_info.distance_tiers = + raft::span(tiers.data(), tiers.size()); + + const double old_distance = 6.f; + const double old_fallback_cost = 11.f; + const double old_cost = vehicle_info.compute_distance_cost(old_distance, old_fallback_cost); + const int old_tier = vehicle_info.find_distance_tier(old_distance); + + ASSERT_EQ(old_tier, 1); + + ASSERT_NEAR(vehicle_info.compute_distance_cost_from_delta( + old_distance, old_fallback_cost, old_cost, 8.f, 15.f, old_tier), + vehicle_info.compute_distance_cost(8.f, 15.f), + 1e-5); + ASSERT_NEAR(vehicle_info.compute_distance_cost_from_delta( + old_distance, old_fallback_cost, old_cost, 12.f, 18.f, old_tier), + vehicle_info.compute_distance_cost(12.f, 18.f), + 1e-5); + ASSERT_NEAR(vehicle_info.compute_distance_cost_from_delta( + old_distance, old_fallback_cost, old_cost, 5.f, 9.f, old_tier), + vehicle_info.compute_distance_cost(5.f, 9.f), + 1e-5); +} + +TEST(distance_tiers_separate_distance, solver_applies_heterogeneous_tier_offsets_per_vehicle) +{ + constexpr int nlocations = 3; + constexpr int norders = 2; + constexpr int nvehicles = 2; + + std::vector cost_matrix = { + 0.f, + 1.f, + 1.f, + 1.f, + 0.f, + 1.f, + 1.f, + 1.f, + 0.f, + }; + std::vector distance_matrix = { + 0.f, + 5.f, + 2.f, + 5.f, + 0.f, + 1.f, + 2.f, + 1.f, + 0.f, + }; + std::vector order_locations = {1, 2}; + std::vector demands = {1, 1}; + std::vector capacities = {1, 1}; + std::vector order_zero_allowed_vehicles = {0}; + std::vector order_one_allowed_vehicles = {1}; + std::vector thresholds = { + 8.f, std::numeric_limits::max(), 5.f, 6.f, std::numeric_limits::max()}; + std::vector fixed_costs = {0.f, 0.f, 5.f, 0.f, 0.f}; + std::vector costs_per_unit = {0.f, 3.f, 0.f, 4.f, 9.f}; + std::vector tier_offsets = {0, 2, 5}; + + raft::handle_t handle; + auto stream = handle.get_stream(); + + auto d_cost_matrix = cuopt::device_copy(cost_matrix, stream); + auto d_distance_matrix = cuopt::device_copy(distance_matrix, stream); + auto d_order_locations = cuopt::device_copy(order_locations, stream); + auto d_demands = cuopt::device_copy(demands, stream); + auto d_capacities = cuopt::device_copy(capacities, stream); + auto d_order_zero_match = cuopt::device_copy(order_zero_allowed_vehicles, stream); + auto d_order_one_match = cuopt::device_copy(order_one_allowed_vehicles, stream); + auto tier_buffers = + make_tier_buffers(stream, thresholds, fixed_costs, costs_per_unit, tier_offsets); + + cuopt::routing::data_model_view_t data_model(&handle, nlocations, nvehicles, norders); + data_model.add_cost_matrix(d_cost_matrix.data()); + data_model.add_distance_matrix(d_distance_matrix.data()); + data_model.set_order_locations(d_order_locations.data()); + data_model.add_capacity_dimension("demand", d_demands.data(), d_capacities.data()); + data_model.add_order_vehicle_match(0, d_order_zero_match.data(), 1); + data_model.add_order_vehicle_match(1, d_order_one_match.data(), 1); + data_model.set_min_vehicles(2); + set_vehicle_distance_tiers(data_model, tier_buffers); + + auto routing_solution = cuopt::routing::solve(data_model); + handle.sync_stream(); + + ASSERT_EQ(routing_solution.get_status(), cuopt::routing::solution_status_t::SUCCESS); + ASSERT_EQ(routing_solution.get_vehicle_count(), 2); + ASSERT_NEAR(routing_solution.get_total_objective(), 15.0f, 1e-5); + + auto node_types_host = cuopt::host_copy(routing_solution.get_node_types(), stream); + auto truck_id_host = cuopt::host_copy(routing_solution.get_truck_id(), stream); + + std::vector non_depot_vehicles; + for (size_t i = 0; i < node_types_host.size(); ++i) { + if (node_types_host[i] != static_cast(cuopt::routing::node_type_t::DEPOT)) { + non_depot_vehicles.push_back(truck_id_host[i]); + } + } + + std::sort(non_depot_vehicles.begin(), non_depot_vehicles.end()); + ASSERT_EQ(non_depot_vehicles.size(), 2); + EXPECT_EQ(non_depot_vehicles[0], 0); + EXPECT_EQ(non_depot_vehicles[1], 1); +} + +TEST(distance_tiers_separate_distance, + solver_vehicle_choice_changes_when_tiers_use_separate_distance_matrix) +{ + constexpr int nlocations = 2; + constexpr int norders = 1; + constexpr int nvehicles = 2; + + std::vector cost_matrix_type_zero = { + 0.f, + 1.f, + 1.f, + 0.f, + }; + std::vector cost_matrix_type_one = { + 0.f, + 2.f, + 2.f, + 0.f, + }; + std::vector distance_matrix_type_zero = { + 0.f, + 5.f, + 5.f, + 0.f, + }; + std::vector distance_matrix_type_one = { + 0.f, + 1.f, + 1.f, + 0.f, + }; + std::vector vehicle_types = {0, 1}; + std::vector order_locations = {1}; + std::vector demands = {1}; + std::vector capacities = {1, 1}; + std::vector thresholds = { + 4.f, std::numeric_limits::max(), 4.f, std::numeric_limits::max()}; + std::vector fixed_costs = {0.f, 0.f, 0.f, 0.f}; + std::vector costs_per_unit = {0.f, 10.f, 0.f, 10.f}; + std::vector tier_offsets = {0, 2, 4}; + + raft::handle_t handle; + auto stream = handle.get_stream(); + + auto d_cost_matrix_type_zero = cuopt::device_copy(cost_matrix_type_zero, stream); + auto d_cost_matrix_type_one = cuopt::device_copy(cost_matrix_type_one, stream); + auto d_distance_matrix_type_zero = cuopt::device_copy(distance_matrix_type_zero, stream); + auto d_distance_matrix_type_one = cuopt::device_copy(distance_matrix_type_one, stream); + auto d_vehicle_types = cuopt::device_copy(vehicle_types, stream); + auto d_order_locations = cuopt::device_copy(order_locations, stream); + auto d_demands = cuopt::device_copy(demands, stream); + auto d_capacities = cuopt::device_copy(capacities, stream); + auto tier_buffers = + make_tier_buffers(stream, thresholds, fixed_costs, costs_per_unit, tier_offsets); + + cuopt::routing::data_model_view_t cost_only_data_model( + &handle, nlocations, nvehicles, norders); + cost_only_data_model.add_cost_matrix(d_cost_matrix_type_zero.data(), 0); + cost_only_data_model.add_cost_matrix(d_cost_matrix_type_one.data(), 1); + cost_only_data_model.set_vehicle_types(d_vehicle_types.data()); + cost_only_data_model.set_order_locations(d_order_locations.data()); + cost_only_data_model.add_capacity_dimension("demand", d_demands.data(), d_capacities.data()); + + auto cost_only_solution = cuopt::routing::solve(cost_only_data_model); + handle.sync_stream(); + ASSERT_EQ(cost_only_solution.get_status(), cuopt::routing::solution_status_t::SUCCESS); + ASSERT_EQ(cost_only_solution.get_vehicle_count(), 1); + ASSERT_NEAR(cost_only_solution.get_total_objective(), 2.0f, 1e-5); + + auto cost_only_node_types = cuopt::host_copy(cost_only_solution.get_node_types(), stream); + auto cost_only_truck_ids = cuopt::host_copy(cost_only_solution.get_truck_id(), stream); + int cost_only_serving_vehicle = -1; + int cost_only_non_depot_count = 0; + for (size_t i = 0; i < cost_only_node_types.size(); ++i) { + if (cost_only_node_types[i] != static_cast(cuopt::routing::node_type_t::DEPOT)) { + cost_only_serving_vehicle = cost_only_truck_ids[i]; + ++cost_only_non_depot_count; + } + } + ASSERT_EQ(cost_only_non_depot_count, 1); + ASSERT_EQ(cost_only_serving_vehicle, 0); + + cuopt::routing::data_model_view_t tiered_data_model( + &handle, nlocations, nvehicles, norders); + tiered_data_model.add_cost_matrix(d_cost_matrix_type_zero.data(), 0); + tiered_data_model.add_cost_matrix(d_cost_matrix_type_one.data(), 1); + tiered_data_model.add_distance_matrix(d_distance_matrix_type_zero.data(), 0); + tiered_data_model.add_distance_matrix(d_distance_matrix_type_one.data(), 1); + tiered_data_model.set_vehicle_types(d_vehicle_types.data()); + tiered_data_model.set_order_locations(d_order_locations.data()); + tiered_data_model.add_capacity_dimension("demand", d_demands.data(), d_capacities.data()); + set_vehicle_distance_tiers(tiered_data_model, tier_buffers); + + auto tiered_solution = cuopt::routing::solve(tiered_data_model); + handle.sync_stream(); + ASSERT_EQ(tiered_solution.get_status(), cuopt::routing::solution_status_t::SUCCESS); + ASSERT_EQ(tiered_solution.get_vehicle_count(), 1); + ASSERT_NEAR(tiered_solution.get_total_objective(), 4.0f, 1e-5); + + auto tiered_node_types = cuopt::host_copy(tiered_solution.get_node_types(), stream); + auto tiered_truck_ids = cuopt::host_copy(tiered_solution.get_truck_id(), stream); + int tiered_serving_vehicle = -1; + int tiered_non_depot_count = 0; + for (size_t i = 0; i < tiered_node_types.size(); ++i) { + if (tiered_node_types[i] != static_cast(cuopt::routing::node_type_t::DEPOT)) { + tiered_serving_vehicle = tiered_truck_ids[i]; + ++tiered_non_depot_count; + } + } + ASSERT_EQ(tiered_non_depot_count, 1); + ASSERT_EQ(tiered_serving_vehicle, 1); +} + +TEST(distance_tiers_separate_distance, + solver_respects_vehicle_max_distance_from_separate_distance_matrix) +{ + constexpr int nlocations = 2; + constexpr int norders = 1; + constexpr int nvehicles = 1; + + std::vector cost_matrix = { + 0.f, + 1.f, + 1.f, + 0.f, + }; + std::vector distance_matrix = { + 0.f, + 5.f, + 5.f, + 0.f, + }; + std::vector order_locations = {1}; + std::vector demands = {1}; + std::vector capacities = {1}; + std::vector max_distances = {9.f}; + + raft::handle_t handle; + auto stream = handle.get_stream(); + + auto d_cost_matrix = cuopt::device_copy(cost_matrix, stream); + auto d_distance_matrix = cuopt::device_copy(distance_matrix, stream); + auto d_order_locations = cuopt::device_copy(order_locations, stream); + auto d_demands = cuopt::device_copy(demands, stream); + auto d_capacities = cuopt::device_copy(capacities, stream); + auto d_max_distances = cuopt::device_copy(max_distances, stream); + + cuopt::routing::data_model_view_t data_model(&handle, nlocations, nvehicles, norders); + data_model.add_cost_matrix(d_cost_matrix.data()); + data_model.add_distance_matrix(d_distance_matrix.data()); + data_model.set_order_locations(d_order_locations.data()); + data_model.add_capacity_dimension("demand", d_demands.data(), d_capacities.data()); + data_model.set_vehicle_max_distances(d_max_distances.data()); + + cuopt::routing::solver_settings_t settings; + cuopt::routing::detail::problem_t problem(data_model, settings); + ASSERT_TRUE(problem.is_cvrp()); + ASSERT_TRUE(problem.is_cvrp_intra()); + + auto routing_solution = cuopt::routing::solve(data_model); + handle.sync_stream(); + ASSERT_EQ(routing_solution.get_status(), cuopt::routing::solution_status_t::INFEASIBLE); +} + +TEST(distance_tiers_separate_distance, cost_node_combine_respects_tiered_max_cost) +{ + using vehicle_info_t = cuopt::routing::detail::VehicleInfo; + using cost_node_t = cuopt::routing::detail::cost_node_t; + using distance_tier_t = cuopt::routing::detail::distance_tier_t; + + std::vector tiers = {{8.f, 0.f, 0.f}, {1.0e9f, 0.f, 3.f}}; + vehicle_info_t vehicle_info{}; + vehicle_info.max_distance = 100.f; + vehicle_info.max_cost = 5.f; + vehicle_info.distance_tiers = + raft::span(tiers.data(), tiers.size()); + + cost_node_t prev{}; + prev.cost_forward = 1.f; + prev.distance_forward = 5.f; + + cost_node_t next{}; + next.cost_backward = 1.f; + next.distance_backward = 5.f; + + const double combined_excess = cost_node_t::combine(prev, next, vehicle_info, 0.f, 0.f); + ASSERT_NEAR(combined_excess, 3.f, 1e-5); +} + +TEST(distance_tiers_separate_distance, flat_fixed_tier_does_not_create_max_cost_excess) +{ + using vehicle_info_t = cuopt::routing::detail::VehicleInfo; + using cost_node_t = cuopt::routing::detail::cost_node_t; + using distance_tier_t = cuopt::routing::detail::distance_tier_t; + + std::vector tiers = {{std::numeric_limits::max(), 50.f, 0.f}}; + vehicle_info_t vehicle_info{}; + vehicle_info.max_cost = 50.f; + vehicle_info.distance_tiers = + raft::span(tiers.data(), tiers.size()); + + cost_node_t prev{}; + prev.distance_forward = 5.f; + cost_node_t next{}; + next.distance_backward = 5.f; + + ASSERT_DOUBLE_EQ(cost_node_t::combine(prev, next, vehicle_info, 0.f, 0.f), 0.); +} + +TEST(distance_tiers_separate_distance, viable_neighbor_score_uses_tiers_and_cost_matrix) +{ + using problem_t = cuopt::routing::detail::problem_t; + using vehicle_info_t = cuopt::routing::detail::VehicleInfo; + using distance_tier_t = cuopt::routing::detail::distance_tier_t; + + std::vector cost_matrix = { + 0.f, + 1.f, + 4.f, + 1.f, + 0.f, + 0.f, + 4.f, + 0.f, + 0.f, + }; + std::vector distance_matrix = { + 0.f, + 5.f, + 1.f, + 5.f, + 0.f, + 0.f, + 1.f, + 0.f, + 0.f, + }; + std::vector tiers = {{2.f, 0.f, 0.f}, {1.0e9f, 0.f, 10.f}}; + + cuopt::routing::h_mdarray_t matrices({1, 3, 3, 3}); + matrices.cost_matrix_index = 0; + matrices.distance_matrix_index = 1; + matrices.time_matrix_index = 2; + std::copy(cost_matrix.begin(), cost_matrix.end(), matrices.get_cost_matrix(0, 0)); + std::copy(distance_matrix.begin(), distance_matrix.end(), matrices.get_cost_matrix(0, 1)); + std::copy(distance_matrix.begin(), distance_matrix.end(), matrices.get_cost_matrix(0, 2)); + + vehicle_info_t vehicle_info{}; + vehicle_info.type = 0; + vehicle_info.matrices = matrices.view(); + vehicle_info.distance_tiers = + raft::span(tiers.data(), tiers.size()); + + const auto from = + cuopt::routing::detail::NodeInfo(0, 0, cuopt::routing::node_type_t::PICKUP); + const auto near_by_distance = + cuopt::routing::detail::NodeInfo(1, 1, cuopt::routing::node_type_t::PICKUP); + const auto near_by_cost = + cuopt::routing::detail::NodeInfo(2, 2, cuopt::routing::node_type_t::PICKUP); + + const double distance_neighbor_score = + problem_t::compute_viable_neighbor_score(from, near_by_distance, vehicle_info); + const double cost_neighbor_score = + problem_t::compute_viable_neighbor_score(from, near_by_cost, vehicle_info); + + ASSERT_NEAR(distance_neighbor_score, 31.f, 1e-5); + ASSERT_NEAR(cost_neighbor_score, 4.f, 1e-5); + ASSERT_LT(cost_neighbor_score, distance_neighbor_score); +} + +TEST(distance_tiers_separate_distance, + problem_ignores_unused_distance_matrix_and_preserves_host_cost_matrix) +{ + using problem_t = cuopt::routing::detail::problem_t; + + constexpr int nlocations = 2; + constexpr int norders = 1; + constexpr int nvehicles = 1; + + std::vector cost_matrix = { + 0.f, + 7.f, + 3.f, + 0.f, + }; + std::vector order_locations = {1}; + std::vector demands = {1}; + std::vector capacities = {1}; + + raft::handle_t handle; + auto stream = handle.get_stream(); + + auto d_cost_matrix = cuopt::device_copy(cost_matrix, stream); + auto d_distance_matrix = cuopt::device_copy(std::vector{0.f, 9.f, 9.f, 0.f}, stream); + auto d_order_locations = cuopt::device_copy(order_locations, stream); + auto d_demands = cuopt::device_copy(demands, stream); + auto d_capacities = cuopt::device_copy(capacities, stream); + + cuopt::routing::data_model_view_t data_model(&handle, nlocations, nvehicles, norders); + data_model.add_cost_matrix(d_cost_matrix.data()); + data_model.add_distance_matrix(d_distance_matrix.data()); + data_model.set_order_locations(d_order_locations.data()); + data_model.add_capacity_dimension("demand", d_demands.data(), d_capacities.data()); + + cuopt::routing::solver_settings_t settings; + problem_t problem(data_model, settings); + + const auto depot = problem.get_start_depot_node_info(0); + const auto order = + cuopt::routing::detail::NodeInfo(0, 1, cuopt::routing::node_type_t::PICKUP); + + ASSERT_NEAR(problem.distance_between(depot, order, 0), 0.f, 1e-5); + ASSERT_NEAR(problem.cost_between(depot, order, 0), 7.f, 1e-5); + ASSERT_NEAR(problem.cost_between(order, depot, 0), 3.f, 1e-5); + ASSERT_EQ(problem.fleet_info.matrices_.extent[1], 1); + ASSERT_TRUE(problem.travel_distance_matrices_h.empty()); +} + +TEST(distance_tiers_separate_distance, cpu_problem_rejects_invalid_distance_values) +{ + for (auto invalid_value : {-1.f, std::numeric_limits::quiet_NaN()}) { + cuopt::routing::cpu_routing_problem_t problem; + problem.num_locations = 2; + problem.fleet_size = 1; + problem.num_orders = 1; + problem.cost_matrices = {{0, {0.f, 1.f, 1.f, 0.f}}}; + problem.distance_matrices = {{0, {0.f, invalid_value, 1.f, 0.f}}}; + + raft::handle_t handle; + EXPECT_THROW(problem.to_device(&handle), std::invalid_argument); + } +} + +TEST(distance_tiers_separate_distance, device_problem_rejects_invalid_distance_values) +{ + constexpr int nlocations = 2; + constexpr int norders = 1; + constexpr int nvehicles = 1; + + raft::handle_t handle; + auto stream = handle.get_stream(); + auto d_cost_matrix = cuopt::device_copy(std::vector{0.f, 1.f, 1.f, 0.f}, stream); + auto d_order_locations = cuopt::device_copy(std::vector{1}, stream); + cuopt::routing::solver_settings_t settings; + + for (auto const& distance_matrix : + {std::vector{0.f, -1.f, 1.f, 0.f}, + std::vector{0.f, std::numeric_limits::quiet_NaN(), 1.f, 0.f}}) { + auto d_distance_matrix = cuopt::device_copy(distance_matrix, stream); + cuopt::routing::data_model_view_t data_model( + &handle, nlocations, nvehicles, norders); + data_model.add_cost_matrix(d_cost_matrix.data()); + data_model.add_distance_matrix(d_distance_matrix.data()); + data_model.set_order_locations(d_order_locations.data()); + EXPECT_ANY_THROW((cuopt::routing::detail::problem_t(data_model, settings))); + } +} + +TEST(distance_tiers_separate_distance, device_problem_validates_vehicle_max_distances) +{ + constexpr int nlocations = 2; + constexpr int norders = 1; + constexpr int nvehicles = 1; + + raft::handle_t handle; + auto stream = handle.get_stream(); + auto d_cost_matrix = cuopt::device_copy(std::vector{0.f, 1.f, 1.f, 0.f}, stream); + auto d_distance_matrix = cuopt::device_copy(std::vector{0.f, 1.f, 1.f, 0.f}, stream); + auto d_order_locations = cuopt::device_copy(std::vector{1}, stream); + cuopt::routing::solver_settings_t settings; + + for (float max_distance : {-1.f, std::numeric_limits::infinity()}) { + auto d_max_distances = cuopt::device_copy(std::vector{max_distance}, stream); + cuopt::routing::data_model_view_t data_model( + &handle, nlocations, nvehicles, norders); + data_model.add_cost_matrix(d_cost_matrix.data()); + data_model.add_distance_matrix(d_distance_matrix.data()); + data_model.set_order_locations(d_order_locations.data()); + data_model.set_vehicle_max_distances(d_max_distances.data()); + EXPECT_ANY_THROW((cuopt::routing::detail::problem_t(data_model, settings))); + } + + auto d_zero_max_distance = cuopt::device_copy(std::vector{0.f}, stream); + cuopt::routing::data_model_view_t data_model(&handle, nlocations, nvehicles, norders); + data_model.add_cost_matrix(d_cost_matrix.data()); + data_model.add_distance_matrix(d_distance_matrix.data()); + data_model.set_order_locations(d_order_locations.data()); + data_model.set_vehicle_max_distances(d_zero_max_distance.data()); + EXPECT_NO_THROW((cuopt::routing::detail::problem_t(data_model, settings))); +} + +TEST(distance_tiers_separate_distance, vehicle_max_costs_are_validated) +{ + constexpr int nlocations = 2; + constexpr int norders = 1; + constexpr int nvehicles = 1; + + for (auto const& max_costs : {std::vector{-1.f}, + std::vector{std::numeric_limits::infinity()}, + std::vector{1.f, 2.f}}) { + cuopt::routing::cpu_routing_problem_t problem; + problem.num_locations = nlocations; + problem.fleet_size = nvehicles; + problem.num_orders = norders; + problem.cost_matrices = {{0, {0.f, 1.f, 1.f, 0.f}}}; + problem.vehicle_max_costs = max_costs; + + raft::handle_t handle; + EXPECT_THROW(problem.to_device(&handle), std::invalid_argument); + } + + raft::handle_t handle; + auto stream = handle.get_stream(); + auto d_cost_matrix = cuopt::device_copy(std::vector{0.f, 1.f, 1.f, 0.f}, stream); + auto d_order_locations = cuopt::device_copy(std::vector{1}, stream); + cuopt::routing::solver_settings_t settings; + + for (float max_cost : {-1.f, std::numeric_limits::quiet_NaN()}) { + auto d_max_costs = cuopt::device_copy(std::vector{max_cost}, stream); + cuopt::routing::data_model_view_t data_model( + &handle, nlocations, nvehicles, norders); + data_model.add_cost_matrix(d_cost_matrix.data()); + data_model.set_order_locations(d_order_locations.data()); + data_model.set_vehicle_max_costs(d_max_costs.data()); + EXPECT_ANY_THROW((cuopt::routing::detail::problem_t(data_model, settings))); + } + + auto d_zero_max_cost = cuopt::device_copy(std::vector{0.f}, stream); + cuopt::routing::data_model_view_t data_model(&handle, nlocations, nvehicles, norders); + data_model.add_cost_matrix(d_cost_matrix.data()); + data_model.set_order_locations(d_order_locations.data()); + data_model.set_vehicle_max_costs(d_zero_max_cost.data()); + EXPECT_NO_THROW((cuopt::routing::detail::problem_t(data_model, settings))); +} + +TEST(distance_tiers_separate_distance, distance_features_require_distance_matrix) +{ + constexpr int nlocations = 2; + constexpr int norders = 1; + constexpr int nvehicles = 1; + + raft::handle_t handle; + auto stream = handle.get_stream(); + auto d_cost_matrix = cuopt::device_copy(std::vector{0.f, 1.f, 1.f, 0.f}, stream); + auto d_order_locations = cuopt::device_copy(std::vector{1}, stream); + auto d_max_distances = cuopt::device_copy(std::vector{10.f}, stream); + + cuopt::routing::data_model_view_t data_model(&handle, nlocations, nvehicles, norders); + data_model.add_cost_matrix(d_cost_matrix.data()); + data_model.set_order_locations(d_order_locations.data()); + data_model.set_vehicle_max_distances(d_max_distances.data()); + + cuopt::routing::solver_settings_t settings; + EXPECT_ANY_THROW((cuopt::routing::detail::problem_t(data_model, settings))); +} + +TEST(distance_tiers_separate_distance, positive_infinite_distance_marks_unreachable_arc) +{ + constexpr int nlocations = 2; + constexpr int norders = 1; + constexpr int nvehicles = 1; + + raft::handle_t handle; + auto stream = handle.get_stream(); + auto d_cost_matrix = cuopt::device_copy(std::vector{0.f, 1.f, 1.f, 0.f}, stream); + auto d_distance_matrix = cuopt::device_copy( + std::vector{0.f, std::numeric_limits::infinity(), 1.f, 0.f}, stream); + auto d_order_locations = cuopt::device_copy(std::vector{1}, stream); + auto tier_buffers = make_uniform_two_band_tiers(stream, nvehicles, 8.f, 0.f); + + cuopt::routing::data_model_view_t data_model(&handle, nlocations, nvehicles, norders); + data_model.add_cost_matrix(d_cost_matrix.data()); + data_model.add_distance_matrix(d_distance_matrix.data()); + data_model.set_order_locations(d_order_locations.data()); + set_vehicle_distance_tiers(data_model, tier_buffers); + + cuopt::routing::solver_settings_t settings; + cuopt::routing::detail::problem_t problem(data_model, settings); + const auto depot = problem.get_start_depot_node_info(0); + const auto order = + cuopt::routing::detail::NodeInfo(0, 1, cuopt::routing::node_type_t::PICKUP); + EXPECT_FLOAT_EQ(static_cast(problem.distance_between(depot, order, 0)), 1.0e30f); + + auto routing_solution = cuopt::routing::solve(data_model); + handle.sync_stream(); + EXPECT_EQ(routing_solution.get_status(), cuopt::routing::solution_status_t::INFEASIBLE); +} + +TEST(distance_tiers_separate_distance, cpu_problem_requires_open_ended_final_tier) +{ + cuopt::routing::cpu_routing_problem_t problem; + problem.num_locations = 2; + problem.fleet_size = 1; + problem.num_orders = 1; + problem.cost_matrices = {{0, {0.f, 1.f, 1.f, 0.f}}}; + problem.distance_matrices = {{0, {0.f, 1.f, 1.f, 0.f}}}; + problem.distance_tier_thresholds = {100.f}; + problem.distance_tier_fixed_costs = {0.f}; + problem.distance_tier_costs_per_unit = {1.f}; + problem.distance_tier_offsets = {0, 1}; + + raft::handle_t handle; + EXPECT_THROW(problem.to_device(&handle), std::invalid_argument); +} + +} // namespace test +} // namespace routing +} // namespace cuopt diff --git a/datasets/get_test_data.sh b/datasets/get_test_data.sh index 472813a003..d567ab2faa 100755 --- a/datasets/get_test_data.sh +++ b/datasets/get_test_data.sh @@ -5,6 +5,9 @@ set -e set -o pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" + ################################################################################ # S3 Dataset Download Support ################################################################################ @@ -144,8 +147,14 @@ https://www.sintef.no/globalassets/project/top/vrptw/solomon/solomon-100.zip solomon " +FSMVRPTWSC_DATASET_DATA=" +# 0.1s +https://github.com/jmanguino/FSMVRPTWSC/archive/a7e1864527042ccb6e118cc473f7a0805ec20284.zip +fsmvrptwsc +" + # Add back ${TSP_DATASET_DATA} when issue #609 is fixed -ALL_DATASET_DATA="${CVRP_DATASET_DATA} ${ACVRP_DATASET_DATA} ${CVRPTW_DATASET_DATA} ${SOLOMON_DATASET_DATA} ${PDPTW_DATASET_DATA}" +ALL_DATASET_DATA="${CVRP_DATASET_DATA} ${ACVRP_DATASET_DATA} ${CVRPTW_DATASET_DATA} ${SOLOMON_DATASET_DATA} ${PDPTW_DATASET_DATA} ${FSMVRPTWSC_DATASET_DATA}" ################################################################################ # Do not change the script below this line if only adding/updating a dataset @@ -157,7 +166,7 @@ function hasArg { } if hasArg -h || hasArg --help; then - echo "$0 [--tsplib]" + echo "$0 [--cvrp] [--acvrp] [--cvrptw] [--solomon] [--fsmvrptwsc] [--pdptw]" exit 0 fi @@ -173,6 +182,8 @@ elif hasArg "--cvrptw"; then DATASET_DATA="${CVRPTW_DATASET_DATA} ${SOLOMON_DATASET_DATA}" elif hasArg "--solomon"; then DATASET_DATA="${SOLOMON_DATASET_DATA}" +elif hasArg "--fsmvrptwsc"; then + DATASET_DATA="${FSMVRPTWSC_DATASET_DATA}" elif hasArg "--pdptw"; then DATASET_DATA="${PDPTW_DATASET_DATA}" else diff --git a/datasets/ref/fsmvrptwsc_small.txt b/datasets/ref/fsmvrptwsc_small.txt new file mode 100644 index 0000000000..331589f288 --- /dev/null +++ b/datasets/ref/fsmvrptwsc_small.txt @@ -0,0 +1,6 @@ +fsmvrptwsc/FSMVRPTWSC-a7e1864527042ccb6e118cc473f7a0805ec20284/Instances/Small.txt,R1a10,332,0.20 +fsmvrptwsc/FSMVRPTWSC-a7e1864527042ccb6e118cc473f7a0805ec20284/Instances/Small.txt,R1b10,104,0.20 +fsmvrptwsc/FSMVRPTWSC-a7e1864527042ccb6e118cc473f7a0805ec20284/Instances/Small.txt,R1c10,74,0.20 +fsmvrptwsc/FSMVRPTWSC-a7e1864527042ccb6e118cc473f7a0805ec20284/Instances/Real.txt,Dia1,24666.7,0.20 +fsmvrptwsc/FSMVRPTWSC-a7e1864527042ccb6e118cc473f7a0805ec20284/Instances/Real.txt,Dia2,27607.1,0.20 +fsmvrptwsc/FSMVRPTWSC-a7e1864527042ccb6e118cc473f7a0805ec20284/Instances/Real.txt,Dia3,25562.7,0.20 diff --git a/docs/cuopt/source/routing-features.rst b/docs/cuopt/source/routing-features.rst index 63a2697efa..29b5d0f620 100644 --- a/docs/cuopt/source/routing-features.rst +++ b/docs/cuopt/source/routing-features.rst @@ -129,6 +129,35 @@ Fixed Cost per Vehicle ----------------------- Vehicles can have different fixed costs associated with them. This helps in scenarios where a single vehicle with a higher cost can be avoided if it can be done with two or more vehicles with lesser costs. This would be dependent on the objective function. +Vehicle Distance Tiers +----------------------- +Vehicle distance tiers define vehicle-specific piecewise pricing based on the +total distance traveled by each route. They are useful when transportation costs +change after distance thresholds, such as minimum trip charges, progressive +mileage rates, or different pricing models across vehicle types. + +Distance tiers use the route distance from a separate distance matrix rather +than the generic optimization cost. A distance matrix is required whenever +distance tiers or ``vehicle_max_distances`` are set, even when it contains the +same values as the cost matrix. In the Python +API, call ``add_distance_matrix`` before ``set_vehicle_distance_tiers``. In the +server API, provide ``distance_matrix_data`` together with +``fleet_data.vehicle_distance_tiers``. + +The ``COST`` objective includes the cost-matrix cost plus the accumulated tier +cost. ``vehicle_max_costs`` limits this combined value, while +``vehicle_max_distances`` limits physical distance. The distance matrix does not +introduce a separate objective to minimize distance. + +Each vehicle can have one or more tiers. A tier contains a ``threshold``, a +``fixed_cost``, and a ``cost_per_unit``. Tier thresholds are evaluated in +ascending order, with each threshold an inclusive upper bound. Costs are +accumulated by distance band. For each band +reached by the route, cuOpt adds the tier fixed cost when it is positive and +adds the in-band distance multiplied by the tier ``cost_per_unit``. A final +open-ended tier must be provided to cover long routes; in the server API, use +``threshold: null`` for this final tier. + Mapping Orders to Vehicles, and Vehicles to Orders --------------------------------------------------- By default, cuOpt will assign orders to vehicles based on the optimal routes. However, in some cases, it makes sense to assign specific orders to specific vehicles, or, conversely, specific vehicles to specific orders. diff --git a/examples/api_distance_tiers_example.py b/examples/api_distance_tiers_example.py new file mode 100644 index 0000000000..9e28c00606 --- /dev/null +++ b/examples/api_distance_tiers_example.py @@ -0,0 +1,269 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +""" +Example: Using Distance Tiers through cuOpt REST API + +This example shows how to call the cuOpt API (self-hosted) with distance tiers +for tiered pricing based on route distance. +""" + +import json +import time + +import requests + +# API endpoint (change to your server address) +API_URL = "http://localhost:5000/cuopt/request" + +# Create the request payload +payload = { + "cost_waypoint_graph_data": None, + "travel_time_waypoint_graph_data": None, + "cost_matrix_data": { + "data": { + "0": [ + [0, 30, 40, 50, 80, 100], + [30, 0, 20, 35, 60, 85], + [40, 20, 0, 25, 55, 75], + [50, 35, 25, 0, 40, 60], + [80, 60, 55, 40, 0, 30], + [100, 85, 75, 60, 30, 0], + ] + } + }, + "distance_matrix_data": { + "data": { + "0": [ + [0, 30, 40, 50, 80, 100], + [30, 0, 20, 35, 60, 85], + [40, 20, 0, 25, 55, 75], + [50, 35, 25, 0, 40, 60], + [80, 60, 55, 40, 0, 30], + [100, 85, 75, 60, 30, 0], + ] + } + }, + "travel_time_matrix_data": None, + "fleet_data": { + "vehicle_locations": [[0, 0], [0, 0]], + "vehicle_ids": ["vehicle-0", "vehicle-1"], + "capacities": None, + "vehicle_time_windows": None, + "vehicle_break_time_windows": None, + "vehicle_break_durations": None, + "vehicle_break_locations": None, + "vehicle_types": None, + "vehicle_order_match": None, + "skip_first_trips": None, + "drop_return_trips": None, + "min_vehicles": None, + "vehicle_max_costs": None, + "vehicle_max_times": None, + "vehicle_fixed_costs": None, + # NEW FIELD: Distance tiers for tiered pricing + "vehicle_distance_tiers": [ + # Vehicle 0 tiers + [ + { + "threshold": 100.0, + "fixed_cost": 50.0, + "cost_per_unit": 0.0, + }, # <= 100 km = 50 fixed + { + "threshold": 200.0, + "fixed_cost": 0.0, + "cost_per_unit": 0.1, + }, # 100 km < distance <= 200 km: 0.1/km + { + "threshold": None, + "fixed_cost": 0.0, + "cost_per_unit": 0.5, + }, # > 200 km = 0.5/km + ], + # Vehicle 1 tiers + [ + { + "threshold": 150.0, + "fixed_cost": 75.0, + "cost_per_unit": 0.0, + }, # <= 150 km = 75 fixed + { + "threshold": None, + "fixed_cost": 0.0, + "cost_per_unit": 0.3, + }, # > 150 km = 0.3/km + ], + ], + }, + "task_data": { + "task_locations": [1, 2, 3, 4, 5], + "task_ids": [ + "customer-1", + "customer-2", + "customer-3", + "customer-4", + "customer-5", + ], + "demand": None, + "pickup_and_delivery_pairs": None, + "task_time_windows": None, + "service_times": None, + "prizes": None, + "order_vehicle_match": None, + }, + "solver_config": {"time_limit": 5}, +} + + +def call_cuopt_api(): + """ + Call the cuOpt API with the distance tiers payload + """ + print("=" * 80) + print("Calling cuOpt API with Distance Tiers") + print("=" * 80) + + print("\nPayload (fleet_data.vehicle_distance_tiers):") + print( + json.dumps(payload["fleet_data"]["vehicle_distance_tiers"], indent=2) + ) + + try: + # Send POST request + response = requests.post( + API_URL, json=payload, headers={"Content-Type": "application/json"} + ) + + # Check if request was successful + if response.status_code == 200: + result = response.json() + + # Check if we got a request ID (async mode) + if "reqId" in result: + req_id = result["reqId"] + print("\n✓ Request submitted successfully!") + print(f" Request ID: {req_id}") + + # Poll for result + print("\nPolling for result...") + server_url = API_URL.removesuffix("/cuopt/request") + status_url = f"{server_url}/cuopt/solution/{req_id}" + + max_attempts = 60 + for attempt in range(max_attempts): + status_response = requests.get(status_url) + status_response.raise_for_status() + status_data = status_response.json() + + if "response" in status_data: + print("\n✓ Solution found!") + display_results( + status_data["response"]["solver_response"] + ) + break + elif "reqId" not in status_data: + print(f"\n✗ Unexpected response: {status_data}") + break + + time.sleep(1) + else: + print("\n✗ Timeout waiting for solution") + + # Direct response (sync mode) + elif "response" in result: + print("\n✓ Solution found!") + display_results(result["response"]["solver_response"]) + + else: + print(f"\n✗ Unexpected response format: {result}") + + else: + print(f"\n✗ API call failed with status {response.status_code}") + print(f" Error: {response.text}") + + except requests.exceptions.ConnectionError: + print("\n✗ Could not connect to cuOpt server") + print(f" Make sure the server is running at {API_URL}") + except Exception as e: + print(f"\n✗ Error: {e}") + + +def display_results(solution_data): + """ + Display the routing solution with distance tier information + """ + print("\n" + "-" * 80) + print("ROUTING SOLUTION") + print("-" * 80) + + if "vehicle_data" in solution_data: + for vehicle_id, vehicle in solution_data["vehicle_data"].items(): + print(f"\nVehicle {vehicle_id}:") + print(f" Route: {' -> '.join(map(str, vehicle['route']))}") + + if "solution_cost" in solution_data: + print(f"\n{'=' * 80}") + print( + "Total Cost (with tiered pricing): " + f"{solution_data['solution_cost']:.2f}" + ) + print(f"{'=' * 80}") + + else: + print("No solution data available") + + +def show_tier_interpretation(): + """ + Show how the tiers are interpreted + """ + print("\n" + "=" * 80) + print("DISTANCE TIER CONFIGURATION") + print("=" * 80) + + tiers = payload["fleet_data"]["vehicle_distance_tiers"] + + for vehicle_id, vehicle_tiers in enumerate(tiers): + print(f"\nVehicle {vehicle_id}:") + for i, tier in enumerate(vehicle_tiers): + threshold = tier["threshold"] + fixed_cost = tier["fixed_cost"] + cost_per_unit = tier["cost_per_unit"] + + if threshold is None: + distance_range = ( + f"Distance > {vehicle_tiers[i - 1]['threshold']} km" + ) + elif i == 0: + distance_range = f"Distance <= {threshold} km" + else: + prev_threshold = vehicle_tiers[i - 1]["threshold"] + distance_range = ( + f"{prev_threshold} km < Distance <= {threshold} km" + ) + + if fixed_cost > 0: + cost_desc = f"Fixed cost: {fixed_cost}" + else: + cost_desc = f"{cost_per_unit}/km" + + print(f" Tier {i + 1}: {distance_range} → {cost_desc}") + + +if __name__ == "__main__": + # Show the tier configuration + show_tier_interpretation() + + # Call the API + print("\n") + call_cuopt_api() + + print("\n" + "=" * 80) + print("EXAMPLE CURL COMMAND") + print("=" * 80) + print(f""" +curl -X POST {API_URL} \\ + -H "Content-Type: application/json" \\ + -d '{json.dumps(payload, indent=2)}' + """) diff --git a/examples/distance_tiers_example.py b/examples/distance_tiers_example.py new file mode 100644 index 0000000000..d77c17e05b --- /dev/null +++ b/examples/distance_tiers_example.py @@ -0,0 +1,260 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +""" +Example: Using Distance-Based Tiered Pricing in cuOpt + +This example demonstrates how to use the distance_tiers feature to apply +different cost structures based on the total route distance. + +Scenario: +- 2 vehicles with different pricing tiers +- Vehicle 0: <= 100 km = 50 fixed, 100-200 km = 0.1/km, > 200 km = 0.5/km +- Vehicle 1: <= 150 km = 75 fixed, > 150 km = 0.3/km +""" + +import cudf +import numpy as np +from cuopt import routing + +# Create data model +n_locations = 6 # 1 depot + 5 customers +n_vehicles = 2 + +data_model = routing.DataModel(n_locations, n_vehicles, n_locations - 1) + +# Define cost matrix (distances in km) +cost_matrix = np.array( + [ + # Depot, C1, C2, C3, C4, C5 + [0, 30, 40, 50, 80, 100], # Depot + [30, 0, 20, 35, 60, 85], # Customer 1 + [40, 20, 0, 25, 55, 75], # Customer 2 + [50, 35, 25, 0, 40, 60], # Customer 3 + [80, 60, 55, 40, 0, 30], # Customer 4 + [100, 85, 75, 60, 30, 0], # Customer 5 + ], + dtype=np.float32, +) + +data_model.add_cost_matrix(cudf.DataFrame(cost_matrix)) +data_model.add_distance_matrix(cudf.DataFrame(cost_matrix)) + +# Set vehicle locations (both start at depot - location 0) +vehicle_starts = cudf.Series([0, 0], dtype=np.int32) +vehicle_returns = cudf.Series([0, 0], dtype=np.int32) +data_model.set_vehicle_locations(vehicle_starts, vehicle_returns) + +# Define order locations (customers to visit) +order_locations = cudf.Series([1, 2, 3, 4, 5], dtype=np.int32) +data_model.set_order_locations(order_locations) + +# ============================================================================ +# SET DISTANCE TIERS - This is the new feature! +# ============================================================================ + +# Vehicle 0 tiers: <= 100km = 50 fixed, 100-200km = 0.1/km, > 200km = 0.5/km +# Vehicle 1 tiers: <= 150km = 75 fixed, > 150km = 0.3/km + +vehicle_ids = cudf.Series( + [ + 0, + 0, + 0, # Vehicle 0 has 3 tiers + 1, + 1, # Vehicle 1 has 2 tiers + ], + dtype=np.int32, +) + +thresholds = cudf.Series( + [ + 100.0, + 200.0, + np.finfo(np.float32).max, # Vehicle 0 open-ended tier + 150.0, + np.finfo(np.float32).max, # Vehicle 1 open-ended tier + ], + dtype=np.float32, +) + +fixed_costs = cudf.Series( + [ + 50.0, + 0.0, + 0.0, # Vehicle 0: only first tier has fixed cost + 75.0, + 0.0, # Vehicle 1: only first tier has fixed cost + ], + dtype=np.float32, +) + +costs_per_unit = cudf.Series( + [ + 0.0, + 0.1, + 0.5, # Vehicle 0: 0, 0.1/km, 0.5/km + 0.0, + 0.3, # Vehicle 1: 0, 0.3/km + ], + dtype=np.float32, +) + +data_model.set_vehicle_distance_tiers( + vehicle_ids, thresholds, fixed_costs, costs_per_unit +) + +# ============================================================================ +# SOLVE +# ============================================================================ + +solver_settings = routing.SolverSettings() +solver_settings.set_time_limit(5) # 5 seconds + +routing_solution = routing.Solve(data_model, solver_settings) + +# ============================================================================ +# DISPLAY RESULTS +# ============================================================================ + +if routing_solution.get_status() == 0: + print("✓ Solution found!") + print("\nRoute Details:") + print("-" * 80) + + vehicle_routes = routing_solution.get_route() + + for vehicle_id in range(n_vehicles): + route = vehicle_routes[vehicle_routes["truck_id"] == vehicle_id] + + if len(route) > 0: + # Get route distance + route_distance = 0.0 + locations = route["location"].to_arrow().to_pylist() + + for i in range(len(locations) - 1): + from_loc = locations[i] + to_loc = locations[i + 1] + route_distance += cost_matrix[from_loc][to_loc] + + print(f"\nVehicle {vehicle_id}:") + print(f" Route: {' -> '.join(map(str, locations))}") + print(f" Total Distance: {route_distance:.2f} km") + + # Calculate cost based on tiers + if vehicle_id == 0: + if route_distance <= 100: + tier_cost = 50.0 + tier_info = "up to 100 km: fixed cost 50" + elif route_distance <= 200: + tier_cost = 50.0 + (route_distance - 100.0) * 0.1 + tier_info = "100-200 km band: 0.1/km" + else: + tier_cost = 60.0 + (route_distance - 200.0) * 0.5 + tier_info = "> 200 km band: 0.5/km" + else: # vehicle_id == 1 + if route_distance <= 150: + tier_cost = 75.0 + tier_info = "up to 150 km: fixed cost 75" + else: + tier_cost = 75.0 + (route_distance - 150.0) * 0.3 + tier_info = "> 150 km band: 0.3/km" + + print(f" Applied Tier: {tier_info}") + print(f" Tiered Distance Cost: {tier_cost:.2f}") + print(f" Route Cost: {route_distance + tier_cost:.2f}") + + print("\n" + "-" * 80) + print(f"Total Objective Cost: {routing_solution.get_total_objective()}") + +else: + print(f"✗ No solution found. Status: {routing_solution.get_status()}") + + +# ============================================================================ +# HELPER FUNCTION: Simplified tier creation +# ============================================================================ + + +def create_distance_tiers_simple(tiers_by_vehicle): + """ + Helper to create distance tiers from a more readable dictionary format. + + Parameters + ---------- + tiers_by_vehicle : list of list of dict + Each element is a list of tier dictionaries for that vehicle. + Each tier dict should have 'threshold' and either 'fixed_cost' or 'cost_per_unit'. + + Returns + ------- + tuple of cudf.Series + (vehicle_ids, thresholds, fixed_costs, costs_per_unit) + + Example + ------- + >>> tiers = [ + ... # Vehicle 0 + ... [ + ... {"threshold": 100, "fixed_cost": 50}, + ... {"threshold": 200, "cost_per_unit": 0.1}, + ... {"threshold": np.finfo(np.float32).max, "cost_per_unit": 0.5} + ... ], + ... # Vehicle 1 + ... [ + ... {"threshold": 150, "fixed_cost": 75}, + ... {"threshold": np.finfo(np.float32).max, "cost_per_unit": 0.3} + ... ] + ... ] + >>> vehicle_ids, thresholds, fixed_costs, costs_per_unit = create_distance_tiers_simple(tiers) + >>> data_model.set_vehicle_distance_tiers(vehicle_ids, thresholds, fixed_costs, costs_per_unit) + """ + vehicle_ids_list = [] + thresholds_list = [] + fixed_costs_list = [] + costs_per_unit_list = [] + + for vehicle_id, tiers in enumerate(tiers_by_vehicle): + for tier in tiers: + vehicle_ids_list.append(vehicle_id) + thresholds_list.append(tier["threshold"]) + fixed_costs_list.append(tier.get("fixed_cost", 0.0)) + costs_per_unit_list.append(tier.get("cost_per_unit", 0.0)) + + return ( + cudf.Series(vehicle_ids_list, dtype=np.int32), + cudf.Series(thresholds_list, dtype=np.float32), + cudf.Series(fixed_costs_list, dtype=np.float32), + cudf.Series(costs_per_unit_list, dtype=np.float32), + ) + + +# Example using the helper function: +if __name__ == "__main__": + print("\n" + "=" * 80) + print("Using helper function:") + print("=" * 80 + "\n") + + tiers_definition = [ + # Vehicle 0 tiers + [ + {"threshold": 100.0, "fixed_cost": 50.0}, + {"threshold": 200.0, "cost_per_unit": 0.1}, + {"threshold": np.finfo(np.float32).max, "cost_per_unit": 0.5}, + ], + # Vehicle 1 tiers + [ + {"threshold": 150.0, "fixed_cost": 75.0}, + {"threshold": np.finfo(np.float32).max, "cost_per_unit": 0.3}, + ], + ] + + vids, thresh, fixed, per_unit = create_distance_tiers_simple( + tiers_definition + ) + + print("Generated tier data:") + print(f" Vehicle IDs: {vids.to_arrow().to_pylist()}") + print(f" Thresholds: {thresh.to_arrow().to_pylist()}") + print(f" Fixed Costs: {fixed.to_arrow().to_pylist()}") + print(f" Costs per Unit: {per_unit.to_arrow().to_pylist()}") diff --git a/examples/vehicle_distance_tiers_example.py b/examples/vehicle_distance_tiers_example.py new file mode 100644 index 0000000000..c4dbdddda6 --- /dev/null +++ b/examples/vehicle_distance_tiers_example.py @@ -0,0 +1,399 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +""" +Vehicle Distance Tiers Example + +This example demonstrates how to use distance-based tiered pricing for vehicles +in cuOpt. Distance tiers allow you to define different cost structures based on +the total distance traveled by each vehicle. + +Use cases: +- Progressive pricing: higher rates for longer distances +- Fixed fees for short trips +- Different pricing models for different vehicle types +""" + +import numpy as np +import cudf +from cuopt import routing + + +def create_simple_problem(): + """ + Create a simple routing problem with 10 locations and 3 vehicles + """ + n_locations = 10 + n_vehicles = 3 + + # Create a simple distance matrix (symmetric) + np.random.seed(42) + distances = np.random.uniform(10, 50, (n_locations, n_locations)) + # Make symmetric and zero diagonal + distances = (distances + distances.T) / 2 + np.fill_diagonal(distances, 0) + + cost_df = cudf.DataFrame(distances.astype(np.float32)) + + # Simple demands and capacities + n_orders = n_locations - 1 # Exclude depot + demand = cudf.Series([10] * n_orders, dtype=np.int32) + capacities = cudf.Series([40] * n_vehicles, dtype=np.int32) + + return cost_df, demand, capacities + + +def example_uniform_tiers(): + """ + Example 1: Uniform tiers - All vehicles have the same pricing structure + + Pricing structure: + - Distance <= 50 km: Fixed fee of $100 + - Distance 50-100 km: $2 per km + - Distance > 100 km: $5 per km + """ + print("=" * 70) + print("EXAMPLE 1: UNIFORM DISTANCE TIERS") + print("=" * 70) + print("\nAll vehicles have the same pricing structure:") + print(" • Distance <= 50 km: Fixed fee of $100") + print(" • Distance 50-100 km: $2 per km") + print(" • Distance > 100 km: $5 per km\n") + + # Create problem + cost_df, demand, capacities = create_simple_problem() + n_locations = len(cost_df) + n_vehicles = len(capacities) + + # Create data model + data_model = routing.DataModel(n_locations, n_vehicles, n_locations - 1) + data_model.add_cost_matrix(cost_df) + data_model.add_distance_matrix(cost_df) + + # Set order locations (all locations except depot at 0) + order_locations = cudf.Series(range(1, n_locations), dtype=np.int32) + data_model.set_order_locations(order_locations) + + # Add capacity constraint + data_model.add_capacity_dimension("demand", demand, capacities) + + # Configure distance tiers - same for all vehicles + vehicle_ids = [] + thresholds = [] + fixed_costs = [] + costs_per_unit = [] + + for v in range(n_vehicles): + # Tier 1: <= 50 km = fixed cost 100 + vehicle_ids.append(v) + thresholds.append(50.0) + fixed_costs.append(100.0) + costs_per_unit.append(0.0) + + # Tier 2: 50-100 km = 2.0 per km + vehicle_ids.append(v) + thresholds.append(100.0) + fixed_costs.append(0.0) + costs_per_unit.append(2.0) + + # Tier 3: > 100 km = 5.0 per km + vehicle_ids.append(v) + thresholds.append(np.finfo(np.float32).max) + fixed_costs.append(0.0) + costs_per_unit.append(5.0) + + # Set tiers + data_model.set_vehicle_distance_tiers( + cudf.Series(vehicle_ids, dtype=np.int32), + cudf.Series(thresholds, dtype=np.float32), + cudf.Series(fixed_costs, dtype=np.float32), + cudf.Series(costs_per_unit, dtype=np.float32), + ) + + # Solve + solver_settings = routing.SolverSettings() + solver_settings.set_time_limit(10.0) + + print("Solving...") + solution = routing.Solve(data_model, solver_settings) + + # Display results + print(f"\nStatus: {solution.get_status()}") + print(f"Total objective: {solution.get_total_objective():.2f}") + + objectives = solution.get_objective_values() + if routing.Objective.COST in objectives: + print( + f"Total cost (with tiers): ${objectives[routing.Objective.COST]:.2f}" + ) + + print("\n✅ Example 1 completed\n") + + +def example_heterogeneous_tiers(): + """ + Example 2: Heterogeneous tiers - Different vehicles have different pricing + + Vehicle types: + - Vehicle 0 (Economy): Cheaper for short trips, expensive for long + - Vehicle 1 (Standard): Balanced pricing + - Vehicle 2 (Premium): More expensive upfront, cheaper for long distances + """ + print("=" * 70) + print("EXAMPLE 2: HETEROGENEOUS DISTANCE TIERS") + print("=" * 70) + print("\nDifferent vehicles have different pricing structures:\n") + print("🔵 Vehicle 0 (Economy):") + print(" • <= 30 km: $50 fixed") + print(" • 30-80 km: $3 per km") + print(" • > 80 km: $6 per km") + print("\n🟡 Vehicle 1 (Standard):") + print(" • <= 60 km: $80 fixed") + print(" • 60-100 km: $2 per km") + print(" • > 100 km: $4 per km") + print("\n🟢 Vehicle 2 (Premium):") + print(" • <= 100 km: $120 fixed") + print(" • > 100 km: $1.5 per km\n") + + # Create problem + cost_df, demand, capacities = create_simple_problem() + n_locations = len(cost_df) + n_vehicles = len(capacities) + + # Create data model + data_model = routing.DataModel(n_locations, n_vehicles, n_locations - 1) + data_model.add_cost_matrix(cost_df) + data_model.add_distance_matrix(cost_df) + + # Set order locations + order_locations = cudf.Series(range(1, n_locations), dtype=np.int32) + data_model.set_order_locations(order_locations) + + # Add capacity constraint + data_model.add_capacity_dimension("demand", demand, capacities) + + # Configure distance tiers - different for each vehicle + vehicle_ids = [] + thresholds = [] + fixed_costs = [] + costs_per_unit = [] + + # Vehicle 0: Economy + vehicle_ids.extend([0, 0, 0]) + thresholds.extend([30.0, 80.0, np.finfo(np.float32).max]) + fixed_costs.extend([50.0, 0.0, 0.0]) + costs_per_unit.extend([0.0, 3.0, 6.0]) + + # Vehicle 1: Standard + vehicle_ids.extend([1, 1, 1]) + thresholds.extend([60.0, 100.0, np.finfo(np.float32).max]) + fixed_costs.extend([80.0, 0.0, 0.0]) + costs_per_unit.extend([0.0, 2.0, 4.0]) + + # Vehicle 2: Premium + vehicle_ids.extend([2, 2]) + thresholds.extend([100.0, np.finfo(np.float32).max]) + fixed_costs.extend([120.0, 0.0]) + costs_per_unit.extend([0.0, 1.5]) + + # Set tiers + data_model.set_vehicle_distance_tiers( + cudf.Series(vehicle_ids, dtype=np.int32), + cudf.Series(thresholds, dtype=np.float32), + cudf.Series(fixed_costs, dtype=np.float32), + cudf.Series(costs_per_unit, dtype=np.float32), + ) + + # Solve + solver_settings = routing.SolverSettings() + solver_settings.set_time_limit(10.0) + + print("Solving...") + solution = routing.Solve(data_model, solver_settings) + + # Display results + print(f"\nStatus: {solution.get_status()}") + print(f"Total objective: {solution.get_total_objective():.2f}") + + objectives = solution.get_objective_values() + if routing.Objective.COST in objectives: + print( + f"Total cost (with tiers): ${objectives[routing.Objective.COST]:.2f}" + ) + + # Show which vehicles were used + route_df = solution.get_route() + truck_ids = route_df["truck_id"].to_numpy() + locations = route_df["location"].to_numpy() + + print("\nVehicle usage:") + for v in range(n_vehicles): + orders = np.sum((truck_ids == v) & (locations != 0)) + vehicle_type = ["Economy", "Standard", "Premium"][v] + if orders > 0: + print(f" Vehicle {v} ({vehicle_type}): {orders} orders") + else: + print(f" Vehicle {v} ({vehicle_type}): Not used") + + print("\n✅ Example 2 completed\n") + + +def example_realistic_scenario(): + """ + Example 3: Realistic delivery scenario + + A delivery company has: + - Small vans: Best for short urban deliveries + - Medium trucks: Good for medium distances + - Large trucks: Efficient for long hauls despite higher base cost + """ + print("=" * 70) + print("EXAMPLE 3: REALISTIC DELIVERY SCENARIO") + print("=" * 70) + print("\nA delivery company optimizing their fleet:\n") + print("🚐 Small Vans (2 available):") + print(" • <= 20 km: $30 fixed (urban deliveries)") + print(" • > 20 km: $4 per km (expensive for long trips)") + print("\n🚚 Medium Trucks (2 available):") + print(" • <= 50 km: $60 fixed") + print(" • 50-100 km: $1.5 per km") + print(" • > 100 km: $3 per km") + print("\n🚛 Large Trucks (1 available):") + print(" • <= 80 km: $100 fixed") + print(" • > 80 km: $1 per km (efficient for long hauls)\n") + + # Create a larger problem + n_locations = 15 + n_vehicles = 5 # 2 small + 2 medium + 1 large + + # Create distance matrix with some structure + np.random.seed(123) + distances = np.random.uniform(5, 80, (n_locations, n_locations)) + distances = (distances + distances.T) / 2 + np.fill_diagonal(distances, 0) + cost_df = cudf.DataFrame(distances.astype(np.float32)) + + # Different capacities for different vehicle types + n_orders = n_locations - 1 + demand = cudf.Series([8] * n_orders, dtype=np.int32) + capacities = cudf.Series([30, 30, 50, 50, 80], dtype=np.int32) + + # Create data model + data_model = routing.DataModel(n_locations, n_vehicles, n_orders) + data_model.add_cost_matrix(cost_df) + data_model.add_distance_matrix(cost_df) + + order_locations = cudf.Series(range(1, n_locations), dtype=np.int32) + data_model.set_order_locations(order_locations) + data_model.add_capacity_dimension("demand", demand, capacities) + + # Configure distance tiers + vehicle_ids = [] + thresholds = [] + fixed_costs = [] + costs_per_unit = [] + + # Small vans (vehicles 0, 1) + for v in [0, 1]: + vehicle_ids.extend([v, v]) + thresholds.extend([20.0, np.finfo(np.float32).max]) + fixed_costs.extend([30.0, 0.0]) + costs_per_unit.extend([0.0, 4.0]) + + # Medium trucks (vehicles 2, 3) + for v in [2, 3]: + vehicle_ids.extend([v, v, v]) + thresholds.extend([50.0, 100.0, np.finfo(np.float32).max]) + fixed_costs.extend([60.0, 0.0, 0.0]) + costs_per_unit.extend([0.0, 1.5, 3.0]) + + # Large truck (vehicle 4) + vehicle_ids.extend([4, 4]) + thresholds.extend([80.0, np.finfo(np.float32).max]) + fixed_costs.extend([100.0, 0.0]) + costs_per_unit.extend([0.0, 1.0]) + + # Set tiers + data_model.set_vehicle_distance_tiers( + cudf.Series(vehicle_ids, dtype=np.int32), + cudf.Series(thresholds, dtype=np.float32), + cudf.Series(fixed_costs, dtype=np.float32), + cudf.Series(costs_per_unit, dtype=np.float32), + ) + + # Solve + solver_settings = routing.SolverSettings() + solver_settings.set_time_limit(15.0) + + print("Solving...") + solution = routing.Solve(data_model, solver_settings) + + # Display results + print(f"\nStatus: {solution.get_status()}") + print(f"Total objective: {solution.get_total_objective():.2f}") + + objectives = solution.get_objective_values() + if routing.Objective.COST in objectives: + print( + f"Total cost (with tiers): ${objectives[routing.Objective.COST]:.2f}" + ) + + # Detailed vehicle usage + route_df = solution.get_route() + truck_ids = route_df["truck_id"].to_numpy() + locations = route_df["location"].to_numpy() + + print("\nOptimal fleet allocation:") + vehicle_types = [ + "Small Van", + "Small Van", + "Medium Truck", + "Medium Truck", + "Large Truck", + ] + + for v in range(n_vehicles): + orders = np.sum((truck_ids == v) & (locations != 0)) + capacity_used = orders * 8 # Each order is 8 units + capacity_total = capacities[v] + + if orders > 0: + print(f" Vehicle {v} ({vehicle_types[v]}):") + print(f" • Orders: {orders}") + print( + f" • Capacity used: {capacity_used}/{capacity_total} units" + ) + else: + print(f" Vehicle {v} ({vehicle_types[v]}): Not used") + + print("\n💡 The solver automatically selected the most cost-effective") + print(" vehicles based on distance tiers and capacity constraints!") + + print("\n✅ Example 3 completed\n") + + +if __name__ == "__main__": + print("\n" + "=" * 70) + print("VEHICLE DISTANCE TIERS - EXAMPLES") + print("=" * 70) + print("\nThese examples demonstrate how to use distance-based tiered") + print("pricing in cuOpt to optimize vehicle routing costs.\n") + + try: + example_uniform_tiers() + print("-" * 70 + "\n") + + example_heterogeneous_tiers() + print("-" * 70 + "\n") + + example_realistic_scenario() + + print("=" * 70) + print("ALL EXAMPLES COMPLETED SUCCESSFULLY ✅") + print("=" * 70) + + except Exception as e: + print(f"\n❌ Error: {e}") + import traceback + + traceback.print_exc() diff --git a/python/cuopt/cuopt/grpc/client/grpc_client.pxd b/python/cuopt/cuopt/grpc/client/grpc_client.pxd index 42851ba48e..3bdbb3a526 100644 --- a/python/cuopt/cuopt/grpc/client/grpc_client.pxd +++ b/python/cuopt/cuopt/grpc/client/grpc_client.pxd @@ -64,6 +64,7 @@ cdef extern from "cuopt/routing/cpu_routing_problem.hpp" namespace "cuopt::routi int32_t num_orders vector[cpu_cost_matrix_t] cost_matrices vector[cpu_cost_matrix_t] transit_time_matrices + vector[cpu_cost_matrix_t] distance_matrices vector[int32_t] vehicle_start_locations vector[int32_t] vehicle_return_locations vector[int32_t] vehicle_tw_earliest @@ -72,8 +73,13 @@ cdef extern from "cuopt/routing/cpu_routing_problem.hpp" namespace "cuopt::routi vector[uint8_t] drop_return_trips vector[uint8_t] skip_first_trips vector[float] vehicle_max_costs + vector[float] vehicle_max_distances vector[float] vehicle_max_times vector[float] vehicle_fixed_costs + vector[float] distance_tier_thresholds + vector[float] distance_tier_fixed_costs + vector[float] distance_tier_costs_per_unit + vector[int32_t] distance_tier_offsets vector[int32_t] order_locations vector[int32_t] order_tw_earliest vector[int32_t] order_tw_latest diff --git a/python/cuopt/cuopt/grpc/client/grpc_client.pyx b/python/cuopt/cuopt/grpc/client/grpc_client.pyx index 2ca33408cd..a5f0c72524 100644 --- a/python/cuopt/cuopt/grpc/client/grpc_client.pyx +++ b/python/cuopt/cuopt/grpc/client/grpc_client.pyx @@ -773,6 +773,7 @@ class RoutingSolveError(RuntimeError): # name in cuopt.routing._deferred._SETTERS so a new setter cannot be missed. HANDLED_SETTERS = frozenset({ "add_cost_matrix", + "add_distance_matrix", "add_transit_time_matrix", "set_order_time_windows", "set_vehicle_time_windows", @@ -795,8 +796,10 @@ HANDLED_SETTERS = frozenset({ "set_drop_return_trips", "set_skip_first_trips", "set_vehicle_max_costs", + "set_vehicle_max_distances", "set_vehicle_max_times", "set_vehicle_fixed_costs", + "set_vehicle_distance_tiers", "set_break_locations", }) @@ -915,7 +918,10 @@ cdef _f64_to_np(const vector[double]& v): cdef void _add_matrix(vector[cpu_cost_matrix_t]& dst, args) except *: # _fill_f32 already casts to float32 and C-order ravels (row-major). cdef cpu_cost_matrix_t cm - cm.vehicle_type = (int(args[1]) if len(args) > 1 else 0) + cdef long vehicle_type = int(args[1]) if len(args) > 1 else 0 + if vehicle_type < 0 or vehicle_type > 255: + raise ValueError("vehicle_type must be within [0, 255]") + cm.vehicle_type = vehicle_type _fill_f32(cm.matrix, args[0]) dst.push_back(cm) @@ -936,6 +942,8 @@ cdef void _populate(cpu_routing_problem_t& p, data_model) except *: for name, args, _ in data_model._calls: if name == "add_cost_matrix": _add_matrix(p.cost_matrices, args) + elif name == "add_distance_matrix": + _add_matrix(p.distance_matrices, args) elif name == "add_transit_time_matrix": _add_matrix(p.transit_time_matrices, args) elif name == "set_order_time_windows": @@ -1024,10 +1032,33 @@ cdef void _populate(cpu_routing_problem_t& p, data_model) except *: _fill_u8(p.skip_first_trips, args[0]) elif name == "set_vehicle_max_costs": _fill_f32(p.vehicle_max_costs, args[0]) + elif name == "set_vehicle_max_distances": + _fill_f32(p.vehicle_max_distances, args[0]) elif name == "set_vehicle_max_times": _fill_f32(p.vehicle_max_times, args[0]) elif name == "set_vehicle_fixed_costs": _fill_f32(p.vehicle_fixed_costs, args[0]) + elif name == "set_vehicle_distance_tiers": + tier_vehicle_ids = np.asarray(_to_host(args[0])) + tier_thresholds = np.asarray(_to_host(args[1])) + tier_order = np.lexsort((tier_thresholds, tier_vehicle_ids)) + tier_vehicle_ids = tier_vehicle_ids[tier_order] + _fill_f32(p.distance_tier_thresholds, tier_thresholds[tier_order]) + _fill_f32( + p.distance_tier_fixed_costs, + np.asarray(_to_host(args[2]))[tier_order], + ) + _fill_f32( + p.distance_tier_costs_per_unit, + np.asarray(_to_host(args[3]))[tier_order], + ) + p.distance_tier_offsets.clear() + p.distance_tier_offsets.push_back(0) + for vid in range(int(fleet)): + p.distance_tier_offsets.push_back( + p.distance_tier_offsets.back() + + int(np.count_nonzero(tier_vehicle_ids == vid)) + ) elif name == "set_break_locations": _fill_i32(p.break_locations, args[0]) else: @@ -1052,6 +1083,7 @@ def problem_summary(data_model): "min_vehicles": int(p.min_vehicles), "cost_matrices": p.cost_matrices.size(), "transit_time_matrices": p.transit_time_matrices.size(), + "distance_matrices": p.distance_matrices.size(), "vehicle_start_locations": p.vehicle_start_locations.size(), "vehicle_return_locations": p.vehicle_return_locations.size(), "vehicle_tw_earliest": p.vehicle_tw_earliest.size(), @@ -1060,6 +1092,8 @@ def problem_summary(data_model): "drop_return_trips": p.drop_return_trips.size(), "skip_first_trips": p.skip_first_trips.size(), "vehicle_max_costs": p.vehicle_max_costs.size(), + "vehicle_max_distances": p.vehicle_max_distances.size(), + "distance_tiers": p.distance_tier_thresholds.size(), "vehicle_max_times": p.vehicle_max_times.size(), "vehicle_fixed_costs": p.vehicle_fixed_costs.size(), "order_locations": p.order_locations.size(), diff --git a/python/cuopt/cuopt/routing/_deferred.py b/python/cuopt/cuopt/routing/_deferred.py index 6035db0c8d..abc549acb1 100644 --- a/python/cuopt/cuopt/routing/_deferred.py +++ b/python/cuopt/cuopt/routing/_deferred.py @@ -46,6 +46,7 @@ "add_break_dimension", "add_capacity_dimension", "add_cost_matrix", + "add_distance_matrix", "add_initial_solutions", "add_order_precedence", "add_order_vehicle_match", @@ -64,8 +65,10 @@ "set_pickup_delivery_pairs", "set_skip_first_trips", "set_vehicle_fixed_costs", + "set_vehicle_distance_tiers", "set_vehicle_locations", "set_vehicle_max_costs", + "set_vehicle_max_distances", "set_vehicle_max_times", "set_vehicle_time_windows", "set_vehicle_types", @@ -95,6 +98,7 @@ "get_vehicle_fixed_costs", "get_vehicle_locations", "get_vehicle_max_costs", + "get_vehicle_max_distances", "get_vehicle_max_times", "get_vehicle_order_match", "get_vehicle_time_windows", diff --git a/python/cuopt/cuopt/routing/vehicle_routing.pxd b/python/cuopt/cuopt/routing/vehicle_routing.pxd index d2a3045fdc..18d18ea264 100644 --- a/python/cuopt/cuopt/routing/vehicle_routing.pxd +++ b/python/cuopt/cuopt/routing/vehicle_routing.pxd @@ -45,6 +45,10 @@ cdef extern from "cuopt/routing/solve.hpp" namespace "cuopt::routing": const f_t* matrix, uint8_t vehicle_type ) except + + void add_distance_matrix( + const f_t* matrix, + uint8_t vehicle_type + ) except + void add_transit_time_matrix( const f_t* secondary_matrix, uint8_t vehicle_type @@ -122,8 +126,15 @@ cdef extern from "cuopt/routing/solve.hpp" namespace "cuopt::routing": i_t n_prec_nodes) except + void set_min_vehicles(i_t min_vehicles) except+ void set_vehicle_max_costs(const f_t *max_costs) except+ + void set_vehicle_max_distances(const f_t *max_distances) except+ void set_vehicle_max_times(const f_t *max_times) except+ void set_vehicle_fixed_costs(const f_t *vehicle_fixed_costs) except+ + void set_vehicle_distance_tiers( + const f_t *thresholds, + const f_t *fixed_costs, + const f_t *costs_per_unit, + const i_t *offsets, + i_t total_tiers) except+ i_t get_num_locations() except+ i_t get_fleet_size() except+ i_t get_num_orders() except+ diff --git a/python/cuopt/cuopt/routing/vehicle_routing.py b/python/cuopt/cuopt/routing/vehicle_routing.py index e22a6e6108..2b4161d11e 100644 --- a/python/cuopt/cuopt/routing/vehicle_routing.py +++ b/python/cuopt/cuopt/routing/vehicle_routing.py @@ -19,6 +19,13 @@ ) +def _validate_vehicle_type(vehicle_type): + if not isinstance(vehicle_type, (int, np.integer)): + raise TypeError("vehicle_type must be an integer") + if not 0 <= int(vehicle_type) <= np.iinfo(np.uint8).max: + raise ValueError("vehicle_type must be within [0, 255]") + + class DataModel(_DeferredDataModel): """ @@ -140,6 +147,8 @@ def add_cost_matrix( >>> data_model.add_cost_matrix(cost_mat_car, 2) """ + _validate_vehicle_type(vehicle_type) + # a[1] is vehicle_type: the recorded call is (cost_mat, vehicle_type). if vehicle_type in {a[1] for a in self._recorded("add_cost_matrix")}: raise ValueError("Vehicle type matrix has already been added") @@ -149,6 +158,64 @@ def add_cost_matrix( super().add_cost_matrix(cost_mat, vehicle_type) + @catch_cuopt_exception + def add_distance_matrix( + self, distance_mat, vehicle_type=0, *, skip_validation=False + ): + """ + Add a matrix for route travel distance. + + This matrix is required when using distance-based features such as + vehicle distance tiers or vehicle maximum distances. It is separate + from the primary cost matrix so users can optimize on one metric while + applying distance-based constraints or tiered pricing on another. + + Parameters + ---------- + distance_mat : cudf.DataFrame dtype - float32 + cudf.DataFrame representing floating point square matrix with + num_location rows and columns. Positive infinity may be used for + an unreachable arc and is limited to the solver's finite matrix + sentinel. Finite values at or above 1e30 are also treated as + unreachable. + vehicle_type : uint8 + Identifier of the vehicle type. + skip_validation : bool + If True, skips Python validation for matrix shape, NULL values, + and non-negative values. The caller is responsible for providing + a valid square matrix matching the number of locations. + """ + + _validate_vehicle_type(vehicle_type) + + if vehicle_type in { + args[1] for args in self._recorded("add_distance_matrix") + }: + raise ValueError( + "Vehicle type distance matrix has already been added" + ) + + if not skip_validation: + validate_matrix( + distance_mat, "distance matrix", self.get_num_locations() + ) + if hasattr(distance_mat, "to_numpy"): + distance_host = distance_mat.to_numpy() + elif hasattr(distance_mat, "get"): + distance_host = distance_mat.get() + else: + distance_host = np.asarray(distance_mat) + finite_values = distance_host[np.isfinite(distance_host)] + if ( + finite_values.size > 0 + and (finite_values > np.finfo(np.float32).max).any() + ): + raise ValueError( + "distance matrix finite values must be representable as float32" + ) + + super().add_distance_matrix(distance_mat, vehicle_type) + @catch_cuopt_exception def add_transit_time_matrix(self, mat, vehicle_type=0): """ @@ -224,6 +291,8 @@ def add_transit_time_matrix(self, mat, vehicle_type=0): >>> data_model.add_transit_time_matrix(time_mat, 0) """ # a[1] is vehicle_type (see add_cost_matrix). + _validate_vehicle_type(vehicle_type) + if vehicle_type in { a[1] for a in self._recorded("add_transit_time_matrix") }: @@ -596,7 +665,20 @@ def set_vehicle_types(self, vehicle_types): self.get_fleet_size(), "number of vehicles", ) - validate_non_negative(vehicle_types, "vehicle types") + if hasattr(vehicle_types, "to_numpy"): + vehicle_types_host = vehicle_types.to_numpy() + elif hasattr(vehicle_types, "get"): + vehicle_types_host = vehicle_types.get() + else: + vehicle_types_host = np.asarray(vehicle_types) + if not np.issubdtype(vehicle_types_host.dtype, np.integer): + raise TypeError("vehicle types must contain integers") + validate_range( + vehicle_types, + "vehicle types", + 0, + np.iinfo(np.uint8).max, + ) super().set_vehicle_types(vehicle_types) @catch_cuopt_exception @@ -1152,12 +1234,13 @@ def add_capacity_dimension(self, name, demand, capacity): @catch_cuopt_exception def set_vehicle_max_costs(self, vehicle_max_costs): """ - Limits per vehicle primary matrix cost accumulated along a route. + Limits the total route cost per vehicle. With distance tiers, this is + the primary matrix cost plus the tiered distance cost. Parameters ---------- vehicle_max_costs : cudf.Series dtype - float32 - Upper bound per vehicle for max distance cumulated on a route + Upper bound per vehicle for total route cost. Examples -------- @@ -1174,7 +1257,21 @@ def set_vehicle_max_costs(self, vehicle_max_costs): self.get_fleet_size(), "number of vehicles", ) - validate_positive(vehicle_max_costs, "vehicle max costs") + if hasattr(vehicle_max_costs, "to_numpy"): + max_costs_host = vehicle_max_costs.to_numpy() + elif hasattr(vehicle_max_costs, "get"): + max_costs_host = vehicle_max_costs.get() + else: + max_costs_host = np.asarray(vehicle_max_costs) + if not np.isfinite(max_costs_host).all(): + raise ValueError( + "vehicle max costs must contain only finite values" + ) + if (max_costs_host > np.finfo(np.float32).max).any(): + raise ValueError( + "vehicle max costs must be representable as float32" + ) + validate_non_negative(vehicle_max_costs, "vehicle max costs") super().set_vehicle_max_costs(vehicle_max_costs) @catch_cuopt_exception @@ -1207,6 +1304,38 @@ def set_vehicle_max_times(self, vehicle_max_times): validate_positive(vehicle_max_times, "vehicle max times") super().set_vehicle_max_times(vehicle_max_times) + @catch_cuopt_exception + def set_vehicle_max_distances(self, vehicle_max_distances): + """Limits the physical distance accumulated by each vehicle route. + + Parameters + ---------- + vehicle_max_distances : cudf.Series dtype - float32 + Upper bound per vehicle based on the distance matrix. + """ + validate_size( + vehicle_max_distances, + "vehicle max distances", + self.get_fleet_size(), + "number of vehicles", + ) + if hasattr(vehicle_max_distances, "to_numpy"): + max_distances_host = vehicle_max_distances.to_numpy() + elif hasattr(vehicle_max_distances, "get"): + max_distances_host = vehicle_max_distances.get() + else: + max_distances_host = np.asarray(vehicle_max_distances) + if not np.isfinite(max_distances_host).all(): + raise ValueError( + "vehicle max distances must contain only finite values" + ) + if (max_distances_host > np.finfo(np.float32).max).any(): + raise ValueError( + "vehicle max distances must be representable as float32" + ) + validate_non_negative(vehicle_max_distances, "vehicle max distances") + super().set_vehicle_max_distances(vehicle_max_distances) + @catch_cuopt_exception def set_vehicle_fixed_costs(self, vehicle_fixed_costs): """ @@ -1243,6 +1372,151 @@ def set_vehicle_fixed_costs(self, vehicle_fixed_costs): ) super().set_vehicle_fixed_costs(vehicle_fixed_costs) + @catch_cuopt_exception + def set_vehicle_distance_tiers( + self, vehicle_ids, thresholds, fixed_costs, costs_per_unit + ): + """ + Set distance-based tiered pricing for vehicles. + + Call add_distance_matrix before setting tiers. Each vehicle can have + multiple distance tiers with different cost structures. Tier costs are + accumulated by distance band in ascending threshold order. + + For each band reached by the route, cuOpt adds fixed_cost when it is + positive and adds the in-band distance multiplied by cost_per_unit. + + Parameters + ---------- + vehicle_ids : cudf.Series dtype - int32 + Vehicle ID for each tier entry. Tiers for the same vehicle should be + consecutive and sorted by threshold in ascending order. + thresholds : cudf.Series dtype - float32 + Finite distance thresholds for each tier. The last tier of each + vehicle must use ``numpy.finfo(numpy.float32).max``. + fixed_costs : cudf.Series dtype - float32 + Fixed cost for each tier. Use 0.0 if the tier uses cost_per_unit instead. + costs_per_unit : cudf.Series dtype - float32 + Cost per distance unit for each tier. Use 0.0 if the tier uses + fixed_cost instead. + + Examples + -------- + >>> from cuopt import routing + >>> import cudf + >>> import numpy as np + >>> + >>> # Define tiers for 2 vehicles + >>> # Vehicle 0: fixed first band, then 0.1/km and 0.5/km bands + >>> # Vehicle 1: fixed first band, then 0.3/km band + >>> + >>> vehicle_ids = cudf.Series([0, 0, 0, 1, 1], dtype=np.int32) + >>> max_distance = np.finfo(np.float32).max + >>> thresholds = cudf.Series([100.0, 200.0, max_distance, 150.0, max_distance], dtype=np.float32) + >>> fixed_costs = cudf.Series([50.0, 0.0, 0.0, 75.0, 0.0], dtype=np.float32) + >>> costs_per_unit = cudf.Series([0.0, 0.1, 0.5, 0.0, 0.3], dtype=np.float32) + >>> + >>> data_model = routing.DataModel(n_locations=10, n_fleet=2) + >>> data_model.add_distance_matrix(distance_mat) + >>> data_model.set_vehicle_distance_tiers( + ... vehicle_ids, thresholds, fixed_costs, costs_per_unit + ... ) + + Notes + ----- + - add_distance_matrix must be called before solving with distance tiers + - All input series must have the same length + - Tiers for each vehicle must be sorted by threshold in ascending order + - At least one tier must be defined for vehicles that use this feature + - For each tier, either fixed_cost OR cost_per_unit should be non-zero + """ + # Validations + if len(vehicle_ids) != len(thresholds): + raise ValueError( + f"vehicle_ids length ({len(vehicle_ids)}) must match thresholds length ({len(thresholds)})" + ) + if len(vehicle_ids) != len(fixed_costs): + raise ValueError( + f"vehicle_ids length ({len(vehicle_ids)}) must match fixed_costs length ({len(fixed_costs)})" + ) + if len(vehicle_ids) != len(costs_per_unit): + raise ValueError( + f"vehicle_ids length ({len(vehicle_ids)}) must match costs_per_unit length ({len(costs_per_unit)})" + ) + + if len(vehicle_ids) == 0: + raise ValueError("At least one distance tier must be provided") + + def to_numpy(values): + if hasattr(values, "to_numpy"): + return values.to_numpy() + if hasattr(values, "get"): + return values.get() + return np.asarray(values) + + vehicle_ids_host = to_numpy(vehicle_ids) + thresholds_host = to_numpy(thresholds) + fixed_costs_host = to_numpy(fixed_costs) + costs_per_unit_host = to_numpy(costs_per_unit) + + if not np.issubdtype(vehicle_ids_host.dtype, np.integer): + raise TypeError("vehicle_ids must contain integers") + for values, name in ( + (thresholds_host, "thresholds"), + (fixed_costs_host, "fixed_costs"), + (costs_per_unit_host, "costs_per_unit"), + ): + if not np.isfinite(values).all(): + raise ValueError(f"{name} must contain only finite values") + if (values > np.finfo(np.float32).max).any(): + raise ValueError(f"{name} must be representable as float32") + + validate_non_negative(thresholds, "thresholds") + validate_non_negative(fixed_costs, "fixed_costs") + validate_non_negative(costs_per_unit, "costs_per_unit") + + # Check that vehicle IDs are valid + max_vehicle_id = int(vehicle_ids_host.max()) + if max_vehicle_id >= self.get_fleet_size(): + raise ValueError( + f"vehicle_ids contains {max_vehicle_id} but fleet size is {self.get_fleet_size()}" + ) + + # Check minimum vehicle ID + min_vehicle_id = int(vehicle_ids_host.min()) + if min_vehicle_id < 0: + raise ValueError( + f"vehicle_ids contains negative value: {min_vehicle_id}" + ) + + expected_vehicle_ids = np.arange(self.get_fleet_size()) + if not np.array_equal( + np.unique(vehicle_ids_host), expected_vehicle_ids + ): + raise ValueError( + "At least one distance tier must be provided for each vehicle" + ) + + thresholds_float32 = thresholds_host.astype(np.float32) + for vehicle_id in expected_vehicle_ids: + vehicle_thresholds = np.sort( + thresholds_float32[vehicle_ids_host == vehicle_id] + ) + if np.any(np.diff(vehicle_thresholds) <= 0): + raise ValueError( + "Distance tier thresholds must be strictly increasing " + "for each vehicle" + ) + if vehicle_thresholds[-1] != np.finfo(np.float32).max: + raise ValueError( + "The last distance tier threshold for each vehicle must " + "be numpy.finfo(numpy.float32).max" + ) + + super().set_vehicle_distance_tiers( + vehicle_ids, thresholds, fixed_costs, costs_per_unit + ) + @catch_cuopt_exception def set_min_vehicles(self, min_vehicles): """ @@ -1432,6 +1706,11 @@ def get_vehicle_max_times(self): """ return super().get_vehicle_max_times() + @catch_cuopt_exception + def get_vehicle_max_distances(self): + """Returns maximum physical distances per vehicle.""" + return super().get_vehicle_max_distances() + @catch_cuopt_exception def get_vehicle_fixed_costs(self): """ diff --git a/python/cuopt/cuopt/routing/vehicle_routing_wrapper.pyx b/python/cuopt/cuopt/routing/vehicle_routing_wrapper.pyx index bda878ada8..cdf892de21 100644 --- a/python/cuopt/cuopt/routing/vehicle_routing_wrapper.pyx +++ b/python/cuopt/cuopt/routing/vehicle_routing_wrapper.pyx @@ -233,6 +233,7 @@ cdef class DataModel: n_orders )) self.costs = {} + self.distance_matrices = {} self.transit_times = {} self.demand_name = [] @@ -261,9 +262,16 @@ cdef class DataModel: self.vehicle_drop_return_trips = cudf.Series() self.vehicle_skip_first_trips = cudf.Series() self.vehicle_max_costs = cudf.Series() + self.vehicle_max_distances = cudf.Series() self.vehicle_max_times = cudf.Series() self.vehicle_fixed_costs = cudf.Series() + # Distance tiers for tiered pricing + self.distance_tier_thresholds = cudf.Series() + self.distance_tier_fixed_costs = cudf.Series() + self.distance_tier_costs_per_unit = cudf.Series() + self.distance_tier_offsets = cudf.Series() + self.vehicle_order_match = {} self.order_vehicle_match = {} self.order_service_times = {} @@ -281,6 +289,14 @@ cdef class DataModel: c_costs, vehicle_type ) + def add_distance_matrix(self, distances, vehicle_type): + distances = prepare_matrix(distances, "distance_matrix") + self.distance_matrices[vehicle_type] = distances + cdef uintptr_t c_distances = self.distance_matrices[vehicle_type].data.ptr + self.c_data_model_view.get().add_distance_matrix( + c_distances, vehicle_type + ) + def add_transit_time_matrix(self, times, vehicle_type): times = prepare_matrix(times, "transit_time_matrix") self.transit_times[vehicle_type] = times @@ -652,6 +668,20 @@ cdef class DataModel: c_vehicle_max_times ) + def set_vehicle_max_distances(self, vehicle_max_distances): + self.vehicle_max_distances = type_cast( + vehicle_max_distances, + np.float32, + "vehicle_max_distances" + ) + + cdef uintptr_t c_vehicle_max_distances = ( + self.vehicle_max_distances.__cuda_array_interface__['data'][0] + ) + self.c_data_model_view.get().set_vehicle_max_distances( + c_vehicle_max_distances + ) + def set_vehicle_fixed_costs(self, vehicle_fixed_costs): self.vehicle_fixed_costs = type_cast( vehicle_fixed_costs, @@ -666,6 +696,64 @@ cdef class DataModel: c_vehicle_fixed_costs ) + def set_vehicle_distance_tiers(self, vehicle_ids, thresholds, fixed_costs, costs_per_unit): + """ + Set distance-based tiered pricing for vehicles. + + Parameters should be sorted by vehicle_id and then by threshold within each vehicle. + """ + import cudf + + # Create DataFrame and sort by vehicle_id to ensure proper grouping + df = cudf.DataFrame({ + 'vehicle_id': vehicle_ids, + 'threshold': thresholds, + 'fixed_cost': fixed_costs, + 'cost_per_unit': costs_per_unit + }).sort_values(['vehicle_id', 'threshold']) + + # Store data + self.distance_tier_thresholds = type_cast( + df['threshold'], np.float32, "thresholds" + ) + self.distance_tier_fixed_costs = type_cast( + df['fixed_cost'], np.float32, "fixed_costs" + ) + self.distance_tier_costs_per_unit = type_cast( + df['cost_per_unit'], np.float32, "costs_per_unit" + ) + + # Calculate offsets for each vehicle + fleet_size = self.get_fleet_size() + offsets = [0] + for vid in range(fleet_size): + count = int((df['vehicle_id'] == vid).sum()) + offsets.append(offsets[-1] + count) + + self.distance_tier_offsets = cudf.Series(offsets, dtype=np.int32) + + # Pass to C++ + cdef uintptr_t c_thresholds = ( + self.distance_tier_thresholds.__cuda_array_interface__['data'][0] + ) + cdef uintptr_t c_fixed_costs = ( + self.distance_tier_fixed_costs.__cuda_array_interface__['data'][0] + ) + cdef uintptr_t c_costs_per_unit = ( + self.distance_tier_costs_per_unit.__cuda_array_interface__['data'][0] + ) + cdef uintptr_t c_offsets = ( + self.distance_tier_offsets.__cuda_array_interface__['data'][0] + ) + + self.c_data_model_view.get().set_vehicle_distance_tiers( + c_thresholds, + c_fixed_costs, + c_costs_per_unit, + c_offsets, + len(self.distance_tier_thresholds) + ) + def set_min_vehicles(self, min_vehicles): self.c_data_model_view.get().set_min_vehicles(min_vehicles) @@ -783,6 +871,9 @@ cdef class DataModel: def get_vehicle_max_times(self): return self.vehicle_max_times + def get_vehicle_max_distances(self): + return self.vehicle_max_distances + def get_vehicle_fixed_costs(self): return self.vehicle_fixed_costs diff --git a/python/cuopt/cuopt/tests/routing/API_COVERAGE.md b/python/cuopt/cuopt/tests/routing/API_COVERAGE.md index c470203ddb..8d1c638967 100644 --- a/python/cuopt/cuopt/tests/routing/API_COVERAGE.md +++ b/python/cuopt/cuopt/tests/routing/API_COVERAGE.md @@ -31,11 +31,13 @@ Summary of which APIs from `assignment.py` and `vehicle_routing.py` are exercise | API | Covered | Where | |-----|---------|--------| | `add_cost_matrix()` | Yes | test_data_model, test_vehicle_properties, test_solver, test_batch_solve, etc. | +| `add_distance_matrix()` | Yes | test_deferred, test_host_arrays, test_routing_grpc_serialization | | `add_transit_time_matrix()` | Yes | test_vehicle_properties, test_solver, test_initial_solutions, test_re_routing, etc. | | `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_vehicle_distance_tiers()` | Yes | test_deferred, test_routing_grpc_serialization, test_vehicle_distance_tiers | | `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. | @@ -52,6 +54,7 @@ Summary of which APIs from `assignment.py` and `vehicle_routing.py` are exercise | `set_order_service_times()` | Yes | test_vehicle_properties, test_data_model, test_solver, etc. | | `add_capacity_dimension()` | Yes | test_data_model, test_vehicle_properties, test_solver, etc. | | `set_vehicle_max_costs()` | Yes | test_vehicle_properties, test_solver_settings | +| `set_vehicle_max_distances()` | Yes | test_host_arrays, test_routing_grpc_serialization | | `set_vehicle_max_times()` | Yes | test_vehicle_properties | | `set_vehicle_fixed_costs()` | Yes | test_vehicle_properties | | `set_min_vehicles()` | Yes | test_vehicle_properties, test_solver_settings, test_initial_solutions, test_solver | @@ -82,6 +85,7 @@ Summary of which APIs from `assignment.py` and `vehicle_routing.py` are exercise | `get_non_uniform_breaks()` | Yes | test_vehicle_properties (test_heterogenous_breaks) | | `get_objective_function()` | Yes | test_data_model | | `get_vehicle_max_costs()` | Yes | test_vehicle_properties (test_vehicle_max_costs) | +| `get_vehicle_max_distances()` | Yes | test_host_arrays | | `get_vehicle_max_times()` | Yes | test_vehicle_properties (test_vehicle_max_times) | | `get_vehicle_fixed_costs()` | Yes | test_vehicle_properties (test_vehicle_fixed_costs) | | `get_vehicle_order_match()` | Yes | test_vehicle_properties (test_vehicle_to_order_match) | diff --git a/python/cuopt/cuopt/tests/routing/test_deferred.py b/python/cuopt/cuopt/tests/routing/test_deferred.py index 2075cd1de5..3f337d10e9 100644 --- a/python/cuopt/cuopt/tests/routing/test_deferred.py +++ b/python/cuopt/cuopt/tests/routing/test_deferred.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +import numpy as np import pytest from cuopt import routing @@ -39,3 +40,102 @@ def test_unknown_method_is_not_recorded(): with pytest.raises(AttributeError): dm.random_func_call(1, 2, 3) assert dm._calls == [] + + +def test_vehicle_max_distance_accepts_zero_and_rejects_nonfinite(): + dm = routing.DataModel(2, 1) + dm.set_vehicle_max_distances(np.array([0.0], dtype=np.float32)) + + for invalid_value in (np.inf, np.nan): + with pytest.raises(ValueError, match="finite"): + dm.set_vehicle_max_distances( + np.array([invalid_value], dtype=np.float32) + ) + + with pytest.raises(ValueError, match="representable as float32"): + dm.set_vehicle_max_distances(np.array([1e100], dtype=np.float64)) + + +def test_vehicle_max_cost_accepts_zero_and_rejects_nonfinite(): + dm = routing.DataModel(2, 1) + dm.set_vehicle_max_costs(np.array([0.0], dtype=np.float32)) + + for invalid_value in (np.inf, np.nan): + with pytest.raises(ValueError, match="finite"): + dm.set_vehicle_max_costs( + np.array([invalid_value], dtype=np.float32) + ) + + with pytest.raises(ValueError, match="representable as float32"): + dm.set_vehicle_max_costs(np.array([1e100], dtype=np.float64)) + + +@pytest.mark.parametrize( + "vehicle_types", + [ + np.array([1.5], dtype=np.float64), + np.array([np.nan], dtype=np.float64), + ], +) +def test_vehicle_types_must_contain_integers(vehicle_types): + dm = routing.DataModel(2, 1) + with pytest.raises(TypeError, match="must contain integers"): + dm.set_vehicle_types(vehicle_types) + + +def test_distance_tiers_require_float32_open_ended_threshold(): + dm = routing.DataModel(2, 1) + vehicle_ids = np.array([0], dtype=np.int32) + costs = np.array([1.0], dtype=np.float32) + + with pytest.raises(ValueError, match="last distance tier threshold"): + dm.set_vehicle_distance_tiers( + vehicle_ids, + np.array([1e9], dtype=np.float32), + np.zeros(1, dtype=np.float32), + costs, + ) + + dm.set_vehicle_distance_tiers( + vehicle_ids, + np.array([np.finfo(np.float32).max], dtype=np.float32), + np.zeros(1, dtype=np.float32), + costs, + ) + + +def test_distance_tier_thresholds_remain_distinct_after_float32_cast(): + dm = routing.DataModel(2, 1) + with pytest.raises(ValueError, match="strictly increasing"): + dm.set_vehicle_distance_tiers( + np.array([0, 0, 0], dtype=np.int32), + np.array( + [1.00000001, 1.00000002, np.finfo(np.float32).max], + dtype=np.float64, + ), + np.zeros(3, dtype=np.float32), + np.ones(3, dtype=np.float32), + ) + + +@pytest.mark.parametrize("vehicle_type", [-1, 256, 1.5]) +def test_matrix_vehicle_type_must_fit_uint8(vehicle_type): + dm = routing.DataModel(2, 1) + matrix = np.zeros((2, 2), dtype=np.float32) + + with pytest.raises((TypeError, ValueError)): + dm.add_distance_matrix(matrix, vehicle_type) + + +def test_distance_matrix_rejects_oversized_finite_values_but_accepts_infinity(): + dm = routing.DataModel(2, 1) + with pytest.raises( + ValueError, match="finite values must be representable" + ): + dm.add_distance_matrix( + np.array([[0.0, 1e100], [1.0, 0.0]], dtype=np.float64) + ) + + dm.add_distance_matrix( + np.array([[0.0, np.inf], [1.0, 0.0]], dtype=np.float32) + ) diff --git a/python/cuopt/cuopt/tests/routing/test_host_arrays.py b/python/cuopt/cuopt/tests/routing/test_host_arrays.py index 1d51a5d4f1..8265a874f9 100644 --- a/python/cuopt/cuopt/tests/routing/test_host_arrays.py +++ b/python/cuopt/cuopt/tests/routing/test_host_arrays.py @@ -27,6 +27,8 @@ dtype=np.float32, ) TRANSIT = (COST + 1).astype(np.float32) # distinct from cost, still asymmetric +DISTANCE = (COST + 2).astype(np.float32) +np.fill_diagonal(DISTANCE, 0) ORDER_LOCATIONS = np.array([0, 1, 2, 3, 4], dtype=np.int32) ORDER_EARLIEST = np.array([0, 0, 0, 0, 0], dtype=np.int32) @@ -42,6 +44,7 @@ VEH_RETURN = np.array([0, 0, 0], dtype=np.int32) VEH_TYPES = np.array([0, 0, 0], dtype=np.uint8) VEH_MAX_COSTS = np.array([1000, 1000, 1000], dtype=np.float32) +VEH_MAX_DISTANCES = np.array([1000, 1000, 1000], dtype=np.float32) VEH_MAX_TIMES = np.array([1000, 1000, 1000], dtype=np.float32) VEH_FIXED_COSTS = np.array([0, 0, 0], dtype=np.float32) @@ -69,6 +72,7 @@ ("vehicle_locations", lambda d: d.get_vehicle_locations()), ("vehicle_types", lambda d: d.get_vehicle_types()), ("vehicle_max_costs", lambda d: d.get_vehicle_max_costs()), + ("vehicle_max_distances", lambda d: d.get_vehicle_max_distances()), ("vehicle_max_times", lambda d: d.get_vehicle_max_times()), ("vehicle_fixed_costs", lambda d: d.get_vehicle_fixed_costs()), ("objective_function", lambda d: d.get_objective_function()), @@ -79,6 +83,7 @@ def _build_full(backend): matrix, series = CONVERTERS[backend] d = routing.DataModel(COST.shape[0], CAPACITY.shape[0]) d.add_cost_matrix(matrix(COST), 0) + d.add_distance_matrix(matrix(DISTANCE), 0) d.add_transit_time_matrix(matrix(TRANSIT), 0) d.set_order_locations(series(ORDER_LOCATIONS)) d.set_order_time_windows(series(ORDER_EARLIEST), series(ORDER_LATEST)) @@ -89,6 +94,7 @@ def _build_full(backend): d.set_vehicle_locations(series(VEH_START), series(VEH_RETURN)) d.set_vehicle_types(series(VEH_TYPES)) d.set_vehicle_max_costs(series(VEH_MAX_COSTS)) + d.set_vehicle_max_distances(series(VEH_MAX_DISTANCES)) d.set_vehicle_max_times(series(VEH_MAX_TIMES)) d.set_vehicle_fixed_costs(series(VEH_FIXED_COSTS)) d.set_objective_function(series(OBJECTIVES), series(OBJECTIVE_WEIGHTS)) 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 cd69b563ec..f25e1476c4 100644 --- a/python/cuopt/cuopt/tests/routing/test_routing_grpc_serialization.py +++ b/python/cuopt/cuopt/tests/routing/test_routing_grpc_serialization.py @@ -39,6 +39,7 @@ def test_populate_scalar_matrix_and_dimension_fields(): np.fill_diagonal(cost, 0) dm.add_cost_matrix(cost, 0) dm.add_cost_matrix(cost * 2, 1) + dm.add_distance_matrix(cost, 0) dm.add_transit_time_matrix(cost, 0) dm.set_vehicle_time_windows( np.zeros(2, np.int32), np.full(2, 100, np.int32) @@ -52,7 +53,17 @@ def test_populate_scalar_matrix_and_dimension_fields(): ) dm.set_vehicle_types(np.array([0, 1], np.uint8)) dm.set_vehicle_max_costs(np.full(2, 99.0, np.float32)) + dm.set_vehicle_max_distances(np.full(2, 88.0, np.float32)) dm.set_vehicle_max_times(np.full(2, 99.0, np.float32)) + dm.set_vehicle_distance_tiers( + np.array([0, 0, 1], np.int32), + np.array( + [10, np.finfo(np.float32).max, np.finfo(np.float32).max], + np.float32, + ), + np.array([5, 0, 7], np.float32), + np.array([0, 2, 0], np.float32), + ) dm.set_objective_function( np.array([0], np.int32), np.array([1.0], np.float32) ) @@ -62,6 +73,7 @@ def test_populate_scalar_matrix_and_dimension_fields(): assert (s["num_locations"], s["fleet_size"], s["num_orders"]) == (5, 2, 5) assert s["cost_matrices"] == 2 assert s["transit_time_matrices"] == 1 + assert s["distance_matrices"] == 1 assert s["vehicle_tw_latest"] == 2 assert s["order_tw_latest"] == 5 assert s["order_locations"] == 5 @@ -70,6 +82,8 @@ def test_populate_scalar_matrix_and_dimension_fields(): assert s["capacity_dimensions"] == 1 assert s["vehicle_types"] == 2 assert s["vehicle_max_costs"] == 2 + assert s["vehicle_max_distances"] == 2 + assert s["distance_tiers"] == 3 assert s["objectives"] == 1 assert s["min_vehicles"] == 1 diff --git a/python/cuopt/cuopt/tests/routing/test_vehicle_distance_tiers.py b/python/cuopt/cuopt/tests/routing/test_vehicle_distance_tiers.py new file mode 100644 index 0000000000..bcc060c7dd --- /dev/null +++ b/python/cuopt/cuopt/tests/routing/test_vehicle_distance_tiers.py @@ -0,0 +1,486 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import numpy as np +import cudf + +from cuopt import routing + + +def test_vehicle_distance_tiers_uniform(): + """ + Test Distance Tiers with synthetic data: 4 vehicles and 20 clients + All vehicles have the same tier configuration (homogeneous fleet) + + This test validates that the distance tier system correctly applies + tiered pricing to vehicle routes based on total distance traveled. + + Configuration: + - 4 vehicles with same cost structure + - 20 clients with capacity demand + - Tier 1: < 40 km = Fixed cost 50 + - Tier 2: 40-80 km = 0.5 per km + - Tier 3: > 80 km = 1.0 per km + """ + + print("🚛 === TEST DISTANCE TIERS WITH SYNTHETIC DATA ===") + print("Configuration: 4 vehicles (same cost structure), 20 clients\n") + + # Basic configuration + n_orders = 20 + n_vehicles = 4 + n_locations = n_orders + 1 # +1 for depot + + # ============================================================================ + # 1) CREATE SYNTHETIC DATA + # ============================================================================ + + print("📋 Generating synthetic data...") + + # 1.a) Create synthetic distance matrix + # Simulate customers in a grid layout + def distance_func(i, j): + if i == j: + return 0.0 + # Simulated euclidean distance based on indices + dx = float((i % 5) - (j % 5)) + dy = float((i // 5) - (j // 5)) + return np.sqrt(dx * dx + dy * dy) * 10.0 + 5.0 # Scale to km + + # Create cost matrix + cost_matrix = np.zeros((n_locations, n_locations), dtype=np.float32) + + for i in range(n_locations): + for j in range(n_locations): + if i == j: + cost_matrix[i, j] = 0.0 + else: + dist = distance_func(i, j) + cost_matrix[i, j] = dist + + cost_df = cudf.DataFrame(cost_matrix) + + print(f"✅ Synthetic matrices created ({n_locations}x{n_locations})") + print(" Distance range: ~5-70 km") + + # Order demand: 5-25 units + demand = cudf.Series( + [5 + (i % 21) for i in range(n_orders)], dtype=np.int32 + ) + + print(" Clients: 20") + print(" Demands: 5-25 units per client\n") + + # ============================================================================ + # 2) CREATE DATA MODEL + # ============================================================================ + + data_model = routing.DataModel(n_locations, n_vehicles, n_orders) + + # 2.a) Add matrices + data_model.add_cost_matrix(cost_df) + data_model.add_distance_matrix(cost_df) + + # 2.b) Order locations (1, 2, 3, ..., n_orders) + order_locations = cudf.Series(range(1, n_orders + 1), dtype=np.int32) + data_model.set_order_locations(order_locations) + + # 2.c) Capacities + capacities = cudf.Series([150] * n_vehicles, dtype=np.int32) + data_model.add_capacity_dimension("capacity", demand, capacities) + + print("✅ Data model configured\n") + + # ============================================================================ + # 3) CONFIGURE DISTANCE TIERS + # ============================================================================ + + print("🎯 Configuring Distance Tiers for 4 vehicles...\n") + + # HOMOGENEOUS FLEET: ALL VEHICLES WITH THE SAME CONFIGURATION + # This validates that the system works correctly with + # uniform cost distribution among vehicles + # + # Uniform configuration for all: 3 distance tiers + # - Tier 1: < 40 km = Fixed cost 50 + # - Tier 2: 40-80 km = 0.5 per km + # - Tier 3: > 80 km = 1.0 per km + + vehicle_ids = [] + thresholds = [] + fixed_costs = [] + costs_per_unit = [] + + for v in range(n_vehicles): + # Tier 1: < 40 km = fixed cost 50 + vehicle_ids.append(v) + thresholds.append(40.0) + fixed_costs.append(50.0) + costs_per_unit.append(0.0) + + # Tier 2: 40-80 km = 0.5 per km + vehicle_ids.append(v) + thresholds.append(80.0) + fixed_costs.append(0.0) + costs_per_unit.append(0.5) + + # Tier 3: > 80 km = 1.0 per km + vehicle_ids.append(v) + thresholds.append(np.finfo(np.float32).max) + fixed_costs.append(0.0) + costs_per_unit.append(1.0) + + # Convert to cuDF Series + vehicle_ids_series = cudf.Series(vehicle_ids, dtype=np.int32) + thresholds_series = cudf.Series(thresholds, dtype=np.float32) + fixed_costs_series = cudf.Series(fixed_costs, dtype=np.float32) + costs_per_unit_series = cudf.Series(costs_per_unit, dtype=np.float32) + + print("📊 Distance Tiers configured (ALL EQUAL):") + print(" 🔵 All vehicles (0-3) have the same structure:") + print(" • Tier 1: < 40 km = Fixed cost 50") + print(" • Tier 2: 40-80 km = 0.5 per km") + print(" • Tier 3: > 80 km = 1.0 per km\n") + print(" ℹ️ This uniform configuration allows validation") + print(" that the system correctly applies tiers") + print(" without introducing variability between vehicles.\n") + + # Set distance tiers in the data model + data_model.set_vehicle_distance_tiers( + vehicle_ids_series, + thresholds_series, + fixed_costs_series, + costs_per_unit_series, + ) + + print("✅ Distance tiers configured correctly in the data model\n") + + # ============================================================================ + # 4) CONFIGURE OBJECTIVES AND SOLVE + # ============================================================================ + + solver_settings = routing.SolverSettings() + solver_settings.set_time_limit(30.0) + + print("🚛 4 vehicles configured") + print(" Capacity: 150 units per vehicle") + print("🚀 Running solver...\n") + + solution = routing.Solve(data_model, solver_settings) + + # ============================================================================ + # 5) ANALYZE RESULTS + # ============================================================================ + + print("\n" + "=" * 60) + print("📊 SOLUTION WITH DISTANCE TIERS (SYNTHETIC DATA)") + print("=" * 60 + "\n") + + status = solution.get_status() + print(f"Status: {status}") + print(f"Total objective: {solution.get_total_objective()}\n") + + objectives = solution.get_objective_values() + print(f"Objective breakdown ({len(objectives)} objectives):") + for obj, value in objectives.items(): + print(f" - {obj}: {value}") + print() + + # Get solution data + route_df = solution.get_route() + locations = route_df["location"].to_arrow().to_pylist() + node_types = route_df["type"].to_arrow().to_pylist() + truck_ids = route_df["truck_id"].to_arrow().to_pylist() + + # Calculate distances per vehicle and apply tiers + print("=" * 60) + print("🚛 VEHICLE ROUTES AND DISTANCE TIER APPLICATION") + print("=" * 60) + + total_manual_cost = 0.0 + total_raw_distance = 0.0 + total_orders_served = 0 + + # Group visits by vehicle + visits_by_vehicle = {v: [] for v in range(n_vehicles)} + for i, truck_id in enumerate(truck_ids): + if node_types[i] == "Delivery": + visits_by_vehicle[truck_id].append(locations[i]) + + for v in range(n_vehicles): + visits = visits_by_vehicle[v] + + if len(visits) == 0: + print(f"\n🚛 Vehicle {v}: ⚪ No orders assigned") + continue + + # Build route: depot -> visits -> depot + route_locs = [0] + visits + [0] + + # Calculate total distance + total_distance = 0.0 + for i in range(len(route_locs) - 1): + from_loc = route_locs[i] + to_loc = route_locs[i + 1] + dist = cost_matrix[from_loc, to_loc] + total_distance += dist + + total_raw_distance += total_distance + total_orders_served += len(visits) + + # Determine the cumulative tier charge. The total solver cost includes + # the primary cost matrix distance plus the tiered distance charge. + applied_cost = total_distance + applied_tier = -1 + + tier_start = v * 3 + tier_configs = [ + ( + thresholds[tier_start], + fixed_costs[tier_start], + costs_per_unit[tier_start], + ), + ( + thresholds[tier_start + 1], + fixed_costs[tier_start + 1], + costs_per_unit[tier_start + 1], + ), + ( + thresholds[tier_start + 2], + fixed_costs[tier_start + 2], + costs_per_unit[tier_start + 2], + ), + ] + + prev_threshold = 0.0 + for tier_idx, (threshold, fixed_cost, cost_per_unit) in enumerate( + tier_configs + ): + if total_distance <= prev_threshold: + break + in_band = min(total_distance, threshold) - prev_threshold + if in_band > 0.0: + applied_tier = tier_idx + if fixed_cost > 0: + applied_cost += fixed_cost + applied_cost += in_band * cost_per_unit + prev_threshold = threshold + if total_distance <= threshold: + break + + total_manual_cost += applied_cost + + vehicle_type = "STANDARD" + icon = "🔵" + + print(f"\n{icon} Vehicle {v} ({vehicle_type}):") + print(f" Route: {' → '.join(map(str, route_locs))}") + print(f" 📏 Raw distance: {total_distance:.2f} km") + print(f" 🎯 Tier applied: {applied_tier}") + print(f" 💰 Cost with tier: {applied_cost:.2f}") + print(f" 📦 Orders served: {len(visits)}") + + # Compare with solver cost + print("\n" + "=" * 60) + print("📊 COST COMPARISON") + print("=" * 60 + "\n") + + print("📊 Summary:") + print(f" Total orders served: {total_orders_served} / {n_orders}") + print(f" Total distance (without tiers): {total_raw_distance:.2f} km") + print( + f" Average distance per vehicle: {total_raw_distance / n_vehicles:.2f} km\n" + ) + + print("💰 Cost analysis:") + print(f" Manually calculated cost (with tiers): {total_manual_cost:.2f}") + + if routing.Objective.COST in objectives: + solver_cost = objectives[routing.Objective.COST] + print(f" Cost returned by solver: {solver_cost:.2f}\n") + + diff = abs(total_manual_cost - solver_cost) + rel_diff = (diff / solver_cost * 100.0) if solver_cost > 0 else 0.0 + + print("📉 Differences:") + print(f" Absolute: {diff:.2f}") + print(f" Relative: {rel_diff:.2f}%\n") + + if rel_diff < 0.1: + print("✅ SUCCESS: Costs match perfectly!") + print(" The solver is correctly applying distance tiers.") + elif rel_diff < 5.0: + print("⚠️ WARNING: Small difference detected") + print( + " May be due to numerical approximations or additional components." + ) + else: + print("❌ ERROR: Significant difference detected") + print(" Possible causes:") + print(" - The solver is not correctly applying distance tiers") + print( + " - There are other cost components not considered in manual calculation" + ) + print(" - Differences in rounding or distance calculation") + else: + print("⚠️ COST objective not found in solution") + + print("\n" + "=" * 60) + print("✅ TEST COMPLETED WITH SYNTHETIC DATA") + print("=" * 60) + print(" ✓ 4 vehicles with SAME cost configuration") + print(" ✓ 20 clients with capacity demand") + print(" ✓ Uniform distance tiers configured and applied") + print(" ✓ Cost validation performed") + print(" ℹ️ Uniform configuration ideal for initial validation") + print("=" * 60 + "\n") + + # Assertions for test validation + assert status == 0, f"Solver did not return optimal status: {status}" + assert total_orders_served == n_orders + assert routing.Objective.COST in objectives + np.testing.assert_allclose( + objectives[routing.Objective.COST], total_manual_cost, rtol=1e-4 + ) + + # Check that distance tiers are having an effect + # (manual cost should be different from raw distance in most cases) + print( + "✅ Test passed: Distance tiers are configured and solver completed successfully" + ) + + +def test_vehicle_distance_tiers_heterogeneous(): + """ + Test Distance Tiers with heterogeneous fleet: different configurations per vehicle + + This test validates that the system correctly handles different tier + configurations for different vehicles. + + Configuration: + - Vehicle 0, 2, 3: Standard configuration (tier at 40 km) + - Vehicle 1: Special configuration (tier at 80 km) + """ + + print("🚛 === TEST DISTANCE TIERS - HETEROGENEOUS FLEET ===") + print( + "Configuration: 4 vehicles (different cost structures), 20 clients\n" + ) + + # Basic configuration + n_orders = 20 + n_vehicles = 4 + n_locations = n_orders + 1 + + # Simplified setup (similar to above but shorter for brevity) + def distance_func(i, j): + if i == j: + return 0.0 + dx = float((i % 5) - (j % 5)) + dy = float((i // 5) - (j // 5)) + return np.sqrt(dx * dx + dy * dy) * 10.0 + 5.0 + + cost_matrix = np.zeros((n_locations, n_locations), dtype=np.float32) + for i in range(n_locations): + for j in range(n_locations): + cost_matrix[i, j] = 0.0 if i == j else distance_func(i, j) + + cost_df = cudf.DataFrame(cost_matrix) + + # Create data model + data_model = routing.DataModel(n_locations, n_vehicles, n_orders) + data_model.add_cost_matrix(cost_df) + data_model.add_distance_matrix(cost_df) + + # Set order locations + order_locations = cudf.Series(range(1, n_orders + 1), dtype=np.int32) + data_model.set_order_locations(order_locations) + + # Simple capacity constraint + demand = cudf.Series([10] * n_orders, dtype=np.int32) + capacities = cudf.Series([60] * n_vehicles, dtype=np.int32) + data_model.add_capacity_dimension("capacity", demand, capacities) + + # Configure HETEROGENEOUS tiers + print("🎯 Configuring Distance Tiers - HETEROGENEOUS FLEET\n") + + vehicle_ids = [] + thresholds = [] + fixed_costs = [] + costs_per_unit = [] + + for v in range(n_vehicles): + if v == 1: + # Vehicle 1: Special configuration with higher threshold + print(f" 🟢 Vehicle {v}: Special configuration (tier at 80 km)") + # Tier 1: < 80 km = fixed cost 50 + vehicle_ids.extend([v, v, v]) + thresholds.extend([80.0, 120.0, np.finfo(np.float32).max]) + fixed_costs.extend([50.0, 0.0, 0.0]) + costs_per_unit.extend([0.0, 0.5, 1.0]) + else: + # Vehicles 0, 2, 3: Standard configuration + print(f" 🔵 Vehicle {v}: Standard configuration (tier at 40 km)") + # Tier 1: < 40 km = fixed cost 50 + vehicle_ids.extend([v, v, v]) + thresholds.extend([40.0, 80.0, np.finfo(np.float32).max]) + fixed_costs.extend([50.0, 0.0, 0.0]) + costs_per_unit.extend([0.0, 0.5, 1.0]) + + print() + + # Convert to cuDF Series + vehicle_ids_series = cudf.Series(vehicle_ids, dtype=np.int32) + thresholds_series = cudf.Series(thresholds, dtype=np.float32) + fixed_costs_series = cudf.Series(fixed_costs, dtype=np.float32) + costs_per_unit_series = cudf.Series(costs_per_unit, dtype=np.float32) + + # Set distance tiers + data_model.set_vehicle_distance_tiers( + vehicle_ids_series, + thresholds_series, + fixed_costs_series, + costs_per_unit_series, + ) + + print("✅ Heterogeneous distance tiers configured\n") + + # Solve + solver_settings = routing.SolverSettings() + solver_settings.set_time_limit(30.0) + + print("🚀 Running solver...\n") + solution = routing.Solve(data_model, solver_settings) + + status = solution.get_status() + print(f"Status: {status}") + print(f"Total objective: {solution.get_total_objective()}\n") + + # Basic validation + assert status == 0, f"Solver did not return optimal status: {status}" + + print("✅ Test passed: Heterogeneous distance tiers work correctly\n") + print("=" * 60) + print("✅ TEST COMPLETED - HETEROGENEOUS FLEET") + print("=" * 60) + print(" ✓ Different tier configurations per vehicle") + print(" ✓ Vehicle 1 has special configuration (80 km threshold)") + print( + " ✓ Vehicles 0, 2, 3 have standard configuration (40 km threshold)" + ) + print(" ✓ Solver completed successfully") + print("=" * 60 + "\n") + + +if __name__ == "__main__": + print("\n" + "=" * 80) + print("RUNNING DISTANCE TIERS TESTS") + print("=" * 80 + "\n") + + test_vehicle_distance_tiers_uniform() + print("\n" + "-" * 80 + "\n") + test_vehicle_distance_tiers_heterogeneous() + + print("\n" + "=" * 80) + print("ALL DISTANCE TIERS TESTS PASSED ✅") + print("=" * 80 + "\n") diff --git a/python/cuopt_server/cuopt_server/tests/test_routing_conversion.py b/python/cuopt_server/cuopt_server/tests/test_routing_conversion.py index 8a1b60bb77..224386e2f6 100644 --- a/python/cuopt_server/cuopt_server/tests/test_routing_conversion.py +++ b/python/cuopt_server/cuopt_server/tests/test_routing_conversion.py @@ -2,17 +2,23 @@ # SPDX-License-Identifier: Apache-2.0 import numpy as np +import msgpack import pytest from cuopt.grpc.routing.grpc_client import problem_summary +from cuopt import routing from cuopt_server.utils.routing import conversion from cuopt_server.utils.utils import build_routing_datamodel_from_json from cuopt_server.utils.routing.data_definition import ( CostMatrices, + DistanceMatrices, FleetData, SolverSettingsConfig, TaskData, + vrp_example_data, + vrp_msgpack_example_data, + vrp_response, ) @@ -48,6 +54,27 @@ def test_default_solver_time_limit(): assert optimization_data.solver_config["time_limit"] == 10 + 1 / 6 +def test_published_distance_tiers_example_is_feasible(): + data_model, solver_settings = build_routing_datamodel_from_json( + vrp_example_data + ) + assert not data_model._recorded("add_initial_solutions") + solution = routing.Solve(data_model, solver_settings) + assert solution.get_status() == 0 + expected = vrp_response["value"]["response"]["solver_response"] + np.testing.assert_allclose( + solution.get_objective_values()[routing.Objective.COST], + expected["objective_values"]["cost"], + ) + + +def test_msgpack_example_matches_json_request(): + payload = vrp_msgpack_example_data.decode("unicode_escape").encode( + "latin1" + ) + assert msgpack.unpackb(payload, raw=False) == vrp_example_data + + def _dense_request(): return { "cost_matrix_data": CostMatrices(data={0: [[0, 1], [1, 0]]}), @@ -97,6 +124,54 @@ def fail(*args, **kwargs): assert summary["cost_matrices"] == 1 +def test_host_conversion_carries_distance_constraints_to_data_model(): + optimization_data = conversion.populate_optimization_data( + cost_matrix_data=CostMatrices(data={0: [[0, 1], [1, 0]]}), + distance_matrix_data=DistanceMatrices(data={0: [[0, 5], [5, 0]]}), + fleet_data=FleetData( + vehicle_locations=[[0, 0]], + vehicle_distance_tiers=[ + [ + { + "threshold": None, + "fixed_cost": 7, + "cost_per_unit": 3, + } + ] + ], + vehicle_max_distances=[12], + ), + task_data=TaskData(task_locations=[1]), + solver_config=SolverSettingsConfig(time_limit=1), + ) + + prepared, cost_matrix, travel_time_matrix, _ = ( + conversion.prep_optimization_data(optimization_data) + ) + _, data_model = conversion.create_data_model( + prepared, + cost_matrix=cost_matrix, + travel_time_matrix=travel_time_matrix, + ) + + stored_distance, vehicle_type = data_model._recorded( + "add_distance_matrix" + )[0] + np.testing.assert_array_equal(stored_distance, [[0, 5], [5, 0]]) + assert vehicle_type == 0 + + (max_distances,) = data_model._recorded("set_vehicle_max_distances")[0] + np.testing.assert_array_equal(max_distances, [12]) + + vehicle_ids, thresholds, fixed_costs, costs_per_unit = ( + data_model._recorded("set_vehicle_distance_tiers")[0] + ) + np.testing.assert_array_equal(vehicle_ids, [0]) + np.testing.assert_array_equal(thresholds, [np.finfo(np.float32).max]) + np.testing.assert_array_equal(fixed_costs, [7]) + np.testing.assert_array_equal(costs_per_unit, [3]) + + def test_build_routing_datamodel_from_json_accepts_dict(): data_model, solver_settings = build_routing_datamodel_from_json( { @@ -122,6 +197,7 @@ def test_host_optimization_model_updates_are_unimplemented(): model = HostOptimizationDataModel() for name in ( "update_cost_matrix", + "update_distance_matrix", "update_travel_time_matrix", "update_fleet_data", "update_task_data", diff --git a/python/cuopt_server/cuopt_server/tests/test_set_distance_matrix.py b/python/cuopt_server/cuopt_server/tests/test_set_distance_matrix.py new file mode 100644 index 0000000000..4a2f69a9dc --- /dev/null +++ b/python/cuopt_server/cuopt_server/tests/test_set_distance_matrix.py @@ -0,0 +1,413 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import copy + +import numpy as np + +from cuopt_server.tests.utils.utils import cuoptproc # noqa +from cuopt_server.tests.utils.utils import RequestClient +from cuopt_server.utils.routing.conversion import ( + _distance_tier_threshold_for_solver, +) +from cuopt_server.utils.routing.data_definition import WaypointGraph +from cuopt_server.utils.routing.validation_distance_matrix import ( + validate_distance_matrix, +) +from cuopt_server.utils.routing.validation_fleet_data import ( + _validate_distance_tiers, +) +from cuopt_server.utils.routing.optimization_data_model import ( + OptimizationDataModel, +) + +client = RequestClient() + +# SET DISTANCE MATRIX TESTING + +valid_data = { + "cost_matrix_data": {"data": {0: [[0, 1, 1], [1, 0, 1], [1, 1, 0]]}}, + "distance_matrix_data": { + "data": {0: [[0, 10, 20], [10, 0, 15], [20, 15, 0]]} + }, + "fleet_data": { + "vehicle_locations": [[0, 0]], + "vehicle_types": [0], + "vehicle_distance_tiers": [ + [{"threshold": None, "fixed_cost": 50, "cost_per_unit": 0}] + ], + "vehicle_max_distances": [100], + }, + "task_data": { + "task_locations": [1, 2], + }, + "solver_config": {"time_limit": 0.1}, +} + + +def validate_only(data): + return client.post( + "/cuopt/request", + params={"validation_only": True}, + json=data, + ) + + +def test_valid_set_distance_matrix(cuoptproc): # noqa + response_set = validate_only(valid_data) + + assert response_set.status_code == 200 + + +def test_null_distance_tier_threshold_converts_to_open_ended_value(): + assert ( + _distance_tier_threshold_for_solver(None) == np.finfo(np.float32).max + ) + assert _distance_tier_threshold_for_solver(100.0) == 100.0 + + +def test_invalid_empty_set_distance_matrix(cuoptproc): # noqa + data = copy.deepcopy(valid_data) + data["distance_matrix_data"] = {"data": {}} + + response_set = validate_only(data) + + assert response_set.status_code == 400 + assert response_set.json() == { + "error": "Distance matrix cannot be null or empty", + "error_result": False, + } + + +def test_invalid_row_length_set_distance_matrix(cuoptproc): # noqa + data = copy.deepcopy(valid_data) + data["distance_matrix_data"] = { + "data": {0: [[0, 10, 20], [10, 0, 15], [20, 15]]} + } + + response_set = validate_only(data) + + assert response_set.status_code == 400 + assert response_set.json() == { + "error": "All rows in the distance matrix must be of the same length", + "error_result": False, + } + + +def test_invalid_shape_set_distance_matrix(cuoptproc): # noqa + data = copy.deepcopy(valid_data) + data["distance_matrix_data"] = {"data": {0: [[0, 10, 20], [10, 0, 15]]}} + + response_set = validate_only(data) + + assert response_set.status_code == 400 + assert response_set.json() == { + "error": "Distance matrix must be a square matrix", + "error_result": False, + } + + +def test_invalid_negative_values_set_distance_matrix(cuoptproc): # noqa + data = copy.deepcopy(valid_data) + data["distance_matrix_data"] = { + "data": {0: [[0, 10, 20], [10, 0, 15], [20, -15, 0]]} + } + + response_set = validate_only(data) + + assert response_set.status_code == 400 + assert response_set.json() == { + "error": "All values in distance matrix must be >= 0", + "error_result": False, + } + + +def test_invalid_infinite_values_validate_distance_matrix(): + is_valid, msg = validate_distance_matrix( + {0: [[0, 10, 20], [10, 0, float("inf")], [20, 15, 0]]}, + vehicle_distance_tiers=[ + [{"threshold": None, "fixed_cost": 50, "cost_per_unit": 0}] + ], + ) + + assert is_valid is False + assert msg == "All values in distance matrix must be finite" + + +def test_invalid_matrices_shape_set_distance_matrix(cuoptproc): # noqa + data = copy.deepcopy(valid_data) + data["distance_matrix_data"] = { + "data": { + 0: [[0, 10, 20], [10, 0, 15], [20, 15, 0]], + 1: [[0, 10], [10, 0]], + } + } + + response_set = validate_only(data) + + assert response_set.status_code == 400 + assert response_set.json() == { + "error": "Distance matrices for all vehicle types must be the same shape", + "error_result": False, + } + + +def test_distance_matrix_shape_must_match_cost_matrix(cuoptproc): # noqa + data = copy.deepcopy(valid_data) + data["distance_matrix_data"] = {"data": {0: [[0, 10], [10, 0]]}} + + response_set = validate_only(data) + + assert response_set.status_code == 400 + assert response_set.json() == { + "error": "Distance matrix shape must match the cost matrix shape", + "error_result": False, + } + + +def test_distance_matrix_values_must_fit_float32(): + is_valid, msg = validate_distance_matrix( + {0: [[0, 1e100], [1, 0]]}, + vehicle_distance_tiers=[ + [{"threshold": None, "fixed_cost": 50, "cost_per_unit": 0}] + ], + ) + + assert is_valid is False + assert ( + msg == "All values in distance matrix must be representable as float32" + ) + + +def test_distance_matrix_vehicle_type_must_fit_uint8(): + is_valid, msg = validate_distance_matrix( + {256: [[0, 1], [1, 0]]}, + require_distance_tiers=False, + ) + + assert is_valid is False + assert msg == "Matrix vehicle types must be integers within [0, 255]" + + +def test_distance_matrix_vehicle_type_must_have_cost_matrix(): + is_valid, msg = validate_distance_matrix( + {1: [[0, 1], [1, 0]]}, + require_distance_tiers=False, + comparison_matrix={0: np.zeros((2, 2), dtype=np.float32)}, + ) + + assert is_valid is False + assert msg == "Distance matrix shape must match the cost matrix shape" + + +def test_distance_tiers_require_strictly_increasing_thresholds(): + is_valid, msg = _validate_distance_tiers( + [ + [ + {"threshold": 10, "fixed_cost": 1, "cost_per_unit": 0}, + {"threshold": 10, "fixed_cost": 0, "cost_per_unit": 1}, + {"threshold": None, "fixed_cost": 0, "cost_per_unit": 2}, + ] + ] + ) + + assert is_valid is False + assert msg == "Distance tier thresholds must be strictly increasing" + + +def test_distance_tier_thresholds_must_remain_distinct_as_float32(): + is_valid, msg = _validate_distance_tiers( + [ + [ + {"threshold": 1.00000001, "fixed_cost": 0, "cost_per_unit": 1}, + {"threshold": 1.00000002, "fixed_cost": 0, "cost_per_unit": 1}, + {"threshold": None, "fixed_cost": 0, "cost_per_unit": 1}, + ] + ] + ) + + assert is_valid is False + assert msg == "Distance tier thresholds must be strictly increasing" + + +def test_explicit_float32_max_threshold_cannot_precede_open_ended_tier(): + is_valid, msg = _validate_distance_tiers( + [ + [ + { + "threshold": np.finfo(np.float32).max, + "fixed_cost": 0, + "cost_per_unit": 1, + }, + {"threshold": None, "fixed_cost": 0, "cost_per_unit": 1}, + ] + ] + ) + + assert is_valid is False + assert msg == "Distance tier thresholds must be strictly increasing" + + +def test_distance_tiers_require_one_final_open_ended_tier(): + is_valid, msg = _validate_distance_tiers( + [ + [ + {"threshold": None, "fixed_cost": 1, "cost_per_unit": 0}, + {"threshold": None, "fixed_cost": 0, "cost_per_unit": 1}, + ] + ] + ) + + assert is_valid is False + assert msg == "The open-ended distance tier must be the final tier" + + +def test_distance_matrix_supports_max_distance_without_tiers(cuoptproc): # noqa + data = copy.deepcopy(valid_data) + del data["fleet_data"]["vehicle_distance_tiers"] + + response_set = validate_only(data) + + assert response_set.status_code == 200 + + +def test_vehicle_max_distance_must_fit_float32(cuoptproc): # noqa + data = copy.deepcopy(valid_data) + data["fleet_data"]["vehicle_max_distances"] = [1e100] + + response_set = validate_only(data) + + assert response_set.status_code == 400 + assert response_set.json()["error"] == ( + "Maximum distance any vehicle can travel must be representable as float32" + ) + + +def test_vehicle_type_must_fit_uint8(cuoptproc): # noqa + data = copy.deepcopy(valid_data) + data["fleet_data"]["vehicle_types"] = [256] + + response_set = validate_only(data) + + assert response_set.status_code == 400 + assert ( + response_set.json()["error"] == "Vehicle types must be within [0, 255]" + ) + + +def test_update_distance_matrix_supports_max_distance_without_tiers(): + data_model = OptimizationDataModel() + matrix = {0: [[0, 1], [1, 0]]} + + assert data_model.set_cost_matrix(matrix)[0] + assert data_model.set_distance_matrix(matrix, None)[0] + assert data_model.update_distance_matrix({0: [[0, 2], [2, 0]]})[0] + + +def test_distance_matrix_allows_cost_waypoint_graph(): + data_model = OptimizationDataModel() + waypoint_graph = WaypointGraph( + edges=[1, 0], offsets=[0, 1], weights=[1.0, 1.0] + ) + + assert data_model.set_cost_waypoint_graph({0: waypoint_graph})[0] + assert data_model.set_distance_matrix({0: [[0, 1], [1, 0]]}, None)[0] + + +def test_validation_only_checks_distance_shape_after_waypoint_preparation( + cuoptproc, # noqa +): + data = copy.deepcopy(valid_data) + del data["cost_matrix_data"] + data["cost_waypoint_graph_data"] = { + "waypoint_graph": { + 0: { + "edges": [1, 2, 0, 2, 0, 1], + "offsets": [0, 2, 4, 6], + "weights": [1, 1, 1, 1, 1, 1], + } + } + } + data["distance_matrix_data"] = {"data": {0: [[0, 1], [1, 0]]}} + + response_set = validate_only(data) + + assert response_set.status_code == 400 + assert response_set.json()["error"] == ( + "Distance matrix shape must match the cost matrix shape" + ) + + +def test_default_vehicle_type_requires_zero_matrix_key(): + data_model = OptimizationDataModel() + assert data_model.set_cost_matrix({1: [[0, 1], [1, 0]]})[0] + + is_valid = data_model.set_fleet_data( + None, + [[0, 0]], + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ) + + assert is_valid == ( + False, + "Set vehicle types when using multiple matrices", + ) + + +def test_invalid_distance_tiers_require_distance_matrix(cuoptproc): # noqa + data = copy.deepcopy(valid_data) + del data["distance_matrix_data"] + del data["fleet_data"]["vehicle_max_distances"] + + response_set = validate_only(data) + + assert response_set.status_code == 400 + assert response_set.json() == { + "error": ( + "distance_matrix_data must be set when vehicle_distance_tiers is " + "provided" + ), + "error_result": False, + } + + +def test_invalid_vehicle_max_distances_require_distance_matrix(cuoptproc): # noqa + data = copy.deepcopy(valid_data) + del data["distance_matrix_data"] + del data["fleet_data"]["vehicle_distance_tiers"] + + response_set = validate_only(data) + + assert response_set.status_code == 400 + assert response_set.json() == { + "error": ( + "distance_matrix_data must be set when vehicle_max_distances is " + "provided" + ), + "error_result": False, + } + + +def test_invalid_extra_arg_set_distance_matrix(cuoptproc): # noqa + data = copy.deepcopy(valid_data) + data["distance_matrix_data"] = { + "data": {0: [[0, 10, 20], [10, 0, 15], [20, 15, 0]]}, + "extra_arg": 1, + } + + response_set = validate_only(data) + + assert response_set.status_code == 422 diff --git a/python/cuopt_server/cuopt_server/tests/test_set_fleet_data.py b/python/cuopt_server/cuopt_server/tests/test_set_fleet_data.py index 35cba7f51c..f7e570db6e 100644 --- a/python/cuopt_server/cuopt_server/tests/test_set_fleet_data.py +++ b/python/cuopt_server/cuopt_server/tests/test_set_fleet_data.py @@ -251,7 +251,7 @@ def test_valid_unique_vehicle_ids_set_fleet_data(cuoptproc): # noqa def test_valid_minimal_set_fleet_data(cuoptproc): # noqa test_data = copy.deepcopy(valid_data) test_data["fleet_data"] = { - "vehicle_locations": [[1, 1], [2, 2], [3, 3], [4, 4]] + "vehicle_locations": [[1, 1], [2, 2], [3, 3], [4, 4]], } response_set = client.post("/cuopt/request", json=test_data) @@ -274,7 +274,7 @@ def test_invalid_values_set_fleet_data(cuoptproc): # noqa "skip_first_trips": [False, False, True, True], "drop_return_trips": [True, False, True, False], "min_vehicles": 0, - "vehicle_max_costs": [0, 0, 0, 0], + "vehicle_max_costs": [-1, 0, 0, 0], "vehicle_max_times": [0, 0, 0, 0], "vehicle_fixed_costs": [-1, 50, 50, 50], } @@ -404,7 +404,7 @@ def test_invalid_values_set_fleet_data(cuoptproc): # noqa "error_result": False, } - # vehicle_max_costs must be greater than 0 + # vehicle_max_costs must be greater than or equal to 0 test_data = copy.deepcopy(valid_data) test_data["fleet_data"]["vehicle_max_costs"] = invalid_fleet_data_values[ "vehicle_max_costs" @@ -413,7 +413,7 @@ def test_invalid_values_set_fleet_data(cuoptproc): # noqa response_set = client.post("/cuopt/request", json=test_data) assert response_set.status_code == 400 assert response_set.json() == { - "error": "Maximum distance any vehicle can travel must be greater than 0", # noqa + "error": "Maximum vehicle route cost must be greater than or equal to 0", "error_result": False, } diff --git a/python/cuopt_server/cuopt_server/tests/utils/utils.py b/python/cuopt_server/cuopt_server/tests/utils/utils.py index eb65a2ba9a..bf043a9452 100644 --- a/python/cuopt_server/cuopt_server/tests/utils/utils.py +++ b/python/cuopt_server/cuopt_server/tests/utils/utils.py @@ -55,6 +55,7 @@ def get_routes( cost_waypoint_graph: Optional[Dict] = None, travel_time_waypoint_graph: Optional[Dict] = None, cost_matrix: Optional[Dict[int, List[List[float]]]] = None, + distance_matrix: Optional[Dict[int, List[List[float]]]] = None, travel_time_matrix: Optional[Dict[int, List[List[float]]]] = None, vehicle_locations: Optional[List[List[int]]] = None, vehicle_ids: Optional[List[str]] = None, @@ -71,8 +72,10 @@ def get_routes( drop_return_trips: Optional[List[bool]] = None, min_vehicles: Optional[int] = None, vehicle_max_costs: Optional[List[float]] = None, + vehicle_max_distances: Optional[List[float]] = None, vehicle_max_times: Optional[List[float]] = None, vehicle_fixed_costs: Optional[List[float]] = None, + vehicle_distance_tiers: Optional[List[List[dict]]] = None, task_locations: Optional[List[int]] = None, demand: Optional[List[List[int]]] = None, pickup_and_delivery_pairs: Optional[List[List[int]]] = None, @@ -103,6 +106,8 @@ def get_routes( options["cost_matrix_data"] = generate_json_data(data=cost_matrix) + options["distance_matrix_data"] = generate_json_data(data=distance_matrix) + options["travel_time_matrix_data"] = generate_json_data( data=travel_time_matrix ) @@ -124,8 +129,10 @@ def get_routes( drop_return_trips=drop_return_trips, min_vehicles=min_vehicles, vehicle_max_costs=vehicle_max_costs, + vehicle_max_distances=vehicle_max_distances, vehicle_max_times=vehicle_max_times, vehicle_fixed_costs=vehicle_fixed_costs, + vehicle_distance_tiers=vehicle_distance_tiers, ) # task data @@ -175,6 +182,7 @@ def cuopt_service_sync( cost_waypoint_graph: Optional[Dict] = None, travel_time_waypoint_graph: Optional[Dict] = None, cost_matrix: Optional[Dict[int, List[List[float]]]] = None, + distance_matrix: Optional[Dict[int, List[List[float]]]] = None, travel_time_matrix: Optional[Dict[int, List[List[float]]]] = None, vehicle_locations: Optional[List[List[int]]] = None, vehicle_ids: Optional[List[str]] = None, @@ -191,8 +199,10 @@ def cuopt_service_sync( drop_return_trips: Optional[List[bool]] = None, min_vehicles: Optional[int] = None, vehicle_max_costs: Optional[List[float]] = None, + vehicle_max_distances: Optional[List[float]] = None, vehicle_max_times: Optional[List[float]] = None, vehicle_fixed_costs: Optional[List[float]] = None, + vehicle_distance_tiers: Optional[List[List[dict]]] = None, task_locations: Optional[List[int]] = None, demand: Optional[List[List[int]]] = None, pickup_and_delivery_pairs: Optional[List[List[int]]] = None, @@ -217,6 +227,8 @@ def cuopt_service_sync( options["cost_matrix_data"] = generate_json_data(data=cost_matrix) + options["distance_matrix_data"] = generate_json_data(data=distance_matrix) + options["travel_time_matrix_data"] = generate_json_data( data=travel_time_matrix ) @@ -238,8 +250,10 @@ def cuopt_service_sync( drop_return_trips=drop_return_trips, min_vehicles=min_vehicles, vehicle_max_costs=vehicle_max_costs, + vehicle_max_distances=vehicle_max_distances, vehicle_max_times=vehicle_max_times, vehicle_fixed_costs=vehicle_fixed_costs, + vehicle_distance_tiers=vehicle_distance_tiers, ) # task data diff --git a/python/cuopt_server/cuopt_server/utils/deprecated/routing/conversion.py b/python/cuopt_server/cuopt_server/utils/deprecated/routing/conversion.py index 29a3a75b3e..40b30d3c96 100644 --- a/python/cuopt_server/cuopt_server/utils/deprecated/routing/conversion.py +++ b/python/cuopt_server/cuopt_server/utils/deprecated/routing/conversion.py @@ -14,6 +14,7 @@ from cuopt_server.utils.data_definition import ( CostMatrices, + DistanceMatrices, FleetData, InitialSolution, SolverSettingsConfig, @@ -52,6 +53,7 @@ def populate_optimization_data( initial_solution: Optional[List[InitialSolution]] = None, solver_config: Optional[SolverSettingsConfig] = None, warnings=[], + distance_matrix_data: Optional[DistanceMatrices] = None, ): optimization_data = OptimizationDataModel() @@ -90,6 +92,21 @@ def populate_optimization_data( elif cost_matrix_data and cost_matrix_data.data: check_valid(optimization_data.set_cost_matrix(cost_matrix_data.data)) + if ( + distance_matrix_data is not None + and distance_matrix_data.data is not None + ): + distance_tiers = ( + fleet_data.vehicle_distance_tiers + if fleet_data is not None + else None + ) + check_valid( + optimization_data.set_distance_matrix( + distance_matrix_data.data, distance_tiers + ) + ) + if ( travel_time_waypoint_graph_data and travel_time_waypoint_graph_data.waypoint_graph @@ -126,6 +143,8 @@ def populate_optimization_data( fleet_data.vehicle_max_times, fleet_data.vehicle_fixed_costs, vehicle_distance_breaks=fleet_data.vehicle_distance_breaks, + vehicle_distance_tiers=fleet_data.vehicle_distance_tiers, + vehicle_max_distances=fleet_data.vehicle_max_distances, ) ) @@ -177,6 +196,7 @@ def create_data_model( optimization_data: OptimizationDataModel, cost_matrix: Optional[dict] = None, travel_time_matrix: Optional[dict] = None, + distance_matrix: Optional[dict] = None, ): warnings = [] # Make sure that we are using pool memory allocator @@ -204,6 +224,9 @@ def create_data_model( for key, value in cost_matrix.items(): data_model.add_cost_matrix(value, key) + if distance_matrix is not None: + for key, value in distance_matrix.items(): + data_model.add_distance_matrix(value, key) if travel_time_matrix is not None: for key, value in travel_time_matrix.items(): data_model.add_transit_time_matrix(value, key) @@ -326,6 +349,34 @@ def create_data_model( optimization_data.fleet_data["vehicle_max_costs"] ) + if optimization_data.fleet_data["vehicle_max_distances"] is not None: + data_model.set_vehicle_max_distances( + optimization_data.fleet_data["vehicle_max_distances"] + ) + + if optimization_data.fleet_data["vehicle_distance_tiers"] is not None: + distance_tiers = optimization_data.fleet_data["vehicle_distance_tiers"] + vehicle_ids = [] + thresholds = [] + fixed_costs = [] + costs_per_unit = [] + for vehicle_id, tiers in enumerate(distance_tiers): + for tier in tiers: + vehicle_ids.append(vehicle_id) + thresholds.append( + np.finfo(np.float32).max + if tier["threshold"] is None + else tier["threshold"] + ) + fixed_costs.append(tier["fixed_cost"]) + costs_per_unit.append(tier["cost_per_unit"]) + data_model.set_vehicle_distance_tiers( + cudf.Series(vehicle_ids, dtype=np.int32), + cudf.Series(thresholds, dtype=np.float32), + cudf.Series(fixed_costs, dtype=np.float32), + cudf.Series(costs_per_unit, dtype=np.float32), + ) + if optimization_data.fleet_data["vehicle_max_times"] is not None: data_model.set_vehicle_max_times( optimization_data.fleet_data["vehicle_max_times"] diff --git a/python/cuopt_server/cuopt_server/utils/deprecated/routing/solver.py b/python/cuopt_server/cuopt_server/utils/deprecated/routing/solver.py index 329e12b9f1..c5513634ba 100644 --- a/python/cuopt_server/cuopt_server/utils/deprecated/routing/solver.py +++ b/python/cuopt_server/cuopt_server/utils/deprecated/routing/solver.py @@ -134,6 +134,7 @@ def solve( warnings, data_model = create_data_model( optimization_data, cost_matrix=cost_matrix, + distance_matrix=optimization_data.distance_matrix or None, travel_time_matrix=travel_time_matrix, ) diff --git a/python/cuopt_server/cuopt_server/utils/deprecated/solver.py b/python/cuopt_server/cuopt_server/utils/deprecated/solver.py index add53a2b71..a8fa90f58f 100644 --- a/python/cuopt_server/cuopt_server/utils/deprecated/solver.py +++ b/python/cuopt_server/cuopt_server/utils/deprecated/solver.py @@ -14,6 +14,7 @@ import cuopt_server.utils.settings as settings from cuopt_server.utils.data_definition import ( CostMatrices, + DistanceMatrices, FleetData, InitialSolution, LPData, @@ -124,6 +125,7 @@ def solve_optimized_routes_sync( validation_only: Optional[bool] = False, warnings=[], reqId="", + distance_matrix_data: Optional[DistanceMatrices] = None, ): from cuopt_server.utils.deprecated.routing.solver import ( solve as routing_solve, @@ -132,14 +134,15 @@ def solve_optimized_routes_sync( begin_time = time.time() optimization_data = populate_optimization_data( - cost_waypoint_graph_data, - travel_time_waypoint_graph_data, - cost_matrix_data, - travel_time_matrix_data, - fleet_data, - task_data, - initial_solution, - solver_config, + cost_waypoint_graph_data=cost_waypoint_graph_data, + travel_time_waypoint_graph_data=travel_time_waypoint_graph_data, + cost_matrix_data=cost_matrix_data, + travel_time_matrix_data=travel_time_matrix_data, + fleet_data=fleet_data, + task_data=task_data, + initial_solution=initial_solution, + solver_config=solver_config, + distance_matrix_data=distance_matrix_data, ) etl_end_time = time.time() @@ -154,6 +157,11 @@ def solve_optimized_routes_sync( ) warnings.extend(addl_warnings) else: + from cuopt_server.utils.routing.conversion import ( + prep_optimization_data, + ) + + prep_optimization_data(optimization_data) res = { "status": 0, "msg": "Input is Valid", diff --git a/python/cuopt_server/cuopt_server/utils/routing/conversion.py b/python/cuopt_server/cuopt_server/utils/routing/conversion.py index 1ad1ee159a..4e3887bcc9 100644 --- a/python/cuopt_server/cuopt_server/utils/routing/conversion.py +++ b/python/cuopt_server/cuopt_server/utils/routing/conversion.py @@ -12,6 +12,7 @@ from cuopt_server.utils.data_definition import ( CostMatrices, + DistanceMatrices, FleetData, InitialSolution, SolverSettingsConfig, @@ -39,6 +40,10 @@ def warn_on_objectives(solver_config): return warnings, solver_config +def _distance_tier_threshold_for_solver(threshold): + return np.finfo(np.float32).max if threshold is None else threshold + + # Standard solve time for VRP def std_solver_time_calc(num_tasks): return 10 + num_tasks / 6 @@ -56,6 +61,7 @@ def populate_optimization_data( initial_solution: Optional[List[InitialSolution]] = None, solver_config: Optional[SolverSettingsConfig] = None, warnings=[], + distance_matrix_data: Optional[DistanceMatrices] = None, ): optimization_data = HostOptimizationDataModel() @@ -94,6 +100,21 @@ def populate_optimization_data( elif cost_matrix_data and cost_matrix_data.data: check_valid(optimization_data.set_cost_matrix(cost_matrix_data.data)) + if ( + distance_matrix_data is not None + and distance_matrix_data.data is not None + ): + distance_tiers = ( + fleet_data.vehicle_distance_tiers + if fleet_data is not None + else None + ) + check_valid( + optimization_data.set_distance_matrix( + distance_matrix_data.data, distance_tiers + ) + ) + if ( travel_time_waypoint_graph_data and travel_time_waypoint_graph_data.waypoint_graph @@ -130,6 +151,8 @@ def populate_optimization_data( fleet_data.vehicle_max_times, fleet_data.vehicle_fixed_costs, vehicle_distance_breaks=fleet_data.vehicle_distance_breaks, + vehicle_distance_tiers=fleet_data.vehicle_distance_tiers, + vehicle_max_distances=fleet_data.vehicle_max_distances, ) ) @@ -200,6 +223,9 @@ def create_data_model( for key, value in cost_matrix.items(): data_model.add_cost_matrix(value, key) + if len(optimization_data.distance_matrix) > 0: + for key, value in optimization_data.distance_matrix.items(): + data_model.add_distance_matrix(value, key) if travel_time_matrix is not None: for key, value in travel_time_matrix.items(): data_model.add_transit_time_matrix(value, key) @@ -327,11 +353,43 @@ def create_data_model( optimization_data.fleet_data["vehicle_max_times"] ) + if optimization_data.fleet_data["vehicle_max_distances"] is not None: + data_model.set_vehicle_max_distances( + optimization_data.fleet_data["vehicle_max_distances"] + ) + if optimization_data.fleet_data["vehicle_fixed_costs"] is not None: data_model.set_vehicle_fixed_costs( optimization_data.fleet_data["vehicle_fixed_costs"] ) + if optimization_data.fleet_data["vehicle_distance_tiers"] is not None: + # Convert the list of lists of dicts to the format expected by set_vehicle_distance_tiers + tiers_by_vehicle = optimization_data.fleet_data[ + "vehicle_distance_tiers" + ] + + vehicle_ids_list = [] + thresholds_list = [] + fixed_costs_list = [] + costs_per_unit_list = [] + + for vehicle_id, tiers in enumerate(tiers_by_vehicle): + for tier in tiers: + vehicle_ids_list.append(vehicle_id) + thresholds_list.append( + _distance_tier_threshold_for_solver(tier["threshold"]) + ) + fixed_costs_list.append(tier.get("fixed_cost", 0.0)) + costs_per_unit_list.append(tier.get("cost_per_unit", 0.0)) + + data_model.set_vehicle_distance_tiers( + pd.Series(vehicle_ids_list, dtype=np.int32), + pd.Series(thresholds_list, dtype=np.float32), + pd.Series(fixed_costs_list, dtype=np.float32), + pd.Series(costs_per_unit_list, dtype=np.float32), + ) + if optimization_data.fleet_data["min_vehicles"] is not None: data_model.set_min_vehicles( optimization_data.fleet_data["min_vehicles"] @@ -414,7 +472,7 @@ def create_data_model( data["order_id"], pd.Series(data["vehicle_ids"]) ) - if optimization_data.initial_solution is not None: + if optimization_data.initial_solution: vehicle_ids, routes, types, sol_offsets = parse_initial_sol( optimization_data.initial_solution ) @@ -501,6 +559,21 @@ def prep_optimization_data(optimization_data): else: raise ValueError("No cost matrix or way point graph provided") + for ( + vehicle_type, + distance_matrix, + ) in optimization_data.distance_matrix.items(): + if ( + vehicle_type not in cost_matrix + or distance_matrix.shape != cost_matrix[vehicle_type].shape + ): + check_valid( + ( + False, + "Distance matrix shape must match the cost matrix shape", + ) + ) + if len(optimization_data.travel_time_matrix) != 0: travel_time_matrix = optimization_data.travel_time_matrix elif len(optimization_data.travel_time_waypoint_graph) != 0: 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 6cef634af3..28bc00d912 100644 --- a/python/cuopt_server/cuopt_server/utils/routing/data_definition.py +++ b/python/cuopt_server/cuopt_server/utils/routing/data_definition.py @@ -7,6 +7,7 @@ from typing import Dict, List, Optional, Union import jsonref +import msgpack from pydantic import BaseModel, Extra, Field, RootModel, root_validator from ..._version import __version_major_minor__ @@ -264,6 +265,45 @@ class CostMatrices(StrictModel): ) +class DistanceMatrices(StrictModel): + data: Optional[Dict[int, List[List[float]]]] = Field( + default=None, + description=( + "dtype : vehicle-type (uint8), distance (float32), distance >= 0.\n" + " \n\n " + "Sqaure matrix with distance to travel from A to B and B to A. \n" + "Values at or above 1e30 are treated as unreachable arcs. \n" + "If there different types of vehicles which have different \n" + "distance matrices, they can be provided with key value pair \n" + "where key is vehicle-type and value is distance matrix. Value of \n" + "vehicle type should be within [0, 255]" + ), + ) + + +class DistanceTier(StrictModel): + threshold: Optional[float] = Field( + ..., + description=( + "dtype: float32 or null. Distance threshold for the tier. " + "Use null for the final open-ended tier." + ), + ) + fixed_cost: float = Field( + default=0.0, + description=( + "dtype: float32, fixed_cost >= 0. Fixed cost for the tier." + ), + ) + cost_per_unit: float = Field( + default=0.0, + description=( + "dtype: float32, cost_per_unit >= 0. Distance unit cost for " + "the tier." + ), + ) + + class FleetData(StrictModel): vehicle_locations: List[List[int]] = Field( ..., @@ -523,6 +563,87 @@ class FleetData(StrictModel): "shows veh-0 (15) > veh-1 (5) + veh-2 (5)" ), ) + vehicle_distance_tiers: Optional[List[List[DistanceTier]]] = Field( + default=None, + examples=[ + [ + [ + { + "threshold": 100.0, + "fixed_cost": 50.0, + "cost_per_unit": 0.0, + }, + { + "threshold": 200.0, + "fixed_cost": 0.0, + "cost_per_unit": 0.1, + }, + { + "threshold": None, + "fixed_cost": 0.0, + "cost_per_unit": 0.5, + }, + ], + [ + { + "threshold": 150.0, + "fixed_cost": 75.0, + "cost_per_unit": 0.0, + }, + { + "threshold": None, + "fixed_cost": 0.0, + "cost_per_unit": 0.3, + }, + ], + ] + ], + description=( + "dtype: List of lists of distance tier objects." + " \n\n " + "Distance-based tiered pricing for each vehicle. " + "Each vehicle can have multiple tiers with different cost structures. " + "Tier costs are accumulated by distance band in ascending threshold order." + " \n\n " + "For each tier, specify 'threshold' (distance limit), " + "where null means the final open-ended tier, " + "'fixed_cost' (use 0 if not applicable), and " + "'cost_per_unit' (cost per distance unit, use 0 if not applicable)." + " \n\n " + "Example for 2 vehicles:" + " \n\n " + " [" + " \n\n " + " [ # Vehicle 0 tiers" + " \n\n " + " {'threshold': 100, 'fixed_cost': 50, 'cost_per_unit': 0}, # <=100km = 50 fixed" + " \n\n " + " {'threshold': 200, 'fixed_cost': 0, 'cost_per_unit': 0.1}, # 100km < distance <= 200km" + " \n\n " + " {'threshold': null, 'fixed_cost': 0, 'cost_per_unit': 0.5} # >200km = 0.5/km" + " \n\n " + " ]," + " \n\n " + " [ # Vehicle 1 tiers" + " \n\n " + " {'threshold': 150, 'fixed_cost': 75, 'cost_per_unit': 0}, # <=150km = 75 fixed" + " \n\n " + " {'threshold': null, 'fixed_cost': 0, 'cost_per_unit': 0.3} # >150km = 0.3/km" + " \n\n " + " ]" + " \n\n " + " ]" + ), + ) + vehicle_max_distances: Optional[List[float]] = Field( + default=None, + examples=[[200, 350]], + description=( + "dtype: float32, max_distances >= 0." + " \n\n " + "Maximum distance a vehicle can travel, based on distance_matrix_data." + ), + ) class TaskData(StrictModel): @@ -779,6 +900,26 @@ class OptimizedRoutingData(StrictModel): "vehicle type should be within [0, 255]" ), ) + distance_matrix_data: Optional[DistanceMatrices] = Field( + default=DistanceMatrices(), + examples=[ + { + "distance_matrix": { + 1: [[0, 1, 1], [1, 0, 1], [1, 1, 0]], + 2: [[0, 1, 1], [1, 0, 1], [1, 2, 0]], + } + } + ], + description=( + "Square matrix with distance to travel from A to B and B to A. \n" + "This matrix is used for distance-based features such as " + "vehicle_distance_tiers and vehicle_max_distances. " + "If there are different types of vehicles which have different \n" + "distance matrices, they can be provided with key value pair \n" + "where key is vehicle-type and value is distance matrix. Value of \n" + "vehicle type should be within [0, 255]" + ), + ) travel_time_matrix_data: Optional[CostMatrices] = Field( default=CostMatrices(), examples=[ @@ -1035,6 +1176,12 @@ class InFeasibleSolve(StrictModel): "2": [[0, 1, 1], [1, 0, 1], [1, 2, 0]], } }, + "distance_matrix_data": { + "data": { + "1": [[0, 1, 1], [1, 0, 1], [1, 1, 0]], + "2": [[0, 1, 1], [1, 0, 1], [1, 2, 0]], + } + }, "travel_time_matrix_data": { "data": { "1": [[0, 1, 1], [1, 0, 1], [1, 1, 0]], @@ -1057,9 +1204,20 @@ class InFeasibleSolve(StrictModel): "skip_first_trips": [True, False], "drop_return_trips": [True, False], "min_vehicles": 2, - "vehicle_max_costs": [7, 10], + "vehicle_max_costs": [100, 100], "vehicle_max_times": [7, 10], "vehicle_fixed_costs": [15, 5], + "vehicle_distance_tiers": [ + [ + {"threshold": 100.0, "fixed_cost": 50.0, "cost_per_unit": 0.0}, + {"threshold": 200.0, "fixed_cost": 0.0, "cost_per_unit": 0.1}, + {"threshold": None, "fixed_cost": 0.0, "cost_per_unit": 0.5}, + ], + [ + {"threshold": 150.0, "fixed_cost": 75.0, "cost_per_unit": 0.0}, + {"threshold": None, "fixed_cost": 0.0, "cost_per_unit": 0.3}, + ], + ], }, "task_data": { "task_locations": [1, 2], @@ -1090,9 +1248,11 @@ class InFeasibleSolve(StrictModel): }, } -# fmt: off -vrp_msgpack_example_data = "\x85\xb0cost_matrix_data\x81\xa4data\x82\xa11\x93\x93\x00\x01\x01\x93\x01\x00\x01\x93\x01\x01\x00\xa12\x93\x93\x00\x01\x01\x93\x01\x00\x01\x93\x01\x02\x00\xb7travel_time_matrix_data\x81\xa4data\x82\xa11\x93\x93\x00\x01\x01\x93\x01\x00\x01\x93\x01\x01\x00\xa12\x93\x93\x00\x01\x01\x93\x01\x00\x01\x93\x01\x02\x00\xaafleet_data\x8f\xb1vehicle_locations\x92\x92\x00\x00\x92\x00\x00\xabvehicle_ids\x92\xa5veh-1\xa5veh-2\xaacapacities\x92\x92\x02\x02\x92\x04\x01\xb4vehicle_time_windows\x92\x92\x00\n\x92\x00\n\xbavehicle_break_time_windows\x91\x92\x92\x01\x02\x92\x02\x03\xb7vehicle_break_durations\x91\x92\x01\x01\xb7vehicle_break_locations\x92\x00\x01\xadvehicle_types\x92\x01\x02\xb3vehicle_order_match\x92\x82\xa9order_ids\x91\x00\xaavehicle_id\x00\x82\xa9order_ids\x91\x01\xaavehicle_id\x01\xb0skip_first_trips\x92\xc3\xc2\xb1drop_return_trips\x92\xc3\xc2\xacmin_vehicles\x02\xb1vehicle_max_costs\x92\x07\n\xb1vehicle_max_times\x92\x07\n\xb3vehicle_fixed_costs\x92\x0f\x05\xa9task_data\x86\xaetask_locations\x92\x01\x02\xa8task_ids\x92\xa6Task-A\xa6Task-B\xa6demand\x92\x92\x01\x01\x92\x03\x01\xb1task_time_windows\x92\x92\x00\x05\x92\x03\t\xadservice_times\x92\x00\x00\xb3order_vehicle_match\x92\x82\xa8order_id\x00\xabvehicle_ids\x91\x00\x82\xa8order_id\x01\xabvehicle_ids\x91\x01\xadsolver_config\x84\xaatime_limit\x01\xaaobjectives\x86\xa4cost\x01\xabtravel_time\x00\xb3variance_route_size\x00\xbbvariance_route_service_time\x00\xa5prize\x00\xb2vehicle_fixed_cost\x00\xacverbose_mode\xc2\xaderror_logging\xc3".encode("unicode_escape") # noqa -# fmt: on +vrp_msgpack_example_data = ( + msgpack.packb(vrp_example_data, use_bin_type=True) + .decode("latin1") + .encode("unicode_escape") +) managed_vrp_example_data = { @@ -1101,7 +1261,7 @@ class InFeasibleSolve(StrictModel): "client_version": __version_major_minor__, } -# cut and pasted from actual run of VRP example data. +# Example response for the tiered VRP request above. # don't reformat :) vrp_response = { "value": { @@ -1109,8 +1269,8 @@ class InFeasibleSolve(StrictModel): "solver_response": { "status": 0, "num_vehicles": 2, - "solution_cost": 2.0, - "objective_values": {"cost": 2.0}, + "solution_cost": 77.0, + "objective_values": {"cost": 77.0}, "vehicle_data": { "veh-1": { "task_id": ["Break", "Task-A"], diff --git a/python/cuopt_server/cuopt_server/utils/routing/host_optimization_data_model.py b/python/cuopt_server/cuopt_server/utils/routing/host_optimization_data_model.py index b8be28aa3c..b73c96ac38 100644 --- a/python/cuopt_server/cuopt_server/utils/routing/host_optimization_data_model.py +++ b/python/cuopt_server/cuopt_server/utils/routing/host_optimization_data_model.py @@ -13,12 +13,16 @@ from cuopt_server.utils.routing.optimization_data_model import ( OptimizationDataModel, + get_distance_tiers_as_dicts, get_none_for_empty_list, get_objectives_as_lists, ) from cuopt_server.utils.routing.validation_cost_matrix import ( validate_cost_matrix, ) +from cuopt_server.utils.routing.validation_distance_matrix import ( + validate_distance_matrix, +) from cuopt_server.utils.routing.validation_fleet_data import ( validate_fleet_data, ) @@ -41,6 +45,11 @@ def update_cost_matrix(self, *args, **kwargs): "HostOptimizationDataModel.update_cost_matrix is unimplemented" ) + def update_distance_matrix(self, *args, **kwargs): + raise NotImplementedError( + "HostOptimizationDataModel.update_distance_matrix is unimplemented" + ) + def update_travel_time_matrix(self, *args, **kwargs): raise NotImplementedError( "HostOptimizationDataModel.update_travel_time_matrix " @@ -95,6 +104,21 @@ def set_travel_time_matrix(self, travel_time_matrix): return is_valid + def set_distance_matrix(self, distance_matrix, vehicle_distance_tiers): + is_valid = validate_distance_matrix( + distance_matrix, + vehicle_distance_tiers=vehicle_distance_tiers, + require_distance_tiers=False, + comparison_matrix=self.cost_matrix or None, + ) + if is_valid[0]: + self.distance_matrix = { + v_type: pd.DataFrame(np.array(matrix, dtype=np.float32)) + for v_type, matrix in distance_matrix.items() + } + + return is_valid + def set_fleet_data( self, vehicle_ids, @@ -114,6 +138,8 @@ def set_fleet_data( vehicle_max_times, vehicle_fixed_costs, vehicle_distance_breaks=None, + vehicle_distance_tiers=None, + vehicle_max_distances=None, ): if not self.is_route_detail_set: return ( @@ -125,6 +151,9 @@ def set_fleet_data( vehicle_types_dict["Travel Time Matrix"] = list( self.travel_time_matrix.keys() ) + vehicle_types_dict["Distance Matrix"] = list( + self.distance_matrix.keys() + ) vehicle_types_dict["Waypoint Graph"] = list(self.waypoint_graph.keys()) vehicle_types_dict["Travel Time Waypoint Graph"] = list( self.travel_time_waypoint_graph.keys() @@ -135,6 +164,10 @@ def set_fleet_data( vehicle_max_costs = get_none_for_empty_list(vehicle_max_costs) vehicle_max_times = get_none_for_empty_list(vehicle_max_times) vehicle_fixed_costs = get_none_for_empty_list(vehicle_fixed_costs) + vehicle_distance_tiers = get_none_for_empty_list( + vehicle_distance_tiers + ) + vehicle_max_distances = get_none_for_empty_list(vehicle_max_distances) vehicle_time_windows = get_none_for_empty_list(vehicle_time_windows) vehicle_break_time_windows = get_none_for_empty_list( vehicle_break_time_windows @@ -174,6 +207,9 @@ def set_fleet_data( updating=False, comparison_locations=None, vehicle_distance_breaks=vehicle_distance_breaks, + vehicle_distance_tiers=vehicle_distance_tiers, + vehicle_max_distances=vehicle_max_distances, + is_distance_matrix_set=len(self.distance_matrix) != 0, ) if is_valid[0]: @@ -205,6 +241,14 @@ def set_fleet_data( self.fleet_data["vehicle_fixed_costs"] = pd.Series( vehicle_fixed_costs, dtype=np.float32 ) + if vehicle_distance_tiers is not None: + self.fleet_data["vehicle_distance_tiers"] = ( + get_distance_tiers_as_dicts(vehicle_distance_tiers) + ) + if vehicle_max_distances is not None: + self.fleet_data["vehicle_max_distances"] = pd.Series( + vehicle_max_distances, dtype=np.float32 + ) if vehicle_time_windows: self.fleet_data["vehicle_time_windows"] = pd.DataFrame( vehicle_time_windows, 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 8bf599c095..4b10683886 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 @@ -9,6 +9,9 @@ from cuopt_server.utils.routing.validation_cost_matrix import ( validate_cost_matrix, ) +from cuopt_server.utils.routing.validation_distance_matrix import ( + validate_distance_matrix, +) from cuopt_server.utils.routing.validation_fleet_data import ( validate_fleet_data, ) @@ -25,6 +28,22 @@ def get_none_for_empty_list(data): return data if data is not None and len(data) > 0 else None +def get_distance_tiers_as_dicts(vehicle_distance_tiers): + return [ + [ + tier + if isinstance(tier, dict) + else ( + tier.model_dump() + if hasattr(tier, "model_dump") + else tier.dict() + ) + for tier in vehicle_tiers + ] + for vehicle_tiers in vehicle_distance_tiers + ] + + def get_objectives_as_lists(objectives): cuopt_objectives = [] objective_weights = [] @@ -76,6 +95,7 @@ def __init__(self) -> None: self.is_route_detail_set = False self.cost_matrix = {} + self.distance_matrix = {} self.travel_time_matrix = {} self.fleet_data = self.reset_fleet_data() @@ -106,6 +126,8 @@ def reset_fleet_data(self): "vehicle_max_costs": None, "vehicle_max_times": None, "vehicle_fixed_costs": None, + "vehicle_distance_tiers": None, + "vehicle_max_distances": None, } def reset_task_data(self): @@ -154,6 +176,12 @@ def get_cost_matrix(self): for key, value in self.cost_matrix.items() } + def get_distance_matrix(self): + return { + key: value.to_numpy().tolist() + for key, value in self.distance_matrix.items() + } + def get_travel_time_matrix(self): return { key: value.to_numpy().tolist() @@ -180,6 +208,11 @@ def get_fleet_data(self): .to_pylist() if self.fleet_data["vehicle_max_costs"] is not None else None, + "vehicle_max_distances": self.fleet_data["vehicle_max_distances"] + .to_arrow() + .to_pylist() + if self.fleet_data["vehicle_max_distances"] is not None + else None, "vehicle_max_times": self.fleet_data["vehicle_max_times"] .to_arrow() .to_pylist() @@ -219,6 +252,9 @@ def get_fleet_data(self): "vehicle_distance_breaks" ], "vehicle_order_match": self.fleet_data["vehicle_order_match"], + "vehicle_distance_tiers": self.fleet_data[ + "vehicle_distance_tiers" + ], "skip_first_trips": self.fleet_data["skip_first_trips"] .to_arrow() .to_pylist() @@ -292,6 +328,7 @@ def get_optimization_data(self): "cost_waypoint_graph": self.get_cost_waypoint_graph(), "travel_time_waypoint_graph": self.get_travel_time_waypoint_graph(), # noqa "cost_matrix": self.get_cost_matrix(), + "distance_matrix": self.get_distance_matrix(), "travel_time_matrix": self.get_travel_time_matrix(), "fleet_data": self.get_fleet_data(), "task_data": self.get_task_data(), @@ -440,6 +477,39 @@ def update_cost_matrix(self, cost_matrix): return is_valid + def set_distance_matrix(self, distance_matrix, vehicle_distance_tiers): + is_valid = validate_distance_matrix( + distance_matrix, + vehicle_distance_tiers=vehicle_distance_tiers, + require_distance_tiers=False, + comparison_matrix=self.cost_matrix or None, + ) + if is_valid[0]: + self.distance_matrix = {} + for v_type, matrix in distance_matrix.items(): + np_distance_matrix = np.array(matrix, dtype=np.float32) + self.distance_matrix[v_type] = cudf.DataFrame( + np_distance_matrix + ) + + return is_valid + + def update_distance_matrix(self, distance_matrix): + is_valid = validate_distance_matrix( + distance_matrix, + vehicle_distance_tiers=self.fleet_data["vehicle_distance_tiers"], + require_distance_tiers=False, + comparison_matrix=self.cost_matrix or None, + ) + if is_valid[0]: + for v_type, matrix in distance_matrix.items(): + np_distance_matrix = np.array(matrix, dtype=np.float32) + self.distance_matrix[v_type] = cudf.DataFrame( + np_distance_matrix + ) + + return is_valid + def set_travel_time_matrix(self, travel_time_matrix): is_valid = validate_cost_matrix( travel_time_matrix, @@ -493,6 +563,8 @@ def set_fleet_data( vehicle_max_times, vehicle_fixed_costs, vehicle_distance_breaks=None, + vehicle_distance_tiers=None, + vehicle_max_distances=None, ): if not self.is_route_detail_set: return ( @@ -504,6 +576,9 @@ def set_fleet_data( vehicle_types_dict["Travel Time Matrix"] = list( self.travel_time_matrix.keys() ) + vehicle_types_dict["Distance Matrix"] = list( + self.distance_matrix.keys() + ) vehicle_types_dict["Waypoint Graph"] = list(self.waypoint_graph.keys()) vehicle_types_dict["Travel Time Waypoint Graph"] = list( self.travel_time_waypoint_graph.keys() @@ -514,6 +589,10 @@ def set_fleet_data( vehicle_max_costs = get_none_for_empty_list(vehicle_max_costs) vehicle_max_times = get_none_for_empty_list(vehicle_max_times) vehicle_fixed_costs = get_none_for_empty_list(vehicle_fixed_costs) + vehicle_distance_tiers = get_none_for_empty_list( + vehicle_distance_tiers + ) + vehicle_max_distances = get_none_for_empty_list(vehicle_max_distances) vehicle_time_windows = get_none_for_empty_list(vehicle_time_windows) vehicle_break_time_windows = get_none_for_empty_list( vehicle_break_time_windows @@ -553,6 +632,9 @@ def set_fleet_data( updating=False, comparison_locations=None, vehicle_distance_breaks=vehicle_distance_breaks, + vehicle_distance_tiers=vehicle_distance_tiers, + vehicle_max_distances=vehicle_max_distances, + is_distance_matrix_set=len(self.distance_matrix) != 0, ) if is_valid[0]: @@ -584,6 +666,14 @@ def set_fleet_data( self.fleet_data["vehicle_fixed_costs"] = cudf.Series( vehicle_fixed_costs, dtype=np.float32 ) + if vehicle_distance_tiers is not None: + self.fleet_data["vehicle_distance_tiers"] = ( + get_distance_tiers_as_dicts(vehicle_distance_tiers) + ) + if vehicle_max_distances is not None: + self.fleet_data["vehicle_max_distances"] = cudf.Series( + vehicle_max_distances, dtype=np.float32 + ) if vehicle_time_windows: self.fleet_data["vehicle_time_windows"] = cudf.DataFrame( vehicle_time_windows, @@ -672,6 +762,8 @@ def update_fleet_data( vehicle_max_times, vehicle_fixed_costs, vehicle_distance_breaks=None, + vehicle_distance_tiers=None, + vehicle_max_distances=None, ): if not self.is_route_detail_set: return ( @@ -684,6 +776,9 @@ def update_fleet_data( vehicle_types_dict["Travel Time Matrix"] = list( self.travel_time_matrix.keys() ) + vehicle_types_dict["Distance Matrix"] = list( + self.distance_matrix.keys() + ) vehicle_types_dict["Waypoint Graph"] = list(self.waypoint_graph.keys()) vehicle_types_dict["Travel Time Waypoint Graph"] = list( self.travel_time_waypoint_graph.keys() @@ -695,6 +790,10 @@ def update_fleet_data( vehicle_max_costs = get_none_for_empty_list(vehicle_max_costs) vehicle_max_times = get_none_for_empty_list(vehicle_max_times) vehicle_fixed_costs = get_none_for_empty_list(vehicle_fixed_costs) + vehicle_distance_tiers = get_none_for_empty_list( + vehicle_distance_tiers + ) + vehicle_max_distances = get_none_for_empty_list(vehicle_max_distances) vehicle_time_windows = get_none_for_empty_list(vehicle_time_windows) skip_first_trips = get_none_for_empty_list(skip_first_trips) vehicle_break_time_windows = get_none_for_empty_list( @@ -735,6 +834,9 @@ def update_fleet_data( updating=True, comparison_locations=self.fleet_data["vehicle_locations"], vehicle_distance_breaks=vehicle_distance_breaks, + vehicle_distance_tiers=vehicle_distance_tiers, + vehicle_max_distances=vehicle_max_distances, + is_distance_matrix_set=len(self.distance_matrix) != 0, ) if is_valid[0]: @@ -762,6 +864,14 @@ def update_fleet_data( self.fleet_data["vehicle_fixed_costs"] = cudf.Series( vehicle_fixed_costs, dtype=np.float32 ) + if vehicle_distance_tiers is not None: + self.fleet_data["vehicle_distance_tiers"] = ( + get_distance_tiers_as_dicts(vehicle_distance_tiers) + ) + if vehicle_max_distances is not None: + self.fleet_data["vehicle_max_distances"] = cudf.Series( + vehicle_max_distances, dtype=np.float32 + ) if vehicle_time_windows: self.fleet_data["vehicle_time_windows"] = cudf.DataFrame( vehicle_time_windows, diff --git a/python/cuopt_server/cuopt_server/utils/routing/validation_cost_matrix.py b/python/cuopt_server/cuopt_server/utils/routing/validation_cost_matrix.py index d75ec53c6e..95c3df2979 100644 --- a/python/cuopt_server/cuopt_server/utils/routing/validation_cost_matrix.py +++ b/python/cuopt_server/cuopt_server/utils/routing/validation_cost_matrix.py @@ -14,6 +14,14 @@ def validate_cost_matrix( ) shape = None for vehicle_type, matrix in cost_matrix.items(): + if ( + not isinstance(vehicle_type, (int, np.integer)) + or not 0 <= vehicle_type <= 255 + ): + return ( + False, + "Matrix vehicle types must be integers within [0, 255]", + ) row_lengths = [len(x) for x in matrix] if not len(set(row_lengths)) == 1: return ( diff --git a/python/cuopt_server/cuopt_server/utils/routing/validation_distance_matrix.py b/python/cuopt_server/cuopt_server/utils/routing/validation_distance_matrix.py new file mode 100644 index 0000000000..cb72f1d4f7 --- /dev/null +++ b/python/cuopt_server/cuopt_server/utils/routing/validation_distance_matrix.py @@ -0,0 +1,88 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import numpy as np + + +def _has_distance_tiers(vehicle_distance_tiers): + if vehicle_distance_tiers is None: + return False + if len(vehicle_distance_tiers) == 0: + return False + return all( + tiers is not None and len(tiers) > 0 + for tiers in vehicle_distance_tiers + ) + + +def validate_distance_matrix( + distance_matrix, + vehicle_distance_tiers=None, + require_distance_tiers=True, + comparison_matrix=None, +): + if distance_matrix is None or len(distance_matrix) == 0: + return (False, "Distance matrix cannot be null or empty") + + if require_distance_tiers and not _has_distance_tiers( + vehicle_distance_tiers + ): + return ( + False, + "vehicle_distance_tiers must be set when distance matrix data is provided", + ) + + shape = None + for vehicle_type, matrix in distance_matrix.items(): + if ( + not isinstance(vehicle_type, (int, np.integer)) + or not 0 <= vehicle_type <= 255 + ): + return ( + False, + "Matrix vehicle types must be integers within [0, 255]", + ) + if matrix is None or len(matrix) == 0: + return (False, "Distance matrix cannot be null or empty") + + row_lengths = [len(row) for row in matrix] + if not len(set(row_lengths)) == 1: + return ( + False, + "All rows in the distance matrix must be of the same length", + ) + + if len(matrix) != len(matrix[0]): + return (False, "Distance matrix must be a square matrix") + + np_distance_matrix = np.array(matrix) + if np_distance_matrix.min() < 0: + return (False, "All values in distance matrix must be >= 0") + + if not np.isfinite(np_distance_matrix).all(): + return (False, "All values in distance matrix must be finite") + if np_distance_matrix.max() > np.finfo(np.float32).max: + return ( + False, + "All values in distance matrix must be representable as float32", + ) + + if shape is None: + shape = np_distance_matrix.shape + elif shape != np_distance_matrix.shape: + return ( + False, + "Distance matrices for all vehicle types must be the same shape", + ) + + if comparison_matrix is not None and ( + vehicle_type not in comparison_matrix + or np_distance_matrix.shape + != comparison_matrix[vehicle_type].shape + ): + return ( + False, + "Distance matrix shape must match the cost matrix shape", + ) + + return (True, "Valid Distance Matrix") 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 f92ba4788a..7c2cd14f19 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 @@ -1,6 +1,127 @@ # SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +import math + +import numpy as np + + +def _get_tier_value(tier, key, default=None): + if isinstance(tier, dict): + return tier.get(key, default) + return getattr(tier, key, default) + + +def _is_finite(value): + try: + return math.isfinite(float(value)) + except (TypeError, ValueError): + return False + + +def _validate_distance_tiers(vehicle_distance_tiers): + if vehicle_distance_tiers is None or len(vehicle_distance_tiers) == 0: + return ( + False, + "vehicle_distance_tiers must define at least one tier per vehicle", + ) + + for vehicle_tiers in vehicle_distance_tiers: + if vehicle_tiers is None or len(vehicle_tiers) == 0: + return ( + False, + "vehicle_distance_tiers must define at least one tier per vehicle", + ) + + open_ended_tiers = 0 + previous_threshold = None + for tier_index, tier in enumerate(vehicle_tiers): + threshold = _get_tier_value(tier, "threshold") + fixed_cost = _get_tier_value(tier, "fixed_cost", 0.0) + cost_per_unit = _get_tier_value(tier, "cost_per_unit", 0.0) + + if threshold is None: + open_ended_tiers += 1 + if tier_index != len(vehicle_tiers) - 1: + return ( + False, + "The open-ended distance tier must be the final tier", + ) + if ( + previous_threshold is not None + and previous_threshold >= np.finfo(np.float32).max + ): + return ( + False, + "Distance tier thresholds must be strictly increasing", + ) + else: + if not _is_finite(threshold): + return ( + False, + "Distance tier threshold values must be finite", + ) + if threshold < 0: + return ( + False, + "Distance tier threshold values must be greater than or equal to 0", + ) + if threshold > np.finfo(np.float32).max: + return ( + False, + "Distance tier threshold values must be representable as float32", + ) + threshold = float(np.float32(threshold)) + if ( + previous_threshold is not None + and threshold <= previous_threshold + ): + return ( + False, + "Distance tier thresholds must be strictly increasing", + ) + previous_threshold = threshold + + if not _is_finite(fixed_cost): + return ( + False, + "Distance tier fixed_cost values must be finite", + ) + if fixed_cost < 0: + return ( + False, + "Distance tier fixed_cost values must be greater than or equal to 0", + ) + if fixed_cost > np.finfo(np.float32).max: + return ( + False, + "Distance tier fixed_cost values must be representable as float32", + ) + + if not _is_finite(cost_per_unit): + return ( + False, + "Distance tier cost_per_unit values must be finite", + ) + if cost_per_unit < 0: + return ( + False, + "Distance tier cost_per_unit values must be greater than or equal to 0", + ) + if cost_per_unit > np.finfo(np.float32).max: + return ( + False, + "Distance tier cost_per_unit values must be representable as float32", + ) + + if open_ended_tiers != 1: + return ( + False, + "Each vehicle_distance_tiers entry must include exactly one null threshold tier", + ) + + return (True, "") + def test_time_window(time_windows, tw_type): # All time windows earliest times must be less than latest times @@ -48,6 +169,9 @@ def validate_fleet_data( updating=False, comparison_locations=None, vehicle_distance_breaks=None, + vehicle_max_distances=None, + vehicle_distance_tiers=None, + is_distance_matrix_set=False, ): if vehicle_locations is not None: for loc in vehicle_locations: @@ -108,12 +232,19 @@ def validate_fleet_data( ) if vehicle_max_costs is not None: - if min(vehicle_max_costs) <= 0: - return ( - False, - "Maximum distance any vehicle can travel must be greater " - "than 0", - ) + for vehicle_max_cost in vehicle_max_costs: + if not _is_finite(vehicle_max_cost): + return (False, "Maximum vehicle route cost must be finite") + if vehicle_max_cost < 0: + return ( + False, + "Maximum vehicle route cost must be greater than or equal to 0", + ) + if vehicle_max_cost > np.finfo(np.float32).max: + return ( + False, + "Maximum vehicle route cost must be representable as float32", + ) fleet_length_check_array.append(len(vehicle_max_costs)) if vehicle_max_times is not None: @@ -132,6 +263,42 @@ def validate_fleet_data( ) fleet_length_check_array.append(len(vehicle_fixed_costs)) + if vehicle_max_distances is not None: + if not is_distance_matrix_set: + return ( + False, + "distance_matrix_data must be set when vehicle_max_distances is provided", + ) + for vehicle_max_distance in vehicle_max_distances: + if not _is_finite(vehicle_max_distance): + return ( + False, + "Maximum distance any vehicle can travel must be finite", + ) + if vehicle_max_distance < 0: + return ( + False, + "Maximum distance any vehicle can travel must be greater than or equal to 0", # noqa + ) + if vehicle_max_distance > np.finfo(np.float32).max: + return ( + False, + "Maximum distance any vehicle can travel must be representable as float32", + ) + fleet_length_check_array.append(len(vehicle_max_distances)) + + if vehicle_distance_tiers is not None: + if not is_distance_matrix_set: + return ( + False, + "distance_matrix_data must be set when vehicle_distance_tiers is provided", + ) + + res = _validate_distance_tiers(vehicle_distance_tiers) + if not res[0]: + return res + fleet_length_check_array.append(len(vehicle_distance_tiers)) + if vehicle_time_windows is not None: fleet_length_check_array.append(len(vehicle_time_windows)) res = test_time_window(vehicle_time_windows, "vehicle_time_windows") @@ -219,6 +386,11 @@ def validate_fleet_data( ) if vehicle_types is not None: + if any( + vehicle_type < 0 or vehicle_type > 255 + for vehicle_type in vehicle_types + ): + return (False, "Vehicle types must be within [0, 255]") unique_vehicle_types = set(vehicle_types) for matrix_type, vehicle_ids in vehicle_types_dict.items(): v_ids = set(vehicle_ids) @@ -226,7 +398,10 @@ def validate_fleet_data( return (False, matrix_type + " not set for all vehicle types") else: for _, vehicle_ids in vehicle_types_dict.items(): - if len(set(vehicle_ids)) > 1: + unique_ids = set(vehicle_ids) + if len(unique_ids) > 1 or ( + len(unique_ids) == 1 and 0 not in unique_ids + ): return ( False, "Set vehicle types when using multiple matrices",