diff --git a/CHANGELOG.md b/CHANGELOG.md index 81d9df6..8760b7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.18.1 (unreleased) + +- Added support for breaks for routing + ## 0.18.0 (2026-07-06) - Added support for releasing GVL diff --git a/README.md b/README.md index 14c92a3..ede2209 100644 --- a/README.md +++ b/README.md @@ -1472,6 +1472,17 @@ routing.solve( ) ``` +Piecewise linear costs can be applied to dimension cumul variables: + +```ruby +time_dimension.set_cumul_var_piecewise_linear_cost( + routing.end(vehicle_id), + 0, # cost at zero + [shift_end, limit], # breakpoints + [0, 50, 200] # one slope per segment +) +``` + ## Bin Packing ### The Knapsack Problem diff --git a/ext/or-tools/routing.cpp b/ext/or-tools/routing.cpp index 88ff5bc..45c9a7f 100644 --- a/ext/or-tools/routing.cpp +++ b/ext/or-tools/routing.cpp @@ -1,8 +1,13 @@ +#include +#include +#include #include +#include #include #include #include +#include #include #include @@ -10,7 +15,9 @@ using operations_research::Assignment; using operations_research::ConstraintSolverParameters; using operations_research::DefaultRoutingSearchParameters; using operations_research::FirstSolutionStrategy; +using operations_research::IntervalVarElement; using operations_research::LocalSearchMetaheuristic; +using operations_research::PiecewiseLinearFunction; using operations_research::RoutingDimension; using operations_research::RoutingDisjunctionIndex; using operations_research::RoutingIndexManager; @@ -198,11 +205,28 @@ void init_routing(Rice::Module& m) { .define_method("index_to_node", &RoutingIndexManager::IndexToNode) .define_method("node_to_index", &RoutingIndexManager::NodeToIndex); + + Rice::define_class_under(m, "IntervalVarElement") + .define_method("var", &IntervalVarElement::Var) + .define_method("start_value", &IntervalVarElement::StartValue) + .define_method("duration_value", &IntervalVarElement::DurationValue) + .define_method("end_value", &IntervalVarElement::EndValue) + .define_method("performed_value", &IntervalVarElement::PerformedValue); + + Rice::define_class_under(m, "IntervalContainer") + .define_method("size", &Assignment::IntervalContainer::Size) + .define_method( + "element", + [](Assignment::IntervalContainer& self, int index) { + return self.Element(index); + }); + Rice::define_class_under(m, "Assignment") .define_method("objective_value", &Assignment::ObjectiveValue) .define_method("value", &Assignment::Value) .define_method("min", &Assignment::Min) - .define_method("max", &Assignment::Max); + .define_method("max", &Assignment::Max) + .define_method("interval_var_container", &Assignment::IntervalVarContainer); // not to be confused with operations_research::sat::IntVar rb_cIntVar @@ -223,6 +247,7 @@ void init_routing(Rice::Module& m) { }); Rice::define_class_under(m, "IntervalVar") + .define_method("name", &operations_research::IntervalVar::name) .define_method("start_min", &operations_research::IntervalVar::StartMin) .define_method("start_max", &operations_research::IntervalVar::StartMax) .define_method("set_start_min", &operations_research::IntervalVar::SetStartMin) @@ -250,6 +275,8 @@ void init_routing(Rice::Module& m) { .define_method("set_global_span_cost_coefficient", &RoutingDimension::SetGlobalSpanCostCoefficient) // alias .define_method("global_span_cost_coefficient=", &RoutingDimension::SetGlobalSpanCostCoefficient) + .define_method("set_slack_cost_coefficient_for_vehicle", &RoutingDimension::SetSlackCostCoefficientForVehicle) + .define_method("set_slack_cost_coefficient_for_all_vehicles", &RoutingDimension::SetSlackCostCoefficientForAllVehicles) .define_method("set_cumul_var_soft_upper_bound", &RoutingDimension::SetCumulVarSoftUpperBound) .define_method("cumul_var_soft_upper_bound?", &RoutingDimension::HasCumulVarSoftUpperBound) .define_method("cumul_var_soft_upper_bound", &RoutingDimension::GetCumulVarSoftUpperBound) @@ -257,7 +284,44 @@ void init_routing(Rice::Module& m) { .define_method("set_cumul_var_soft_lower_bound", &RoutingDimension::SetCumulVarSoftLowerBound) .define_method("cumul_var_soft_lower_bound?", &RoutingDimension::HasCumulVarSoftLowerBound) .define_method("cumul_var_soft_lower_bound", &RoutingDimension::GetCumulVarSoftLowerBound) - .define_method("cumul_var_soft_lower_bound_coefficient", &RoutingDimension::GetCumulVarSoftLowerBoundCoefficient); + .define_method("cumul_var_soft_lower_bound_coefficient", &RoutingDimension::GetCumulVarSoftLowerBoundCoefficient) + .define_method( + "set_cumul_var_piecewise_linear_cost", + [](RoutingDimension& self, int64_t index, int64_t initial_level, std::vector breakpoints, std::vector slopes) { + if (initial_level < 0) { + throw std::invalid_argument("initial_level must be nonnegative"); + } + if (breakpoints.empty()) { + throw std::invalid_argument("breakpoints must not be empty"); + } + if (slopes.size() != breakpoints.size() + 1) { + throw std::invalid_argument("slopes must contain one more value than breakpoints"); + } + if (std::adjacent_find( + breakpoints.begin(), + breakpoints.end(), + [](int64_t left, int64_t right) { return left >= right; }) != breakpoints.end()) { + throw std::invalid_argument("breakpoints must be strictly increasing"); + } + if (std::any_of(slopes.begin(), slopes.end(), [](int64_t slope) { return slope < 0; })) { + throw std::invalid_argument("slopes must be nonnegative"); + } + + std::unique_ptr cost( + PiecewiseLinearFunction::CreateFullDomainFunction( + initial_level, + std::move(breakpoints), + std::move(slopes))); + if (cost->Value(0) < 0) { + throw std::invalid_argument("cost must be nonnegative at zero"); + } + self.SetCumulVarPiecewiseLinearCost(index, *cost); + }) + .define_method( + "set_break_intervals_of_vehicle", + [](RoutingDimension& self, std::vector breaks, int vehicle, std::vector node_visit_transits) { + self.SetBreakIntervalsOfVehicle(breaks, vehicle, node_visit_transits); + }); Rice::define_class_under(m, "RoutingDisjunctionIndex"); @@ -291,6 +355,11 @@ void init_routing(Rice::Module& m) { [](operations_research::Solver& self, operations_research::IntVar& start_variable, int64_t duration, const std::string& name) { return self.MakeFixedDurationIntervalVar(&start_variable, duration, name); }) + .define_method( + "fixed_duration_interval_var", + [](operations_research::Solver& self, int64_t start_min, int64_t start_max, int64_t duration, bool optional, const std::string& name) { + return self.MakeFixedDurationIntervalVar(start_min, start_max, duration, optional, name); + }) .define_method( "cumulative", [](operations_research::Solver& self, Array rb_intervals, const std::vector& demands, int64_t capacity, const std::string& name) { diff --git a/test/routing_piecewise_cumul_cost_test.rb b/test/routing_piecewise_cumul_cost_test.rb new file mode 100644 index 0000000..1085a6e --- /dev/null +++ b/test/routing_piecewise_cumul_cost_test.rb @@ -0,0 +1,110 @@ +require_relative "test_helper" + +class RoutingPiecewiseCumulCostTest < Minitest::Test + def test_adds_piecewise_cost_to_objective + assert_equal 0, solve(elapsed: 4).objective_value + assert_equal 10, solve(elapsed: 10).objective_value + assert_equal 18, solve(elapsed: 12).objective_value + end + + def test_retains_cost_for_warm_solve + routing, dimension = build_routing(elapsed: 12) + set_cost(dimension, routing.end(0)) + initial_solution = routing.read_assignment_from_routes([[1]], true) + + solution = routing.solve_from_assignment_with_parameters( + initial_solution, + ORTools.default_routing_search_parameters + ) + + assert_equal 18, solution.objective_value + end + + def test_copies_cost_into_model + routing, dimension = build_routing(elapsed: 12) + breakpoints = [5, 10] + slopes = [0, 2, 4] + + dimension.set_cumul_var_piecewise_linear_cost( + routing.end(0), + 0, + breakpoints, + slopes + ) + breakpoints.clear + slopes.clear + GC.start + + assert_equal 18, routing.solve(first_solution_strategy: :path_cheapest_arc).objective_value + end + + def test_does_not_change_objective_when_disabled + routing, _dimension = build_routing(elapsed: 12) + + assert_equal 0, routing.solve(first_solution_strategy: :path_cheapest_arc).objective_value + end + + def test_validates_curve + routing, dimension = build_routing(elapsed: 12) + index = routing.end(0) + + error = assert_raises(ArgumentError) do + dimension.set_cumul_var_piecewise_linear_cost(index, -1, [5], [0, 1]) + end + assert_equal "initial_level must be nonnegative", error.message + + error = assert_raises(ArgumentError) do + dimension.set_cumul_var_piecewise_linear_cost(index, 0, [], [0]) + end + assert_equal "breakpoints must not be empty", error.message + + error = assert_raises(ArgumentError) do + dimension.set_cumul_var_piecewise_linear_cost(index, 0, [5], [0]) + end + assert_equal "slopes must contain one more value than breakpoints", error.message + + error = assert_raises(ArgumentError) do + dimension.set_cumul_var_piecewise_linear_cost(index, 0, [5, 5], [0, 1, 2]) + end + assert_equal "breakpoints must be strictly increasing", error.message + + error = assert_raises(ArgumentError) do + dimension.set_cumul_var_piecewise_linear_cost(index, 0, [5], [0, -1]) + end + assert_equal "slopes must be nonnegative", error.message + + error = assert_raises(ArgumentError) do + dimension.set_cumul_var_piecewise_linear_cost(index, 0, [5], [1, 2]) + end + assert_equal "cost must be nonnegative at zero", error.message + end + + private + + def solve(elapsed:) + routing, dimension = build_routing(elapsed: elapsed) + set_cost(dimension, routing.end(0)) + routing.solve(first_solution_strategy: :path_cheapest_arc) + end + + def build_routing(elapsed:) + manager = ORTools::RoutingIndexManager.new(2, 1, 0) + routing = ORTools::RoutingModel.new(manager) + zero_transit = routing.register_transit_matrix([[0, 0], [0, 0]]) + time_transit = routing.register_transit_matrix([[0, 0], [elapsed, 0]]) + + routing.set_arc_cost_evaluator_of_all_vehicles(zero_transit) + routing.add_dimension(time_transit, 0, 100, true, "Time") + + [routing, routing.mutable_dimension("Time")] + end + + def set_cost(dimension, index) + dimension.set_cumul_var_piecewise_linear_cost( + index, + 0, + [5, 10], + [0, 2, 4] + ) + end +end