From 144041c700d98611a32f2b46fd3d362eb2e442f7 Mon Sep 17 00:00:00 2001 From: Nikolai Poperechnyi Date: Wed, 10 Jun 2026 16:53:43 +0300 Subject: [PATCH] fix(routing): honor break deadlines on short routes Signed-off-by: Nikolai Poperechnyi --- cpp/src/routing/adapters/adapted_generator.cu | 3 +- cpp/src/routing/adapters/adapted_modifier.cu | 10 + cpp/src/routing/diversity/diverse_solver.hpp | 2 + cpp/src/routing/ges/squeeze.cu | 40 +- cpp/src/routing/ges/squeeze.cuh | 198 ++++++--- .../routing/local_search/breaks_insertion.cu | 4 +- cpp/src/routing/problem/problem.cu | 5 + cpp/src/routing/problem/special_nodes.cuh | 12 +- cpp/tests/routing/routing_test.cuh | 127 ++++-- cpp/tests/routing/unit_tests/breaks.cu | 125 ++++++ .../routing/unit_tests/distance_breaks.cu | 34 +- .../routing/utilities/check_constraints.cu | 116 +++-- .../tests/routing/test_distance_breaks.py | 79 ++-- .../tests/routing/test_vehicle_properties.py | 398 +++++++++++++++++- 14 files changed, 940 insertions(+), 213 deletions(-) diff --git a/cpp/src/routing/adapters/adapted_generator.cu b/cpp/src/routing/adapters/adapted_generator.cu index da4027add2..ed0d82ca3a 100644 --- a/cpp/src/routing/adapters/adapted_generator.cu +++ b/cpp/src/routing/adapters/adapted_generator.cu @@ -121,10 +121,11 @@ void adapted_generator_t::generate_solution( sol.sol.eject_until_feasible(); resource.ges.init_ejection_pool(); resource.ges.fixed_route_loop(); - if (dim_info.has_dimension(dim_t::BREAK)) { resource.ges.try_squeeze_breaks_feasible(); } } resource.ges.repair_empty_routes(); + // Construction can eject breaks before reinserting requests into the routes. + if (dim_info.has_dimension(dim_t::BREAK)) { resource.ges.try_squeeze_breaks_feasible(); } sol.populate_host_data(true); cuopt_func_call(sol.check_device_host_coherence()); diff --git a/cpp/src/routing/adapters/adapted_modifier.cu b/cpp/src/routing/adapters/adapted_modifier.cu index 4675581a6d..09c1adee58 100644 --- a/cpp/src/routing/adapters/adapted_modifier.cu +++ b/cpp/src/routing/adapters/adapted_modifier.cu @@ -35,6 +35,10 @@ void adapted_modifier_t::perturbate( for (i_t i = 0; i < perturbation_count; ++i) { resource.ls.run_random_local_search(adapted_solution.sol, false); } + if (adapted_solution.problem->has_vehicle_breaks()) { + resource.ges.set_solution_ptr(&adapted_solution.sol); + resource.ges.squeeze_breaks(); + } adapted_solution.populate_host_data(true); adapted_solution.check_device_host_coherence(); cuopt_func_call(adapted_solution.sol.check_cost_coherence(gpu_weight)); @@ -59,6 +63,12 @@ void adapted_modifier_t::improve( resource.ls.start_timer(time_limit); resource.ls.run_best_local_search( adapted_solution.sol, consider_unserviced, time_limit_enabled, run_cycle_finder); + // Moving requests can make a previously skipped break required, or shorten a route enough + // to omit a break. Reconcile both cases before publishing its cost and feasibility. + if (adapted_solution.problem->has_vehicle_breaks()) { + resource.ges.set_solution_ptr(&adapted_solution.sol); + resource.ges.squeeze_breaks(); + } adapted_solution.populate_host_data(); adapted_solution.check_device_host_coherence(); cuopt_func_call(adapted_solution.sol.check_cost_coherence(gpu_weight)); diff --git a/cpp/src/routing/diversity/diverse_solver.hpp b/cpp/src/routing/diversity/diverse_solver.hpp index 67bf300084..2d8463c647 100644 --- a/cpp/src/routing/diversity/diverse_solver.hpp +++ b/cpp/src/routing/diversity/diverse_solver.hpp @@ -861,6 +861,8 @@ struct solve { } next_injection = injection_it; } + // Loading an initial solution strips its break nodes. + if (p->has_vehicle_breaks()) { lm.squeeze_breaks(temp_pair.first, final_weights); } } else { auto time_limit = timer.clamp_remaining_time(sol_gen_time); g.generate_solution( diff --git a/cpp/src/routing/ges/squeeze.cu b/cpp/src/routing/ges/squeeze.cu index e104d6c1ac..d9485d9e55 100644 --- a/cpp/src/routing/ges/squeeze.cu +++ b/cpp/src/routing/ges/squeeze.cu @@ -365,15 +365,18 @@ bool guided_ejection_search_t::try_squeeze_feasible( } template -void guided_ejection_search_t::squeeze_breaks() +void normalize_solution_breaks(solution_t& solution, + infeasible_cost_t weights, + bool preserve_empty_breaks) { raft::common::nvtx::range fun_scope("squeeze_breaks"); - solution_ptr->global_runtime_checks(false, false, "squeeze_breaks_begin"); - auto stream = solution_ptr->sol_handle->get_stream(); - size_t n_break_dims = solution_ptr->problem_ptr->get_max_break_dimensions(); - size_t n_blocks = solution_ptr->n_routes; - size_t sh_size = solution_ptr->check_routes_can_insert_and_get_sh_size(n_break_dims) + - sizeof(i_t) * n_break_dims; + solution.global_runtime_checks(false, false, "squeeze_breaks_begin"); + auto stream = solution.sol_handle->get_stream(); + size_t n_break_dims = solution.problem_ptr->get_max_break_dimensions(); + size_t n_blocks = solution.n_routes; + if (n_break_dims == 0 || n_blocks == 0) { return; } + size_t sh_size = + solution.check_routes_can_insert_and_get_sh_size(n_break_dims) + sizeof(i_t) * n_break_dims; size_t TPB = 128; if (!set_shmem_of_kernel(squeeze_breaks_kernel, sh_size)) { @@ -382,11 +385,16 @@ void guided_ejection_search_t::squeeze_breaks() } squeeze_breaks_kernel<<>>( - solution_ptr->view(), false, local_search_ptr_->move_candidates.weights); - RAFT_CHECK_CUDA(solution_ptr->sol_handle->get_stream().get()); - solution_ptr->compute_cost(); - solution_ptr->global_runtime_checks(false, false, "squeeze_breaks_end"); - return; + solution.view(), false, weights, preserve_empty_breaks); + RAFT_CHECK_CUDA(stream.get()); + solution.compute_cost(); + solution.global_runtime_checks(false, false, "squeeze_breaks_end"); +} + +template +void guided_ejection_search_t::squeeze_breaks() +{ + normalize_solution_breaks(*solution_ptr, local_search_ptr_->move_candidates.weights, false); } template @@ -398,7 +406,9 @@ bool guided_ejection_search_t::try_squeeze_breaks_feasible() if (n_break_dims == 0) { return solution_ptr->is_feasible(); } auto stream = solution_ptr->sol_handle->get_stream(); - squeeze_breaks(); + // Empty routes still participate in construction. Their breaks constrain future greedy + // insertions, preventing requests from repeatedly returning to an incompatible vehicle. + normalize_solution_breaks(*solution_ptr, local_search_ptr_->move_candidates.weights, true); if (solution_ptr->is_feasible()) { return true; } @@ -414,6 +424,8 @@ bool guided_ejection_search_t::try_squeeze_breaks_feasible() local_search_ptr_->set_active_weights(local_search_ptr_->move_candidates.weights, original_incl_objective); + // Repair can move requests across routes and change which break deadlines are reached. + normalize_solution_breaks(*solution_ptr, local_search_ptr_->move_candidates.weights, true); return solution_ptr->is_feasible(); } @@ -435,6 +447,8 @@ template int guided_ejection_search_t::try_multiple_ template int guided_ejection_search_t::try_multiple_feasible_insertions( int, bool); +template void guided_ejection_search_t::squeeze_breaks(); +template void guided_ejection_search_t::squeeze_breaks(); template bool guided_ejection_search_t::try_squeeze_breaks_feasible(); template bool guided_ejection_search_t::try_squeeze_breaks_feasible(); } // namespace detail diff --git a/cpp/src/routing/ges/squeeze.cuh b/cpp/src/routing/ges/squeeze.cuh index 64fc8ea264..789bda0749 100644 --- a/cpp/src/routing/ges/squeeze.cuh +++ b/cpp/src/routing/ges/squeeze.cuh @@ -402,14 +402,56 @@ __global__ void eject_inserted_requests( } } +struct break_route_end_t { + double time; + double distance; +}; + +// Replay the earliest schedule without time warping: adding accumulated excess to the stored +// departure time is not exact when a later wait absorbs an earlier time-window violation. +template +DI break_route_end_t get_break_route_end(const typename route_t::view_t& route, + bool include_breaks) +{ + const auto vehicle = route.vehicle_info(); + const bool has_time = route.dimensions_info().has_dimension(dim_t::TIME); + auto prev = route.get_node(0); + break_route_end_t end{has_time ? prev.time_dim.window_start : 0., 0.}; + for (i_t idx = 1; idx <= route.get_num_nodes(); ++idx) { + auto next = route.get_node(idx); + if (!include_breaks && next.node_info().is_break()) { continue; } + end.distance += get_distance(prev.node_info(), next.node_info(), vehicle); + if (has_time) { + end.time = max(end.time + get_transit_time(prev.node_info(), next.node_info(), vehicle, true), + next.time_dim.window_start); + } + prev = next; + } + return end; +} + +template +DI bool is_break_required(const typename special_nodes_t::view_t& break_nodes, + const break_route_end_t& end, + bool has_time) +{ + // A valid distance deadline can equal the unbounded sentinel used by time breaks. + if (!break_nodes.is_distance_break.empty() && break_nodes.is_distance_break[0]) { + return end.distance > break_nodes.distance_max[0]; + } + // Without a modeled schedule, there is no completion time that justifies omitting a break. + return !has_time || end.time > break_nodes.latest_time[0]; +} + /** - * @brief Inserts each missing break dimension into routes that lack it, one break per outer - * iteration, picking the least-cost insertion position for each. One block per route. + * @brief Remove unnecessary breaks and insert missing breaks whose deadlines the route exceeds. + * Revisit skipped dimensions after an insertion extends the route. One block per route. */ template __global__ void squeeze_breaks_kernel(typename solution_t::view_t solution, const bool include_objective, - infeasible_cost_t weights) + infeasible_cost_t weights, + const bool preserve_empty_breaks) { extern __shared__ i_t shmem[]; @@ -417,6 +459,7 @@ __global__ void squeeze_breaks_kernel(typename solution_t::vi auto global_route = solution.routes[route_id]; const int n_break_dims = solution.problem.get_break_dimensions(global_route.get_vehicle_id()); + if (n_break_dims == 0) { return; } typename route_t::view_t sh_route; sh_route = route_t::view_t::create_shared_route( @@ -431,69 +474,106 @@ __global__ void squeeze_breaks_kernel(typename solution_t::vi } __syncthreads(); - for (i_t tid = threadIdx.x; tid < sh_route.get_num_nodes(); tid += blockDim.x) { - auto node = sh_route.get_node(tid); - if (node.node_info().is_break()) { - cuopt_assert(node.node_info().break_dim() >= 0 && node.node_info().break_dim() < n_break_dims, - "Break dimension out of bounds"); - break_dim_counters[node.node_info().break_dim()] = 1; + const bool has_time = sh_route.dimensions_info().has_dimension(dim_t::TIME); + const bool has_requests = sh_route.get_num_service_nodes() > 0; + const bool keep_empty_breaks = preserve_empty_breaks && !has_requests; + __shared__ break_route_end_t route_end; + __syncthreads(); + if (threadIdx.x == 0) { + // Test existing breaks against the route without any breaks. Otherwise two optional + // detours can make each other appear required even though the work finishes before both. + const auto end_without_breaks = get_break_route_end(sh_route, false); + for (i_t idx = sh_route.get_num_nodes() - 1; idx > 0; --idx) { + auto node = sh_route.get_node(idx); + if (!node.node_info().is_break()) { continue; } + const auto break_dim = node.node_info().break_dim(); + cuopt_assert(break_dim >= 0 && break_dim < n_break_dims, "Break dimension out of bounds"); + auto break_nodes = + solution.problem.special_nodes.subset(sh_route.get_vehicle_id(), break_dim); + if (keep_empty_breaks || (has_requests && is_break_required( + break_nodes, end_without_breaks, has_time))) { + // Preserve placements already improved by local search. + break_dim_counters[break_dim] = 1; + } else { + sh_route.eject_node(idx, solution.route_node_map, false); + } } + route_t::view_t::compute_forward(sh_route); + route_t::view_t::compute_backward(sh_route); + sh_route.compute_cost(); + route_end = get_break_route_end(sh_route, true); } __syncthreads(); - for (int break_dim_idx = 0; break_dim_idx < n_break_dims; ++break_dim_idx) { - if (break_dim_counters[break_dim_idx] == 1) { continue; } - const auto old_objective_cost = sh_route.get_objective_cost(); - const auto old_infeasbility_cost = sh_route.get_infeasibility_cost(); - auto break_nodes = - solution.problem.special_nodes.subset(sh_route.get_vehicle_id(), break_dim_idx); - - i_t route_size = sh_route.get_num_nodes(); - i_t num_break_nodes = break_nodes.size(); - double thread_best_cost = std::numeric_limits::max(); - i_t thread_best_idx = -1; - i_t thread_best_break_node_id = -1; - for (int index = threadIdx.x; index < route_size * num_break_nodes; index += blockDim.x) { - i_t break_node_id = index / route_size; - i_t insertion_idx = index % route_size; - - auto break_node = create_break_node( - break_nodes, break_node_id, solution.problem.dimensions_info); - - auto curr_node = sh_route.get_node(insertion_idx); - auto next_node = sh_route.get_node(insertion_idx + 1); - - curr_node.calculate_forward_all(break_node, sh_route.vehicle_info()); - - double cost_difference = break_node.calculate_forward_all_and_delta(next_node, - sh_route.vehicle_info(), - include_objective, - weights, - old_objective_cost, - old_infeasbility_cost); - if (cost_difference < thread_best_cost) { - thread_best_cost = cost_difference; - thread_best_idx = insertion_idx; - thread_best_break_node_id = break_node_id; + // Each successful pass inserts at least one previously missing dimension, so at most + // n_break_dims passes are needed, including when dimensions are not in deadline order. + __shared__ bool inserted_break; + for (int pass = 0; (has_requests || keep_empty_breaks) && pass < n_break_dims; ++pass) { + if (threadIdx.x == 0) { inserted_break = false; } + __syncthreads(); + for (int break_dim_idx = 0; break_dim_idx < n_break_dims; ++break_dim_idx) { + __syncthreads(); + if (break_dim_counters[break_dim_idx] == 1) { continue; } + const auto old_objective_cost = sh_route.get_objective_cost(); + const auto old_infeasbility_cost = sh_route.get_infeasibility_cost(); + auto break_nodes = + solution.problem.special_nodes.subset(sh_route.get_vehicle_id(), break_dim_idx); + if (!keep_empty_breaks && !is_break_required(break_nodes, route_end, has_time)) { + continue; } - } - i_t t_id = threadIdx.x; - __shared__ i_t reduction_idx; - __shared__ double reduction_buf[2 * raft::WarpSize]; - block_reduce_ranked(thread_best_cost, t_id, reduction_buf, &reduction_idx); - - if (threadIdx.x == reduction_idx && thread_best_break_node_id >= 0 && - reduction_buf[0] != std::numeric_limits::max()) { - auto break_node = create_break_node( - break_nodes, thread_best_break_node_id, solution.problem.dimensions_info); - // do not update the intra indices yet - sh_route.insert_node(thread_best_idx, break_node, solution.route_node_map, false); - route_t::view_t::compute_forward(sh_route); - route_t::view_t::compute_backward(sh_route); - sh_route.compute_cost(); - } + i_t route_size = sh_route.get_num_nodes(); + i_t num_break_nodes = break_nodes.size(); + double thread_best_cost = std::numeric_limits::max(); + i_t thread_best_idx = -1; + i_t thread_best_break_node_id = -1; + for (int index = threadIdx.x; index < route_size * num_break_nodes; index += blockDim.x) { + i_t break_node_id = index / route_size; + i_t insertion_idx = index % route_size; + + auto break_node = create_break_node( + break_nodes, break_node_id, solution.problem.dimensions_info); + + auto curr_node = sh_route.get_node(insertion_idx); + auto next_node = sh_route.get_node(insertion_idx + 1); + + curr_node.calculate_forward_all(break_node, sh_route.vehicle_info()); + + double cost_difference = break_node.calculate_forward_all_and_delta(next_node, + sh_route.vehicle_info(), + include_objective, + weights, + old_objective_cost, + old_infeasbility_cost); + if (cost_difference < thread_best_cost) { + thread_best_cost = cost_difference; + thread_best_idx = insertion_idx; + thread_best_break_node_id = break_node_id; + } + } + i_t t_id = threadIdx.x; + __shared__ i_t reduction_idx; + __shared__ double reduction_buf[2 * raft::WarpSize]; + block_reduce_ranked(thread_best_cost, t_id, reduction_buf, &reduction_idx); + + if (threadIdx.x == reduction_idx && thread_best_break_node_id >= 0 && + reduction_buf[0] != std::numeric_limits::max()) { + auto break_node = create_break_node( + break_nodes, thread_best_break_node_id, solution.problem.dimensions_info); + // do not update the intra indices yet + sh_route.insert_node(thread_best_idx, break_node, solution.route_node_map, false); + route_t::view_t::compute_forward(sh_route); + route_t::view_t::compute_backward(sh_route); + sh_route.compute_cost(); + route_end = get_break_route_end(sh_route, true); + break_dim_counters[break_dim_idx] = 1; + inserted_break = true; + } + + __syncthreads(); + } + if (!inserted_break) { break; } __syncthreads(); } diff --git a/cpp/src/routing/local_search/breaks_insertion.cu b/cpp/src/routing/local_search/breaks_insertion.cu index b85bf9e828..0aa6021604 100644 --- a/cpp/src/routing/local_search/breaks_insertion.cu +++ b/cpp/src/routing/local_search/breaks_insertion.cu @@ -38,7 +38,9 @@ __global__ void find_break_insertions_kernel( i_t route_id = blockIdx.x / n_max_break_dims; i_t ejected_break_dim = blockIdx.x % n_max_break_dims; auto global_route = solution.routes[route_id]; - if (ejected_break_dim >= global_route.get_num_breaks()) { return; } + if (ejected_break_dim >= solution.problem.get_break_dimensions(global_route.get_vehicle_id())) { + return; + } for (int i = 0; i < global_route.get_num_nodes(); ++i) { auto node = global_route.get_node(i); diff --git a/cpp/src/routing/problem/problem.cu b/cpp/src/routing/problem/problem.cu index 9da8b738a1..b840a6df23 100644 --- a/cpp/src/routing/problem/problem.cu +++ b/cpp/src/routing/problem/problem.cu @@ -549,6 +549,7 @@ void problem_t::populate_special_nodes() std::vector> node_infos_h; std::vector node_earliest_h, node_latest_h; std::vector node_distance_min_h, node_distance_max_h; + std::vector node_is_distance_break_h; std::vector break_loc_to_idx_h; if (!uniform_breaks.empty()) { @@ -647,6 +648,7 @@ void problem_t::populate_special_nodes() node_latest_h.reserve(2 * n_vehicles); node_distance_min_h.reserve(2 * n_vehicles); node_distance_max_h.reserve(2 * n_vehicles); + node_is_distance_break_h.reserve(2 * n_vehicles); break_nodes_offset_h.push_back(0); @@ -708,6 +710,7 @@ void problem_t::populate_special_nodes() node_latest_h.push_back(break_latest_h[v][dim]); node_distance_min_h.push_back(vehicle_break.distance_min_); node_distance_max_h.push_back(vehicle_break.distance_max_); + node_is_distance_break_h.push_back(vehicle_break.is_distance_based_); } break_nodes_offset_h.push_back(offset); @@ -753,6 +756,8 @@ void problem_t::populate_special_nodes() if (special_nodes.has_distance_break) { special_nodes.distance_min = cuopt::device_copy(node_distance_min_h, handle_ptr->get_stream()); special_nodes.distance_max = cuopt::device_copy(node_distance_max_h, handle_ptr->get_stream()); + special_nodes.is_distance_break = + cuopt::device_copy(node_is_distance_break_h, handle_ptr->get_stream()); } special_nodes.break_loc_to_idx = cuopt::device_copy(break_loc_to_idx_h, handle_ptr->get_stream()); RAFT_CHECK_CUDA(handle_ptr->get_stream().get()); diff --git a/cpp/src/routing/problem/special_nodes.cuh b/cpp/src/routing/problem/special_nodes.cuh index 0398f6e222..90a4ff6d7b 100644 --- a/cpp/src/routing/problem/special_nodes.cuh +++ b/cpp/src/routing/problem/special_nodes.cuh @@ -12,6 +12,8 @@ #include #include +#include + namespace cuopt { namespace routing { namespace detail { @@ -30,6 +32,7 @@ class special_nodes_t { latest_time(0, handle_ptr->get_stream()), distance_min(0, handle_ptr->get_stream()), distance_max(0, handle_ptr->get_stream()), + is_distance_break(0, handle_ptr->get_stream()), break_loc_to_idx(0, handle_ptr->get_stream()) { } @@ -58,6 +61,8 @@ class special_nodes_t { if (!distance_min.empty()) { v.distance_min = raft::device_span(distance_min.data() + offset, sz); v.distance_max = raft::device_span(distance_max.data() + offset, sz); + v.is_distance_break = + raft::device_span(is_distance_break.data() + offset, sz); } return v; @@ -79,6 +84,7 @@ class special_nodes_t { // populated only when distance-based breaks are present raft::device_span distance_min; raft::device_span distance_max; + raft::device_span is_distance_break; raft::device_span break_loc_to_idx; }; @@ -94,8 +100,9 @@ class special_nodes_t { v.earliest_time = cuopt::make_span(earliest_time); v.latest_time = cuopt::make_span(latest_time); if (!distance_min.is_empty()) { - v.distance_min = cuopt::make_span(distance_min); - v.distance_max = cuopt::make_span(distance_max); + v.distance_min = cuopt::make_span(distance_min); + v.distance_max = cuopt::make_span(distance_max); + v.is_distance_break = cuopt::make_span(is_distance_break); } v.break_loc_to_idx = cuopt::make_span(break_loc_to_idx); @@ -121,6 +128,7 @@ class special_nodes_t { rmm::device_uvector latest_time; rmm::device_uvector distance_min; rmm::device_uvector distance_max; + rmm::device_uvector is_distance_break; rmm::device_uvector break_loc_to_idx; }; } // namespace detail diff --git a/cpp/tests/routing/routing_test.cuh b/cpp/tests/routing/routing_test.cuh index a855803b00..f6895d3d23 100644 --- a/cpp/tests/routing/routing_test.cuh +++ b/cpp/tests/routing/routing_test.cuh @@ -502,7 +502,6 @@ class base_test_t { // Recompute arrival times for each route for (auto const& id : temp_truck_ids) { - i_t break_dim = -1; std::vector arrival_stamp; std::vector latest_stamp; auto order = route[i]; @@ -526,11 +525,20 @@ class base_test_t { auto transit_matrix_h = matrices_h.get_time_matrix(vehicle_type); bool is_depot = node_type == node_type_t::DEPOT; + bool is_break = node_type == node_type_t::BREAK; + if (is_break) { + ASSERT_GE(order, 0); + ASSERT_LT(order, n_break_dim_); + } - double earliest = - is_depot ? depot_earliest : std::max(vehicle_earliest, (double)earliest_time_h[order]); + double earliest = is_break + ? break_earliest_h[order * n_vehicles + id] + : (is_depot ? depot_earliest + : std::max(vehicle_earliest, (double)earliest_time_h[order])); double latest = - is_depot ? depot_latest : std::min(vehicle_latest, (double)latest_time_h[order]); + is_break + ? break_latest_h[order * n_vehicles + id] + : (is_depot ? depot_latest : std::min(vehicle_latest, (double)latest_time_h[order])); // route does not have to start from the vehicle earliest time. It just has to start before // latest time to lower wait time @@ -549,22 +557,25 @@ class base_test_t { auto new_node_type = (node_type_t)node_types[i]; auto curr_is_break_order = node_type == node_type_t::BREAK; auto curr_is_depot = node_type == node_type_t::DEPOT; - if (curr_is_break_order) ++break_dim; auto next_is_break_order = new_node_type == node_type_t::BREAK; auto next_is_depot = new_node_type == node_type_t::DEPOT; double transit = transit_matrix_h[order_loc * n_locations + new_order_loc]; + if (next_is_break_order) { + ASSERT_GE(new_order, 0); + ASSERT_LT(new_order, n_break_dim_); + } - // For tests we assume break dimensions come in order + // Break node IDs identify dimensions even when earlier dimensions are omitted. double curr_service = curr_is_break_order - ? break_duration_h[break_dim * n_vehicles + id] + ? break_duration_h[order * n_vehicles + id] : (curr_is_depot ? 0. : vehicle_service_time_h[order]); double order_arrival = stamp + transit + curr_service; double order_earliest = next_is_break_order - ? break_earliest_h[(break_dim + 1) * n_vehicles + id] + ? break_earliest_h[new_order * n_vehicles + id] : (next_is_depot ? depot_earliest : static_cast(earliest_time_h[new_order])); double order_latest = next_is_break_order - ? break_latest_h[(break_dim + 1) * n_vehicles + id] + ? break_latest_h[new_order * n_vehicles + id] : (next_is_depot ? depot_latest : latest_time_h[new_order]); double curr_wait = std::max(0.0, order_earliest - order_arrival); order_arrival += curr_wait; @@ -603,47 +614,81 @@ class base_test_t { auto const& locations = h_routing_solution.locations; auto const& node_types = h_routing_solution.node_types; - auto cost_matrix_h = matrices_h.get_cost_matrix(0); - i_t prev_loc = -1; - i_t curr_truck = -1; - f_t cumulative = 0.f; - size_t break_count = 0; - - for (size_t i = 0; i < truck_id.size(); ++i) { - if (truck_id[i] != curr_truck) { - curr_truck = truck_id[i]; - cumulative = 0.f; - prev_loc = locations[i]; - continue; + size_t begin = 0; + while (begin < truck_id.size()) { + auto vehicle_id = truck_id[begin]; + size_t end = begin + 1; + while (end < truck_id.size() && truck_id[end] == vehicle_id) { + ++end; } - i_t loc = locations[i]; - cumulative += cost_matrix_h[prev_loc * n_locations + loc]; - prev_loc = loc; - if (static_cast(node_types[i]) == node_type_t::BREAK) { - ++break_count; - ASSERT_LE(cumulative, max_range + 1e-3f) - << "break at cumulative distance " << cumulative << " exceeds max_range " << max_range; + auto vehicle_type = vehicle_types_h.empty() ? 0 : vehicle_types_h[vehicle_id]; + auto cost_matrix_h = matrices_h.get_cost_matrix(vehicle_type); + double cumulative = 0.; + bool found_break = false; + for (size_t i = begin; i < end; ++i) { + // Skipped first/return trips are absent from the assignment. + if (i > begin) { + cumulative += cost_matrix_h[locations[i - 1] * n_locations + locations[i]]; + } + if (static_cast(node_types[i]) == node_type_t::BREAK) { + ASSERT_EQ(h_routing_solution.route[i], 0); + ASSERT_FALSE(found_break) << "Duplicate distance break for vehicle " << vehicle_id; + found_break = true; + ASSERT_LE(cumulative, static_cast(max_range) + 1e-3) + << "break at cumulative distance " << cumulative << " exceeds max_range " << max_range; + } } + if (!found_break) { + ASSERT_LE(cumulative, static_cast(max_range) + 1e-3) + << "Vehicle " << vehicle_id << " is missing required distance break 0"; + } + begin = end; } - ASSERT_GT(break_count, 0u) - << "expected at least one BREAK node in the solution, none were emitted"; } void check_vehicle_breaks(host_assignment_t const& h_routing_solution) { - auto truck_id = h_routing_solution.truck_id; - auto node_types = h_routing_solution.node_types; - i_t curr_truck_id = -1; - i_t break_dim = n_break_dim_; - for (size_t i = 0; i < truck_id.size(); i++) { - if (truck_id[i] != curr_truck_id) { - ASSERT_EQ(break_dim, n_break_dim_); - curr_truck_id = truck_id[i]; - break_dim = 0; + auto const& truck_id = h_routing_solution.truck_id; + auto const& node_types = h_routing_solution.node_types; + auto const& route = h_routing_solution.route; + fleet_order_constraints_h = fleet_order_constraints_d.to_host(stream_view_); + size_t begin = 0; + while (begin < truck_id.size()) { + auto vehicle_id = truck_id[begin]; + size_t end = begin + 1; + while (end < truck_id.size() && truck_id[end] == vehicle_id) { + ++end; + } + std::vector seen_breaks(n_break_dim_, false); + for (size_t i = begin; i < end; ++i) { + if (static_cast(node_types[i]) != node_type_t::BREAK) { continue; } + auto dim = route[i]; + ASSERT_GE(dim, 0); + ASSERT_LT(dim, n_break_dim_); + ASSERT_FALSE(seen_breaks[dim]) << "Duplicate break " << dim << " vehicle " << vehicle_id; + seen_breaks[dim] = true; + auto offset = dim * n_vehicles + vehicle_id; + ASSERT_GE(h_routing_solution.stamp[i], + static_cast(break_earliest_h[offset]) - 1e-3); + ASSERT_LE(h_routing_solution.stamp[i], static_cast(break_latest_h[offset]) + 1e-3); + } + double route_end_time = h_routing_solution.stamp[end - 1]; + auto final_type = static_cast(node_types[end - 1]); + if (final_type == node_type_t::BREAK) { + route_end_time += break_duration_h[route[end - 1] * n_vehicles + vehicle_id]; + } else if (final_type != node_type_t::DEPOT) { + route_end_time += + fleet_order_constraints_h.order_service_times[vehicle_id * n_orders + route[end - 1]]; + } + for (i_t dim = 0; dim < n_break_dim_; ++dim) { + if (!seen_breaks[dim]) { + ASSERT_LE(route_end_time, + static_cast(break_latest_h[dim * n_vehicles + vehicle_id]) + 1e-3) + << "Vehicle " << vehicle_id << " is missing required time break " << dim; + } } - if (node_types[i] == (i_t)node_type_t::BREAK) ++break_dim; + begin = end; } - ASSERT_EQ(break_dim, n_break_dim_); } void check_capacity(host_assignment_t const& routing_solution, diff --git a/cpp/tests/routing/unit_tests/breaks.cu b/cpp/tests/routing/unit_tests/breaks.cu index 0d8a578b6e..8e730fda1f 100644 --- a/cpp/tests/routing/unit_tests/breaks.cu +++ b/cpp/tests/routing/unit_tests/breaks.cu @@ -5,6 +5,8 @@ */ /* clang-format on */ +#include +#include #include #include @@ -20,6 +22,129 @@ namespace cuopt { namespace routing { namespace test { +namespace { + +using break_test_solution = detail::solution_t; +using break_test_route = detail::route_t; + +// Load actual break nodes; public initial-solution injection discards supplied breaks. +__global__ void load_route_with_optional_breaks(break_test_solution::view_t sol) +{ + auto& route = sol.routes[0]; + for (int order = 0; order < sol.problem.order_info.get_num_orders(); ++order) { + sol.route_node_map.route_id_per_node[order] = -1; + sol.route_node_map.intra_route_idx_per_node[order] = -1; + } + auto start = sol.problem.get_start_depot_node_info(0); + auto end = sol.problem.get_return_depot_node_info(0); + route.set_num_nodes(4); + route.set_node( + 0, detail::create_depot_node(sol.problem, start, start, 0)); + for (int dim = 0; dim < 2; ++dim) { + route.set_node(dim + 1, + detail::create_break_node( + sol.problem.special_nodes.subset(0, dim), 0, sol.problem.dimensions_info)); + } + auto order = detail::create_node(sol.problem, 0); + route.set_node(3, order); + sol.route_node_map.set_route_id_and_intra_idx(order.node_info(), 0, 3); + route.set_node(4, + detail::create_depot_node(sol.problem, end, end, 0)); + detail::set_route_data(sol.problem, route); + break_test_route::view_t::compute_forward(route); + break_test_route::view_t::compute_backward(route); + route.compute_cost(); + sol.routes_to_copy[0] = 1; + sol.routes_to_search[0] = 1; +} + +void test_break_normalization_after_route_changes(bool distance_breaks) +{ + raft::handle_t handle; + auto stream = handle.get_stream(); + // Depot 0, nearby customer 10, distant customer 60, and break location -55. + std::vector matrix = {0, 10, 60, 55, 10, 0, 50, 65, 60, 50, 0, 115, 55, 65, 115, 0}; + auto d_matrix = cuopt::device_copy(matrix, stream); + auto d_orders = cuopt::device_copy(std::vector{1, 2}, stream); + auto d_break_location = cuopt::device_copy(std::vector{3}, stream); + data_model_view_t data_model(&handle, 4, 1, 2); + data_model.add_cost_matrix(d_matrix.data()); + data_model.add_transit_time_matrix(d_matrix.data()); + data_model.set_order_locations(d_orders.data()); + for (int dim = 0; dim < 2; ++dim) { + if (distance_breaks) { + data_model.add_vehicle_distance_break(0, 0.f, 60.f, 5, d_break_location.data(), 1); + } else { + data_model.add_vehicle_break( + 0, dim == 0 ? 0 : 60, dim == 0 ? 60 : 65, 5, d_break_location.data(), 1); + } + } + + solver_settings_t settings; + detail::problem_t problem(data_model, settings); + detail::solution_handle_t sol_handle(stream); + break_test_solution sol(problem, 0, &sol_handle, {0}); + detail::local_search_t local_search( + &sol_handle, 2, 1, problem.order_info.depot_included_, problem.viables); + local_search.set_active_weights(detail::default_weights); + detail::guided_ejection_search_t ges(sol, &local_search); + + load_route_with_optional_breaks<<<1, 1, 0, stream>>>(sol.view()); + RAFT_CUDA_TRY(cudaGetLastError()); + sol.compute_cost(); + ASSERT_EQ(sol.get_route(0).get_num_breaks(), 2); + ASSERT_DOUBLE_EQ(sol.get_objective_cost()[objective_t::COST], 130.); + // Keeping either detour makes the other appear required. Both must be reconsidered. + ges.squeeze_breaks(); + ASSERT_TRUE(sol.is_feasible()); + ASSERT_EQ(sol.get_route(0).get_num_breaks(), 0); + ASSERT_DOUBLE_EQ(sol.get_objective_cost()[objective_t::COST], 20.); + + detail::NodeInfo<> near_order(0, 1, node_type_t::DELIVERY); + detail::NodeInfo<> far_order(1, 2, node_type_t::DELIVERY); + sol.add_nodes_to_route({far_order}, 0, 1); + sol.compute_cost(); + ASSERT_DOUBLE_EQ(sol.get_objective_cost()[objective_t::COST], 120.); + ges.squeeze_breaks(); + ASSERT_TRUE(sol.is_feasible()); + ASSERT_EQ(sol.get_route(0).get_num_breaks(), 2); + ASSERT_DOUBLE_EQ(sol.get_objective_cost()[objective_t::COST], 230.); + + // Remove a real customer while retaining the existing break nodes. + ASSERT_TRUE(sol.remove_nodes({far_order})); + ASSERT_EQ(sol.get_route(0).get_num_breaks(), 2); + ges.squeeze_breaks(); + ASSERT_TRUE(sol.is_feasible()); + ASSERT_EQ(sol.get_route(0).get_num_breaks(), 0); + ASSERT_DOUBLE_EQ(sol.get_objective_cost()[objective_t::COST], 20.); + EXPECT_EQ(sol.route_node_map.get_route_id_and_intra_idx(near_order), std::make_pair(0, 1)); + EXPECT_EQ(sol.route_node_map.get_route_id(far_order), -1); + + sol.add_nodes_to_route({far_order}, 0, 1); + ges.squeeze_breaks(); + ASSERT_EQ(sol.get_route(0).get_num_breaks(), 2); + ASSERT_TRUE(sol.remove_nodes({near_order, far_order})); + ASSERT_EQ(sol.get_route(0).get_num_service_nodes(), 0); + ASSERT_EQ(sol.get_route(0).get_num_breaks(), 2); + ges.squeeze_breaks(); + ASSERT_TRUE(sol.is_feasible()); + ASSERT_EQ(sol.get_route(0).get_num_breaks(), 0); + ASSERT_EQ(sol.get_route(0).n_nodes.value(stream), 1); + ASSERT_DOUBLE_EQ(sol.get_objective_cost()[objective_t::COST], 0.); +} + +} // namespace + +TEST(vehicle_breaks, time_breaks_normalized_after_route_changes) +{ + test_break_normalization_after_route_changes(false); +} + +TEST(vehicle_breaks, distance_breaks_normalized_after_route_changes) +{ + test_break_normalization_after_route_changes(true); +} + static std::vector cost_matrix = {0, 1, 1, 1, 0, 1, 1, 1, 0}; static std::vector break_earliest = {0, 1}; static std::vector break_latest = {2, 3}; diff --git a/cpp/tests/routing/unit_tests/distance_breaks.cu b/cpp/tests/routing/unit_tests/distance_breaks.cu index 376edc0961..900747a4d3 100644 --- a/cpp/tests/routing/unit_tests/distance_breaks.cu +++ b/cpp/tests/routing/unit_tests/distance_breaks.cu @@ -363,8 +363,8 @@ TEST(distance_breaks, default_case) auto v_cost_matrix = copy_array_to_device(cost_matrix_3x3, stream); cuopt::routing::data_model_view_t data_model(&handle, 3, 2); data_model.add_cost_matrix(v_cost_matrix.data()); - data_model.add_vehicle_distance_break(0, 0.f, 2.f, 1, nullptr, 0); - data_model.add_vehicle_distance_break(1, 0.f, 2.f, 1, nullptr, 0); + data_model.add_vehicle_distance_break(0, 0.f, 1.5f, 1, nullptr, 0); + data_model.add_vehicle_distance_break(1, 0.f, 1.5f, 1, nullptr, 0); data_model.set_min_vehicles(2); auto routing_solution = cuopt::routing::solve(data_model); @@ -405,7 +405,8 @@ TEST(distance_breaks, default_objective_weight) cuopt::routing::data_model_view_t data_model(&handle, 3, 1, 1); data_model.add_cost_matrix(v_cost_matrix.data()); data_model.set_order_locations(v_order_locations.data()); - data_model.add_vehicle_distance_break(0, 10.f, 100.f, 0, v_break_locations.data(), 1); + // The two-unit route requires the break; its only feasible arrival is at distance one. + data_model.add_vehicle_distance_break(0, 1.25f, 1.5f, 0, v_break_locations.data(), 1); if (mode != objective_mode::DEFAULTS) { data_model.set_objective_function(v_objectives.data(), v_weights.data(), weights.size()); @@ -424,9 +425,9 @@ TEST(distance_breaks, default_objective_weight) EXPECT_EQ(objective_values.count(objective_t::DISTANCE_BREAK_COST), 0u); EXPECT_DOUBLE_EQ(solution.get_total_objective(), 3.); } else { - EXPECT_DOUBLE_EQ(objective_values.at(objective_t::DISTANCE_BREAK_COST), 8.); + EXPECT_DOUBLE_EQ(objective_values.at(objective_t::DISTANCE_BREAK_COST), 0.25); EXPECT_DOUBLE_EQ(solution.get_total_objective(), - mode == objective_mode::OMIT_DISTANCE_BREAK_COST ? 14. : 11.); + mode == objective_mode::OMIT_DISTANCE_BREAK_COST ? 6.25 : 3.25); } } } @@ -448,9 +449,9 @@ TEST(distance_breaks, with_break_locations) data_model.add_cost_matrix(v_cost_matrix.data()); data_model.set_order_locations(v_order_locations.data()); data_model.add_vehicle_distance_break( - 0, 0.f, 2.f, 1, v_break_locations.data(), (int)v_break_locations.size()); + 0, 0.f, 1.5f, 1, v_break_locations.data(), (int)v_break_locations.size()); data_model.add_vehicle_distance_break( - 1, 0.f, 2.f, 1, v_break_locations.data(), (int)v_break_locations.size()); + 1, 0.f, 1.5f, 1, v_break_locations.data(), (int)v_break_locations.size()); data_model.set_min_vehicles(2); auto settings = cuopt::routing::solver_settings_t{}; @@ -471,13 +472,13 @@ TEST(distance_breaks, with_break_locations) } } -// Stacking add_vehicle_distance_break calls produces one break per cycle per vehicle. +// Stacking distance breaks enforces every deadline exceeded by the route. TEST(distance_breaks, multi_cycle) { raft::handle_t handle; auto stream = handle.get_stream(); - // Two vehicles, each with two charge cycles: [0, 2) and [2, 4). + // Both deadlines are below each vehicle's two-unit route length. std::vector order_locations = {1, 2}; auto v_cost_matrix = copy_array_to_device(cost_matrix_5x5, stream); @@ -488,8 +489,8 @@ TEST(distance_breaks, multi_cycle) data_model.set_order_locations(v_order_locations.data()); for (int vid = 0; vid < 2; ++vid) { - data_model.add_vehicle_distance_break(vid, 0.f, 2.f, 1, nullptr, 0); - data_model.add_vehicle_distance_break(vid, 2.f, 4.f, 1, nullptr, 0); + data_model.add_vehicle_distance_break(vid, 0.f, 0.5f, 1, nullptr, 0); + data_model.add_vehicle_distance_break(vid, 1.f, 1.5f, 1, nullptr, 0); } data_model.set_min_vehicles(2); @@ -513,6 +514,7 @@ TEST(distance_breaks, multi_cycle) for (auto const& [vid, cnt] : break_count) { ASSERT_EQ(cnt, 2); } + ASSERT_EQ(break_count.size(), 2); } // Solver chooses the longer route so the break lands inside [0, d_max]. @@ -574,8 +576,8 @@ TEST(distance_breaks, early_arrival_objective) // clang-format off std::vector cost_matrix_4 = { 0, 50, 50, 1, - 50, 0, 10, 60, - 50, 10, 0, 60, + 50, 0, 10, 40, + 50, 10, 0, 40, 1, 60, 60, 0, }; // clang-format on @@ -593,7 +595,7 @@ TEST(distance_breaks, early_arrival_objective) cuopt::routing::data_model_view_t data_model(&handle, 4, 1, 2); data_model.add_cost_matrix(v_cost_matrix.data()); data_model.set_order_locations(v_order_locations.data()); - data_model.add_vehicle_distance_break(0, 40.f, 200.f, 0, v_break_locations.data(), 1); + data_model.add_vehicle_distance_break(0, 40.f, 100.f, 0, v_break_locations.data(), 1); data_model.set_objective_function( v_objectives.data(), v_objective_weights.data(), v_objective_weights.size()); @@ -616,7 +618,7 @@ TEST(distance_breaks, early_arrival_objective) if (static_cast(h.node_types[i]) == node_type_t::BREAK) { found_break = true; EXPECT_GE(cumulative, 40.f - 1e-3f); - EXPECT_LE(cumulative, 200.f + 1e-3f); + EXPECT_LE(cumulative, 100.f + 1e-3f); } prev_loc = loc; } @@ -632,7 +634,7 @@ TEST(distance_breaks, mixed_fleet) auto v_cost_matrix = copy_array_to_device(cost_matrix_3x3, stream); cuopt::routing::data_model_view_t data_model(&handle, 3, 2); data_model.add_cost_matrix(v_cost_matrix.data()); - data_model.add_vehicle_distance_break(0, 0.f, 2.f, 1, nullptr, 0); + data_model.add_vehicle_distance_break(0, 0.f, 1.5f, 1, nullptr, 0); data_model.set_min_vehicles(2); auto settings = cuopt::routing::solver_settings_t{}; diff --git a/cpp/tests/routing/utilities/check_constraints.cu b/cpp/tests/routing/utilities/check_constraints.cu index 068182d7ba..2feb6878f7 100644 --- a/cpp/tests/routing/utilities/check_constraints.cu +++ b/cpp/tests/routing/utilities/check_constraints.cu @@ -103,8 +103,22 @@ void check_route(data_model_view_t const& data_model, if (data_model.get_order_locations() == nullptr) { visited.insert(0); } - bool has_breaks = data_model.has_vehicle_breaks(); - std::vector> uniform_break_earliest_h, uniform_break_latest_h; + bool has_breaks = data_model.has_vehicle_breaks(); + bool has_time_schedule = !time_matrices_h.empty() || !vehicle_max_times_h.empty() || + data_model.get_vehicle_time_windows().first != nullptr || + std::get<0>(data_model.get_order_time_windows()) != nullptr; + auto [objective_ptr, objective_weights, num_objectives] = data_model.get_objective_function(); + if (num_objectives > 0) { + auto objectives_h = cuopt::host_copy(objective_ptr, num_objectives, stream); + auto weights_h = cuopt::host_copy(objective_weights, num_objectives, stream); + for (size_t dim = 0; dim < objectives_h.size(); ++dim) { + if (objectives_h[dim] == objective_t::TRAVEL_TIME && weights_h[dim] > 0.) { + has_time_schedule = true; + } + } + } + std::vector> uniform_break_earliest_h, uniform_break_latest_h, + uniform_break_duration_h; std::unordered_set uniform_break_locations_set; if (has_breaks) { auto const& uniform = data_model.get_uniform_breaks(); @@ -114,6 +128,8 @@ void check_route(data_model_view_t const& data_model, cuopt::host_copy(e_ptr, static_cast(fleet_size), stream)); uniform_break_latest_h.push_back( cuopt::host_copy(l_ptr, static_cast(fleet_size), stream)); + uniform_break_duration_h.push_back( + cuopt::host_copy(d_ptr, static_cast(fleet_size), stream)); } auto [break_loc_ptr, n_break_loc] = data_model.get_break_locations(); if (n_break_loc > 0) { @@ -157,18 +173,32 @@ void check_route(data_model_view_t const& data_model, } if (has_breaks) { - int break_dim = 0; auto const& non_uniform = data_model.get_non_uniform_breaks(); bool use_uniform = !uniform_break_earliest_h.empty(); bool use_non_uniform = (non_uniform.count(id) > 0); + size_t num_break_dims = use_uniform ? uniform_break_earliest_h.size() + : use_non_uniform ? non_uniform.at(id).size() + : 0; + std::vector seen_breaks(num_break_dims, false); + double cumulative_distance = 0.; + double final_break_duration = 0.; for (size_t k = i_vehicle_start; k < i; ++k) { + // The assignment omits the first/return depot when its trip is skipped. + if (k > i_vehicle_start) { + cumulative_distance += cost_matrix_h[locations[k - 1] * n_locations + locations[k]]; + } if (static_cast(node_types[k]) == node_type_t::BREAK) { - double arrival = h_routing_solution.stamp[k]; - i_t break_loc_id = locations[k]; - if (use_uniform && break_dim < static_cast(uniform_break_earliest_h.size())) { - // std::cout<<"VEHID: "<(break_dim), num_break_dims); + ASSERT_FALSE(seen_breaks[break_dim]) + << "Duplicate break " << break_dim << " vehicle " << id; + seen_breaks[break_dim] = true; + double arrival = h_routing_solution.stamp[k]; + i_t break_loc_id = locations[k]; + double break_duration = 0.; + if (use_uniform) { + break_duration = uniform_break_duration_h[break_dim][id]; ASSERT_GE(arrival, static_cast(uniform_break_earliest_h[break_dim][id]) - 1e-6) << "Break " << break_dim << " vehicle " << id << " arrival " << arrival << " before earliest " << uniform_break_earliest_h[break_dim][id]; @@ -180,35 +210,61 @@ void check_route(data_model_view_t const& data_model, << "Break " << break_dim << " vehicle " << id << " at location " << break_loc_id << " not in allowed break locations"; } - } else if (use_non_uniform) { - auto const& breaks = non_uniform.at(id); - if (break_dim < static_cast(breaks.size())) { - auto const& b = breaks[break_dim]; + } else { + auto const& b = non_uniform.at(id)[break_dim]; + break_duration = b.duration_; + if (b.is_distance_based_) { + ASSERT_LE(cumulative_distance, static_cast(b.distance_max_) + 1e-3) + << "Distance break " << break_dim << " vehicle " << id; + } else { ASSERT_GE(arrival, static_cast(b.earliest_) - 1e-6) << "Non-uniform break " << break_dim << " vehicle " << id; ASSERT_LE(arrival, static_cast(b.latest_) + 1e-6) << "Non-uniform break " << break_dim << " vehicle " << id; - if (b.locations_.size() > 0) { - auto allowed_locs = cuopt::host_copy(b.locations_, stream); - bool found = std::find(allowed_locs.begin(), allowed_locs.end(), break_loc_id) != - allowed_locs.end(); - ASSERT_TRUE(found) - << "Non-uniform break " << break_dim << " vehicle " << id << " at location " - << break_loc_id << " not in allowed break locations"; - } + } + if (b.locations_.size() > 0) { + auto allowed_locs = cuopt::host_copy(b.locations_, stream); + bool found = std::find(allowed_locs.begin(), allowed_locs.end(), break_loc_id) != + allowed_locs.end(); + ASSERT_TRUE(found) << "Non-uniform break " << break_dim << " vehicle " << id + << " at location " << break_loc_id + << " not in allowed break locations"; } } - ++break_dim; + if (k + 1 == i) { final_break_duration = break_duration; } } } - if (use_uniform) { - ASSERT_EQ(break_dim, static_cast(uniform_break_earliest_h.size())) - << "Vehicle " << id << " break count " << break_dim << " expected " - << uniform_break_earliest_h.size(); - } else if (use_non_uniform) { - ASSERT_EQ(break_dim, static_cast(non_uniform.at(id).size())) - << "Vehicle " << id << " non-uniform break count " << break_dim << " expected " - << non_uniform.at(id).size(); + + double route_end_time = h_routing_solution.stamp[i - 1] + final_break_duration; + auto final_node_type = static_cast(node_types[i - 1]); + if (final_node_type != node_type_t::DEPOT && final_node_type != node_type_t::BREAK) { + auto const& service_times = data_model.get_order_service_times(); + auto service_it = service_times.find(id); + if (service_it == service_times.end()) { service_it = service_times.find(-1); } + if (service_it != service_times.end()) { + auto service_times_h = cuopt::host_copy(service_it->second, stream); + route_end_time += service_times_h[route[i - 1]]; + } + } + for (size_t dim = 0; dim < num_break_dims; ++dim) { + if (seen_breaks[dim]) { continue; } + if (use_uniform) { + ASSERT_TRUE(has_time_schedule) << "Vehicle " << id << " is missing time break " << dim + << " without a modeled schedule"; + ASSERT_LE(route_end_time, static_cast(uniform_break_latest_h[dim][id]) + 1e-3) + << "Vehicle " << id << " is missing required time break " << dim; + } else { + auto const& b = non_uniform.at(id)[dim]; + if (b.is_distance_based_) { + ASSERT_LE(cumulative_distance, static_cast(b.distance_max_) + 1e-3) + << "Vehicle " << id << " is missing required distance break " << dim; + } else { + ASSERT_TRUE(has_time_schedule) << "Vehicle " << id << " is missing time break " << dim + << " without a modeled schedule"; + ASSERT_LE(route_end_time, static_cast(b.latest_) + 1e-3) + << "Vehicle " << id << " is missing required time break " << dim; + } + } } } diff --git a/python/cuopt/cuopt/tests/routing/test_distance_breaks.py b/python/cuopt/cuopt/tests/routing/test_distance_breaks.py index 5d07b3a59d..6f7564f51c 100644 --- a/python/cuopt/cuopt/tests/routing/test_distance_breaks.py +++ b/python/cuopt/cuopt/tests/routing/test_distance_breaks.py @@ -202,11 +202,11 @@ def _solve(dm, time_limit=10): def test_solve_basic_break_assigned(): - """Each vehicle with a distance break receives exactly one break in the solution.""" + """Each route exceeding its distance deadline receives the required break.""" dm = routing.DataModel(3, 2) dm.add_cost_matrix(cudf.DataFrame(_COST_3X3, dtype="float32")) - dm.add_vehicle_distance_break(0, 0.0, 2.0, 1) - dm.add_vehicle_distance_break(1, 0.0, 2.0, 1) + dm.add_vehicle_distance_break(0, 0.0, 1.5, 1) + dm.add_vehicle_distance_break(1, 0.0, 1.5, 1) dm.set_min_vehicles(2) sol = _solve(dm) @@ -217,12 +217,9 @@ def test_solve_basic_break_assigned(): for i in range(routes.shape[0]): if routes["type"][i] == "Break": vid = routes["truck_id"][i] - breaks_per_vehicle[vid] = breaks_per_vehicle.get(vid, 0) + 1 + breaks_per_vehicle.setdefault(vid, []).append(routes["route"][i]) - assert 0 in breaks_per_vehicle - assert 1 in breaks_per_vehicle - assert breaks_per_vehicle[0] == 1 - assert breaks_per_vehicle[1] == 1 + assert breaks_per_vehicle == {0: [0], 1: [0]} @pytest.mark.parametrize("objective_mode", ["defaults", "omit", "disable"]) @@ -231,8 +228,9 @@ def test_default_distance_break_cost_weight(objective_mode): dm = routing.DataModel(3, 1, 1) dm.add_cost_matrix(cudf.DataFrame(_COST_3X3, dtype="float32")) dm.set_order_locations(cudf.Series([1], dtype="int32")) + # The base route has distance 2; the required break can be reached at 1. dm.add_vehicle_distance_break( - 0, 10.0, 100.0, 0, cudf.Series([2], dtype="int32") + 0, 1.25, 1.5, 0, cudf.Series([2], dtype="int32") ) if objective_mode != "defaults": objectives = [routing.Objective.COST] @@ -253,10 +251,13 @@ def test_default_distance_break_cost_weight(objective_mode): assert routing.Objective.DISTANCE_BREAK_COST not in objectives assert sol.get_total_objective() == 3.0 else: - assert objectives[routing.Objective.DISTANCE_BREAK_COST] == 8.0 + assert objectives[routing.Objective.DISTANCE_BREAK_COST] == 0.25 assert sol.get_total_objective() == ( - 14.0 if objective_mode == "omit" else 11.0 + 6.25 if objective_mode == "omit" else 3.25 ) + routes = sol.get_route().to_pandas() + assert routes[routes["type"] == "Break"]["route"].tolist() == [0] + assert routes["location"].tolist() == [0, 2, 1, 0] def test_solve_break_at_break_location(): @@ -267,8 +268,8 @@ def test_solve_break_at_break_location(): dm = routing.DataModel(5, 2, 2) dm.add_cost_matrix(cudf.DataFrame(_COST_5X5, dtype="float32")) dm.set_order_locations(order_locations) - dm.add_vehicle_distance_break(0, 0.0, 2.0, 1, locations) - dm.add_vehicle_distance_break(1, 0.0, 2.0, 1, locations) + dm.add_vehicle_distance_break(0, 0.0, 1.5, 1, locations) + dm.add_vehicle_distance_break(1, 0.0, 1.5, 1, locations) dm.set_min_vehicles(2) sol = _solve(dm) @@ -276,26 +277,25 @@ def test_solve_break_at_break_location(): routes = sol.get_route().to_pandas() break_loc_set = {3, 4} - vehicles_with_breaks = set() + breaks_per_vehicle = {} for i in range(routes.shape[0]): if routes["type"][i] == "Break": - vehicles_with_breaks.add(int(routes["truck_id"][i])) + vid = int(routes["truck_id"][i]) + breaks_per_vehicle.setdefault(vid, []).append(routes["route"][i]) assert routes["location"][i] in break_loc_set - assert vehicles_with_breaks == {0, 1}, ( - f"expected breaks on vehicles {{0, 1}}, got {vehicles_with_breaks}" - ) + assert breaks_per_vehicle == {0: [0], 1: [0]} def test_solve_multi_cycle_break_count(): - """Each used vehicle with 2 cycles receives exactly 2 break nodes.""" + """Both deadlines precede route completion, so each vehicle needs both breaks.""" order_locations = cudf.Series([1, 2], dtype="int32") dm = routing.DataModel(5, 2, 2) dm.add_cost_matrix(cudf.DataFrame(_COST_5X5, dtype="float32")) dm.set_order_locations(order_locations) for vid in [0, 1]: - dm.add_vehicle_distance_break(vid, 0.0, 2.0, 1) - dm.add_vehicle_distance_break(vid, 2.0, 4.0, 1) + dm.add_vehicle_distance_break(vid, 0.0, 0.5, 1) + dm.add_vehicle_distance_break(vid, 1.0, 1.5, 1) dm.set_min_vehicles(2) sol = _solve(dm) @@ -306,13 +306,13 @@ def test_solve_multi_cycle_break_count(): for i in range(routes.shape[0]): if routes["type"][i] == "Break": vid = int(routes["truck_id"][i]) - breaks_per_vehicle[vid] = breaks_per_vehicle.get(vid, 0) + 1 + breaks_per_vehicle.setdefault(vid, []).append(routes["route"][i]) assert set(breaks_per_vehicle) == {0, 1}, ( f"expected breaks on vehicles {{0, 1}}, got {set(breaks_per_vehicle)}" ) - for vid, cnt in breaks_per_vehicle.items(): - assert cnt == 2 + for dimensions in breaks_per_vehicle.values(): + assert sorted(dimensions) == [0, 1] def test_solve_break_distance_window_enforced(): @@ -349,17 +349,20 @@ def test_solve_break_distance_window_enforced(): prev_loc = loc assert found_break, "no break found in solution" + assert routes[routes["type"] == "Break"]["route"].tolist() == [0] def test_solve_full_feature_api(): """Exercises every add_vehicle_distance_break parameter at non-default values. - Two cycle targets of 10 and 30, with hard limits of 20 and 40 and a high - early-break penalty, make the solver prefer distinct break locations for - each cycle on a 5-location unit-cost grid (arc 10 between distinct locations). + Each one-customer route has distance 50 before adding breaks, exceeding + both hard limits 20 and 40. Soft targets 10 and 30 and a high early-break + penalty favor taking the breaks at separate visits to eligible locations. """ - # depot(0), customers(1, 2), break locations(3, 4); arc 10 between distinct locations. + # Depot(0), customers(1, 2), break locations(3, 4). cost = [[0 if i == j else 10 for j in range(5)] for i in range(5)] + for customer in (1, 2): + cost[0][customer] = cost[customer][0] = 25 order_locations = cudf.Series([1, 2], dtype="int32") locations = cudf.Series([3, 4], dtype="int32") cycle_windows = [(10.0, 20.0), (30.0, 40.0)] @@ -388,7 +391,7 @@ def test_solve_full_feature_api(): cost_flat = [c for row in cost for c in row] break_loc_set = {int(s) for s in locations.to_arrow().to_pylist()} - breaks_per_vehicle: dict[int, list[float]] = {} + breaks_per_vehicle: dict[int, dict[int, float]] = {} cumulative_per_vehicle: dict[int, float] = {} prev_loc_per_vehicle: dict[int, int] = {} n_loc = 5 @@ -404,9 +407,11 @@ def test_solve_full_feature_api(): prev_loc_per_vehicle[vid] = loc if routes["type"][i] == "Break": - breaks_per_vehicle.setdefault(vid, []).append( - cumulative_per_vehicle[vid] - ) + dimension = int(routes["route"][i]) + vehicle_breaks = breaks_per_vehicle.setdefault(vid, {}) + assert dimension in range(len(cycle_windows)) + assert dimension not in vehicle_breaks + vehicle_breaks[dimension] = cumulative_per_vehicle[vid] assert loc in break_loc_set, ( f"vehicle {vid} break at location {loc} not in break locations " f"{break_loc_set}" @@ -416,11 +421,8 @@ def test_solve_full_feature_api(): f"expected breaks on vehicles {{0, 1}}, got {set(breaks_per_vehicle)}" ) for vid, break_distances in breaks_per_vehicle.items(): - assert len(break_distances) == len(cycle_windows), ( - f"vehicle {vid} has {len(break_distances)} breaks, " - f"expected {len(cycle_windows)}" - ) - for k, d in enumerate(break_distances): + assert set(break_distances) == set(range(len(cycle_windows))) + for k, d in break_distances.items(): lo, hi = cycle_windows[k] assert lo - 1e-6 <= d <= hi + 1e-6, ( f"vehicle {vid} cycle {k} break at cumulative {d} outside window " @@ -432,7 +434,7 @@ def test_solve_mixed_fleet_break_assignment(): """Only the vehicle with a distance break configured receives break nodes.""" dm = routing.DataModel(3, 2) dm.add_cost_matrix(cudf.DataFrame(_COST_3X3, dtype="float32")) - dm.add_vehicle_distance_break(0, 0.0, 2.0, 1) + dm.add_vehicle_distance_break(0, 0.0, 1.5, 1) dm.set_min_vehicles(2) sol = _solve(dm) @@ -449,3 +451,4 @@ def test_solve_mixed_fleet_break_assignment(): assert found_break_v0, ( "vehicle 0 has a distance break configured but received none" ) + assert routes[routes["type"] == "Break"]["route"].tolist() == [0] diff --git a/python/cuopt/cuopt/tests/routing/test_vehicle_properties.py b/python/cuopt/cuopt/tests/routing/test_vehicle_properties.py index cdf25291f2..efe2ac5783 100644 --- a/python/cuopt/cuopt/tests/routing/test_vehicle_properties.py +++ b/python/cuopt/cuopt/tests/routing/test_vehicle_properties.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import numpy as np +import pytest import cudf @@ -496,17 +497,16 @@ def test_heterogenous_breaks(): s.set_time_limit(30) routing_solution = routing.Solve(d, s) - # TO DO: Check if breaks are adhered to - assert routing_solution.get_status() == 0 - counters = {} + assert routing_solution.get_status() == 0, routing_solution.get_message() + taken_breaks = {} routes = routing_solution.get_route().to_pandas() break_locations_1_list = break_locations_1.to_arrow().to_pylist() # make sure the break locations are the right ones and # the arrival stamps satisfy the break time constraints for i in range(routes.shape[0]): truck_id = routes["truck_id"][i] - if truck_id not in counters: - counters[truck_id] = 0 + if truck_id not in taken_breaks: + taken_breaks[truck_id] = set() if routes["type"][i] == "Break": break_dim = routes["route"][i] location = routes["location"][i] @@ -518,14 +518,18 @@ def test_heterogenous_breaks(): else: assert arrival_time >= break_times_2[break_dim][0] assert arrival_time <= break_times_2[break_dim][1] - counters[truck_id] = counters[truck_id] + 1 + assert break_dim not in taken_breaks[truck_id] + taken_breaks[truck_id].add(break_dim) - # Make sure the achieved number of breaks is same as the specified - for truck_id, num_breaks in counters.items(): - if truck_id < num_v_type_1: - assert num_breaks == num_breaks_1 - else: - assert num_breaks == num_breaks_2 + # Every used vehicle must take each break whose deadline it passes. + for truck_id, vehicle_route in routes.groupby("truck_id"): + break_times = ( + break_times_1 if truck_id < num_v_type_1 else break_times_2 + ) + route_end = vehicle_route["arrival_stamp"].iloc[-1] + for break_dim, (_, latest) in enumerate(break_times): + if route_end > latest: + assert break_dim in taken_breaks[truck_id] # ----- Vehicle dependent service times ----- @@ -733,3 +737,373 @@ def test_empty_routes_with_breaks(): h_route = solution_vehicle_x["route"].to_arrow().to_pylist() route_len = len(h_route) assert route_len > 3 + + +def _solve_with_initial_break_route(dm, initial_solution): + if initial_solution != "none": + types = ["Depot", "Delivery", "Depot"] + if initial_solution == "with_break": + types.insert(1, "Break") + n_stops = len(types) + dm.add_initial_solutions( + cudf.Series([0] * n_stops, dtype=np.int32), + cudf.Series([0] * n_stops, dtype=np.int32), + cudf.Series(types), + cudf.Series([0, n_stops], dtype=np.int32), + ) + settings = routing.SolverSettings() + settings.set_time_limit(5) + sol = routing.Solve(dm, settings) + if initial_solution != "none" and sol.get_status() == 0: + accepted = sol.get_accepted_solutions().to_arrow().to_pylist() + assert len(accepted) == 1 + # -1 means the solve did not attempt to inject the initial solution. + assert accepted[0] >= 0 + return sol + + +@pytest.mark.parametrize( + "initial_solution", ["none", "without_break", "with_break"] +) +def test_required_break_unreachable_is_infeasible(initial_solution): + coords = np.array( + [[0.0, 0.0], [10.0, 0.0], [0.0, 200.0]], dtype=np.float32 + ) + diff = coords[:, None] - coords[None, :] + matrix = cudf.DataFrame(np.linalg.norm(diff, axis=-1).astype(np.float32)) + + dm = routing.DataModel(3, n_fleet=1, n_orders=1) + dm.add_cost_matrix(matrix) + dm.add_transit_time_matrix(matrix) + dm.set_order_locations(cudf.Series([1], dtype=np.int32)) + dm.set_order_time_windows( + cudf.Series([0], dtype=np.int32), + cudf.Series([1000], dtype=np.int32), + ) + dm.set_vehicle_time_windows( + cudf.Series([0], dtype=np.int32), + cudf.Series([1000], dtype=np.int32), + ) + dm.set_break_locations(cudf.Series([2], dtype=np.int32)) + dm.add_break_dimension( + cudf.Series([0], dtype=np.int32), + cudf.Series([5], dtype=np.int32), + cudf.Series([5], dtype=np.int32), + ) + + sol = _solve_with_initial_break_route(dm, initial_solution) + assert sol.get_status() == 1, sol.get_message() + + +@pytest.mark.parametrize( + "initial_solution", ["none", "without_break", "with_break"] +) +def test_required_break_is_inserted(initial_solution): + matrix = cudf.DataFrame( + [[0, 10, 5], [10, 0, 15], [5, 15, 0]], dtype=np.float32 + ) + dm = routing.DataModel(3, n_fleet=1, n_orders=1) + dm.add_cost_matrix(matrix) + dm.add_transit_time_matrix(matrix) + dm.set_order_locations(cudf.Series([1], dtype=np.int32)) + dm.set_break_locations(cudf.Series([2], dtype=np.int32)) + dm.add_break_dimension( + cudf.Series([0], dtype=np.int32), + cudf.Series([15], dtype=np.int32), + cudf.Series([5], dtype=np.int32), + ) + + sol = _solve_with_initial_break_route(dm, initial_solution) + assert sol.get_status() == 0, sol.get_message() + # The customer is reached before the deadline, but the return leg crosses it. + assert sol.get_total_objective() == pytest.approx(30) + route = sol.get_route().to_pandas() + breaks = route[route["type"] == "Break"] + assert breaks["route"].tolist() == [0] + assert breaks["location"].tolist() == [2] + assert route[route["type"] == "Delivery"]["route"].tolist() == [0] + assert route["location"].tolist() == [0, 2, 1, 0] + assert route["route"].tolist() == [0, 0, 0, 0] + arrivals = route["arrival_stamp"].tolist() + break_index = breaks.index[0] + assert 5 <= arrivals[break_index] <= 15 + assert arrivals[break_index + 1] >= arrivals[break_index] + 20 + assert arrivals[-1] >= arrivals[0] + 35 + + +@pytest.mark.parametrize( + "initial_solution", ["none", "without_break", "with_break"] +) +def test_distance_break_unreachable_is_infeasible(initial_solution): + matrix = cudf.DataFrame( + [[0, 10, 20], [10, 0, 20], [20, 20, 0]], dtype=np.float32 + ) + transit = cudf.DataFrame( + [[0, 1, 1], [1, 0, 1], [1, 1, 0]], dtype=np.float32 + ) + dm = routing.DataModel(3, n_fleet=1, n_orders=1) + dm.add_cost_matrix(matrix) + dm.add_transit_time_matrix(transit) + dm.set_order_locations(cudf.Series([1], dtype=np.int32)) + # Every path to the required break exceeds its hard distance limit. + dm.add_vehicle_distance_break( + 0, 0.0, 10.0, 5, cudf.Series([2], dtype=np.int32) + ) + + sol = _solve_with_initial_break_route(dm, initial_solution) + assert sol.get_status() == 1, sol.get_message() + + +@pytest.mark.parametrize( + "initial_solution", ["none", "without_break", "with_break"] +) +def test_distance_break_is_inserted_before_route_end(initial_solution): + matrix = cudf.DataFrame( + [[0, 10, 5], [10, 0, 15], [5, 15, 0]], dtype=np.float32 + ) + transit = cudf.DataFrame( + [[0, 1, 1], [1, 0, 1], [1, 1, 0]], dtype=np.float32 + ) + dm = routing.DataModel(3, n_fleet=1, n_orders=1) + dm.add_cost_matrix(matrix) + dm.add_transit_time_matrix(transit) + dm.set_order_locations(cudf.Series([1], dtype=np.int32)) + # The return leg crosses the distance deadline, despite taking little time. + dm.add_vehicle_distance_break( + 0, 0.0, 15.0, 5, cudf.Series([2], dtype=np.int32) + ) + + sol = _solve_with_initial_break_route(dm, initial_solution) + assert sol.get_status() == 0, sol.get_message() + assert sol.get_total_objective() == pytest.approx(30) + route = sol.get_route().to_pandas() + breaks = route[route["type"] == "Break"] + assert breaks["route"].tolist() == [0] + assert breaks["location"].tolist() == [2] + assert route[route["type"] == "Delivery"]["route"].tolist() == [0] + assert route["location"].tolist() == [0, 2, 1, 0] + arrivals = route["arrival_stamp"].tolist() + break_index = breaks.index[0] + assert arrivals[break_index + 1] >= arrivals[break_index] + 6 + assert arrivals[-1] >= arrivals[0] + 8 + + +@pytest.mark.parametrize( + "initial_solution", ["none", "without_break", "with_break"] +) +@pytest.mark.parametrize( + "shift_start, earliest, latest", + [(0, 100, 200), (0, 0, 20), (100, 100, 120)], +) +def test_time_break_after_route_end_is_skipped( + initial_solution, shift_start, earliest, latest +): + coords = np.array( + [[0.0, 0.0], [10.0, 0.0], [0.0, 200.0]], dtype=np.float32 + ) + diff = coords[:, None] - coords[None, :] + matrix = cudf.DataFrame(np.linalg.norm(diff, axis=-1).astype(np.float32)) + dm = routing.DataModel(3, n_fleet=1, n_orders=1) + dm.add_cost_matrix(matrix) + dm.add_transit_time_matrix(matrix) + dm.set_order_locations(cudf.Series([1], dtype=np.int32)) + dm.set_vehicle_time_windows( + cudf.Series([shift_start], dtype=np.int32), + cudf.Series([1000], dtype=np.int32), + ) + dm.set_break_locations(cudf.Series([2], dtype=np.int32)) + dm.add_break_dimension( + cudf.Series([earliest], dtype=np.int32), + cudf.Series([latest], dtype=np.int32), + cudf.Series([5], dtype=np.int32), + ) + + sol = _solve_with_initial_break_route(dm, initial_solution) + assert sol.get_status() == 0, sol.get_message() + assert sol.get_total_objective() == pytest.approx(20) + route = sol.get_route().to_pandas() + assert route["type"].tolist() == ["Depot", "Delivery", "Depot"] + assert route["location"].tolist() == [0, 1, 0] + assert route["arrival_stamp"].iloc[-1] <= latest + + +@pytest.mark.parametrize( + "initial_solution", ["none", "without_break", "with_break"] +) +@pytest.mark.parametrize( + "distance_limit", [20.0, 100.0, np.finfo(np.float32).max] +) +@pytest.mark.parametrize("model_time", [False, True]) +def test_distance_break_after_route_end_is_skipped( + initial_solution, distance_limit, model_time +): + matrix = cudf.DataFrame( + [[0, 10, 20], [10, 0, 20], [20, 20, 0]], dtype=np.float32 + ) + transit = cudf.DataFrame( + [[0, 100, 100], [100, 0, 100], [100, 100, 0]], dtype=np.float32 + ) + dm = routing.DataModel(3, n_fleet=1, n_orders=1) + dm.add_cost_matrix(matrix) + if model_time: + dm.add_transit_time_matrix(transit) + dm.set_order_locations(cudf.Series([1], dtype=np.int32)) + dm.add_vehicle_distance_break( + 0, 0.0, distance_limit, 5, cudf.Series([2], dtype=np.int32) + ) + + sol = _solve_with_initial_break_route(dm, initial_solution) + assert sol.get_status() == 0, sol.get_message() + assert sol.get_total_objective() == pytest.approx(20) + route = sol.get_route().to_pandas() + assert route["type"].tolist() == ["Depot", "Delivery", "Depot"] + assert route["location"].tolist() == [0, 1, 0] + if model_time: + # Time exceeds the finite limits; distance is the applicable dimension. + assert route["arrival_stamp"].iloc[-1] >= 200 + + +@pytest.mark.parametrize("break_kind", ["time", "distance"]) +@pytest.mark.parametrize("late_break_required", [False, True]) +def test_break_deadlines_are_checked_after_earlier_break_insertion( + break_kind, late_break_required +): + matrix = cudf.DataFrame( + [[0, 10, 5], [10, 0, 15], [5, 15, 0]], dtype=np.float32 + ) + dm = routing.DataModel(3, n_fleet=1, n_orders=1) + dm.add_cost_matrix(matrix) + dm.add_transit_time_matrix(matrix) + dm.set_order_locations(cudf.Series([1], dtype=np.int32)) + locations = cudf.Series([2], dtype=np.int32) + # Dimension 0 starts out optional. Dimension 1's detour can require it. + late_deadline = 100 + if late_break_required: + late_deadline = 32 if break_kind == "time" else 25 + if break_kind == "time": + dm.set_break_locations(locations) + for latest in (late_deadline, 15): + dm.add_break_dimension( + cudf.Series([0], dtype=np.int32), + cudf.Series([latest], dtype=np.int32), + cudf.Series([5], dtype=np.int32), + ) + else: + for distance_limit in (late_deadline, 15): + dm.add_vehicle_distance_break(0, 0.0, distance_limit, 5, locations) + + sol = _solve_with_initial_break_route(dm, "none") + assert sol.get_status() == 0, sol.get_message() + assert sol.get_total_objective() == pytest.approx(30) + route = sol.get_route().to_pandas() + breaks = route[route["type"] == "Break"] + expected_dimensions = [0, 1] if late_break_required else [1] + assert sorted(breaks["route"].tolist()) == expected_dimensions + assert breaks["location"].tolist() == [2] * len(expected_dimensions) + + +def test_time_break_deadline_includes_waiting_for_customer(): + matrix = cudf.DataFrame( + [[0, 10, 5], [10, 0, 15], [5, 15, 0]], dtype=np.float32 + ) + dm = routing.DataModel(3, n_fleet=1, n_orders=1) + dm.add_cost_matrix(matrix) + dm.add_transit_time_matrix(matrix) + dm.set_order_locations(cudf.Series([1], dtype=np.int32)) + dm.set_order_time_windows( + cudf.Series([100], dtype=np.int32), + cudf.Series([100], dtype=np.int32), + ) + dm.set_break_locations(cudf.Series([2], dtype=np.int32)) + dm.add_break_dimension( + cudf.Series([0], dtype=np.int32), + cudf.Series([30], dtype=np.int32), + cudf.Series([5], dtype=np.int32), + ) + + sol = _solve_with_initial_break_route(dm, "none") + assert sol.get_status() == 0, sol.get_message() + # Travel alone takes 20, but waiting keeps the route active past 30. + assert sol.get_total_objective() == pytest.approx(30) + route = sol.get_route().to_pandas() + assert route["location"].tolist() == [0, 2, 1, 0] + assert route[route["type"] == "Break"]["arrival_stamp"].iloc[0] <= 30 + assert route[route["type"] == "Delivery"]["arrival_stamp"].iloc[0] == 100 + + +@pytest.mark.parametrize("latest", [100, np.iinfo(np.int32).max]) +def test_time_break_without_time_dimension_is_preserved(latest): + matrix = cudf.DataFrame( + [[0, 10, 20], [10, 0, 20], [20, 20, 0]], dtype=np.float32 + ) + dm = routing.DataModel(3, n_fleet=1, n_orders=1) + dm.add_cost_matrix(matrix) + dm.set_order_locations(cudf.Series([1], dtype=np.int32)) + dm.set_break_locations(cudf.Series([2], dtype=np.int32)) + dm.add_break_dimension( + cudf.Series([0], dtype=np.int32), + cudf.Series([latest], dtype=np.int32), + cudf.Series([0], dtype=np.int32), + ) + + sol = _solve_with_initial_break_route(dm, "none") + assert sol.get_status() == 0, sol.get_message() + # Cost alone cannot establish that the route finishes before a time deadline. + assert sol.get_total_objective() == pytest.approx(50) + route = sol.get_route().to_pandas() + breaks = route[route["type"] == "Break"] + assert breaks["route"].tolist() == [0] + assert breaks["location"].tolist() == [2] + assert route[route["type"] == "Delivery"]["route"].tolist() == [0] + + +def test_mixed_break_types_without_time_dimension(): + matrix = cudf.DataFrame( + [[0, 10, 20], [10, 0, 20], [20, 20, 0]], dtype=np.float32 + ) + dm = routing.DataModel(3, n_fleet=1, n_orders=1) + dm.add_cost_matrix(matrix) + dm.set_order_locations(cudf.Series([1], dtype=np.int32)) + locations = cudf.Series([2], dtype=np.int32) + dm.add_vehicle_break(0, 0, 100, 0, locations) + dm.add_vehicle_distance_break( + 0, 0.0, np.finfo(np.float32).max, 0, locations + ) + + sol = _solve_with_initial_break_route(dm, "none") + assert sol.get_status() == 0, sol.get_message() + assert sol.get_total_objective() == pytest.approx(50) + route = sol.get_route().to_pandas() + breaks = route[route["type"] == "Break"] + # The time break is retained; the distance break's own type permits skipping it. + assert breaks["route"].tolist() == [0] + assert breaks["location"].tolist() == [2] + assert route[route["type"] == "Delivery"]["route"].tolist() == [0] + + +@pytest.mark.parametrize("uniform", [True, False]) +def test_time_break_outside_configured_shift_is_rejected(uniform): + matrix = cudf.DataFrame([[0, 10], [10, 0]], dtype=np.float32) + dm = routing.DataModel(2, n_fleet=1, n_orders=1) + dm.add_cost_matrix(matrix) + dm.add_transit_time_matrix(matrix) + dm.set_order_locations(cudf.Series([1], dtype=np.int32)) + dm.set_vehicle_time_windows( + cudf.Series([0], dtype=np.int32), + cudf.Series([20], dtype=np.int32), + ) + if uniform: + dm.add_break_dimension( + cudf.Series([100], dtype=np.int32), + cudf.Series([200], dtype=np.int32), + cudf.Series([5], dtype=np.int32), + ) + else: + dm.add_vehicle_break(0, 100, 200, 5) + + settings = routing.SolverSettings() + settings.set_time_limit(1) + sol = routing.Solve(dm, settings) + # Skipping a break on a short route does not relax input shift validation. + assert sol.get_status() != 0 + assert "break times should be within" in str(sol.get_error_message())