diff --git a/ortools/sat/python/BUILD.bazel b/ortools/sat/python/BUILD.bazel index f9828fe416a..9910230317f 100644 --- a/ortools/sat/python/BUILD.bazel +++ b/ortools/sat/python/BUILD.bazel @@ -69,6 +69,8 @@ py_library( deps = [ ":cp_model_helper", ":swig_helper", + requirement("numpy"), + requirement("pandas"), "//ortools/sat:cp_model_py_pb2", "//ortools/sat:sat_parameters_py_pb2", "//ortools/util/python:sorted_interval_list", diff --git a/ortools/sat/python/cp_model.py b/ortools/sat/python/cp_model.py index f9abfd81270..7aa939da5f6 100644 --- a/ortools/sat/python/cp_model.py +++ b/ortools/sat/python/cp_model.py @@ -51,6 +51,7 @@ import threading import time from typing import ( + Callable, Dict, Iterable, List, @@ -64,6 +65,7 @@ import warnings import numpy as np +import pandas as pd from ortools.sat import cp_model_pb2 from ortools.sat import sat_parameters_pb2 @@ -116,6 +118,7 @@ LinearExprT = Union["LinearExpr", "IntVar", IntegralT] ObjLinearExprT = Union["LinearExpr", "IntVar", NumberT] ArcT = Tuple[IntegralT, IntegralT, LiteralT] +_IndexOrSeries = Union[pd.Index, pd.Series] def DisplayBounds(bounds: Sequence[int]) -> str: @@ -1143,6 +1146,86 @@ def NewConstant(self, value: IntegralT) -> IntVar: """Declares a constant integer.""" return IntVar(self.__model, self.GetOrMakeIndexFromConstant(value), None) + def NewIntVarSeries( + self, + name: str, + index: pd.Index, + lower_bounds: Union[IntegralT, pd.Series], + upper_bounds: Union[IntegralT, pd.Series], + ) -> pd.Series: + """Creates a series of (scalar-valued) variables with the given name. + + Args: + name (str): Required. The name of the variable set. + index (pd.Index): Required. The index to use for the variable set. + lower_bounds (Union[int, pd.Series]): A lower bound for variables in the + set. If a `pd.Series` is passed in, it will be based on the + corresponding values of the pd.Series. + upper_bounds (Union[int, pd.Series]): An upper bound for variables in the + set. If a `pd.Series` is passed in, it will be based on the + corresponding values of the pd.Series. + + Returns: + pd.Series: The variable set indexed by its corresponding dimensions. + + Raises: + TypeError: if the `index` is invalid (e.g. a `DataFrame`). + ValueError: if the `name` is not a valid identifier or already exists. + ValueError: if the `lowerbound` is greater than the `upperbound`. + ValueError: if the index of `lower_bound`, or `upper_bound` does not match + the input index. + """ + if not isinstance(index, pd.Index): + raise TypeError("Non-index object is used as index") + if not name.isidentifier(): + raise ValueError("name={} is not a valid identifier".format(name)) + if ( + isinstance(lower_bounds, IntegralT) + and isinstance(upper_bounds, IntegralT) + and lower_bounds > upper_bounds + ): + raise ValueError( + f"lower_bound={lower_bounds} is greater than" + f" upper_bound={upper_bounds} for variable set={name}" + ) + + lower_bounds = _ConvertToSeriesAndValidateIndex(lower_bounds, index) + upper_bounds = _ConvertToSeriesAndValidateIndex(upper_bounds, index) + return pd.Series( + index=index, + data=[ + # pylint: disable=g-complex-comprehension + IntVar( + model=self.__model, + name=f"{name}[{i}]", + domain=Domain(lower_bounds[i], upper_bounds[i]), + ) + for i in index + ], + ) + + def NewBoolVarSeries( + self, + name: str, + index: pd.Index, + ) -> pd.Series: + """Creates a series of (scalar-valued) variables with the given name. + + Args: + name (str): Required. The name of the variable set. + index (pd.Index): Required. The index to use for the variable set. + + Returns: + pd.Series: The variable set indexed by its corresponding dimensions. + + Raises: + TypeError: if the `index` is invalid (e.g. a `DataFrame`). + ValueError: if the `name` is not a valid identifier or already exists. + """ + return self.NewIntVarSeries( + name=name, index=index, lower_bounds=0, upper_bounds=1 + ) + # Linear constraints. def AddLinearConstraint( @@ -2556,7 +2639,7 @@ def StopSearch(self) -> None: if self.__solve_wrapper: self.__solve_wrapper.StopSearch() - def _solution(self) -> cp_model_pb2.CpSolverResponse: + def _Solution(self) -> cp_model_pb2.CpSolverResponse: """Checks Solve() has been called, and returns the solution.""" if self.__solution is None: raise RuntimeError("Solve() has not been called.") @@ -2564,57 +2647,99 @@ def _solution(self) -> cp_model_pb2.CpSolverResponse: def Value(self, expression: LinearExprT) -> int: """Returns the value of a linear expression after solve.""" - return EvaluateLinearExpr(expression, self._solution()) + return EvaluateLinearExpr(expression, self._Solution()) + + def Values(self, variables: _IndexOrSeries) -> pd.Series: + """Returns the values of the input variables. + + If `variables` is a `pd.Index`, then the output will be indexed by the + variables. If `variables` is a `pd.Series` indexed by the underlying + dimensions, then the output will be indexed by the same underlying + dimensions. + + Args: + variables (Union[pd.Index, pd.Series]): The set of variables from which to + get the values. + + Returns: + pd.Series: The values of all variables in the set. + """ + solution = self._Solution() + return _AttributeSeries( + func=lambda v: solution.solution[v.Index()], + values=variables, + ) def BooleanValue(self, literal: LiteralT) -> bool: """Returns the boolean value of a literal after solve.""" - return EvaluateBooleanExpression(literal, self._solution()) + return EvaluateBooleanExpression(literal, self._Solution()) + + def BooleanValues(self, variables: _IndexOrSeries) -> pd.Series: + """Returns the values of the input variables. + + If `variables` is a `pd.Index`, then the output will be indexed by the + variables. If `variables` is a `pd.Series` indexed by the underlying + dimensions, then the output will be indexed by the same underlying + dimensions. + + Args: + variables (Union[pd.Index, pd.Series]): The set of variables from which to + get the values. + + Returns: + pd.Series: The values of all variables in the set. + """ + solution = self._Solution() + return _AttributeSeries( + func=lambda literal: EvaluateBooleanExpression(literal, solution), + values=variables, + ) def ObjectiveValue(self) -> float: """Returns the value of the objective after solve.""" - return self._solution().objective_value + return self._Solution().objective_value def BestObjectiveBound(self) -> float: """Returns the best lower (upper) bound found when min(max)imizing.""" - return self._solution().best_objective_bound + return self._Solution().best_objective_bound def StatusName(self, status: ... = None) -> str: """Returns the name of the status returned by Solve().""" if status is None: - status = self._solution().status + status = self._Solution().status return cp_model_pb2.CpSolverStatus.Name(status) def NumBooleans(self) -> int: """Returns the number of boolean variables managed by the SAT solver.""" - return self._solution().num_booleans + return self._Solution().num_booleans def NumConflicts(self) -> int: """Returns the number of conflicts since the creation of the solver.""" - return self._solution().num_conflicts + return self._Solution().num_conflicts def NumBranches(self) -> int: """Returns the number of search branches explored by the solver.""" - return self._solution().num_branches + return self._Solution().num_branches def WallTime(self) -> float: """Returns the wall time in seconds since the creation of the solver.""" - return self._solution().wall_time + return self._Solution().wall_time def UserTime(self) -> float: """Returns the user time in seconds since the creation of the solver.""" - return self._solution().user_time + return self._Solution().user_time def ResponseStats(self) -> str: """Returns some statistics on the solution found as a string.""" - return swig_helper.CpSatHelper.SolverResponseStats(self._solution()) + return swig_helper.CpSatHelper.SolverResponseStats(self._Solution()) def ResponseProto(self) -> cp_model_pb2.CpSolverResponse: """Returns the response object.""" - return self._solution() + return self._Solution() def SufficientAssumptionsForInfeasibility(self) -> Sequence[int]: """Returns the indices of the infeasible assumptions.""" - return self._solution().sufficient_assumptions_for_infeasibility + return self._Solution().sufficient_assumptions_for_infeasibility def SolutionInfo(self) -> str: """Returns some information on the solve process. @@ -2625,7 +2750,7 @@ def SolutionInfo(self) -> str: Raises: RuntimeError: if Solve() has not been called. """ - return self._solution().solution_info + return self._Solution().solution_info class CpSolverSolutionCallback(swig_helper.SolutionCallback): @@ -2806,3 +2931,58 @@ def on_solution_callback(self) -> None: def solution_count(self) -> int: """Returns the number of solutions found.""" return self.__solution_count + + +def _GetIndex(obj: _IndexOrSeries) -> pd.Index: + """Returns the indices of `obj` as a `pd.Index`.""" + if isinstance(obj, pd.Series): + return obj.index + return obj + + +def _AttributeSeries( + *, + func: Callable[[IntVar], IntegralT], + values: _IndexOrSeries, +) -> pd.Series: + """Returns the attributes of `values`. + + Args: + func: The function to call for getting the attribute data. + values: The values that the function will be applied (element-wise) to. + + Returns: + pd.Series: The attribute values. + """ + return pd.Series( + data=[func(v) for v in values], + index=_GetIndex(values), + ) + + +def _ConvertToSeriesAndValidateIndex( + value_or_series: Union[IntegralT, pd.Series], index: pd.Index +) -> pd.Series: + """Returns a pd.Series of the given index with the corresponding values. + + Args: + value_or_series: the values to be converted (if applicable). + index: the index of the resulting pd.Series. + + Returns: + pd.Series: The set of values with the given index. + + Raises: + TypeError: If the type of `value_or_series` is not recognized. + ValueError: If the index does not match. + """ + if isinstance(value_or_series, (bool, IntegralT)): + result = pd.Series(data=value_or_series, index=index) + elif isinstance(value_or_series, pd.Series): + if value_or_series.index.equals(index): + result = value_or_series + else: + raise ValueError("index does not match") + else: + raise TypeError("invalid type={}".format(type(value_or_series))) + return result diff --git a/ortools/sat/samples/BUILD.bazel b/ortools/sat/samples/BUILD.bazel index 689a04112b6..fd9a0fd5e59 100644 --- a/ortools/sat/samples/BUILD.bazel +++ b/ortools/sat/samples/BUILD.bazel @@ -25,6 +25,8 @@ code_sample_cc_py(name = "assumptions_sample_sat") code_sample_cc_py(name = "binpacking_problem_sat") +code_sample_py(name = "bin_packing_sat") + code_sample_cc_py(name = "bool_or_sample_sat") code_sample_py(name = "boolean_product_sample_sat") diff --git a/ortools/sat/samples/assignment_sat.py b/ortools/sat/samples/assignment_sat.py index f1d3491d0de..51147821af6 100644 --- a/ortools/sat/samples/assignment_sat.py +++ b/ortools/sat/samples/assignment_sat.py @@ -15,6 +15,10 @@ """Solve a simple assignment problem.""" # [START program] # [START import] +import io + +import pandas as pd + from ortools.sat.python import cp_model # [END import] @@ -22,15 +26,31 @@ def main(): # Data # [START data_model] - costs = [ - [90, 80, 75, 70], - [35, 85, 55, 65], - [125, 95, 90, 95], - [45, 110, 95, 115], - [50, 100, 90, 100], - ] - num_workers = len(costs) - num_tasks = len(costs[0]) + data_str = """ + worker task cost + w1 t1 90 + w1 t2 80 + w1 t3 75 + w1 t4 70 + w2 t1 35 + w2 t2 85 + w2 t3 55 + w2 t4 65 + w3 t1 125 + w3 t2 95 + w3 t3 90 + w3 t4 95 + w4 t1 45 + w4 t2 110 + w4 t3 95 + w4 t4 115 + w5 t1 50 + w5 t2 110 + w5 t3 90 + w5 t4 100 + """ + + data = pd.read_table(io.StringIO(data_str), sep=r"\s+") # [END data_model] # Model @@ -40,32 +60,23 @@ def main(): # Variables # [START variables] - x = [] - for i in range(num_workers): - t = [] - for j in range(num_tasks): - t.append(model.NewBoolVar(f"x[{i},{j}]")) - x.append(t) + x = model.NewBoolVarSeries(name="x", index=data.index) # [END variables] # Constraints # [START constraints] # Each worker is assigned to at most one task. - for i in range(num_workers): - model.AddAtMostOne(x[i][j] for j in range(num_tasks)) + for unused_name, tasks in data.groupby("worker"): + model.AddAtMostOne(x[tasks.index]) # Each task is assigned to exactly one worker. - for j in range(num_tasks): - model.AddExactlyOne(x[i][j] for i in range(num_workers)) + for unused_name, workers in data.groupby("task"): + model.AddExactlyOne(x[workers.index]) # [END constraints] # Objective # [START objective] - objective_terms = [] - for i in range(num_workers): - for j in range(num_tasks): - objective_terms.append(costs[i][j] * x[i][j]) - model.Minimize(sum(objective_terms)) + model.Minimize(data.cost.dot(x)) # [END objective] # Solve @@ -77,12 +88,10 @@ def main(): # Print solution. # [START print_solution] if status == cp_model.OPTIMAL or status == cp_model.FEASIBLE: - print(f"Total cost = {solver.ObjectiveValue()}") - print() - for i in range(num_workers): - for j in range(num_tasks): - if solver.BooleanValue(x[i][j]): - print(f"Worker {i} assigned to task {j} Cost = {costs[i][j]}") + print(f"Total cost = {solver.ObjectiveValue()}\n") + selected = data.loc[solver.BooleanValues(x).loc[lambda x: x].index] + for unused_index, row in selected.iterrows(): + print(f"{row.task} assigned to {row.worker} with a cost of {row.cost}") else: print("No solution found.") # [END print_solution] diff --git a/ortools/sat/samples/bin_packing_sat.py b/ortools/sat/samples/bin_packing_sat.py new file mode 100644 index 00000000000..a1be1932b51 --- /dev/null +++ b/ortools/sat/samples/bin_packing_sat.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +# Copyright 2010-2022 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Solve a simple bin packing problem using a MIP solver.""" +# [START program] +# [START import] +import io + +import pandas as pd + +from ortools.sat.python import cp_model +# [END import] + + +# [START program_part1] +# [START data_model] +def create_data_model(): + """Create the data for the example.""" + + items_str = """ + item weight + i1 48 + i2 30 + i3 19 + i4 36 + i5 36 + i6 27 + i7 42 + i8 42 + i9 36 + i10 24 + i11 30 + """ + + bins_str = """ + bin capacity + b1 100 + b2 100 + b3 100 + b4 100 + b5 100 + b6 100 + b7 100 + """ + + items = pd.read_table(io.StringIO(items_str), index_col=0, sep=r"\s+") + bins = pd.read_table(io.StringIO(bins_str), index_col=0, sep=r"\s+") + return items, bins + # [END data_model] + + +def main(): + # [START data] + items, bins = create_data_model() + # [END data] + # [END program_part1] + + # [START model] + # Create the model. + model = cp_model.CpModel() + # [END model] + + # [START program_part2] + # [START variables] + # Variables + # x[i, j] = 1 if item i is packed in bin j. + items_x_bins = pd.MultiIndex.from_product( + [items.index, bins.index], names=["item", "bin"] + ) + x = model.NewBoolVarSeries(name="x", index=items_x_bins) + + # y[j] = 1 if bin j is used. + y = model.NewBoolVarSeries(name="y", index=bins.index) + # [END variables] + + # [START constraints] + # Constraints + # Each item must be in exactly one bin. + for unused_name, all_copies in x.groupby("item"): + model.AddExactlyOne(x[all_copies.index]) + + # The amount packed in each bin cannot exceed its capacity. + for selected_bin in bins.index: + items_in_bin = x.xs(selected_bin, level="bin") + model.Add( + items_in_bin.dot(items.weight) + <= bins.loc[selected_bin].capacity * y[selected_bin] + ) + # [END constraints] + + # [START objective] + # Objective: minimize the number of bins used. + model.Minimize(y.sum()) + # [END objective] + + # [START solve] + # Create the solver with the CP-SAT backend, and solve the model. + solver = cp_model.CpSolver() + status = solver.Solve(model) + # [END solve] + + # [START print_solution] + if status == cp_model.OPTIMAL: + print(f"Number of bins used = {solver.ObjectiveValue()}") + + x_values = solver.BooleanValues(x) + y_values = solver.BooleanValues(y) + active_bins = y_values.loc[lambda x: x].index + + for b in active_bins: + print(f"Bin {b}") + items_in_bin = x_values.xs(b, level="bin").loc[lambda x: x].index + for item in items_in_bin: + print(f" Item {item} - weight {items.loc[item].weight}") + print(f" Packed items weight: {items.loc[items_in_bin].sum().to_string()}") + print() + + print(f"Total packed weight: {items.weight.sum()}") + print() + print(f"Time = {solver.WallTime()} seconds") + else: + print("The problem does not have an optimal solution.") + # [END print_solution] + + +if __name__ == "__main__": + main() +# [END program_part2] +# [END program] diff --git a/ortools/sat/samples/code_samples.bzl b/ortools/sat/samples/code_samples.bzl index f5a94e202cb..21cfe9351f4 100644 --- a/ortools/sat/samples/code_samples.bzl +++ b/ortools/sat/samples/code_samples.bzl @@ -47,6 +47,8 @@ def code_sample_py(name): main = name + ".py", deps = [ requirement("absl-py"), + requirement("numpy"), + requirement("pandas"), "//ortools/sat/python:cp_model", ], python_version = "PY3", @@ -64,6 +66,7 @@ def code_sample_py(name): deps = [ requirement("absl-py"), requirement("numpy"), + requirement("pandas"), requirement("protobuf"), ], python_version = "PY3",