Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 36 additions & 1 deletion ext/or-tools/routing.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
#include <optional>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>

#include <absl/time/time.h>
#include <ortools/constraint_solver/routing.h>
#include <ortools/constraint_solver/routing_parameters.h>
#include <ortools/util/piecewise_linear_function.h>
#include <rice/rice.hpp>
#include <rice/stl.hpp>

Expand All @@ -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;
Expand Down Expand Up @@ -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<int64_t> breakpoints, std::vector<int64_t> 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<PiecewiseLinearFunction> 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<RoutingDisjunctionIndex>(m, "RoutingDisjunctionIndex");

Expand Down
110 changes: 110 additions & 0 deletions test/routing_piecewise_cumul_cost_test.rb
Original file line number Diff line number Diff line change
@@ -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