diff --git a/cpp/src/routing/adapters/adapted_generator.cu b/cpp/src/routing/adapters/adapted_generator.cu index 073be1ff1e..52cae50294 100644 --- a/cpp/src/routing/adapters/adapted_generator.cu +++ b/cpp/src/routing/adapters/adapted_generator.cu @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2022-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ @@ -125,6 +125,7 @@ void adapted_generator_t::generate_solution( } resource.ges.repair_empty_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/ges/squeeze.cu b/cpp/src/routing/ges/squeeze.cu index c81fa60199..7232671284 100644 --- a/cpp/src/routing/ges/squeeze.cu +++ b/cpp/src/routing/ges/squeeze.cu @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2022-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ @@ -410,6 +410,7 @@ bool guided_ejection_search_t::try_squeeze_breaks_feasible() local_search_ptr_->set_active_weights(local_search_ptr_->move_candidates.weights, original_incl_objective); + squeeze_breaks(); return solution_ptr->is_feasible(); } diff --git a/cpp/src/routing/ges/squeeze.cuh b/cpp/src/routing/ges/squeeze.cuh index 538ebe6c1e..40b35dd40b 100644 --- a/cpp/src/routing/ges/squeeze.cuh +++ b/cpp/src/routing/ges/squeeze.cuh @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2022-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ @@ -439,6 +439,15 @@ __global__ void squeeze_breaks_kernel(typename solution_t::vi for (int break_dim_idx = 0; break_dim_idx < n_break_dims; ++break_dim_idx) { if (break_dim_counters[break_dim_idx] == 1) { continue; } + if (sh_route.get_num_service_nodes() > 0 && + sh_route.dimensions_info().has_dimension(dim_t::TIME)) { + const auto vehicle_info = sh_route.vehicle_info(); + const auto route_end_node = sh_route.get_node(sh_route.get_num_nodes()).time_dim; + const double route_end_time = + route_end_node.departure_forward + route_end_node.excess_forward; + // Breaks become mandatory only once the route reaches their deadline. + if (route_end_time < vehicle_info.break_latest[break_dim_idx]) { continue; } + } const auto old_objective_cost = sh_route.get_objective_cost(); const auto old_infeasbility_cost = sh_route.get_infeasibility_cost(); auto break_nodes = diff --git a/cpp/src/routing/local_search/breaks_insertion.cu b/cpp/src/routing/local_search/breaks_insertion.cu index 8fd06d83f1..0ec732eb0a 100644 --- a/cpp/src/routing/local_search/breaks_insertion.cu +++ b/cpp/src/routing/local_search/breaks_insertion.cu @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2023-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ @@ -33,7 +33,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/route/route.cuh b/cpp/src/routing/route/route.cuh index b624acb903..72abaf3551 100644 --- a/cpp/src/routing/route/route.cuh +++ b/cpp/src/routing/route/route.cuh @@ -663,6 +663,29 @@ class route_t { .compute_cost(this->vehicle_info(), *n_nodes, objective_cost[0], infeasibility_cost[0]); }); + if (dimensions_info().has_dimension(dim_t::BREAK) && + dimensions_info().has_dimension(dim_t::TIME)) { + const auto vehicle_info = this->vehicle_info(); + const i_t n_breaks = vehicle_info.num_breaks(); + if (n_breaks > 0) { + const auto route_end_node = this->get_node(*n_nodes).time_dim; + const double route_end_time = + route_end_node.departure_forward + route_end_node.excess_forward; + i_t missing_required_breaks = 0; + for (i_t break_dim = 0; break_dim < n_breaks; ++break_dim) { + if (vehicle_info.break_latest[break_dim] > route_end_time) { continue; } + + bool has_break = false; + for (i_t node_idx = 0; node_idx < *n_nodes; ++node_idx) { + const auto node_info = this->get_node(node_idx).node_info(); + has_break = has_break || (node_info.is_break() && node_info.break_dim() == break_dim); + } + missing_required_breaks += !has_break; + } + infeasibility_cost[0][dim_t::BREAK] += missing_required_breaks; + } + } + return thrust::make_tuple(objective_cost[0], infeasibility_cost[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..e0fac27887 100644 --- a/python/cuopt/cuopt/tests/routing/test_vehicle_properties.py +++ b/python/cuopt/cuopt/tests/routing/test_vehicle_properties.py @@ -497,7 +497,7 @@ def test_heterogenous_breaks(): routing_solution = routing.Solve(d, s) # TO DO: Check if breaks are adhered to - assert routing_solution.get_status() == 0 + assert routing_solution.get_status() == 0, routing_solution.get_message() counters = {} routes = routing_solution.get_route().to_pandas() break_locations_1_list = break_locations_1.to_arrow().to_pylist() @@ -521,11 +521,23 @@ def test_heterogenous_breaks(): counters[truck_id] = counters[truck_id] + 1 # Make sure the achieved number of breaks is same as the specified - for truck_id, num_breaks in counters.items(): + arrival_col = next( + col + for col in ("arrival_stamp", "arrival_time", "arrival") + if col in routes.columns + ) + for truck_id in routes.truck_id.unique(): + truck_id = int(truck_id) + route_end = routes[routes.truck_id == truck_id][arrival_col].max() if truck_id < num_v_type_1: - assert num_breaks == num_breaks_1 + expected_breaks = sum( + break_latest <= route_end for _, break_latest in break_times_1 + ) else: - assert num_breaks == num_breaks_2 + expected_breaks = sum( + break_latest <= route_end for _, break_latest in break_times_2 + ) + assert counters.get(truck_id, 0) == expected_breaks # ----- Vehicle dependent service times ----- @@ -733,3 +745,68 @@ 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 test_break_after_route_end_is_not_inserted(): + 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([150], dtype=np.int32), + cudf.Series([200], dtype=np.int32), + cudf.Series([5], dtype=np.int32), + ) + + sol = routing.Solve(dm) + assert sol.get_status() == 0 + assert abs(sol.get_total_objective() - 20) < 0.01 + + route = sol.get_route() + assert "Break" not in route["type"].to_arrow().to_pylist() + assert route["location"].to_arrow().to_pylist() == [0, 1, 0] + + +def test_required_break_unreachable_is_infeasible(): + 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 = routing.Solve(dm) + assert sol.get_status() == 1