From 3ccc634a923bf5326dc7d28504d0b75c00eac67f Mon Sep 17 00:00:00 2001 From: Eric Kreutzer Date: Tue, 28 Jul 2026 14:06:25 -0600 Subject: [PATCH] feat(routing): Bind piecewise cumul costs Expose RoutingDimension piecewise linear cumul costs with Ruby array inputs. Validate curve shape before constructing the native function and rely on OR-Tools' copied model ownership during cold and warm solves. --- CHANGELOG.md | 4 + README.md | 11 +++ ext/or-tools/routing.cpp | 37 +++++++- test/routing_piecewise_cumul_cost_test.rb | 110 ++++++++++++++++++++++ 4 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 test/routing_piecewise_cumul_cost_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 81d9df6..39d1acd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +- Added `set_cumul_var_piecewise_linear_cost` to `RoutingDimension` + ## 0.18.0 (2026-07-06) - Added support for releasing GVL diff --git a/README.md b/README.md index fd1b27d..de194f8 100644 --- a/README.md +++ b/README.md @@ -1482,6 +1482,17 @@ search_parameters.improvement_limit_parameters = { } ``` +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 the first breakpoint + [shift_end, limit], # breakpoints + [0, 50, 200] # one slope per segment +) +``` + ### Solution Tracing ```ruby diff --git a/ext/or-tools/routing.cpp b/ext/or-tools/routing.cpp index f953898..d606e99 100644 --- a/ext/or-tools/routing.cpp +++ b/ext/or-tools/routing.cpp @@ -4,11 +4,13 @@ #include #include #include +#include #include #include #include #include +#include #include #include @@ -17,6 +19,7 @@ using operations_research::ConstraintSolverParameters; using operations_research::DefaultRoutingSearchParameters; using operations_research::FirstSolutionStrategy; using operations_research::LocalSearchMetaheuristic; +using operations_research::PiecewiseLinearFunction; using operations_research::RoutingDimension; using operations_research::RoutingDisjunctionIndex; using operations_research::RoutingIndexManager; @@ -498,7 +501,39 @@ 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); + }); Rice::define_class_under(m, "RoutingDisjunctionIndex"); 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