From 3acda0a0e9e2a242bc1c257bf51ef7d8071c5a2a Mon Sep 17 00:00:00 2001 From: Laurent Perron Date: Sun, 9 Jul 2023 13:54:23 +0200 Subject: [PATCH] switch model_builder python from numpy to pandas --- ortools/linear_solver/python/BUILD.bazel | 26 +- ortools/linear_solver/python/model_builder.py | 1288 +++++++----- .../python/model_builder_helper.cc | 10 + .../python/model_builder_test.py | 1752 ++++++++++++++--- ortools/linear_solver/python/pandas_model.py | 1598 --------------- .../linear_solver/python/pandas_model_test.py | 1747 ---------------- .../linear_solver/samples/assignment_mb.py | 63 +- .../linear_solver/samples/bin_packing_mb.py | 108 +- .../linear_solver/samples/code_samples.bzl | 2 + .../samples/simple_lp_program_mb.py | 8 +- .../samples/simple_mip_program_mb.py | 8 +- 11 files changed, 2391 insertions(+), 4219 deletions(-) delete mode 100644 ortools/linear_solver/python/pandas_model.py delete mode 100644 ortools/linear_solver/python/pandas_model_test.py diff --git a/ortools/linear_solver/python/BUILD.bazel b/ortools/linear_solver/python/BUILD.bazel index c05db601a16..d72d568e8e9 100644 --- a/ortools/linear_solver/python/BUILD.bazel +++ b/ortools/linear_solver/python/BUILD.bazel @@ -37,7 +37,6 @@ py_test( "//ortools/linear_solver/testdata:large_model.mps.gz", "//ortools/linear_solver/testdata:maximization.mps", ], - python_version = "PY3", deps = [ ":model_builder_helper", ":model_builder_numbers", @@ -65,6 +64,7 @@ py_library( ":model_builder_helper", ":model_builder_numbers", requirement("numpy"), + requirement("pandas"), "//ortools/linear_solver:linear_solver_py_pb2", ], ) @@ -76,32 +76,8 @@ py_test( "//ortools/linear_solver/testdata:maximization.mps", "//ortools/linear_solver/testdata:small_model.lp", ], - python_version = "PY3", deps = [ ":model_builder", - ":model_builder_helper", - ":model_builder_numbers", - requirement("absl-py"), - requirement("numpy"), - ], -) - -py_library( - name = "pandas_model", - srcs = ["pandas_model.py"], - deps = [ - ":model_builder_helper", - requirement("numpy"), - requirement("pandas"), - "//ortools/linear_solver:linear_solver_py_pb2", - ], -) - -py_test( - name = "pandas_model_test", - srcs = ["pandas_model_test.py"], - deps = [ - ":pandas_model", requirement("absl-py"), requirement("pandas"), "//ortools/linear_solver:linear_solver_py_pb2", diff --git a/ortools/linear_solver/python/model_builder.py b/ortools/linear_solver/python/model_builder.py index 4b3a1b25138..e99e36682b5 100644 --- a/ortools/linear_solver/python/model_builder.py +++ b/ortools/linear_solver/python/model_builder.py @@ -31,47 +31,37 @@ Other methods and functions listed are primarily used for developing OR-Tools, rather than for solving specific optimization problems. """ - +import abc +import collections +import dataclasses import math import numbers -from typing import Any, Callable, Dict, List, Literal, Optional, Sequence, Tuple, Union +import typing +from typing import Callable, Mapping, Optional, Sequence, Union import numpy as np from numpy import typing as npt -from numpy.lib import mixins +import pandas as pd +from ortools.linear_solver import linear_solver_pb2 from ortools.linear_solver.python import model_builder_helper as mbh from ortools.linear_solver.python import model_builder_numbers as mbn # Custom types. -NumberT = Union[numbers.Number, np.number] -IntegerT = Union[numbers.Integral, np.integer] +NumberT = Union[int, float, numbers.Real, np.number] +IntegerT = Union[int, numbers.Integral, np.integer] LinearExprT = Union["LinearExpr", NumberT] -ConstraintT = Union["VarCompVar", "BoundedLinearExpression", bool] -ShapeT = Union[IntegerT, Sequence[IntegerT]] -VariablesT = Union["VariableContainer", "Variable"] -NumpyFuncT = Callable[ - [ - "VariableContainer", - Optional[Union[NumberT, npt.NDArray[np.number], Sequence[NumberT]]], - ], - LinearExprT, -] -SliceT = Union[ - slice, - int, - List[int], - "ellipsis", - Tuple[Union[int, slice, List[int], "ellipsis"], ...], -] +ConstraintT = Union["_BoundedLinearExpr", bool] +_IndexOrSeries = Union[pd.Index, pd.Series] +_VariableOrConstraint = Union["LinearConstraint", "Variable"] # Forward solve statuses. SolveStatus = mbh.SolveStatus -class LinearExpr: +class LinearExpr(metaclass=abc.ABCMeta): """Holds an linear expression. A linear expression is built from constants and variables. @@ -136,7 +126,7 @@ def weighted_sum( # pytype: disable=annotation-type-mismatch # numpy-scalars coefficients: Sequence[NumberT], *, constant: NumberT = 0.0, - ) -> LinearExprT: + ) -> Union[NumberT, "_WeightedSum"]: """Creates `sum(expressions[i] * coefficients[i]) + constant`. It can perform simple simplifications and returns different object, @@ -148,7 +138,7 @@ def weighted_sum( # pytype: disable=annotation-type-mismatch # numpy-scalars constant: a numerical constant. Returns: - a LinearExpr instance or a numerical constant. + a _WeightedSum instance or a numerical constant. """ if len(expressions) != len(coefficients): raise ValueError( @@ -162,6 +152,7 @@ def weighted_sum( # pytype: disable=annotation-type-mismatch # numpy-scalars # Collect sub-arrays to concatenate. indices = [] coeffs = [] + helper = None for e, c in zip(expressions, coefficients): if mbn.is_zero(c): continue @@ -169,15 +160,29 @@ def weighted_sum( # pytype: disable=annotation-type-mismatch # numpy-scalars if mbn.is_a_number(e): checked_constant += np.double(c * e) elif isinstance(e, Variable): + if not helper: + helper = e.helper indices.append(np.array([e.index], dtype=np.int32)) coeffs.append(np.array([c], dtype=np.double)) elif isinstance(e, _WeightedSum): + if not helper: + helper = e.helper checked_constant += np.double(c * e.constant) indices.append(e.variable_indices) coeffs.append(e.coefficients * c) - - if indices: + elif isinstance(e, LinearExpr): + expr = _as_flat_linear_expression(e) + # pylint: disable=protected-access + checked_constant += np.double(c * expr._offset) + for variable, coeff in expr._terms.items(): + if not helper: + helper = variable.helper + indices.append(np.array([variable.index], dtype=np.int32)) + coeffs.append(np.array([coeff * c], dtype=np.double)) + + if helper: return _WeightedSum( + helper=helper, variable_indices=np.concatenate(indices, axis=0), coefficients=np.concatenate(coeffs, axis=0), constant=checked_constant, @@ -215,141 +220,64 @@ def term( # pytype: disable=annotation-type-mismatch # numpy-scalars return np.double(expression) * checked_coefficient + checked_constant if isinstance(expression, Variable): return _WeightedSum( + helper=expression.helper, variable_indices=np.array([expression.index], dtype=np.int32), coefficients=np.array([checked_coefficient], dtype=np.double), constant=checked_constant, ) if isinstance(expression, _WeightedSum): return _WeightedSum( + helper=expression.helper, variable_indices=np.copy(expression.variable_indices), coefficients=expression.coefficients * checked_coefficient, constant=expression.constant * checked_coefficient + checked_constant, ) + if isinstance(expression, LinearExpr): + return expression * checked_coefficient + checked_constant raise TypeError(f"Unknown expression {expression!r} of type {type(expression)}") def __hash__(self): return object.__hash__(self) - def __abs__(self): - return NotImplemented + def __add__(self, arg: LinearExprT) -> "_Sum": + return _Sum(self, arg) - def __add__(self, arg: LinearExprT) -> LinearExprT: - if mbn.is_a_number(arg): - return LinearExpr.sum([self], constant=arg) - return LinearExpr.weighted_sum( - [self, arg], [1.0, 1.0], constant=0.0 - ) # pytype: disable=wrong-arg-types # numpy-scalars - - def __radd__(self, arg: LinearExprT): + def __radd__(self, arg: LinearExprT) -> "_Sum": return self.__add__(arg) - def __sub__(self, arg: LinearExprT): - if mbn.is_a_number(arg): - return LinearExpr.sum([self], constant=arg * -1.0) - return LinearExpr.weighted_sum( - [self, arg], [1.0, -1.0], constant=0.0 - ) # pytype: disable=wrong-arg-types # numpy-scalars + def __sub__(self, arg: LinearExprT) -> "_Sum": + return _Sum(self, -arg) - def __rsub__(self, arg: LinearExprT): - return LinearExpr.weighted_sum( - [self, arg], [-1.0, 1.0], constant=0.0 - ) # pytype: disable=wrong-arg-types # numpy-scalars + def __rsub__(self, arg: LinearExprT) -> "_Sum": + return _Sum(-self, arg) - def __mul__(self, arg: NumberT): - arg = mbn.assert_is_a_number(arg) - if mbn.is_one(arg): - return self - elif mbn.is_zero(arg): - return 0.0 - return self.multiply_by(arg) - - def multiply_by(self, arg: NumberT) -> LinearExprT: - raise NotImplementedError("LinearExpr.multiply_by") + def __mul__(self, arg: NumberT) -> "_Product": + return _Product(self, arg) - def __rmul__(self, arg: NumberT): + def __rmul__(self, arg: NumberT) -> "_Product": return self.__mul__(arg) - def __div__(self, arg: NumberT): - coeff = mbn.assert_is_a_number(arg) - if mbn.is_zero(coeff): - raise ValueError("Cannot call the division operator with a zero divisor") + def __truediv__(self, coeff: NumberT) -> "_Product": return self.__mul__(1.0 / coeff) - def __truediv__(self, _): - return NotImplemented - - def __mod__(self, _): - return NotImplemented - - def __pow__(self, _): - return NotImplemented - - def __lshift__(self, _): - return NotImplemented - - def __rshift__(self, _): - return NotImplemented - - def __and__(self, _): - return NotImplemented - - def __or__(self, _): - return NotImplemented - - def __xor__(self, _): - return NotImplemented - - def __neg__(self): - return self.__mul__( - -1.0 - ) # pytype: disable=unsupported-operands # numpy-scalars + def __neg__(self) -> "_Product": + return _Product(self, -1) def __bool__(self): raise NotImplementedError(f"Cannot use a LinearExpr {self} as a Boolean value") - def __eq__( - self, arg: Optional[LinearExprT] - ) -> Union[bool, "BoundedLinearExpression"]: - if arg is None: - return False - if mbn.is_a_number(arg): - arg = mbn.assert_is_a_number(arg) - return BoundedLinearExpression(self, arg, arg) - else: - return BoundedLinearExpression( - self - arg, 0, 0 - ) # pytype: disable=wrong-arg-types # numpy-scalars + def __eq__(self, arg: LinearExprT) -> "BoundedLinearExpression": + return BoundedLinearExpression(self - arg, 0, 0) def __ge__(self, arg: LinearExprT) -> "BoundedLinearExpression": - if mbn.is_a_number(arg): - arg = mbn.assert_is_a_number(arg) - return BoundedLinearExpression( - self, arg, math.inf - ) # pytype: disable=wrong-arg-types # numpy-scalars - else: - return BoundedLinearExpression( - self - arg, 0, math.inf - ) # pytype: disable=wrong-arg-types # numpy-scalars + return BoundedLinearExpression( + self - arg, 0, math.inf + ) # pytype: disable=wrong-arg-types # numpy-scalars def __le__(self, arg: LinearExprT) -> "BoundedLinearExpression": - if mbn.is_a_number(arg): - arg = mbn.assert_is_a_number(arg) - return BoundedLinearExpression( - self, -math.inf, arg - ) # pytype: disable=wrong-arg-types # numpy-scalars - else: - return BoundedLinearExpression( - self - arg, -math.inf, 0 - ) # pytype: disable=wrong-arg-types # numpy-scalars - - def __ne__(self, arg: LinearExprT): - return NotImplemented - - def __lt__(self, arg: LinearExprT): - return NotImplemented - - def __gt__(self, arg: LinearExprT): - return NotImplemented + return BoundedLinearExpression( + self - arg, -math.inf, 0 + ) # pytype: disable=wrong-arg-types # numpy-scalars class _WeightedSum(LinearExpr): @@ -357,12 +285,14 @@ class _WeightedSum(LinearExpr): def __init__( self, + helper: mbh.ModelBuilderHelper, *, variable_indices: npt.NDArray[np.int32], coefficients: npt.NDArray[np.double], constant: np.double = np.double(0.0), ): super().__init__() + self.__helper: mbh.ModelBuilderHelper = helper self.__variable_indices: npt.NDArray[np.int32] = variable_indices self.__coefficients: npt.NDArray[np.double] = mbn.assert_is_a_number_array( coefficients @@ -374,6 +304,7 @@ def multiply_by(self, arg: NumberT) -> LinearExprT: return 0.0 # pytype: disable=bad-return-type # numpy-scalars if self.__variable_indices.size > 0: return _WeightedSum( + helper=self.__helper, variable_indices=np.copy(self.__variable_indices), coefficients=self.__coefficients * arg, constant=self.__constant * arg, @@ -381,6 +312,10 @@ def multiply_by(self, arg: NumberT) -> LinearExprT: else: return self.constant * arg + @property + def helper(self) -> mbh.ModelBuilderHelper: + return self.__helper + @property def variable_indices(self) -> npt.NDArray[np.int32]: return self.__variable_indices @@ -393,13 +328,11 @@ def coefficients(self) -> npt.NDArray[np.double]: def constant(self) -> np.double: return self.__constant - def pretty_string(self, helper: mbh.ModelBuilderHelper) -> str: + def pretty_string(self) -> str: """Pretty print a linear expression into a string.""" output: str = "" for index, coeff in zip(self.variable_indices, self.coefficients): - var_name = helper.var_name(index) - if not var_name: - var_name = f"unnamed_var_{index}" + var_name = Variable(self.helper, index, None, None, None).name if not output and mbn.is_one(coeff): output = var_name @@ -493,35 +426,18 @@ def is_equal_to(self, other: LinearExprT) -> bool: return self.index == other.index and self.helper == other.helper def __str__(self) -> str: - name = self.__helper.var_name(self.__index) - if not name: - if self.__helper.VarIsInteger(self.__index): - return "unnamed_int_var_%i" % self.__index - else: - return "unnamed_num_var_%i" % self.__index - return name + return self.name def __repr__(self) -> str: - index = self.__index - name = self.__helper.var_name(index) - lb = self.__helper.var_lower_bound(index) - ub = self.__helper.var_upper_bound(index) - is_integer = self.__helper.var_is_integral(index) - if name: - if is_integer: - return f"{name}(index={index}, lb={lb}, ub={ub}, integer)" - else: - return f"{name}(index={index}, lb={lb}, ub={ub})" - else: - if is_integer: - return f"unnamed_var(index={index}, lb={lb}, ub={ub}, integer)" - else: - return f"unnamed_var(index={index}, lb={lb}, ub={ub})" + return self.__str__() @property def name(self) -> str: """Returns the name of the variable.""" - return self.__helper.var_name(self.__index) + var_name = self.__helper.var_name(self.__index) + if var_name: + return var_name + return f"variable#{self.index}" @name.setter def name(self, name: str) -> None: @@ -570,22 +486,10 @@ def __eq__(self, arg: Optional[LinearExprT]) -> ConstraintT: if arg is None: return False if isinstance(arg, Variable): - return VarCompVar(self, arg, True) - else: - if mbn.is_a_number(arg): - arg = mbn.assert_is_a_number(arg) - return BoundedLinearExpression(self, arg, arg) - else: - return BoundedLinearExpression( - self - arg, 0.0, 0.0 - ) # pytype: disable=wrong-arg-types # numpy-scalars - - def __ne__(self, arg: LinearExprT) -> ConstraintT: - if arg is None: - return True - if isinstance(arg, Variable): - return VarCompVar(self, arg, False) - return NotImplemented + return VarEqVar(self, arg) + return BoundedLinearExpression( + self - arg, 0.0, 0.0 + ) # pytype: disable=wrong-arg-types # numpy-scalars def __hash__(self): return hash((self.__helper, self.__index)) @@ -596,188 +500,103 @@ def multiply_by(self, arg: NumberT) -> LinearExprT: ) # pytype: disable=wrong-arg-types # numpy-scalars -_REGISTERED_NUMPY_VARIABLE_FUNCS: Dict[Any, NumpyFuncT] = {} - - -class VariableContainer(mixins.NDArrayOperatorsMixin): - """Variable container.""" - - def __init__(self, helper: mbh.ModelBuilderHelper, indices: npt.NDArray[np.int32]): - self.__helper: mbh.ModelBuilderHelper = helper - self.__variable_indices: npt.NDArray[np.int32] = indices - - @property - def variable_indices(self) -> npt.NDArray[np.int32]: - return self.__variable_indices - - def __getitem__(self, pos: SliceT) -> VariablesT: - # delegate the treatment of the 'pos' query to __variable_indices. - index_or_slice: Union[ - np.int32, npt.NDArray[np.int32] - ] = self.__variable_indices[pos] - if np.isscalar(index_or_slice): - return Variable(self.__helper, index_or_slice, None, None, None) - else: - return VariableContainer(self.__helper, index_or_slice) - - def index_at(self, pos: SliceT) -> Union[np.int32, npt.NDArray[np.int32]]: - """Returns the index of the variable at the position 'pos'.""" - return self.__variable_indices[pos] - - # pylint: disable=invalid-name - @property - def T(self) -> "VariableContainer": - """Returns a view upon the transposed numpy array of variables.""" - return VariableContainer(self.__helper, self.__variable_indices.T) - - # pylint: enable=invalid-name - - @property - def shape(self) -> Sequence[int]: - """Returns the shape of the numpy array.""" - return self.__variable_indices.shape - - @property - def size(self) -> int: - """Returns the number of variables in the numpy array.""" - return self.__variable_indices.size - - def ravel(self) -> "VariableContainer": - """returns the ravel array of variables.""" - return VariableContainer(self.__helper, self.__variable_indices.ravel()) +class _BoundedLinearExpr(metaclass=abc.ABCMeta): + """Interface for types that can build bounded linear (boolean) expressions. - def flatten(self) -> "VariableContainer": - """returns the flattened array of variables.""" - return VariableContainer(self.__helper, self.__variable_indices.flatten()) + Classes derived from _BoundedLinearExpr are used to build linear constraints + to be satisfied. - def __str__(self) -> str: - return f"VariableContainer({self.__variable_indices})" + * BoundedLinearExpression: a linear expression with upper and lower bounds. + * VarEqVar: an equality comparison between two variables. + """ - def __repr__(self) -> str: - return f"VariableContainer({self.__helper}, {repr(self.__variable_indices)})" + @abc.abstractmethod + def _add_linear_constraint( + self, helper: mbh.ModelBuilderHelper, name: str + ) -> "LinearConstraint": + """Creates a new linear constraint in the helper. - def __len__(self): - return self.__variable_indices.shape[0] + Args: + helper (mbh.ModelBuilderHelper): The helper to create the constraint. + name (str): The name of the linear constraint. - def __array_ufunc__( - self, - ufunc: np.ufunc, - method: Literal[ - "__call__", "reduce", "reduceat", "accumulate", "outer", "inner" - ], - *inputs: Any, - **kwargs: Any, - ) -> LinearExprT: - if method != "__call__": - return NotImplemented # pytype: disable=bad-return-type # numpy-scalars - function = _REGISTERED_NUMPY_VARIABLE_FUNCS.get(ufunc) - if function is None: - return NotImplemented # pytype: disable=bad-return-type # numpy-scalars - if len(inputs) <= 2 and isinstance(inputs[0], VariableContainer): - return function(*inputs, **kwargs) - if len(inputs) == 2 and isinstance(inputs[1], VariableContainer): - return function(inputs[1], inputs[0], **kwargs) - return NotImplemented # pytype: disable=bad-return-type # numpy-scalars - - def __array_function__( - self, func: Any, types: Any, inputs: Any, kwargs: Any - ) -> LinearExprT: - function = _REGISTERED_NUMPY_VARIABLE_FUNCS.get(func) - if function is None: - return NotImplemented # pytype: disable=bad-return-type # numpy-scalars - if len(inputs) <= 2 and isinstance(inputs[0], VariableContainer): - return function(*inputs, **kwargs) - if len(inputs) == 2 and isinstance(inputs[1], VariableContainer): - return function(inputs[1], inputs[0], **kwargs) - return NotImplemented # pytype: disable=bad-return-type # numpy-scalars - - -def _implements(np_function: Any) -> Callable[[NumpyFuncT], NumpyFuncT]: - """Register an __array_function__ implementation for VariableContainer objects.""" - - def decorator(func: NumpyFuncT) -> NumpyFuncT: - _REGISTERED_NUMPY_VARIABLE_FUNCS[np_function] = func - return func - - return decorator - - -@_implements(np.sum) -def sum_variable_container( # pytype: disable=annotation-type-mismatch # numpy-scalars - container: VariableContainer, constant: NumberT = 0.0 -) -> LinearExprT: - """Implementation of np.sum for VariableContainer objects.""" - indices: npt.NDArray[np.int32] = container.variable_indices - return _WeightedSum( - variable_indices=indices.flatten(), - coefficients=np.ones(indices.size), - constant=np.double(constant), - ) + Returns: + LinearConstraint: A reference to the linear constraint in the helper. + """ -@_implements(np.dot) -def dot_variable_container( - container: VariableContainer, - arg: Union[np.double, npt.NDArray[np.double]], -) -> LinearExprT: - """Implementation of np.dot for VariableContainer objects.""" - if len(container.shape) != 1: - raise ValueError( - "dot_variable_container only supports 1D variable containers (shape =" - f" {container.shape})" - ) - indices: npt.NDArray[np.int32] = container.variable_indices - if np.isscalar(arg): - return _WeightedSum( - variable_indices=indices.flatten(), - coefficients=np.full(indices.size, arg), - constant=0.0, - ) - else: - arg: npt.NDArray[np.double] = np.array(arg, dtype=np.double) - assert container.shape == arg.shape, (container.shape, arg.shape) - return _WeightedSum( - variable_indices=indices.flatten(), - coefficients=arg.flatten(), - constant=0.0, - ) +def _add_linear_constraint( + constraint: Union[bool, _BoundedLinearExpr], + helper: mbh.ModelBuilderHelper, + name: str, +): + """Creates a new linear constraint in the helper. + It handles boolean values (which might arise in the construction of + BoundedLinearExpressions). -class VarCompVar: - """Represents var == /!= var.""" + Args: + constraint: The constraint to be created. + helper: The helper to create the constraint. + name: The name of the constraint to be created. - def __init__(self, left: Variable, right: Variable, is_equality: bool): - self.__left: Variable = left - self.__right: Variable = right - self.__is_equality: bool = is_equality + Returns: + LinearConstraint: a constraint in the helper corresponding to the input. - def __str__(self) -> str: - if self.__is_equality: - return f"{self.__left} == {self.__right}" + Raises: + TypeError: If constraint is an invalid type. + """ + if isinstance(constraint, bool): + c = LinearConstraint(helper) + helper.set_constraint_name(c.index, name) + if constraint: + # constraint that is always feasible: -inf <= nothing <= inf + helper.set_constraint_lower_bound(c.index, -math.inf) + helper.set_constraint_upper_bound(c.index, math.inf) else: - return f"{self.__left} != {self.__right}" + # constraint that is always infeasible: -1 <= nothing <= -1 + helper.set_constraint_lower_bound(c.index, -1) + helper.set_constraint_upper_bound(c.index, -1) + return c + if isinstance(constraint, _BoundedLinearExpr): + # pylint: disable=protected-access + return constraint._add_linear_constraint(helper, name) + raise TypeError("invalid type={}".format(type(constraint))) - def __repr__(self) -> str: - return f"VarCompVar({self.__left}, {self.__right}, {self.__is_equality})" - @property - def left(self) -> Variable: - return self.__left +@dataclasses.dataclass(repr=False, eq=False, frozen=True) +class VarEqVar(_BoundedLinearExpr): + """Represents var == var.""" - @property - def right(self) -> Variable: - return self.__right + __slots__ = ("left", "right") - @property - def is_equality(self) -> bool: - return self.__is_equality + left: Variable + right: Variable - def __bool__(self) -> bool: - return bool(self.__left.index == self.__right.index) == self.__is_equality + def __str__(self): + return f"{self.left} == {self.right}" + def __repr__(self): + return self.__str__() -# TODO(user): investigate storing left and right expressions. -class BoundedLinearExpression: + def __bool__(self) -> bool: + return hash(self.left) == hash(self.right) + + def _add_linear_constraint( + self, helper: mbh.ModelBuilderHelper, name: str + ) -> "LinearConstraint": + c = LinearConstraint(helper) + helper.set_constraint_lower_bound(c.index, 0.0) + helper.set_constraint_upper_bound(c.index, 0.0) + # pylint: disable=protected-access + helper.add_term_to_constraint(c.index, self.left.index, 1.0) + helper.add_term_to_constraint(c.index, self.right.index, -1.0) + # pylint: enable=protected-access + helper.set_constraint_name(c.index, name) + return c + + +class BoundedLinearExpression(_BoundedLinearExpr): """Represents a linear constraint: `lb <= linear expression <= ub`. The only use of this class is to be added to the ModelBuilder through @@ -804,7 +623,10 @@ def __str__(self) -> str: elif self.__ub < math.inf: return str(self.__expr) + " <= " + str(self.__ub) else: - return "True (unbounded expr " + str(self.__expr) + ")" + return str(self.__expr) + " free" + + def __repr__(self): + return self.__str__() @property def expression(self) -> LinearExprT: @@ -823,6 +645,20 @@ def __bool__(self) -> bool: f"Cannot use a BoundedLinearExpression {self} as a Boolean value" ) + def _add_linear_constraint( + self, helper: mbh.ModelBuilderHelper, name: str + ) -> "LinearConstraint": + c = LinearConstraint(helper) + expr = _as_flat_linear_expression(self.__expr) + # pylint: disable=protected-access + for variable, coeff in expr._terms.items(): + helper.add_term_to_constraint(c.index, variable.index, coeff) + helper.set_constraint_lower_bound(c.index, self.__lb - expr._offset) + helper.set_constraint_upper_bound(c.index, self.__ub - expr._offset) + # pylint: enable=protected-access + helper.set_constraint_name(c.index, name) + return c + class LinearConstraint: """Stores a linear equation. @@ -834,12 +670,17 @@ class LinearConstraint: linear_constraint = model.add(x + 2 * y == 5) """ - def __init__(self, helper: mbh.ModelBuilderHelper): - self.__index: np.int32 = helper.add_linear_constraint() + def __init__( + self, helper: mbh.ModelBuilderHelper, index: Optional[IntegerT] = None + ): + if index is None: + self.__index = helper.add_linear_constraint() + else: + self.__index = index self.__helper: mbh.ModelBuilderHelper = helper @property - def index(self) -> np.int32: + def index(self) -> IntegerT: """Returns the index of the constraint in the helper.""" return self.__index @@ -866,12 +707,21 @@ def upper_bound(self, bound: NumberT) -> None: @property def name(self) -> str: - return self.__helper.constraint_name(self.__index) + constraint_name = self.__helper.constraint_name(self.__index) + if constraint_name: + return constraint_name + return f"linear_constraint#{self.__index}" @name.setter def name(self, name: str) -> None: return self.__helper.set_constraint_name(self.__index, name) + def __str__(self): + return self.name + + def __repr__(self): + return self.__str__() + def add_term(self, var: Variable, coeff: NumberT) -> None: self.__helper.add_term_to_constraint(self.__index, var.index, coeff) @@ -888,6 +738,169 @@ class ModelBuilder: def __init__(self): self.__helper: mbh.ModelBuilderHelper = mbh.ModelBuilderHelper() + @typing.overload + def _get_linear_constraints(self, constraints: Optional[pd.Index]) -> pd.Index: + ... + + @typing.overload + def _get_linear_constraints(self, constraints: pd.Series) -> pd.Series: + ... + + def _get_linear_constraints( + self, constraints: Optional[_IndexOrSeries] = None + ) -> _IndexOrSeries: + if constraints is None: + return self.get_linear_constraints() + return constraints + + @typing.overload + def _get_variables(self, variables: Optional[pd.Index]) -> pd.Index: + ... + + @typing.overload + def _get_variables(self, variables: pd.Series) -> pd.Series: + ... + + def _get_variables( + self, variables: Optional[_IndexOrSeries] = None + ) -> _IndexOrSeries: + if variables is None: + return self.get_variables() + return variables + + def get_linear_constraints(self) -> pd.Index: + """Gets all linear constraints in the model.""" + return pd.Index( + [self.linear_constraint_from_index(i) for i in range(self.num_constraints)], + name="linear_constraint", + ) + + def get_linear_constraint_expressions( + self, constraints: Optional[_IndexOrSeries] = None + ) -> pd.Series: + """Gets the expressions of all linear constraints in the set. + + If `constraints` is a `pd.Index`, then the output will be indexed by the + constraints. If `constraints` is a `pd.Series` indexed by the underlying + dimensions, then the output will be indexed by the same underlying + dimensions. + + Args: + constraints (Union[pd.Index, pd.Series]): Optional. The set of linear + constraints from which to get the expressions. If unspecified, all + linear constraints will be in scope. + + Returns: + pd.Series: The expressions of all linear constraints in the set. + """ + return _attribute_series( + # pylint: disable=g-long-lambda + func=lambda c: _as_flat_linear_expression( + # pylint: disable=g-complex-comprehension + sum( + coeff * Variable(self.__helper, var_id, None, None, None) + for var_id, coeff in zip( + c.helper.constraint_var_indices(c.index), + c.helper.constraint_coefficients(c.index), + ) + ) + ), + values=self._get_linear_constraints(constraints), + ) + + def get_linear_constraint_lower_bounds( + self, constraints: Optional[_IndexOrSeries] = None + ) -> pd.Series: + """Gets the lower bounds of all linear constraints in the set. + + If `constraints` is a `pd.Index`, then the output will be indexed by the + constraints. If `constraints` is a `pd.Series` indexed by the underlying + dimensions, then the output will be indexed by the same underlying + dimensions. + + Args: + constraints (Union[pd.Index, pd.Series]): Optional. The set of linear + constraints from which to get the lower bounds. If unspecified, all + linear constraints will be in scope. + + Returns: + pd.Series: The lower bounds of all linear constraints in the set. + """ + return _attribute_series( + func=lambda c: c.lower_bound, # pylint: disable=protected-access + values=self._get_linear_constraints(constraints), + ) + + def get_linear_constraint_upper_bounds( + self, constraints: Optional[_IndexOrSeries] = None + ) -> pd.Series: + """Gets the upper bounds of all linear constraints in the set. + + If `constraints` is a `pd.Index`, then the output will be indexed by the + constraints. If `constraints` is a `pd.Series` indexed by the underlying + dimensions, then the output will be indexed by the same underlying + dimensions. + + Args: + constraints (Union[pd.Index, pd.Series]): Optional. The set of linear + constraints. If unspecified, all linear constraints will be in scope. + + Returns: + pd.Series: The upper bounds of all linear constraints in the set. + """ + return _attribute_series( + func=lambda c: c.upper_bound, # pylint: disable=protected-access + values=self._get_linear_constraints(constraints), + ) + + def get_variables(self) -> pd.Index: + """Gets all variables in the model.""" + return pd.Index( + [self.var_from_index(i) for i in range(self.num_variables)], + name="variable", + ) + + def get_variable_lower_bounds( + self, variables: Optional[_IndexOrSeries] = None + ) -> pd.Series: + """Gets the lower bounds of all variables in the set. + + 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]): Optional. The set of variables + from which to get the lower bounds. If unspecified, all variables will + be in scope. + + Returns: + pd.Series: The lower bounds of all variables in the set. + """ + return _attribute_series( + func=lambda v: v.lower_bound, # pylint: disable=protected-access + values=self._get_variables(variables), + ) + + def get_variable_upper_bounds( + self, variables: Optional[_IndexOrSeries] = None + ) -> pd.Series: + """Gets the upper bounds of all variables in the set. + + Args: + variables (Union[pd.Index, pd.Series]): Optional. The set of variables + from which to get the upper bounds. If unspecified, all variables will + be in scope. + + Returns: + pd.Series: The upper bounds of all variables in the set. + """ + return _attribute_series( + func=lambda v: v.upper_bound, # pylint: disable=protected-access + values=self._get_variables(variables), + ) + # Integer variable. def new_var( @@ -949,194 +962,174 @@ def new_constant(self, value: NumberT) -> Variable: """Declares a constant variable.""" return self.new_var(value, value, False, None) - def new_var_array( + def new_var_series( self, - *, - lower_bounds: npt.ArrayLike, - upper_bounds: npt.ArrayLike, - is_integral: npt.ArrayLike, - shape: Optional[ShapeT] = None, - name: Optional[str] = None, - ) -> VariableContainer: - """Creates a vector of variables from bounds, shape, is_integral.""" - # Convert the shape to a list of sizes if needed. - if shape is not None and np.isscalar(shape): - shape = [shape] - - if not np.isscalar(lower_bounds): - if shape is None: - shape = np.shape(lower_bounds) - elif shape != np.shape(lower_bounds): - raise ValueError( - "lower_bounds, upper_bounds, is_integral and shape must have" - " compatible shapes (when defined)" - ) + name: str, + index: pd.Index, + lower_bounds: Union[NumberT, pd.Series] = -math.inf, + upper_bounds: Union[NumberT, pd.Series] = math.inf, + is_integral: Union[bool, pd.Series] = False, + ) -> pd.Series: + """Creates a series of (scalar-valued) variables with the given name. - if not np.isscalar(upper_bounds): - if shape is None: - shape = np.shape(upper_bounds) - elif shape != np.shape(upper_bounds): - raise ValueError( - "lower_bounds, upper_bounds, is_integral and shape must have" - " compatible shapes (when defined)" - ) + 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, float, pd.Series]): Optional. 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. Defaults to -inf. + upper_bounds (Union[int, float, pd.Series]): Optional. 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. Defaults to +inf. + is_integral (bool, pd.Series): Optional. Indicates if the variable can + only take integer values. If a `pd.Series` is passed in, it will be + based on the corresponding values of the pd.Series. Defaults to False. - if not np.isscalar(is_integral): - if shape is None: - shape = np.shape(is_integral) - elif shape != np.shape(is_integral): - raise ValueError( - "lower_bounds, upper_bounds, is_integral and shape must have" - " compatible shapes (when defined)" + 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`, `upper_bound`, or `is_integer` + 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, NumberT) + and isinstance(upper_bounds, NumberT) + and lower_bounds > upper_bounds + ): + raise ValueError( + "lower_bound={} is greater than upper_bound={} for variable set={}".format( + lower_bounds, upper_bounds, name ) - - if shape is None: - raise ValueError("a shape must be defined") - - name = name or "" - + ) if ( - np.isscalar(lower_bounds) - and np.isscalar(upper_bounds) - and np.isscalar(is_integral) + isinstance(is_integral, bool) + and is_integral + and isinstance(lower_bounds, NumberT) + and isinstance(upper_bounds, NumberT) + and math.isfinite(lower_bounds) + and math.isfinite(upper_bounds) + and math.ceil(lower_bounds) > math.floor(upper_bounds) ): - var_indices = self.__helper.add_var_array( - shape, lower_bounds, upper_bounds, is_integral, name + raise ValueError( + "ceil(lower_bound={})={}".format(lower_bounds, math.ceil(lower_bounds)) + + " is greater than floor(" + + "upper_bound={})={}".format(upper_bounds, math.floor(upper_bounds)) + + " for variable set={}".format(name) ) - return VariableContainer(self.__helper, var_indices) - - # Convert scalars to np.arrays if needed. - if np.isscalar(lower_bounds): - lower_bounds = np.full(shape, lower_bounds) - if np.isscalar(upper_bounds): - upper_bounds = np.full(shape, upper_bounds) - if np.isscalar(is_integral): - is_integral = np.full(shape, is_integral) - - var_indices = self.__helper.add_var_array_with_bounds( - lower_bounds, upper_bounds, is_integral, name - ) - return VariableContainer(self.__helper, var_indices) - - def new_num_var_array( - self, - *, - lower_bounds: npt.ArrayLike, - upper_bounds: npt.ArrayLike, - shape: Optional[ShapeT] = None, - name: Optional[str] = None, - ) -> VariableContainer: - """Creates a vector of continuous variables from shape and bounds.""" - # Convert the shape to a list of sizes if needed. - if shape is not None and np.isscalar(shape): - shape = [shape] - - if not np.isscalar(lower_bounds): - if shape is None: - shape = np.shape(lower_bounds) - elif shape != np.shape(lower_bounds): - raise ValueError( - "lower_bounds, upper_bounds, and shape must have" - " compatible shapes (when defined)" - ) - - if not np.isscalar(upper_bounds): - if shape is None: - shape = np.shape(upper_bounds) - elif shape != np.shape(upper_bounds): - raise ValueError( - "lower_bounds, upper_bounds, and shape must have" - " compatible shapes (when defined)" + lower_bounds = _convert_to_series_and_validate_index(lower_bounds, index) + upper_bounds = _convert_to_series_and_validate_index(upper_bounds, index) + is_integrals = _convert_to_series_and_validate_index(is_integral, index) + return pd.Series( + index=index, + data=[ + # pylint: disable=g-complex-comprehension + Variable( + helper=self.__helper, + name=f"{name}[{i}]", + lb=lower_bounds[i], + ub=upper_bounds[i], + is_integral=is_integrals[i], ) - - if shape is None: - raise ValueError("a shape must be defined") - - name = name or "" - - if np.isscalar(lower_bounds) and np.isscalar(upper_bounds): - var_indices = self.__helper.add_var_array( - shape, lower_bounds, upper_bounds, False, name - ) - return VariableContainer(self.__helper, var_indices) - - # Convert scalars to np.arrays if needed. - if np.isscalar(lower_bounds): - lower_bounds = np.full(shape, lower_bounds) - if np.isscalar(upper_bounds): - upper_bounds = np.full(shape, upper_bounds) - - var_indices = self.__helper.add_var_array_with_bounds( - lower_bounds, upper_bounds, np.zeros(shape, dtype=bool), name + for i in index + ], ) - return VariableContainer(self.__helper, var_indices) - def new_int_var_array( + def new_num_var_series( self, - *, - lower_bounds: npt.ArrayLike, - upper_bounds: npt.ArrayLike, - shape: Optional[ShapeT] = None, - name: Optional[str] = None, - ) -> VariableContainer: - """Creates a vector of integer variables from shape and bounds.""" - # Convert the shape to a list of sizes if needed. - if shape is not None and np.isscalar(shape): - shape = [shape] - - if not np.isscalar(lower_bounds): - if shape is None: - shape = np.shape(lower_bounds) - elif shape != np.shape(lower_bounds): - raise ValueError( - "lower_bounds, upper_bounds, and shape must have" - " compatible shapes (when defined)" - ) - - if not np.isscalar(upper_bounds): - if shape is None: - shape = np.shape(upper_bounds) - elif shape != np.shape(upper_bounds): - raise ValueError( - "lower_bounds, upper_bounds, and shape must have" - " compatible shapes (when defined)" - ) + name: str, + index: pd.Index, + lower_bounds: Union[NumberT, pd.Series] = -math.inf, + upper_bounds: Union[NumberT, pd.Series] = math.inf, + ) -> pd.Series: + """Creates a series of continuous variables with the given name. - if shape is None: - raise ValueError("a shape must be defined") + 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, float, pd.Series]): Optional. 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. Defaults to -inf. + upper_bounds (Union[int, float, pd.Series]): Optional. 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. Defaults to +inf. - name = name or "" + 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`, `upper_bound`, or `is_integer` + does not match the input index. + """ + return self.new_var_series(name, index, lower_bounds, upper_bounds, False) - if np.isscalar(lower_bounds) and np.isscalar(upper_bounds): - var_indices = self.__helper.add_var_array( - shape, lower_bounds, upper_bounds, True, name - ) - return VariableContainer(self.__helper, var_indices) + def new_int_var_series( + self, + name: str, + index: pd.Index, + lower_bounds: Union[NumberT, pd.Series] = -math.inf, + upper_bounds: Union[NumberT, pd.Series] = math.inf, + ) -> pd.Series: + """Creates a series of integer variables with the given name. - # Convert scalars to np.arrays if needed. - if np.isscalar(lower_bounds): - lower_bounds = np.full(shape, lower_bounds) - if np.isscalar(upper_bounds): - upper_bounds = np.full(shape, upper_bounds) + 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, float, pd.Series]): Optional. 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. Defaults to -inf. + upper_bounds (Union[int, float, pd.Series]): Optional. 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. Defaults to +inf. - var_indices = self.__helper.add_var_array_with_bounds( - lower_bounds, upper_bounds, np.ones(shape, dtype=bool), name - ) - return VariableContainer(self.__helper, var_indices) + 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`, `upper_bound`, or `is_integer` + does not match the input index. + """ + return self.new_var_series(name, index, lower_bounds, upper_bounds, True) - def new_bool_var_array( + def new_bool_var_series( self, - shape: ShapeT, - name: Optional[str] = None, - ) -> VariableContainer: - """Creates a vector of Boolean variables.""" - if mbn.is_integral(shape): - shape = [shape] + name: str, + index: pd.Index, + ) -> pd.Series: + """Creates a series of Boolean 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. - name = name or "" + 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`, `upper_bound`, or `is_integer` + does not match the input index. + """ + return self.new_var_series(name, index, 0, 1, True) - var_indices = self.__helper.add_var_array(shape, 0.0, 1.0, True, name) - return VariableContainer(self.__helper, var_indices) + def linear_constraint_from_index(self, index: IntegerT) -> LinearConstraint: + """Rebuilds a linear constraint object from the model and its index.""" + return LinearConstraint(self.__helper, index) def var_from_index(self, index: IntegerT) -> Variable: """Rebuilds a variable object from the model and its index.""" @@ -1177,6 +1170,19 @@ def add_linear_constraint( # pytype: disable=annotation-type-mismatch # numpy- self.__helper.add_terms_to_constraint( ct.index, linear_expr.variable_indices, linear_expr.coefficients ) + elif isinstance(linear_expr, LinearExpr): + flat_expr = _as_flat_linear_expression(linear_expr) + # pylint: disable=protected-access + self.__helper.set_constraint_lower_bound(ct.index, lb - flat_expr._offset) + self.__helper.set_constraint_upper_bound(ct.index, ub - flat_expr._offset) + variable_indices = [] + coefficients = [] + for variable, coeff in flat_expr._terms.items(): + variable_indices.append(variable.index) + coefficients.append(coeff) + self.__helper.add_terms_to_constraint( + ct.index, variable_indices, coefficients + ) else: raise TypeError( f"Not supported: ModelBuilder.add_linear_constraint({linear_expr})" @@ -1184,7 +1190,9 @@ def add_linear_constraint( # pytype: disable=annotation-type-mismatch # numpy- ) return ct - def add(self, ct: ConstraintT, name: Optional[str] = None) -> LinearConstraint: + def add( + self, ct: Union[ConstraintT, pd.Series], name: Optional[str] = None + ) -> Union[LinearConstraint, pd.Series]: """Adds a `BoundedLinearExpression` to the model. Args: @@ -1198,9 +1206,7 @@ def add(self, ct: ConstraintT, name: Optional[str] = None) -> LinearConstraint: return self.add_linear_constraint( ct.expression, ct.lower_bound, ct.upper_bound, name ) - elif isinstance(ct, VarCompVar): - if not ct.is_equality: - raise TypeError("Not supported: ModelBuilder.Add(" + str(ct) + ")") + elif isinstance(ct, VarEqVar): new_ct = LinearConstraint(self.__helper) new_ct.lower_bound = 0.0 new_ct.upper_bound = 0.0 @@ -1211,6 +1217,14 @@ def add(self, ct: ConstraintT, name: Optional[str] = None) -> LinearConstraint: ct.right, -1.0 ) # pytype: disable=wrong-arg-types # numpy-scalars return new_ct + elif isinstance(ct, pd.Series): + return pd.Series( + index=ct.index, + data=[ + _add_linear_constraint(expr, self.__helper, f"{name}[{i}]") + for (i, expr) in zip(ct.index, ct) + ], + ) elif ct and isinstance(ct, bool): return self.add_linear_constraint( linear_expr=0.0 @@ -1243,9 +1257,19 @@ def __optimize(self, linear_expr: LinearExprT, maximize: bool) -> None: self.helper.set_var_objective_coefficient(linear_expr.index, 1.0) elif isinstance(linear_expr, _WeightedSum): self.helper.set_objective_offset(linear_expr.constant) - self.__helper.set_objective_coefficients( + self.helper.set_objective_coefficients( linear_expr.variable_indices, linear_expr.coefficients ) + elif isinstance(linear_expr, LinearExpr): + flat_expr = _as_flat_linear_expression(linear_expr) + # pylint: disable=protected-access + self.helper.set_objective_offset(flat_expr._offset) + variable_indices = [] + coefficients = [] + for variable, coeff in flat_expr._terms.items(): + variable_indices.append(variable.index) + coefficients.append(coeff) + self.helper.set_objective_coefficients(variable_indices, coefficients) else: raise TypeError( f"Not supported: ModelBuilder.minimize/maximize({linear_expr})" @@ -1259,6 +1283,15 @@ def objective_offset(self) -> np.double: def objective_offset(self, value: NumberT) -> None: self.__helper.set_objective_offset(value) + def objective_expression(self) -> "_LinearExpression": + return _as_flat_linear_expression( + sum( + variable * self.__helper.var_objective_coefficient(variable.index) + for variable in self.get_variables() + ) + + self.__helper.objective_offset() + ) + # Input/Output def export_to_lp_string(self, obfuscate: bool = False) -> str: options: mbh.MPModelExportOptions = mbh.MPModelExportOptions() @@ -1270,6 +1303,10 @@ def export_to_mps_string(self, obfuscate: bool = False) -> str: options.obfuscate = obfuscate return self.__helper.export_to_mps_string(options) + def export_to_proto(self) -> linear_solver_pb2.MPModelProto: + """Exports the optimization model to a ProtoBuf format.""" + return mbh.to_mpmodel_proto(self.__helper) + def import_from_mps_string(self, mps_string: str) -> bool: return self.__helper.import_from_mps_string(mps_string) @@ -1338,20 +1375,14 @@ def solve(self, model: ModelBuilder) -> SolveStatus: self.__solve_helper.solve(model.helper) return SolveStatus(self.__solve_helper.status()) - def __check_has_feasible_solution(self) -> None: - """Checks that solve has run and has found a feasible solution.""" - if not self.__solve_helper.has_solution(): - raise RuntimeError( - "solve() has not been called, or no solution has been found." - ) - def stop_search(self): """Stops the current search asynchronously.""" self.__solve_helper.interrupt_solve() def value(self, expr: LinearExprT) -> np.double: """Returns the value of a linear expression after solve.""" - self.__check_has_feasible_solution() + if not self.__solve_helper.has_solution(): + return pd.NA if mbn.is_a_number(expr): return expr elif isinstance(expr, Variable): @@ -1360,34 +1391,72 @@ def value(self, expr: LinearExprT) -> np.double: return self.__solve_helper.expression_value( expr.variable_indices, expr.coefficients, expr.constant ) + elif isinstance(expr, LinearExpr): + flat_expr = _as_flat_linear_expression(expr) + variable_indices = [] + coefficients = [] + # pylint: disable=protected-access + for variable, coeff in flat_expr._terms.items(): + variable_indices.append(variable.index) + coefficients.append(coeff) + return self.__solve_helper.expression_value( + variable_indices, coefficients, flat_expr._offset + ) else: raise TypeError(f"Unknown expression {expr!r} of type {type(expr)}") + 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. + """ + if not self.__solve_helper.has_solution(): + return _attribute_series(func=lambda v: pd.NA, values=variables) + return _attribute_series( + func=lambda v: self.__solve_helper.var_value(v.index), + values=variables, + ) + def reduced_cost(self, var: Variable) -> np.double: """Returns the reduced cost of a linear expression after solve.""" - self.__check_has_feasible_solution() + if not self.__solve_helper.has_solution(): + return pd.NA return self.__solve_helper.reduced_cost(var.index) def dual_value(self, ct: LinearConstraint) -> np.double: """Returns the dual value of a linear constraint after solve.""" - self.__check_has_feasible_solution() + if not self.__solve_helper.has_solution(): + return pd.NA return self.__solve_helper.dual_value(ct.index) def activity(self, ct: LinearConstraint) -> np.double: """Returns the activity of a linear constraint after solve.""" - self.__check_has_feasible_solution() + if not self.__solve_helper.has_solution(): + return pd.NA return self.__solve_helper.activity(ct.index) @property def objective_value(self) -> np.double: """Returns the value of the objective after solve.""" - self.__check_has_feasible_solution() + if not self.__solve_helper.has_solution(): + return pd.NA return self.__solve_helper.objective_value() @property def best_objective_bound(self) -> np.double: """Returns the best lower (upper) bound found when min(max)imizing.""" - self.__check_has_feasible_solution() + if not self.__solve_helper.has_solution(): + return pd.NA return self.__solve_helper.best_objective_bound() @property @@ -1405,3 +1474,170 @@ def wall_time(self) -> np.double: @property def user_time(self) -> np.double: return self.__solve_helper.user_time() + + +# The maximum number of terms to display in a linear expression's repr. +_MAX_LINEAR_EXPRESSION_REPR_TERMS = 5 + + +@dataclasses.dataclass(repr=False, eq=False, frozen=True) +class _LinearExpression(LinearExpr): + """For variables x, an expression: offset + sum_{i in I} coeff_i * x_i.""" + + __slots__ = ("_terms", "_offset") + + _terms: Mapping["Variable", float] + _offset: float + + def __repr__(self): + return self.__str__() + + def __str__(self): + result = [] + if self._offset != 0.0: + result.append(str(self._offset)) + sorted_keys = sorted(self._terms.keys(), key=str) + num_displayed_terms = 0 + for variable in sorted_keys: + if num_displayed_terms == _MAX_LINEAR_EXPRESSION_REPR_TERMS: + result.append(" + ...") + break + coefficient = self._terms[variable] + if coefficient == 0.0: + continue + if result: + if coefficient > 0: + result.append(" + ") + else: + result.append(" - ") + elif coefficient < 0: + result.append("- ") + if abs(coefficient) != 1.0: + result.append(f"{abs(coefficient)} * ") + result.append(f"{variable}") + num_displayed_terms += 1 + return "".join(result) + + +def _as_flat_linear_expression(base_expr: LinearExprT) -> _LinearExpression: + """Converts floats, ints and Linear objects to a LinearExpression.""" + # pylint: disable=protected-access + if isinstance(base_expr, _LinearExpression): + return base_expr + terms = collections.defaultdict(lambda: 0.0) + offset: float = 0.0 + to_process = [(base_expr, 1.0)] + while to_process: # Flatten AST of LinearTypes. + expr, coeff = to_process.pop() + if isinstance(expr, _Sum): + to_process.append((expr._left, coeff)) + to_process.append((expr._right, coeff)) + elif isinstance(expr, Variable): + terms[expr] += coeff + elif isinstance(expr, NumberT): + offset += coeff * expr + elif isinstance(expr, _Product): + to_process.append((expr._expression, coeff * expr._coefficient)) + elif isinstance(expr, _LinearExpression): + offset += coeff * expr._offset + for variable, variable_coefficient in expr._terms.items(): + terms[variable] += coeff * variable_coefficient + elif isinstance(expr, _WeightedSum): + offset += coeff * expr.constant + for variable_index, variable_coefficient in zip( + expr.variable_indices, expr.coefficients + ): + variable = Variable(expr.helper, variable_index, None, None, None) + terms[variable] += coeff * variable_coefficient + else: + raise TypeError( + "Unrecognized linear expression: " + str(expr) + f" {type(expr)}" + ) + return _LinearExpression(terms, offset) + + +@dataclasses.dataclass(repr=False, eq=False, frozen=True) +class _Sum(LinearExpr): + """Represents the (deferred) sum of two expressions.""" + + __slots__ = ("_left", "_right") + + _left: LinearExprT + _right: LinearExprT + + def __repr__(self): + return self.__str__() + + def __str__(self): + return str(_as_flat_linear_expression(self)) + + +@dataclasses.dataclass(repr=False, eq=False, frozen=True) +class _Product(LinearExpr): + """Represents the (deferred) product of an expression by a constant.""" + + __slots__ = ("_expression", "_coefficient") + + _expression: LinearExpr + _coefficient: NumberT + + def __repr__(self): + return self.__str__() + + def __str__(self): + return str(_as_flat_linear_expression(self)) + + +def _get_index(obj: _IndexOrSeries) -> pd.Index: + """Returns the indices of `obj` as a `pd.Index`.""" + if isinstance(obj, pd.Series): + return obj.index + return obj + + +def _attribute_series( + *, + func: Callable[[_VariableOrConstraint], NumberT], + 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=_get_index(values), + ) + + +def _convert_to_series_and_validate_index( + value_or_series: Union[bool, NumberT, 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, NumberT)): + 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/linear_solver/python/model_builder_helper.cc b/ortools/linear_solver/python/model_builder_helper.cc index f23f9d18c76..9d249fad670 100644 --- a/ortools/linear_solver/python/model_builder_helper.cc +++ b/ortools/linear_solver/python/model_builder_helper.cc @@ -31,6 +31,7 @@ #include "absl/log/check.h" #include "absl/strings/str_cat.h" #include "absl/strings/string_view.h" +#include "ortools/base/logging.h" #include "ortools/linear_solver/linear_solver.pb.h" #include "ortools/linear_solver/model_exporter.h" #include "pybind11/eigen.h" @@ -177,10 +178,19 @@ PYBIND11_MODULE(model_builder_helper, m) { arg("mps_string")) .def("import_from_mps_file", &ModelBuilderHelper::ImportFromMpsFile, arg("mps_file")) +#if defined(USE_LP_PARSER) .def("import_from_lp_string", &ModelBuilderHelper::ImportFromLpString, arg("lp_string")) .def("import_from_lp_file", &ModelBuilderHelper::ImportFromLpFile, arg("lp_file")) +#else + .def("import_from_lp_string", [](const std::string& lp_string) { + LOG(INFO) << "Parsing LP string is not compiled in"; + }) + .def("import_from_lp_file", [](const std::string& lp_file) { + LOG(INFO) << "Parsing LP file is not compiled in"; + }) +#endif .def( "fill_model_from_sparse_data", [](ModelBuilderHelper* helper, diff --git a/ortools/linear_solver/python/model_builder_test.py b/ortools/linear_solver/python/model_builder_test.py index 122de470112..be4d989eacd 100644 --- a/ortools/linear_solver/python/model_builder_test.py +++ b/ortools/linear_solver/python/model_builder_test.py @@ -15,13 +15,18 @@ """Tests for ModelBuilder.""" import math +from typing import Any, Callable, Mapping, Union from absl.testing import absltest +from absl.testing import parameterized import numpy as np import numpy.testing as np_testing +import pandas as pd import os +from google.protobuf import text_format +from ortools.linear_solver import linear_solver_pb2 from ortools.linear_solver.python import model_builder as mb @@ -59,6 +64,19 @@ def run_minimal_linear_example(self, solver_name): self.assertEqual(-math.inf, c2.lower_bound) solver = mb.ModelSolver(solver_name) + self.assertTrue(pd.isna(solver.value(x1))) + self.assertTrue(pd.isna(solver.value(x2))) + self.assertTrue(pd.isna(solver.value(x3))) + self.assertTrue(pd.isna(solver.reduced_cost(x1))) + self.assertTrue(pd.isna(solver.reduced_cost(x2))) + self.assertTrue(pd.isna(solver.dual_value(c0))) + self.assertTrue(pd.isna(solver.dual_value(c1))) + self.assertTrue(pd.isna(solver.dual_value(c2))) + self.assertTrue(pd.isna(solver.activity(c0))) + self.assertTrue(pd.isna(solver.activity(c1))) + self.assertTrue(pd.isna(solver.activity(c2))) + self.assertTrue(pd.isna(solver.objective_value)) + self.assertTrue(pd.isna(solver.best_objective_bound)) self.assertEqual(mb.SolveStatus.OPTIMAL, solver.solve(model)) # The problem has an optimal solution. @@ -183,7 +201,7 @@ def test_class_api(self): np.array([1, 1, 1], dtype=np.double), e1.coefficients ) self.assertEqual(e1.constant, 0.0) - self.assertEqual(e1.pretty_string(model.helper), "x + y + z") + self.assertEqual(e1.pretty_string(), "x + y + z") e2 = mb.LinearExpr.sum([e1, 4.0]) np_testing.assert_array_equal(expected_vars, e2.variable_indices) @@ -191,7 +209,7 @@ def test_class_api(self): np.array([1, 1, 1], dtype=np.double), e2.coefficients ) self.assertEqual(e2.constant, 4.0) - self.assertEqual(e2.pretty_string(model.helper), "x + y + z + 4.0") + self.assertEqual(e2.pretty_string(), "x + y + z + 4.0") e3 = mb.LinearExpr.term(e2, 2) np_testing.assert_array_equal(expected_vars, e3.variable_indices) @@ -199,9 +217,7 @@ def test_class_api(self): np.array([2, 2, 2], dtype=np.double), e3.coefficients ) self.assertEqual(e3.constant, 8.0) - self.assertEqual( - e3.pretty_string(model.helper), "2.0 * x + 2.0 * y + 2.0 * z + 8.0" - ) + self.assertEqual(e3.pretty_string(), "2.0 * x + 2.0 * y + 2.0 * z + 8.0") e4 = mb.LinearExpr.weighted_sum([x, t], [-1, 1], constant=2) np_testing.assert_array_equal( @@ -211,9 +227,9 @@ def test_class_api(self): np.array([-1, 1], dtype=np.double), e4.coefficients ) self.assertEqual(e4.constant, 2.0) - self.assertEqual(e4.pretty_string(model.helper), "-x + t + 2.0") + self.assertEqual(e4.pretty_string(), "-x + t + 2.0") - e4b = e4 * 3.0 + e4b = mb.LinearExpr.weighted_sum([e4 * 3], [1]) np_testing.assert_array_equal( np.array([0, 3], dtype=np.int32), e4b.variable_indices ) @@ -221,7 +237,7 @@ def test_class_api(self): np.array([-3, 3], dtype=np.double), e4b.coefficients ) self.assertEqual(e4b.constant, 6.0) - self.assertEqual(e4b.pretty_string(model.helper), "-3.0 * x + 3.0 * t + 6.0") + self.assertEqual(e4b.pretty_string(), "-3.0 * x + 3.0 * t + 6.0") e5 = mb.LinearExpr.sum([e1, -3, e4]) np_testing.assert_array_equal( @@ -231,7 +247,7 @@ def test_class_api(self): np.array([1, 1, 1, -1, 1], dtype=np.double), e5.coefficients ) self.assertEqual(e5.constant, -1.0) - self.assertEqual(e5.pretty_string(model.helper), "x + y + z - x + t - 1.0") + self.assertEqual(e5.pretty_string(), "x + y + z - x + t - 1.0") e6 = mb.LinearExpr.term(x, 2.0, constant=1.0) np_testing.assert_array_equal( @@ -246,6 +262,13 @@ def test_class_api(self): e8 = mb.LinearExpr.term(2, 3, constant=4) self.assertEqual(e8, 10) + e9 = mb.LinearExpr.term(x * 2 + 3, 1, constant=0) + e10 = mb.LinearExpr.term(x, 2, constant=3) + self.assertEqual( + str(mb._as_flat_linear_expression(e9)), + str(mb._as_flat_linear_expression(e10)), + ) + def test_variables(self): model = mb.ModelBuilder() x = model.new_int_var(0.0, 4.0, "x") @@ -266,68 +289,6 @@ def test_variables(self): self.assertEqual(x, x_copy) self.assertNotEqual(x, y) - # array - xs = model.new_int_var_array( - shape=10, lower_bounds=0.0, upper_bounds=5.0, name="xs_" - ) - self.assertEqual(10, xs.size) - self.assertEqual("xs_4", str(xs[4])) - lbs = np.array([1.0, 2.0, 3.0]) - ubs = [3.0, 4.0, 5.0] - ys = model.new_int_var_array(lower_bounds=lbs, upper_bounds=ubs, name="ys_") - self.assertEqual("VariableContainer([12 13 14])", str(ys)) - zs = model.new_int_var_array( - lower_bounds=[1.0, 2.0, 3], upper_bounds=[4, 4, 4], name="zs_" - ) - self.assertEqual(3, zs.size) - self.assertEqual((3,), zs.shape) - self.assertEqual("zs_1", str(zs[1])) - self.assertEqual("zs_2(index=17, lb=3.0, ub=4.0, integer)", repr(zs[2])) - self.assertTrue(zs[2].is_integral) - - bs = model.new_bool_var_array([4, 5], "bs_") - self.assertEqual((4, 5), bs.shape) - self.assertEqual((5, 4), bs.T.shape) - self.assertEqual(31, bs.index_at((2, 3))) - self.assertEqual(20, bs.size) - self.assertEqual((20,), bs.flatten().shape) - self.assertTrue(bs[1, 1].is_integral) - - # Slices are [lb, ub) closed - open. - self.assertEqual(5, bs[3, :].size) - self.assertEqual(6, bs[1:3, 2:5].size) - - sum_bs = np.sum(bs) - self.assertEqual(20, sum_bs.variable_indices.size) - np_testing.assert_array_equal( - sum_bs.variable_indices, bs.variable_indices.flatten() - ) - np_testing.assert_array_equal(sum_bs.coefficients, np.ones(20)) - - sum_bs_cte = np.sum(bs, 2.2) - self.assertEqual(20, sum_bs_cte.variable_indices.size) - np_testing.assert_array_equal( - sum_bs_cte.variable_indices, bs.variable_indices.flatten() - ) - np_testing.assert_array_equal(sum_bs.coefficients, np.ones(20)) - self.assertEqual(sum_bs_cte.constant, 2.2) - - times_bs = np.dot(bs[1], 4) - np_testing.assert_array_equal( - times_bs.variable_indices, bs[1].variable_indices.flatten() - ) - np_testing.assert_array_equal(times_bs.coefficients, np.full(5, 4.0)) - - times_bs_rev = np.dot(4, bs[2]) - np_testing.assert_array_equal( - times_bs_rev.variable_indices, bs[2].variable_indices.flatten() - ) - np_testing.assert_array_equal(times_bs_rev.coefficients, np.full(5, 4.0)) - - dot_bs = np.dot(bs[2], np.array([1, 2, 3, 4, 5], dtype=np.double)) - np_testing.assert_array_equal(dot_bs.variable_indices, bs[2].variable_indices) - np_testing.assert_array_equal(dot_bs.coefficients, [1, 2, 3, 4, 5]) - # Tests the hash method. var_set = set() var_set.add(x) @@ -335,178 +296,6 @@ def test_variables(self): self.assertIn(x_copy, var_set) self.assertNotIn(y, var_set) - def test_numpy_var_arrays(self): - model = mb.ModelBuilder() - - x = model.new_var_array( - lower_bounds=0.0, - upper_bounds=4.0, - shape=[2, 3], - is_integral=False, - ) - np_testing.assert_array_equal(x.shape, [2, 3]) - - y = model.new_var_array( - lower_bounds=[[0.0, 1.0, 2.0], [0.0, 0.0, 2.0]], - upper_bounds=4.0, - is_integral=False, - name="y", - ) - np_testing.assert_array_equal(y.shape, [2, 3]) - - z = model.new_var_array( - lower_bounds=0.0, - upper_bounds=[[2.0, 1.0, 2.0], [3.0, 4.0, 2.0]], - is_integral=False, - name="z", - ) - np_testing.assert_array_equal(z.shape, [2, 3]) - - with self.assertRaises(ValueError): - x = model.new_var_array( - lower_bounds=0.0, - upper_bounds=4.0, - is_integral=False, - ) - - with self.assertRaises(ValueError): - x = model.new_var_array( - lower_bounds=[0, 0], - upper_bounds=[1, 2, 3], - is_integral=False, - ) - - with self.assertRaises(ValueError): - x = model.new_var_array( - shape=[2, 3], - lower_bounds=0.0, - upper_bounds=[1, 2, 3], - is_integral=False, - ) - - with self.assertRaises(ValueError): - x = model.new_var_array( - shape=[2, 3], - lower_bounds=[1, 2], - upper_bounds=4.0, - is_integral=False, - ) - - with self.assertRaises(ValueError): - x = model.new_var_array( - shape=[2, 3], - lower_bounds=0.0, - upper_bounds=4.0, - is_integral=[False, True], - ) - - with self.assertRaises(ValueError): - x = model.new_var_array( - lower_bounds=[1, 2], - upper_bounds=4.0, - is_integral=[False, False, False], - ) - - def test_numpy_num_var_arrays(self): - model = mb.ModelBuilder() - - x = model.new_num_var_array( - lower_bounds=0.0, - upper_bounds=4.0, - shape=[2, 3], - ) - np_testing.assert_array_equal(x.shape, [2, 3]) - - y = model.new_num_var_array( - lower_bounds=[[0.0, 1.0, 2.0], [0.0, 0.0, 2.0]], - upper_bounds=4.0, - name="y", - ) - np_testing.assert_array_equal(y.shape, [2, 3]) - - z = model.new_num_var_array( - lower_bounds=0.0, - upper_bounds=[[2.0, 1.0, 2.0], [3.0, 4.0, 2.0]], - name="z", - ) - np_testing.assert_array_equal(z.shape, [2, 3]) - - with self.assertRaises(ValueError): - x = model.new_num_var_array( - lower_bounds=0.0, - upper_bounds=4.0, - ) - - with self.assertRaises(ValueError): - x = model.new_num_var_array( - lower_bounds=[0, 0], - upper_bounds=[1, 2, 3], - ) - - with self.assertRaises(ValueError): - x = model.new_num_var_array( - shape=[2, 3], - lower_bounds=0.0, - upper_bounds=[1, 2, 3], - ) - - with self.assertRaises(ValueError): - x = model.new_num_var_array( - shape=[2, 3], - lower_bounds=[1, 2], - upper_bounds=4.0, - ) - - def test_numpy_int_var_arrays(self): - model = mb.ModelBuilder() - - x = model.new_int_var_array( - lower_bounds=0.0, - upper_bounds=4.0, - shape=[2, 3], - ) - np_testing.assert_array_equal(x.shape, [2, 3]) - - y = model.new_int_var_array( - lower_bounds=[[0.0, 1.0, 2.0], [0.0, 0.0, 2.0]], - upper_bounds=4.0, - name="y", - ) - np_testing.assert_array_equal(y.shape, [2, 3]) - - z = model.new_int_var_array( - lower_bounds=0.0, - upper_bounds=[[2.0, 1.0, 2.0], [3.0, 4.0, 2.0]], - name="z", - ) - np_testing.assert_array_equal(z.shape, [2, 3]) - - with self.assertRaises(ValueError): - x = model.new_int_var_array( - lower_bounds=0.0, - upper_bounds=4.0, - ) - - with self.assertRaises(ValueError): - x = model.new_int_var_array( - lower_bounds=[0, 0], - upper_bounds=[1, 2, 3], - ) - - with self.assertRaises(ValueError): - x = model.new_int_var_array( - shape=[2, 3], - lower_bounds=0.0, - upper_bounds=[1, 2, 3], - ) - - with self.assertRaises(ValueError): - x = model.new_int_var_array( - shape=[2, 3], - lower_bounds=[1, 2], - upper_bounds=4.0, - ) - def test_duplicate_variables(self): model = mb.ModelBuilder() x = model.new_int_var(0.0, 4.0, "x") @@ -553,7 +342,7 @@ def test_issue_3614(self): status = solver.solve(model) self.assertEqual(mb.SolveStatus.OPTIMAL, status) - def test_varcompvar(self): + def test_vareqvar(self): model = mb.ModelBuilder() x = model.new_int_var(0.0, 4.0, "x") y = model.new_int_var(0.0, 4.0, "y") @@ -562,5 +351,1480 @@ def test_varcompvar(self): self.assertEqual(ct.right.index, y.index) +class InternalHelperTest(absltest.TestCase): + def test_anonymous_variables(self): + helper = mb.ModelBuilder().helper + index = helper.add_var() + variable = mb.Variable(helper, index, None, None, None) + self.assertEqual(variable.name, f"variable#{index}") + + def test_anonymous_constraints(self): + helper = mb.ModelBuilder().helper + index = helper.add_linear_constraint() + constraint = mb.LinearConstraint(helper, index) + self.assertEqual(constraint.name, f"linear_constraint#{index}") + + +class LinearBaseTest(parameterized.TestCase): + def setUp(self): + super().setUp() + simple_model = mb.ModelBuilder() + self.x = simple_model.new_var_series( + name="x", index=pd.Index(range(3), name="i") + ) + self.y = simple_model.new_var_series( + name="y", index=pd.Index(range(5), name="i") + ) + self.simple_model = simple_model + + @parameterized.named_parameters( + # Variable / Indexing + dict( + testcase_name="x[0]", + expr=lambda x, y: x[0], + expected_repr="x[0]", + ), + dict( + testcase_name="x[1]", + expr=lambda x, y: x[1], + expected_repr="x[1]", + ), + dict( + testcase_name="x[2]", + expr=lambda x, y: x[2], + expected_repr="x[2]", + ), + dict( + testcase_name="y[0]", + expr=lambda x, y: y[0], + expected_repr="y[0]", + ), + dict( + testcase_name="y[4]", + expr=lambda x, y: y[4], + expected_repr="y[4]", + ), + # Sum + dict( + testcase_name="x[0] + 5", + expr=lambda x, y: x[0] + 5, + expected_repr="5.0 + x[0]", + ), + dict( + testcase_name="x[0] - 5", + expr=lambda x, y: x[0] - 5, + expected_repr="-5.0 + x[0]", + ), + dict( + testcase_name="5 - x[0]", + expr=lambda x, y: 5 - x[0], + expected_repr="5.0 - x[0]", + ), + dict( + testcase_name="5 + x[0]", + expr=lambda x, y: 5 + x[0], + expected_repr="5.0 + x[0]", + ), + dict( + testcase_name="x[0] + y[0]", + expr=lambda x, y: x[0] + y[0], + expected_repr="x[0] + y[0]", + ), + dict( + testcase_name="x[0] + y[0] + 5", + expr=lambda x, y: x[0] + y[0] + 5, + expected_repr="5.0 + x[0] + y[0]", + ), + dict( + testcase_name="5 + x[0] + y[0]", + expr=lambda x, y: 5 + x[0] + y[0], + expected_repr="5.0 + x[0] + y[0]", + ), + dict( + testcase_name="5 + x[0] - x[0]", + expr=lambda x, y: 5 + x[0] - x[0], + expected_repr="5.0", + ), + dict( + testcase_name="5 + x[0] - y[0]", + expr=lambda x, y: 5 + x[0] - y[0], + expected_repr="5.0 + x[0] - y[0]", + ), + dict( + testcase_name="x.sum()", + expr=lambda x, y: x.sum(), + expected_repr="x[0] + x[1] + x[2]", + ), + dict( + testcase_name="x.add(y, fill_value=0).sum() + 5", + expr=lambda x, y: x.add(y, fill_value=0).sum() + 5, + expected_repr="5.0 + x[0] + x[1] + x[2] + y[0] + y[1] + ...", + ), + # Product + dict( + testcase_name="- x.sum()", + expr=lambda x, y: -x.sum(), + expected_repr="- x[0] - x[1] - x[2]", + ), + dict( + testcase_name="5 - x.sum()", + expr=lambda x, y: 5 - x.sum(), + expected_repr="5.0 - x[0] - x[1] - x[2]", + ), + dict( + testcase_name="x.sum() / 2.0", + expr=lambda x, y: x.sum() / 2.0, + expected_repr="0.5 * x[0] + 0.5 * x[1] + 0.5 * x[2]", + ), + dict( + testcase_name="(3 * x).sum()", + expr=lambda x, y: (3 * x).sum(), + expected_repr="3.0 * x[0] + 3.0 * x[1] + 3.0 * x[2]", + ), + dict( + testcase_name="(x * 3).sum()", + expr=lambda x, y: (x * 3).sum(), + expected_repr="3.0 * x[0] + 3.0 * x[1] + 3.0 * x[2]", + ), + dict( + testcase_name="x.sum() * 3", + expr=lambda x, y: x.sum() * 3, + expected_repr="3.0 * x[0] + 3.0 * x[1] + 3.0 * x[2]", + ), + dict( + testcase_name="3 * x.sum()", + expr=lambda x, y: 3 * x.sum(), + expected_repr="3.0 * x[0] + 3.0 * x[1] + 3.0 * x[2]", + ), + dict( + testcase_name="0 * x.sum() + y.sum()", + expr=lambda x, y: 0 * x.sum() + y.sum(), + expected_repr="y[0] + y[1] + y[2] + y[3] + y[4]", + ), + # LinearExpression + dict( + testcase_name="_as_flat_linear_expression(x.sum())", + expr=lambda x, y: mb._as_flat_linear_expression(x.sum()), + expected_repr="x[0] + x[1] + x[2]", + ), + dict( + testcase_name=( + "_as_flat_linear_expression(_as_flat_linear_expression(x.sum()))" + ), + # pylint: disable=g-long-lambda + expr=lambda x, y: mb._as_flat_linear_expression( + mb._as_flat_linear_expression(x.sum()) + ), + expected_repr="x[0] + x[1] + x[2]", + ), + dict( + testcase_name="""_as_flat_linear_expression(sum([ + _as_flat_linear_expression(x.sum()), + _as_flat_linear_expression(x.sum()), + ]))""", + # pylint: disable=g-long-lambda + expr=lambda x, y: mb._as_flat_linear_expression( + sum( + [ + mb._as_flat_linear_expression(x.sum()), + mb._as_flat_linear_expression(x.sum()), + ] + ) + ), + expected_repr="2.0 * x[0] + 2.0 * x[1] + 2.0 * x[2]", + ), + ) + def test_repr(self, expr, expected_repr): + x = self.x + y = self.y + self.assertEqual(repr(expr(x, y)), expected_repr) + + +class LinearBaseErrorsTest(absltest.TestCase): + def test_unknown_linear_type(self): + with self.assertRaisesRegex(TypeError, r"Unrecognized linear expression"): + + class UnknownLinearType(mb.LinearExpr): + pass + + mb._as_flat_linear_expression(UnknownLinearType()) + + def test_division_by_zero(self): + with self.assertRaises(ZeroDivisionError): + model = mb.ModelBuilder() + x = model.new_var_series(name="x", index=pd.Index(range(1))) + print(x / 0) + + def test_boolean_expression(self): + with self.assertRaisesRegex(NotImplementedError, r"Cannot use a LinearExpr"): + model = mb.ModelBuilder() + x = model.new_var_series(name="x", index=pd.Index(range(1))) + bool(x.sum()) + + +class BoundedLinearBaseTest(parameterized.TestCase): + def setUp(self): + super().setUp() + simple_model = mb.ModelBuilder() + self.x = simple_model.new_var_series( + name="x", index=pd.Index(range(3), name="i") + ) + self.y = simple_model.new_var_series( + name="y", index=pd.Index(range(5), name="i") + ) + self.simple_model = simple_model + + @parameterized.product( + lhs=( + lambda x, y: x.sum(), + lambda x, y: -x.sum(), + lambda x, y: x.sum() * 0, + lambda x, y: x.sum() * 3, + lambda x, y: x[0], + lambda x, y: x[1], + lambda x, y: x[2], + lambda x, y: -math.inf, + lambda x, y: -1, + lambda x, y: 0, + lambda x, y: 1, + lambda x, y: 1.1, + lambda x, y: math.inf, + ), + rhs=( + lambda x, y: y.sum(), + lambda x, y: -y.sum(), + lambda x, y: y.sum() * 0, + lambda x, y: y.sum() * 3, + lambda x, y: y[0], + lambda x, y: y[1], + lambda x, y: y[2], + lambda x, y: -math.inf, + lambda x, y: -1, + lambda x, y: 0, + lambda x, y: 1, + lambda x, y: 1.1, + lambda x, y: math.inf, + ), + op=( + lambda lhs, rhs: lhs == rhs, + lambda lhs, rhs: lhs <= rhs, + lambda lhs, rhs: lhs >= rhs, + ), + ) + def test_repr(self, lhs, rhs, op): + x = self.x + y = self.y + l: mb.LinearExprT = lhs(x, y) + r: mb.LinearExprT = rhs(x, y) + result = op(l, r) + if isinstance(l, mb.LinearExpr) or isinstance(r, mb.LinearExpr): + self.assertIsInstance(result, mb._BoundedLinearExpr) + self.assertIn("=", repr(result), msg="is one of ==, <=, or >=") + else: + self.assertIsInstance(result, bool) + + def test_doublesided_bounded_expressions(self): + x = self.x + self.assertEqual( + "0.0 <= x[0] <= 1.0", repr(mb.BoundedLinearExpression(x[0], 0, 1)) + ) + + def test_free_bounded_expressions(self): + self.assertEqual( + "x[0] free", + repr(mb.BoundedLinearExpression(self.x[0], -math.inf, math.inf)), + ) + + def test_var_eq_var_as_bool(self): + x = self.x + y = self.y + self.assertEqual(x[0], x[0]) + self.assertNotEqual(x[0], x[1]) + self.assertNotEqual(x[0], y[0]) + + self.assertEqual(x[1], x[1]) + self.assertNotEqual(x[1], x[0]) + self.assertNotEqual(x[1], y[1]) + + self.assertEqual(y[0], y[0]) + self.assertNotEqual(y[0], y[1]) + self.assertNotEqual(y[0], x[0]) + + self.assertEqual(y[1], y[1]) + self.assertNotEqual(y[1], y[0]) + self.assertNotEqual(y[1], x[1]) + + +class BoundedLinearBaseErrorsTest(absltest.TestCase): + def test_bounded_linear_expression_as_bool(self): + with self.assertRaisesRegex(NotImplementedError, "Boolean value"): + model = mb.ModelBuilder() + x = model.new_var_series(name="x", index=pd.Index(range(1))) + bool(mb.BoundedLinearExpression(x, 0, 1)) + + +class ModelBuilderErrorsTest(absltest.TestCase): + def test_new_var_series_errors(self): + with self.assertRaisesRegex(TypeError, r"Non-index object"): + model = mb.ModelBuilder() + model.new_var_series(name="", index=pd.DataFrame()) + with self.assertRaisesRegex(TypeError, r"invalid type"): + model = mb.ModelBuilder() + model.new_var_series(name="x", index=pd.Index([0]), lower_bounds="0") + with self.assertRaisesRegex(TypeError, r"invalid type"): + model = mb.ModelBuilder() + model.new_var_series(name="x", index=pd.Index([0]), upper_bounds="0") + with self.assertRaisesRegex(TypeError, r"invalid type"): + model = mb.ModelBuilder() + model.new_var_series(name="x", index=pd.Index([0]), is_integral="True") + with self.assertRaisesRegex(ValueError, r"not a valid identifier"): + model = mb.ModelBuilder() + model.new_var_series(name="", index=pd.Index([0])) + with self.assertRaisesRegex(ValueError, r"is greater than"): + model = mb.ModelBuilder() + model.new_var_series( + name="x", + index=pd.Index([0]), + lower_bounds=0.2, + upper_bounds=0.1, + ) + with self.assertRaisesRegex(ValueError, r"is greater than"): + model = mb.ModelBuilder() + model.new_var_series( + name="x", + index=pd.Index([0]), + lower_bounds=0.1, + upper_bounds=0.2, + is_integral=True, + ) + with self.assertRaisesRegex(ValueError, r"index does not match"): + model = mb.ModelBuilder() + model.new_var_series( + name="x", index=pd.Index([0]), lower_bounds=pd.Series([1, 2]) + ) + with self.assertRaisesRegex(ValueError, r"index does not match"): + model = mb.ModelBuilder() + model.new_var_series( + name="x", index=pd.Index([0]), upper_bounds=pd.Series([1, 2]) + ) + with self.assertRaisesRegex(ValueError, r"index does not match"): + model = mb.ModelBuilder() + model.new_var_series( + name="x", index=pd.Index([0]), is_integral=pd.Series([False, True]) + ) + + def test_add_linear_constraints_errors(self): + with self.assertRaisesRegex(TypeError, r"Not supported"): + model = mb.ModelBuilder() + model.add("True", name="c") + with self.assertRaisesRegex(TypeError, r"invalid type="): + model = mb.ModelBuilder() + model.add(pd.Series(["T"]), name="c") + + +class ModelBuilderVariablesTest(parameterized.TestCase): + _variable_indices = ( + pd.Index(range(3)), + pd.Index(range(5), name="i"), + pd.MultiIndex.from_product(((1, 2), ("a", "b", "c")), names=["i", "j"]), + pd.MultiIndex.from_product((("a", "b"), (1, 2, 3))), + ) + _bounds = ( + lambda index: (-math.inf, -10.5), + lambda index: (-math.inf, -1), + lambda index: (-math.inf, 0), + lambda index: (-math.inf, 10), + lambda index: (-math.inf, math.inf), + lambda index: (-10, -1.1), + lambda index: (-10, 0), + lambda index: (-10, -10), + lambda index: (-10, 3), + lambda index: (-9, math.inf), + lambda index: (-1, 1), + lambda index: (0, 0), + lambda index: (0, 1), + lambda index: (0, math.inf), + lambda index: (1, 1), + lambda index: (1, 10.1), + lambda index: (1, math.inf), + lambda index: (100.1, math.inf), + # pylint: disable=g-long-lambda + lambda index: ( + pd.Series(-math.inf, index=index), + pd.Series(-10.5, index=index), + ), + lambda index: ( + pd.Series(-math.inf, index=index), + pd.Series(-1, index=index), + ), + lambda index: ( + pd.Series(-math.inf, index=index), + pd.Series(0, index=index), + ), + lambda index: ( + pd.Series(-math.inf, index=index), + pd.Series(10, index=index), + ), + lambda index: ( + pd.Series(-math.inf, index=index), + pd.Series(math.inf, index=index), + ), + lambda index: (pd.Series(-10, index=index), pd.Series(-1.1, index=index)), + lambda index: (pd.Series(-10, index=index), pd.Series(0, index=index)), + lambda index: (pd.Series(-10, index=index), pd.Series(-10, index=index)), + lambda index: (pd.Series(-10, index=index), pd.Series(3, index=index)), + lambda index: ( + pd.Series(-9, index=index), + pd.Series(math.inf, index=index), + ), + lambda index: (pd.Series(-1, index=index), pd.Series(1, index=index)), + lambda index: (pd.Series(0, index=index), pd.Series(0, index=index)), + lambda index: (pd.Series(0, index=index), pd.Series(1, index=index)), + lambda index: ( + pd.Series(0, index=index), + pd.Series(math.inf, index=index), + ), + lambda index: (pd.Series(1, index=index), pd.Series(1, index=index)), + lambda index: (pd.Series(1, index=index), pd.Series(10.1, index=index)), + lambda index: ( + pd.Series(1, index=index), + pd.Series(math.inf, index=index), + ), + lambda index: ( + pd.Series(100.1, index=index), + pd.Series(math.inf, index=index), + ), + ) + _is_integer = ( + lambda index: False, + lambda index: True, + lambda index: pd.Series(False, index=index), + lambda index: pd.Series(True, index=index), + ) + + @parameterized.product( + index=_variable_indices, bounds=_bounds, is_integer=_is_integer + ) + def test_new_var_series(self, index, bounds, is_integer): + model = mb.ModelBuilder() + variables = model.new_var_series( + name="test_variable", + index=index, + lower_bounds=bounds(index)[0], + upper_bounds=bounds(index)[1], + is_integral=is_integer(index), + ) + self.assertLen(variables, len(index)) + self.assertLen(set(variables), len(index)) + for i in index: + self.assertEqual(repr(variables[i]), f"test_variable[{i}]") + + @parameterized.product( + index=_variable_indices, bounds=_bounds, is_integer=_is_integer + ) + def test_get_variable_lower_bounds(self, index, bounds, is_integer): + lower_bound, upper_bound = bounds(index) + model = mb.ModelBuilder() + x = model.new_var_series( + name="x", + index=index, + lower_bounds=lower_bound, + upper_bounds=upper_bound, + is_integral=is_integer(index), + ) + y = model.new_var_series( + name="y", + index=index, + lower_bounds=lower_bound, + upper_bounds=upper_bound, + is_integral=is_integer(index), + ) + for lower_bounds in ( + model.get_variable_lower_bounds(x), + model.get_variable_lower_bounds(y), + ): + self.assertSequenceAlmostEqual( + lower_bounds, + mb._convert_to_series_and_validate_index(lower_bound, index), + ) + self.assertSequenceAlmostEqual( + model.get_variable_lower_bounds(), + pd.concat( + [ + model.get_variable_lower_bounds(x), + model.get_variable_lower_bounds(y), + ] + ), + ) + variables = model.get_variables() + lower_bounds = model.get_variable_lower_bounds(variables) + self.assertSequenceAlmostEqual(lower_bounds.index, variables) + + @parameterized.product( + index=_variable_indices, bounds=_bounds, is_integer=_is_integer + ) + def test_get_variable_upper_bounds(self, index, bounds, is_integer): + lower_bound, upper_bound = bounds(index) + model = mb.ModelBuilder() + x = model.new_var_series( + name="x", + index=index, + lower_bounds=lower_bound, + upper_bounds=upper_bound, + is_integral=is_integer(index), + ) + y = model.new_var_series( + name="y", + index=index, + lower_bounds=lower_bound, + upper_bounds=upper_bound, + is_integral=is_integer(index), + ) + for upper_bounds in ( + model.get_variable_upper_bounds(x), + model.get_variable_upper_bounds(y), + ): + self.assertSequenceAlmostEqual( + upper_bounds, + mb._convert_to_series_and_validate_index(upper_bound, index), + ) + self.assertSequenceAlmostEqual( + model.get_variable_upper_bounds(), + pd.concat( + [ + model.get_variable_upper_bounds(x), + model.get_variable_upper_bounds(y), + ] + ), + ) + variables = model.get_variables() + upper_bounds = model.get_variable_upper_bounds(variables) + self.assertSequenceAlmostEqual(upper_bounds.index, variables) + + +class ModelBuilderLinearConstraintsTest(parameterized.TestCase): + constraint_test_cases = [ + # pylint: disable=g-long-lambda + dict( + testcase_name="True", + name="true", + bounded_exprs=lambda x, y: True, + constraint_count=1, + lower_bounds=[-math.inf], + upper_bounds=[math.inf], + expression_terms=lambda x, y: [{}], + expression_offsets=[0], + ), + dict( + testcase_name="pd.Series(True)", + name="true", + bounded_exprs=lambda x, y: pd.Series(True), + constraint_count=1, + lower_bounds=[-math.inf], + upper_bounds=[math.inf], + expression_terms=lambda x, y: [{}], + expression_offsets=[0], + ), + dict( + testcase_name="False", + name="false", + bounded_exprs=lambda x, y: False, + constraint_count=1, + lower_bounds=[-1], + upper_bounds=[-1], + expression_terms=lambda x, y: [{}], + expression_offsets=[0], + ), + dict( + testcase_name="pd.Series(False)", + name="false", + bounded_exprs=lambda x, y: pd.Series(False), + constraint_count=1, + lower_bounds=[-1], + upper_bounds=[-1], + expression_terms=lambda x, y: [{}], + expression_offsets=[0], + ), + dict( + testcase_name="x[0] <= 1.5", + name="x0_le_c", + bounded_exprs=lambda x, y: x[0] <= 1.5, + constraint_count=1, + lower_bounds=[-math.inf], + upper_bounds=[1.5], + expression_terms=lambda x, y: [{x[0]: 1}], + expression_offsets=[0], + ), + dict( + testcase_name="x[0] == 1", + name="x0_eq_c", + bounded_exprs=lambda x, y: x[0] == 1, + constraint_count=1, + lower_bounds=[1], + upper_bounds=[1], + expression_terms=lambda x, y: [{x[0]: 1}], + expression_offsets=[0], + ), + dict( + testcase_name="x[0] >= -1", + name="x0_ge_c", + bounded_exprs=lambda x, y: x[0] >= -1, + constraint_count=1, + lower_bounds=[-1], + upper_bounds=[math.inf], + expression_terms=lambda x, y: [{x[0]: 1}], + expression_offsets=[0], + ), + dict( + testcase_name="-1.5 <= x[0]", + name="c_le_x0", + bounded_exprs=lambda x, y: -1.5 <= x[0], + constraint_count=1, + lower_bounds=[-1.5], + upper_bounds=[math.inf], + expression_terms=lambda x, y: [{x[0]: 1}], + expression_offsets=[0], + ), + dict( + testcase_name="0 == x[0]", + name="c_eq_x0", + bounded_exprs=lambda x, y: 0 == x[0], + constraint_count=1, + lower_bounds=[0], + upper_bounds=[0], + expression_terms=lambda x, y: [{x[0]: 1}], + expression_offsets=[0], + ), + dict( + testcase_name="10 >= x[0]", + name="c_ge_x0", + bounded_exprs=lambda x, y: 10 >= x[0], + constraint_count=1, + lower_bounds=[-math.inf], + upper_bounds=[10], + expression_terms=lambda x, y: [{x[0]: 1}], + expression_offsets=[0], + ), + dict( + testcase_name="x[0] <= x[0]", + name="x0_le_x0", + bounded_exprs=lambda x, y: x[0] <= x[0], + constraint_count=1, + lower_bounds=[-math.inf], + upper_bounds=[0], + expression_terms=lambda x, y: [{x[0]: 0}], + expression_offsets=[0], + ), + dict( + testcase_name="x[0] == x[0]", + name="x0_eq_x0", + bounded_exprs=lambda x, y: x[0] == x[0], + constraint_count=1, + lower_bounds=[0], + upper_bounds=[0], + expression_terms=lambda x, y: [{x[0]: 0}], + expression_offsets=[0], + ), + dict( + testcase_name="pd.Series(x[0] == x[0])", + name="x0_eq_x0_series", + bounded_exprs=lambda x, y: pd.Series(x[0] == x[0]), + constraint_count=1, + lower_bounds=[0], + upper_bounds=[0], + expression_terms=lambda x, y: [{x[0]: 0}], + expression_offsets=[0], + ), + dict( + testcase_name="x[0] >= x[0]", + name="x0_ge_x0", + bounded_exprs=lambda x, y: x[0] >= x[0], + constraint_count=1, + lower_bounds=[0], + upper_bounds=[math.inf], + expression_terms=lambda x, y: [{x[0]: 0}], + expression_offsets=[0], + ), + dict( + # x[0] - x[0] <= 3 + testcase_name="x[0] - 1 <= x[0] + 2", + name="x0c_le_x0c", + bounded_exprs=lambda x, y: pd.Series(x[0] - 1 <= x[0] + 2), + constraint_count=1, + lower_bounds=[-math.inf], + upper_bounds=[3], + expression_terms=lambda x, y: [{x[0]: 0}], + expression_offsets=[0], + ), + dict( + # x[0] - x[0] == 3 + testcase_name="x[0] - 1 == x[0] + 2", + name="x0c_eq_x0c", + bounded_exprs=lambda x, y: pd.Series(x[0] - 1 == x[0] + 2), + constraint_count=1, + lower_bounds=[3], + upper_bounds=[3], + expression_terms=lambda x, y: [{x[0]: 0}], + expression_offsets=[0], + ), + dict( + # x[0] - x[0] >= 3 + testcase_name="x[0] - 1 >= x[0] + 2", + name="x0c_ge_x0c", + bounded_exprs=lambda x, y: pd.Series(x[0] - 1 >= x[0] + 2), + constraint_count=1, + lower_bounds=[3], + upper_bounds=[math.inf], + expression_terms=lambda x, y: [{x[0]: 0}], + expression_offsets=[0], + ), + dict( + testcase_name="x[0] <= x[1]", + name="x0_le_x1", + bounded_exprs=lambda x, y: x[0] <= x[1], + constraint_count=1, + lower_bounds=[-math.inf], + upper_bounds=[0], + expression_terms=lambda x, y: [{x[0]: 1, x[1]: -1}], + expression_offsets=[0], + ), + dict( + testcase_name="x[0] == x[1]", + name="x0_eq_x1", + bounded_exprs=lambda x, y: x[0] == x[1], + constraint_count=1, + lower_bounds=[0], + upper_bounds=[0], + expression_terms=lambda x, y: [{x[0]: 1, x[1]: -1}], + expression_offsets=[0], + ), + dict( + testcase_name="x[0] >= x[1]", + name="x0_ge_x1", + bounded_exprs=lambda x, y: x[0] >= x[1], + constraint_count=1, + lower_bounds=[0], + upper_bounds=[math.inf], + expression_terms=lambda x, y: [{x[0]: 1, x[1]: -1}], + expression_offsets=[0], + ), + dict( + # x[0] - x[1] <= -3 + testcase_name="x[0] + 1 <= x[1] - 2", + name="x0c_le_x1c", + bounded_exprs=lambda x, y: x[0] + 1 <= x[1] - 2, + constraint_count=1, + lower_bounds=[-math.inf], + upper_bounds=[-3], + expression_terms=lambda x, y: [{x[0]: 1, x[1]: -1}], + expression_offsets=[0], + ), + dict( + # x[0] - x[1] == -3 + testcase_name="x[0] + 1 == x[1] - 2", + name="x0c_eq_x1c", + bounded_exprs=lambda x, y: x[0] + 1 == x[1] - 2, + constraint_count=1, + lower_bounds=[-3], + upper_bounds=[-3], + expression_terms=lambda x, y: [{x[0]: 1, x[1]: -1}], + expression_offsets=[0], + ), + dict( + # x[0] - x[1] >= -3 + testcase_name="x[0] + 1 >= x[1] - 2", + name="x0c_ge_x1c", + bounded_exprs=lambda x, y: pd.Series(x[0] + 1 >= x[1] - 2), + constraint_count=1, + lower_bounds=[-3], + upper_bounds=[math.inf], + expression_terms=lambda x, y: [{x[0]: 1, x[1]: -1}], + expression_offsets=[0], + ), + dict( + testcase_name="x <= 0", + name="x_le_c", + bounded_exprs=lambda x, y: x.apply(lambda expr: expr <= 0), + constraint_count=3, + lower_bounds=[-math.inf] * 3, + upper_bounds=[0] * 3, + expression_terms=lambda x, y: [{xi: 1} for xi in x], + expression_offsets=[0] * 3, + ), + dict( + testcase_name="x >= 0", + name="x_ge_c", + bounded_exprs=lambda x, y: x.apply(lambda expr: expr >= 0), + constraint_count=3, + lower_bounds=[0] * 3, + upper_bounds=[math.inf] * 3, + expression_terms=lambda x, y: [{xi: 1} for xi in x], + expression_offsets=[0] * 3, + ), + dict( + testcase_name="x == 0", + name="x_eq_c", + bounded_exprs=lambda x, y: x.apply(lambda expr: expr == 0), + constraint_count=3, + lower_bounds=[0] * 3, + upper_bounds=[0] * 3, + expression_terms=lambda x, y: [{xi: 1} for xi in x], + expression_offsets=[0] * 3, + ), + dict( + testcase_name="y == 0", + name="y_eq_c", + bounded_exprs=(lambda x, y: y.apply(lambda expr: expr == 0)), + constraint_count=2 * 3, + lower_bounds=[0] * 2 * 3, + upper_bounds=[0] * 2 * 3, + expression_terms=lambda x, y: [{yi: 1} for yi in y], + expression_offsets=[0] * 3 * 2, + ), + dict( + testcase_name='y.groupby("i").sum() == 0', + name="ygroupbyi_eq_c", + bounded_exprs=( + lambda x, y: y.groupby("i").sum().apply(lambda expr: expr == 0) + ), + constraint_count=2, + lower_bounds=[0] * 2, + upper_bounds=[0] * 2, + expression_terms=lambda x, y: [ + {y[1, "a"]: 1, y[1, "b"]: 1, y[1, "c"]: 1}, + {y[2, "a"]: 1, y[2, "b"]: 1, y[2, "c"]: 1}, + ], + expression_offsets=[0] * 2, + ), + dict( + testcase_name='y.groupby("j").sum() == 0', + name="ygroupbyj_eq_c", + bounded_exprs=( + lambda x, y: y.groupby("j").sum().apply(lambda expr: expr == 0) + ), + constraint_count=3, + lower_bounds=[0] * 3, + upper_bounds=[0] * 3, + expression_terms=lambda x, y: [ + {y[1, "a"]: 1, y[2, "a"]: 1}, + {y[1, "b"]: 1, y[2, "b"]: 1}, + {y[1, "c"]: 1, y[2, "c"]: 1}, + ], + expression_offsets=[0] * 3, + ), + dict( + testcase_name='3 * x + y.groupby("i").sum() <= 0', + name="broadcast_align_fill", + bounded_exprs=( + lambda x, y: (3 * x) + .add(y.groupby("i").sum(), fill_value=0) + .apply(lambda expr: expr <= 0) + ), + constraint_count=3, + lower_bounds=[-math.inf] * 3, + upper_bounds=[0] * 3, + expression_terms=lambda x, y: [ + {x[0]: 3}, + {x[1]: 3, y[1, "a"]: 1, y[1, "b"]: 1, y[1, "c"]: 1}, + {x[2]: 3, y[2, "a"]: 1, y[2, "b"]: 1, y[2, "c"]: 1}, + ], + expression_offsets=[0] * 3, + ), + ] + + def create_test_model(self, name, bounded_exprs): + model = mb.ModelBuilder() + x = model.new_var_series( + name="x", + index=pd.Index(range(3), name="i"), + ) + y = model.new_var_series( + name="y", + index=pd.MultiIndex.from_product( + ((1, 2), ("a", "b", "c")), names=["i", "j"] + ), + ) + model.add(name=name, ct=bounded_exprs(x, y)) + return model, {"x": x, "y": y} + + @parameterized.named_parameters( + # pylint: disable=g-complex-comprehension + { + f: tc[f] + for f in [ + "testcase_name", + "name", + "bounded_exprs", + "constraint_count", + ] + } + for tc in constraint_test_cases + ) + def test_get_linear_constraints( + self, + name, + bounded_exprs, + constraint_count, + ): + model, _ = self.create_test_model(name, bounded_exprs) + linear_constraints = model.get_linear_constraints() + self.assertIsInstance(linear_constraints, pd.Index) + self.assertLen(linear_constraints, constraint_count) + + def test_get_linear_constraints_empty(self): + linear_constraints = mb.ModelBuilder().get_linear_constraints() + self.assertIsInstance(linear_constraints, pd.Index) + self.assertEmpty(linear_constraints) + + @parameterized.named_parameters( + # pylint: disable=g-complex-comprehension + { + f: tc[f] + for f in [ + "testcase_name", + "name", + "bounded_exprs", + "lower_bounds", + ] + } + for tc in constraint_test_cases + ) + def test_get_linear_constraint_lower_bounds( + self, + name, + bounded_exprs, + lower_bounds, + ): + model, _ = self.create_test_model(name, bounded_exprs) + for linear_constraint_lower_bounds in ( + model.get_linear_constraint_lower_bounds(), + model.get_linear_constraint_lower_bounds(model.get_linear_constraints()), + ): + self.assertSequenceAlmostEqual(linear_constraint_lower_bounds, lower_bounds) + + @parameterized.named_parameters( + # pylint: disable=g-complex-comprehension + { + f: tc[f] + for f in [ + "testcase_name", + "name", + "bounded_exprs", + "upper_bounds", + ] + } + for tc in constraint_test_cases + ) + def test_get_linear_constraint_upper_bounds( + self, + name, + bounded_exprs, + upper_bounds, + ): + model, _ = self.create_test_model(name, bounded_exprs) + for linear_constraint_upper_bounds in ( + model.get_linear_constraint_upper_bounds(), + model.get_linear_constraint_upper_bounds(model.get_linear_constraints()), + ): + self.assertSequenceAlmostEqual(linear_constraint_upper_bounds, upper_bounds) + + @parameterized.named_parameters( + # pylint: disable=g-complex-comprehension + { + f: tc[f] + for f in [ + "testcase_name", + "name", + "bounded_exprs", + "expression_terms", + "expression_offsets", + ] + } + for tc in constraint_test_cases + ) + def test_get_linear_constraint_expressions( + self, + name, + bounded_exprs, + expression_terms, + expression_offsets, + ): + model, variables = self.create_test_model(name, bounded_exprs) + x = variables["x"] + y = variables["y"] + for linear_constraint_expressions in ( + model.get_linear_constraint_expressions(), + model.get_linear_constraint_expressions(model.get_linear_constraints()), + ): + expr_terms = expression_terms(x, y) + self.assertLen(linear_constraint_expressions, len(expr_terms)) + for expr, expr_term in zip(linear_constraint_expressions, expr_terms): + self.assertDictEqual(expr._terms, expr_term) + self.assertSequenceAlmostEqual( + [expr._offset for expr in linear_constraint_expressions], + expression_offsets, + ) + + +class ModelBuilderObjectiveTest(parameterized.TestCase): + _expressions = ( + lambda x, y: -3, + lambda x, y: 0, + lambda x, y: 10, + lambda x, y: x[0], + lambda x, y: x[1], + lambda x, y: x[2], + lambda x, y: y[0], + lambda x, y: y[1], + lambda x, y: x[0] + 5, + lambda x, y: -3 + y[1], + lambda x, y: 3 * x[0], + lambda x, y: x[0] * 3 * 5 - 3, + lambda x, y: x.sum(), + lambda x, y: 101 + 2 * 3 * x.sum(), + lambda x, y: x.sum() * 2, + lambda x, y: sum(y), + lambda x, y: x.sum() + 2 * y.sum() + 3, + ) + _variable_indices = ( + pd.Index(range(3)), + pd.Index(range(3), name="i"), + pd.Index(range(10), name="i"), + ) + + def assertLinearExpressionAlmostEqual( + self, + expr1: mb._LinearExpression, + expr2: mb._LinearExpression, + ) -> None: + """Test that the two linear expressions are almost equal.""" + for variable, coeff in expr1._terms.items(): + self.assertAlmostEqual(expr2._terms.get(variable, 0), coeff) + for variable, coeff in expr2._terms.items(): + self.assertAlmostEqual(expr1._terms.get(variable, 0), coeff) + self.assertAlmostEqual(expr1._offset, expr2._offset) + + @parameterized.product( + expression=_expressions, + variable_indices=_variable_indices, + is_maximize=(True, False), + ) + def test_set_objective( + self, + expression: Callable[[pd.Series, pd.Series], mb.LinearExprT], + variable_indices: pd.Index, + is_maximize: bool, + ): + model = mb.ModelBuilder() + x = model.new_var_series(name="x", index=variable_indices) + y = model.new_var_series(name="y", index=variable_indices) + objective_expression = mb._as_flat_linear_expression(expression(x, y)) + if is_maximize: + model.maximize(objective_expression) + else: + model.minimize(objective_expression) + got_objective_expression = model.objective_expression() + self.assertLinearExpressionAlmostEqual( + got_objective_expression, objective_expression + ) + + def test_set_new_objective(self): + model = mb.ModelBuilder() + x = model.new_var_series(name="x", index=pd.Index(range(3))) + old_objective_expression = 1 + new_objective_expression = mb._as_flat_linear_expression(x.sum() - 2.3) + + # Set and check for old objective. + model.maximize(old_objective_expression) + got_objective_expression = model.objective_expression() + for var_coeff in got_objective_expression._terms.values(): + self.assertAlmostEqual(var_coeff, 0) + self.assertAlmostEqual(got_objective_expression._offset, 1) + + # Set to a new objective and check that it is different. + model.minimize(new_objective_expression) + got_objective_expression = model.objective_expression() + self.assertLinearExpressionAlmostEqual( + got_objective_expression, new_objective_expression + ) + + @parameterized.product( + expression=_expressions, + variable_indices=_variable_indices, + ) + def test_minimize( + self, + expression: Callable[[pd.Series, pd.Series], mb.LinearExprT], + variable_indices: pd.Index, + ): + model = mb.ModelBuilder() + x = model.new_var_series(name="x", index=variable_indices) + y = model.new_var_series(name="y", index=variable_indices) + objective_expression = mb._as_flat_linear_expression(expression(x, y)) + model.minimize(objective_expression) + got_objective_expression = model.objective_expression() + self.assertLinearExpressionAlmostEqual( + got_objective_expression, objective_expression + ) + + @parameterized.product( + expression=_expressions, + variable_indices=_variable_indices, + ) + def test_maximize( + self, + expression: Callable[[pd.Series, pd.Series], float], + variable_indices: pd.Index, + ): + model = mb.ModelBuilder() + x = model.new_var_series(name="x", index=variable_indices) + y = model.new_var_series(name="y", index=variable_indices) + objective_expression = mb._as_flat_linear_expression(expression(x, y)) + model.maximize(objective_expression) + got_objective_expression = model.objective_expression() + self.assertLinearExpressionAlmostEqual( + got_objective_expression, objective_expression + ) + + +class ModelBuilderProtoTest(absltest.TestCase): + def test_export_to_proto(self): + expected = linear_solver_pb2.MPModelProto() + text_format.Parse( + """ + name: "test_name" + maximize: true + objective_offset: 0 + variable { + lower_bound: 0 + upper_bound: 1000 + objective_coefficient: 1 + is_integer: false + name: "x[0]" + } + variable { + lower_bound: 0 + upper_bound: 1000 + objective_coefficient: 1 + is_integer: false + name: "x[1]" + } + constraint { + var_index: 0 + coefficient: 1 + lower_bound: -inf + upper_bound: 10 + name: "Ct[0]" + } + constraint { + var_index: 1 + coefficient: 1 + lower_bound: -inf + upper_bound: 10 + name: "Ct[1]" + } + """, + expected, + ) + model = mb.ModelBuilder() + model.name = "test_name" + x = model.new_var_series("x", pd.Index(range(2)), 0, 1000) + model.add(ct=x.apply(lambda expr: expr <= 10), name="Ct") + model.maximize(x.sum()) + self.assertEqual(str(expected), str(model.export_to_proto())) + + +class SolverTest(parameterized.TestCase): + _solvers = ( + { + "name": "sat", + "is_integer": True, # CP-SAT supports only pure integer variables. + }, + { + "name": "glop", + "solver_specific_parameters": "use_preprocessing: False", + "is_integer": False, # GLOP does not properly support integers. + }, + { + "name": "scip", + "is_integer": False, + }, + { + "name": "scip", + "is_integer": True, + }, + ) + _variable_indices = ( + pd.Index(range(0)), # No variables. + pd.Index(range(1)), # Single variable. + pd.Index(range(3)), # Multiple variables. + ) + _variable_bounds = (-1, 0, 10.1) + _solve_statuses = ( + mb.SolveStatus.OPTIMAL, + mb.SolveStatus.INFEASIBLE, + mb.SolveStatus.UNBOUNDED, + ) + _set_objectives = (True, False) + _objective_senses = (True, False) + _objective_expressions = ( + sum, + lambda x: sum(x) + 5.2, + lambda x: -10.1, + lambda x: 0, + ) + + def _create_model( + self, + variable_indices: pd.Index = pd.Index(range(3)), + variable_bound: float = 0, + is_integer: bool = False, + solve_status: mb.SolveStatus = mb.SolveStatus.OPTIMAL, + set_objective: bool = True, + is_maximize: bool = True, + objective_expression: Callable[[pd.Series], float] = lambda x: x.sum(), + ) -> mb.ModelBuilder: + """Constructs an optimization problem. + + It has the following formulation: + + ``` + maximize / minimize objective_expression(x) + satisfying constraints + (if solve_status != UNBOUNDED and objective_sense == MAXIMIZE) + x[variable_indices] <= variable_bound + (if solve_status != UNBOUNDED and objective_sense == MINIMIZE) + x[variable_indices] >= variable_bound + x[variable_indices] is_integer + False (if solve_status == INFEASIBLE) + ``` + + Args: + variable_indices (pd.Index): The indices of the variable(s). + variable_bound (float): The upper- or lower-bound(s) of the variable(s). + is_integer (bool): Whether the variables should be integer. + solve_status (mb.SolveStatus): The solve status to target. + set_objective (bool): Whether to set the objective of the model. + is_maximize (bool): Whether to maximize the objective of the model. + objective_expression (Callable[[pd.Series], float]): The expression to + maximize or minimize if set_objective=True. + + Returns: + mb.ModelBuilder: The resulting problem. + """ + model = mb.ModelBuilder() + # Variable(s) + x = model.new_var_series( + name="x", + index=pd.Index(variable_indices), + is_integral=is_integer, + ) + # Constraint(s) + if solve_status == mb.SolveStatus.INFEASIBLE: + # Force infeasibility here to test that we get pd.NA later. + model.add(False, name="bool") + elif solve_status != mb.SolveStatus.UNBOUNDED: + if is_maximize: + model.add(x.apply(lambda xi: xi <= variable_bound), "upper_bound") + else: + model.add(x.apply(lambda xi: xi >= variable_bound), "lower_bound") + # Objective + if set_objective: + if is_maximize: + model.maximize(objective_expression(x)) + else: + model.minimize(objective_expression(x)) + return model + + @parameterized.product( + solver=_solvers, + variable_indices=_variable_indices, + variable_bound=_variable_bounds, + solve_status=_solve_statuses, + set_objective=_set_objectives, + is_maximize=_objective_senses, + objective_expression=_objective_expressions, + ) + def test_solve_status( + self, + solver: dict[str, Union[str, Mapping[str, Any], bool]], + variable_indices: pd.Index, + variable_bound: float, + solve_status: mb.SolveStatus, + set_objective: bool, + is_maximize: bool, + objective_expression: Callable[[pd.Series], float], + ): + model = self._create_model( + variable_indices=variable_indices, + variable_bound=variable_bound, + is_integer=solver["is_integer"], + solve_status=solve_status, + set_objective=set_objective, + is_maximize=is_maximize, + objective_expression=objective_expression, + ) + model_solver = mb.ModelSolver(solver["name"]) + if solver.get("solver_specific_parameters"): + model_solver.set_solver_specific_parameters( + solver.get("solver_specific_parameters") + ) + got_solve_status = model_solver.solve(model) + + # pylint: disable=g-explicit-length-test + # (we disable explicit-length-test here because `variable_indices: pd.Index` + # evaluates to an ambiguous boolean value.) + if len(variable_indices) > 0: # Test cases with >=1 variable. + self.assertNotEmpty(variable_indices) + if ( + isinstance( + objective_expression(model.get_variables()), + (int, float), + ) + and solve_status != mb.SolveStatus.INFEASIBLE + ): + # Feasibility implies optimality when objective is a constant term. + self.assertEqual(got_solve_status, mb.SolveStatus.OPTIMAL) + elif not set_objective and solve_status != mb.SolveStatus.INFEASIBLE: + # Feasibility implies optimality when objective is not set. + self.assertEqual(got_solve_status, mb.SolveStatus.OPTIMAL) + elif solver["name"] == "sat" and got_solve_status == 8: + # CP_SAT returns status=8 for some infeasible and unbounded cases. + self.assertIn( + solve_status, + (mb.SolveStatus.INFEASIBLE, mb.SolveStatus.UNBOUNDED), + ) + else: + self.assertEqual(got_solve_status, solve_status) + elif solve_status == mb.SolveStatus.UNBOUNDED: + # Unbounded problems are optimal when there are no variables. + self.assertEqual(got_solve_status, mb.SolveStatus.OPTIMAL) + else: + self.assertEqual(got_solve_status, solve_status) + + @parameterized.product( + solver=_solvers, + variable_indices=_variable_indices, + variable_bound=_variable_bounds, + solve_status=_solve_statuses, + set_objective=_set_objectives, + is_maximize=_objective_senses, + objective_expression=_objective_expressions, + ) + def test_get_variable_values( + self, + solver: dict[str, Union[str, Mapping[str, Any], bool]], + variable_indices: pd.Index, + variable_bound: float, + solve_status: mb.SolveStatus, + set_objective: bool, + is_maximize: bool, + objective_expression: Callable[[pd.Series], float], + ): + model = self._create_model( + variable_indices=variable_indices, + variable_bound=variable_bound, + is_integer=solver["is_integer"], + solve_status=solve_status, + set_objective=set_objective, + is_maximize=is_maximize, + objective_expression=objective_expression, + ) + model_solver = mb.ModelSolver(solver["name"]) + if solver.get("solver_specific_parameters"): + model_solver.set_solver_specific_parameters( + solver.get("solver_specific_parameters") + ) + got_solve_status = model_solver.solve(model) + variables = model.get_variables() + variable_values = model_solver.values(variables) + # Test the type of `variable_values` (we always get pd.Series) + self.assertIsInstance(variable_values, pd.Series) + # Test the index of `variable_values` (match the input variables [if any]) + self.assertSequenceAlmostEqual( + variable_values.index, + mb._get_index(model._get_variables(variables)), + ) + if got_solve_status not in ( + mb.SolveStatus.OPTIMAL, + mb.SolveStatus.FEASIBLE, + ): + # self.assertSequenceAlmostEqual does not work here because we cannot do + # equality comparison for NA values (NAs will propagate and we will get + # 'TypeError: boolean value of NA is ambiguous') + for variable_value in variable_values: + self.assertTrue(pd.isna(variable_value)) + elif set_objective and not isinstance( + objective_expression(model.get_variables()), + (int, float), + ): + # The variable values are only well-defined when the objective is set + # and depends on the variable(s). + if not solver["is_integer"]: + self.assertSequenceAlmostEqual( + variable_values, [variable_bound] * len(variable_values) + ) + elif is_maximize: + self.assertTrue(solver["is_integer"]) # Assert a known assumption. + self.assertSequenceAlmostEqual( + variable_values, + [math.floor(variable_bound)] * len(variable_values), + ) + else: + self.assertTrue(solver["is_integer"]) # Assert a known assumption. + self.assertSequenceAlmostEqual( + variable_values, + [math.ceil(variable_bound)] * len(variable_values), + ) + + @parameterized.product( + solver=_solvers, + variable_indices=_variable_indices, + variable_bound=_variable_bounds, + solve_status=_solve_statuses, + set_objective=_set_objectives, + is_maximize=_objective_senses, + objective_expression=_objective_expressions, + ) + def test_get_objective_value( + self, + solver: dict[str, Union[str, Mapping[str, Any], bool]], + variable_indices: pd.Index, + variable_bound: float, + solve_status: mb.SolveStatus, + set_objective: bool, + is_maximize: bool, + objective_expression: Callable[[pd.Series], float], + ): + model = self._create_model( + variable_indices=variable_indices, + variable_bound=variable_bound, + is_integer=solver["is_integer"], + solve_status=solve_status, + set_objective=set_objective, + is_maximize=is_maximize, + objective_expression=objective_expression, + ) + model_solver = mb.ModelSolver(solver["name"]) + if solver.get("solver_specific_parameters"): + model_solver.set_solver_specific_parameters( + solver.get("solver_specific_parameters") + ) + got_status = model_solver.solve(model) + + # Test objective value + if got_status not in (mb.SolveStatus.OPTIMAL, mb.SolveStatus.FEASIBLE): + self.assertTrue(pd.isna(model_solver.objective_value)) + return + if set_objective: + variable_values = model_solver.values(model.get_variables()) + self.assertAlmostEqual( + model_solver.objective_value, + objective_expression(variable_values), + ) + else: + self.assertAlmostEqual(model_solver.objective_value, 0) + + if __name__ == "__main__": absltest.main() diff --git a/ortools/linear_solver/python/pandas_model.py b/ortools/linear_solver/python/pandas_model.py deleted file mode 100644 index a5adac7812b..00000000000 --- a/ortools/linear_solver/python/pandas_model.py +++ /dev/null @@ -1,1598 +0,0 @@ -# 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. - -"""Pandas-native API for optimization.""" - -import abc -import collections -import dataclasses -import enum -import math -import sys -import typing -from typing import Callable, Mapping, NoReturn, Optional, Union - -import numpy as np -import pandas as pd - -from ortools.linear_solver import linear_solver_pb2 -from ortools.linear_solver.python import model_builder_helper as mbh - -_Number = Union[int, float, np.number] -_LinearType = Union[_Number, "_LinearBase"] - -# The maximum number of terms to display in a linear expression's repr. -_MAX_LINEAR_EXPRESSION_REPR_TERMS = 5 - - -class _LinearBase(metaclass=abc.ABCMeta): - """Interface for types that can build linear expressions with +, -, * and /. - - Classes derived from LinearBase (plus float and int scalars) are used to build - expression trees describing a linear expression. Operation nodes of the - expression tree include: - - * _Sum: describes a deferred sum of LinearTypes. - * _Product: describes a deferred product of a scalar and a LinearType. - - Leaf nodes of the expression tree include: - - * float and int scalars. - * Variable: a single variable. - * LinearExpression: a flattened form of a linear expression. - """ - - def __add__(self, arg: _LinearType) -> "_Sum": - return _Sum(self, arg) - - def __radd__(self, arg: _LinearType) -> "_Sum": - return self.__add__(arg) - - def __sub__(self, arg: _LinearType) -> "_Sum": - return _Sum(self, -arg) - - def __rsub__(self, arg: _LinearType) -> "_Sum": - return _Sum(-self, arg) - - def __mul__(self, arg: _Number) -> "_Product": - return _Product(self, arg) - - def __rmul__(self, arg: _Number) -> "_Product": - return self.__mul__(arg) - - def __truediv__(self, coeff: _Number) -> "_Product": - return self.__mul__(1.0 / coeff) - - def __neg__(self) -> "_Product": - return _Product(self, -1) - - def __bool__(self) -> NoReturn: - raise NotImplementedError( - f"Cannot use a LinearExpression as a Boolean value: {self}" - ) - - def __eq__(self, arg: _LinearType) -> "_BoundedLinearExpression": - return _BoundedLinearExpression( - _expression=self - arg, _lower_bound=0, _upper_bound=0 - ) - - def __ge__(self, arg: _LinearType) -> "_BoundedLinearExpression": - return _BoundedLinearExpression( - _expression=self - arg, _lower_bound=0, _upper_bound=math.inf - ) - - def __le__(self, arg: _LinearType) -> "_BoundedLinearExpression": - return _BoundedLinearExpression( - _expression=self - arg, _lower_bound=-math.inf, _upper_bound=0 - ) - - -@dataclasses.dataclass(repr=False, eq=False, frozen=True) -class _LinearExpression(_LinearBase): - """For variables x, an expression: offset + sum_{i in I} coeff_i * x_i.""" - - __slots__ = ("_terms", "_offset") - - _terms: Mapping["_Variable", float] - _offset: float - - def __repr__(self): - return self.__str__() - - def __str__(self): - result = [str(self._offset)] - sorted_keys = sorted(self._terms.keys(), key=str) - num_displayed_terms = 0 - for variable in sorted_keys: - if num_displayed_terms == _MAX_LINEAR_EXPRESSION_REPR_TERMS: - result.append(" + ...") - break - coefficient = self._terms[variable] - if coefficient == 0.0: - continue - if coefficient > 0: - result.append(" + ") - else: - result.append(" - ") - if abs(coefficient) != 1.0: - result.append(f"{abs(coefficient)} * ") - result.append(f"{variable}") - num_displayed_terms += 1 - return "".join(result) - - -def _as_flat_linear_expression(base: _LinearType) -> _LinearExpression: - """Converts floats, ints and Linear objects to a LinearExpression.""" - # pylint: disable=protected-access - if isinstance(base, _LinearExpression): - return base - terms = collections.defaultdict(lambda: 0.0) - offset: float = 0.0 - to_process = [(base, 1.0)] - while to_process: # Flatten AST of LinearTypes. - expr, coeff = to_process.pop() - if isinstance(expr, _Sum): - to_process.append((expr._left, coeff)) - to_process.append((expr._right, coeff)) - elif isinstance(expr, _Variable): - terms[expr] += coeff - elif isinstance(expr, (int, float, np.number)): # i.e. is _Number - offset += coeff * expr - elif isinstance(expr, _Product): - to_process.append((expr._expression, coeff * expr._coefficient)) - elif isinstance(expr, _LinearExpression): - offset += coeff * expr._offset - for variable, variable_coefficient in expr._terms.items(): - terms[variable] += coeff * variable_coefficient - else: - raise TypeError( - "Unrecognized linear expression: " + str(expr) + f" {type(expr)}" - ) - return _LinearExpression(terms, offset) - - -@dataclasses.dataclass(repr=False, eq=False, frozen=True) -class _Sum(_LinearBase): - """Represents the (deferred) sum of two expressions.""" - - __slots__ = ("_left", "_right") - - _left: _LinearType - _right: _LinearType - - def __repr__(self): - return self.__str__() - - def __str__(self): - return str(_as_flat_linear_expression(self)) - - -@dataclasses.dataclass(repr=False, eq=False, frozen=True) -class _Product(_LinearBase): - """Represents the (deferred) product of an expression by a constant.""" - - __slots__ = ("_expression", "_coefficient") - - _expression: _LinearBase - _coefficient: _Number - - def __repr__(self): - return self.__str__() - - def __str__(self): - return str(_as_flat_linear_expression(self)) - - -@dataclasses.dataclass(repr=False, eq=False, frozen=True) -class _Variable(_LinearBase): - """A variable (continuous or integral). - - A Variable is an object that can take on any value within its domain. - Variables (e.g. x and y) appear in constraints like: - - x + y >= 5 - - Solving a model is equivalent to finding, for each variable, a value in its - domain, such that all constraints are satisfied. - """ - - __slots__ = ("_helper", "_index") - - _helper: mbh.ModelBuilderHelper - _index: int - - def __str__(self): - return self._name - - def __repr__(self): - return self.__str__() - - @property - def _name(self) -> str: - var_name = self._helper.var_name(self._index) - if var_name: - return var_name - return f"variable#{self._index}" - - @property - def _lower_bound(self) -> _Number: - """Returns the lower bound of the variable.""" - return self._helper.var_lower_bound(self._index) - - @property - def _upper_bound(self) -> _Number: - """Returns the upper bound of the variable.""" - return self._helper.var_upper_bound(self._index) - - @typing.overload - def __eq__(self, rhs: "_Variable") -> "_VarEqVar": - ... - - def __eq__(self, rhs: _LinearType) -> "_BoundedLinearBase": - if isinstance(rhs, _Variable): - return _VarEqVar(self, rhs) - return _BoundedLinearExpression( - _expression=self - rhs, _lower_bound=0, _upper_bound=0 - ) - - def __hash__(self): - return hash((self._helper, self._index)) - - -def _create_variable( - helper: mbh.ModelBuilderHelper, - *, - name: str, - lower_bound: _Number, - upper_bound: _Number, - is_integral: bool, -) -> _Variable: - """Creates a new variable in the helper. - - Args: - helper (mbh.ModelBuilderHelper): The helper to create the variable. - name (str): The name of the variable. - lower_bound (Union[int, float]): The lower bound of the variable. - upper_bound (Union[int, float]): The upper bound of the variable. - is_integral (bool): Whether the variable can only take integer values. - - Returns: - _Variable: A reference to the variable in the helper. - """ - index = helper.add_var() - helper.set_var_lower_bound(index, lower_bound) - helper.set_var_upper_bound(index, upper_bound) - helper.set_var_integrality(index, is_integral) - helper.set_var_name(index, name) - return _Variable(helper, index) - - -class _BoundedLinearBase(metaclass=abc.ABCMeta): - """Interface for types that can build bounded linear (boolean) expressions. - - Classes derived from _BoundedLinearBase are used to build linear constraints - to be satisfied. - - * BoundedLinearExpression: a linear expression with upper and lower bounds. - * VarEqVar: an equality comparison between two variables. - """ - - @abc.abstractmethod - def _create_linear_constraint( - self, helper: mbh.ModelBuilderHelper, name: str - ) -> "_LinearConstraint": - """Creates a new linear constraint in the helper. - - Args: - helper (mbh.ModelBuilderHelper): The helper to create the constraint. - name (str): The name of the linear constraint. - - Returns: - _LinearConstraint: A reference to the linear constraint in the helper. - """ - - -def _create_linear_constraint( - constraint: Union[bool, _BoundedLinearBase], - helper: mbh.ModelBuilderHelper, - name: str, -): - """Creates a new linear constraint in the helper. - - It handles boolean values (which might arise in the construction of - _BoundedLinearExpressions). - - Args: - constraint: The constraint to be created. - helper: The helper to create the constraint. - name: The name of the constraint to be created. - - Returns: - _LinearConstraint: a constraint in the helper corresponding to the input. - - Raises: - TypeError: If constraint is an invalid type. - """ - if isinstance(constraint, bool): - bound = 1 # constraint that is always infeasible: 1 <= nothing <= 1 - if constraint: - bound = 0 # constraint that is always feasible: 0 <= nothing <= 0 - index = helper.add_linear_constraint() - helper.set_constraint_lower_bound(index, bound) - helper.set_constraint_upper_bound(index, bound) - helper.set_constraint_name(index, name) - return _LinearConstraint(helper, index) - if isinstance(constraint, _BoundedLinearBase): - # pylint: disable=protected-access - return constraint._create_linear_constraint(helper, name) - raise TypeError("invalid type={}".format(type(constraint))) - - -@dataclasses.dataclass(repr=False, eq=False, frozen=True) -class _BoundedLinearExpression(_BoundedLinearBase): - """Represents a linear constraint: `lb <= linear expression <= ub`.""" - - __slots__ = ("_expression", "_lower_bound", "_upper_bound") - - _expression: _LinearBase - _lower_bound: _Number - _upper_bound: _Number - - def __str__(self): - if math.isfinite(self._lower_bound) and math.isfinite(self._upper_bound): - if self._lower_bound == self._upper_bound: - return f"{self._expression} == {self._lower_bound}" - return f"{self._lower_bound} <= {self._expression} <= {self._upper_bound}" - if math.isfinite(self._lower_bound): - return f"{self._expression} >= {self._lower_bound}" - if math.isfinite(self._upper_bound): - return f"{self._expression} <= {self._upper_bound}" - return f"{self._expression} free" - - def __repr__(self): - return self.__str__() - - def __bool__(self) -> NoReturn: - raise NotImplementedError( - f"Cannot use a BoundedLinearExpression {self} as a Boolean value. If" - " this message is due to code like `x >= 0` where x is a `pd.Series`," - " you can write it as `x.apply(lambda expr: expr >= 0)` instead." - ) - - def _create_linear_constraint( - self, helper: mbh.ModelBuilderHelper, name: str - ) -> "_LinearConstraint": - index = helper.add_linear_constraint() - expr = _as_flat_linear_expression(self._expression) - # pylint: disable=protected-access - for variable, coeff in expr._terms.items(): - helper.add_term_to_constraint(index, variable._index, coeff) - helper.set_constraint_lower_bound(index, self._lower_bound - expr._offset) - helper.set_constraint_upper_bound(index, self._upper_bound - expr._offset) - # pylint: enable=protected-access - helper.set_constraint_name(index, name) - return _LinearConstraint(helper, index) - - -@dataclasses.dataclass(repr=False, eq=False, frozen=True) -class _VarEqVar(_BoundedLinearBase): - """The result of the equality comparison between two Variables. - - We use an object here to delay the evaluation of equality so that we can use - the operator== in two use-cases: - - 1. when the user want to test that two Variable values reference the same - variable. This is supported by having this object support implicit - conversion to bool. - - 2. when the user want to use the equality to create a constraint of equality - between two variables. - """ - - __slots__ = ("_left", "_right") - - _left: _Variable - _right: _Variable - - def __str__(self): - return f"{self._left} == {self._right}" - - def __repr__(self): - return self.__str__() - - def __bool__(self) -> bool: - return hash(self._left) == hash(self._right) - - def _create_linear_constraint( - self, helper: mbh.ModelBuilderHelper, name: str - ) -> "_LinearConstraint": - index = helper.add_linear_constraint() - helper.set_constraint_lower_bound(index, 0.0) - helper.set_constraint_upper_bound(index, 0.0) - # pylint: disable=protected-access - helper.add_term_to_constraint(index, self._left._index, 1.0) - helper.add_term_to_constraint(index, self._right._index, -1.0) - # pylint: enable=protected-access - helper.set_constraint_name(index, name) - return _LinearConstraint(helper, index) - - -@dataclasses.dataclass(repr=False, eq=False, frozen=True) -class _LinearConstraint: - """A linear constraint for an optimization model. - - A LinearConstraint adds the following restriction on feasible solutions to an - optimization model: - - lb <= sum_{i in I} a_i * x_i <= ub - - where x_i are the variables of the model. lb == ub is allowed and represents - the equality constraint: sum_{i in I} a_i * x_i == b. - """ - - __slots__ = ("_helper", "_index") - - _helper: mbh.ModelBuilderHelper - _index: int - - @property - def _lower_bound(self) -> _Number: - return self._helper.constraint_lower_bound(self._index) - - @property - def _upper_bound(self) -> _Number: - return self._helper.constraint_upper_bound(self._index) - - @property - def _name(self) -> str: - constraint_name = self._helper.constraint_name(self._index) - if constraint_name: - return constraint_name - return f"linear_constraint#{self._index}" - - def __str__(self): - return self._name - - def __repr__(self): - return self.__str__() - - -_IndexOrSeries = Union[pd.Index, pd.Series] -_VariableOrConstraint = Union[_LinearConstraint, _Variable] - - -def _get_index(obj: _IndexOrSeries) -> pd.Index: - """Returns the indices of `obj` as a `pd.Index`.""" - if isinstance(obj, pd.Series): - return obj.index - return obj - - -def _attribute_series( - *, - func: Callable[[_VariableOrConstraint], _Number], - 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=_get_index(values), - ) - - -def _convert_to_series_and_validate_index( - value_or_series: Union[bool, _Number, 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, int, float, np.number)): # i.e. scalar - 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 - - -@enum.unique -class ObjectiveSense(enum.Enum): - """The sense (maximize or minimize) of the optimization objective.""" - - MINIMIZE = enum.auto() - MAXIMIZE = enum.auto() - - -class OptimizationModel: - """Pandas-like API for optimization models. - - It is a wrapper around ortools, providing indexing functionality through - Pandas for representing index dimensions (such as nodes, edges, skus, etc). - """ - - __slots__ = ( - "_helper", - "_variables", - "_linear_constraints", - ) - - def __init__(self, name: str = "") -> None: - """Initializes an optimization model with the given name.""" - if not name.isidentifier(): - raise ValueError("name={} is not a valid identifier".format(name)) - self._helper: mbh.ModelBuilderHelper = mbh.ModelBuilderHelper() - self._helper.set_name(name) - self._variables: dict[str, pd.Series] = {} - self._linear_constraints: dict[str, pd.Series] = {} - - def __str__(self): - return ( - f"OptimizationModel(name={self.get_name()}) with the following" - f" schema:\n{self.get_schema()}" - ) - - def __repr__(self): - return self.__str__() - - def to_proto(self) -> linear_solver_pb2.MPModelProto: - """Exports the optimization model to a ProtoBuf format.""" - return mbh.to_mpmodel_proto(self._helper) - - @typing.overload - def _get_linear_constraints(self, constraints: Optional[pd.Index]) -> pd.Index: - ... - - @typing.overload - def _get_linear_constraints(self, constraints: pd.Series) -> pd.Series: - ... - - def _get_linear_constraints( - self, constraints: Optional[_IndexOrSeries] = None - ) -> _IndexOrSeries: - if constraints is None: - return self.get_linear_constraints() - return constraints - - @typing.overload - def _get_variables(self, variables: Optional[pd.Index]) -> pd.Index: - ... - - @typing.overload - def _get_variables(self, variables: pd.Series) -> pd.Series: - ... - - def _get_variables( - self, variables: Optional[_IndexOrSeries] = None - ) -> _IndexOrSeries: - if variables is None: - return self.get_variables() - return variables - - def create_linear_constraints( - self, - name: str, - bounded_exprs: Union[_BoundedLinearBase, pd.Series], - ) -> pd.Series: - """Sets a linear constraint set with the `name` based on `bounded_exprs`. - - Example usage: - - ``` - # pylint: disable=line-too-long - from ortools.linear_solver.python import pandas_model as pdm - import pandas as pd - - model = pdm.OptimizationModel(name='example') - x = model.create_variables( - name='x', - index=pd.Index(range(3)), - lower_bound=0, - ) - - model.create_linear_constraints( - name='c', - bounded_exprs=pd.Series([ - x.dot([10, 4, 5]) <= 600, - x.dot([2, 2, 6]) <= 300, - sum(x) <= 100, - ]), - ) - ``` - - Args: - name (str): Required. The name of the linear constraint set. - bounded_exprs (Union[BoundedLinearBase, pd.Series]): Required. The linear - inequalities defining the constraints, indexed by the underlying - dimensions of the constraints. If a single BoundedLinearExpression is - passed in, it will be converted into a `pd.Series` with no underlying - dimension and with an index value of `0`. - - Returns: - pd.Series: The constraint set indexed by its corresponding dimensions. It - is equivalent to `get_linear_constraint_references(name=name)`. - - Raises: - ValueError: if `name` is not a valid identifier, or already exists. - TypeError: if `bounded_exprs` has an invalid type. - """ - if not name.isidentifier(): - raise ValueError("name={} is not a valid identifier".format(name)) - if name in self._linear_constraints: - raise ValueError( - "name={} already exists as a set of linear constraints".format(name) - ) - if isinstance(bounded_exprs, (bool, _BoundedLinearBase)): - bounded_exprs = pd.Series(bounded_exprs) - if not isinstance(bounded_exprs, pd.Series): - raise TypeError("invalid type={}".format(type(bounded_exprs))) - # Set the new linear constraints. - self._linear_constraints[name] = pd.Series( - index=bounded_exprs.index, - data=[ - _create_linear_constraint(bool_expr, self._helper, f"{name}[{i}]") - for (i, bool_expr) in zip(bounded_exprs.index, bounded_exprs) - ], - ) - return self.get_linear_constraint_references(name=name) - - def create_variables( - self, - name: str, - index: pd.Index, - lower_bound: Union[_Number, pd.Series] = -math.inf, - upper_bound: Union[_Number, pd.Series] = math.inf, - is_integer: Union[bool, pd.Series] = False, - ) -> pd.Series: - """Creates a set of (scalar-valued) variables with the given name. - - Example usage: - - ``` - # pylint: disable=line-too-long - from ortools.linear_solver.python import pandas_model as pdm - import pandas as pd - - model = pdm.OptimizationModel(name='example') - - model.create_variables( - name='x', - index=pd.Index(range(3)), - lower_bound=0, - ) - ``` - - Args: - name (str): Required. The name of the variable set. - index (pd.Index): Required. The index to use for the variable set. - lower_bound (Union[int, float, pd.Series]): Optional. 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. Defaults to -inf. - upper_bound (Union[int, float, pd.Series]): Optional. 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. Defaults to +inf. - is_integer (bool, pd.Series): Optional. Indicates if the variable can only - take integer values. If a `pd.Series` is passed in, it will be based on - the corresponding values of the pd.Series. Defaults to False. - - Returns: - pd.Series: The variable set indexed by its corresponding dimensions. It is - equivalent to the result from `get_variable_references(name=name)`. - - 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`, `upper_bound`, or `is_integer` - 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 name in self._variables: - raise ValueError("name={} already exists".format(name)) - if ( - isinstance(lower_bound, (int, float, np.number)) # i.e. is _Number - and isinstance(upper_bound, (int, float, np.number)) # i.e. is _Number - and lower_bound > upper_bound - ): - raise ValueError( - "lower_bound={} is greater than upper_bound={} for variable set={}".format( - lower_bound, upper_bound, name - ) - ) - if ( - isinstance(is_integer, bool) - and is_integer - and isinstance(lower_bound, (int, float, np.number)) # i.e. is _Number - and isinstance(upper_bound, (int, float, np.number)) # i.e. is _Number - and math.isfinite(lower_bound) - and math.isfinite(upper_bound) - and math.ceil(lower_bound) > math.floor(upper_bound) - ): - raise ValueError( - "ceil(lower_bound={})={}".format(lower_bound, math.ceil(lower_bound)) - + " is greater than floor(" - + "upper_bound={})={}".format(upper_bound, math.floor(upper_bound)) - + " for variable set={}".format(name) - ) - lower_bounds = _convert_to_series_and_validate_index(lower_bound, index) - upper_bounds = _convert_to_series_and_validate_index(upper_bound, index) - is_integers = _convert_to_series_and_validate_index(is_integer, index) - self._variables[name] = pd.Series( - index=index, - data=[ - # pylint: disable=g-complex-comprehension - _create_variable( - helper=self._helper, - name=f"{name}[{i}]", - lower_bound=lower_bounds[i], - upper_bound=upper_bounds[i], - is_integral=is_integers[i], - ) - for i in index - ], - ) - return self.get_variable_references(name=name) - - def get_linear_constraints(self, name: Optional[str] = None) -> pd.Index: - """Gets the set of linear constraints. - - Example usage: - - ``` - # pylint: disable=line-too-long - from ortools.linear_solver.python import pandas_model as pdm - import pandas as pd - - model = pdm.OptimizationModel(name='example') - x = model.create_variables( - name='x', - index=pd.Index(range(3)), - lower_bound=0, - ) - model.create_linear_constraints( - name='c', - bounded_exprs=pd.Series([ - x.dot([10, 4, 5]) <= 600, - x.dot([2, 2, 6]) <= 300, - sum(x) <= 100, - ]), - ) - - model.get_linear_constraints() - ``` - - Args: - name (str): Optional. The name of the linear constraint set. If it is - unspecified, all linear constraints will be in scope. - - Returns: - pd.Index: The set of linear constraints. - - Raises: - KeyError: If name is provided but not found as a linear constraint set. - """ - if not self._linear_constraints: - return pd.Index(data=[], dtype=object, name="linear_constraint") - if name: - return pd.Index( - data=self.get_linear_constraint_references(name=name).values, - name="linear_constraint", - ) - return pd.concat( - [ - # pylint: disable=g-complex-comprehension - pd.Series( - dtype=object, - index=pd.Index(constraints.values, name="linear_constraint"), - ) - for constraints in self._linear_constraints.values() - ] - ).index - - def get_linear_constraint_expressions( - self, constraints: Optional[_IndexOrSeries] = None - ) -> pd.Series: - """Gets the expressions of all linear constraints in the set. - - If `constraints` is a `pd.Index`, then the output will be indexed by the - constraints. If `constraints` is a `pd.Series` indexed by the underlying - dimensions, then the output will be indexed by the same underlying - dimensions. - - Example usage: - - ``` - # pylint: disable=line-too-long - from ortools.linear_solver.python import pandas_model as pdm - import pandas as pd - - model = pdm.OptimizationModel(name='example') - x = model.create_variables( - name='x', - index=pd.Index(range(3)), - lower_bound=0, - ) - model.create_linear_constraints( - name='c', - bounded_exprs=pd.Series([ - x.dot([10, 4, 5]) <= 600, - x.dot([2, 2, 6]) <= 300, - sum(x) <= 100, - ]), - ) - - model.get_linear_constraint_expressions() - ``` - - Args: - constraints (Union[pd.Index, pd.Series]): Optional. The set of linear - constraints from which to get the expressions. If unspecified, all - linear constraints will be in scope. - - Returns: - pd.Series: The expressions of all linear constraints in the set. - """ - return _attribute_series( - # pylint: disable=g-long-lambda - func=lambda c: _as_flat_linear_expression( - # pylint: disable=g-complex-comprehension - sum( - coefficient * _Variable(self._helper, variable_id) - for variable_id, coefficient in zip( - # pylint: disable=protected-access - c._helper.constraint_var_indices(c._index), - c._helper.constraint_coefficients(c._index), - ) - ) - ), - values=self._get_linear_constraints(constraints), - ) - - def get_linear_constraint_lower_bounds( - self, constraints: Optional[_IndexOrSeries] = None - ) -> pd.Series: - """Gets the lower bounds of all linear constraints in the set. - - If `constraints` is a `pd.Index`, then the output will be indexed by the - constraints. If `constraints` is a `pd.Series` indexed by the underlying - dimensions, then the output will be indexed by the same underlying - dimensions. - - Example usage: - - ``` - # pylint: disable=line-too-long - from ortools.linear_solver.python import pandas_model as pdm - import pandas as pd - - model = pdm.OptimizationModel(name='example') - x = model.create_variables( - name='x', - index=pd.Index(range(3)), - lower_bound=0, - ) - model.create_linear_constraints( - name='c', - bounded_exprs=pd.Series([ - x.dot([10, 4, 5]) <= 600, - x.dot([2, 2, 6]) <= 300, - sum(x) <= 100, - ]), - ) - - model.get_linear_constraint_lower_bounds() - ``` - - Args: - constraints (Union[pd.Index, pd.Series]): Optional. The set of linear - constraints from which to get the lower bounds. If unspecified, all - linear constraints will be in scope. - - Returns: - pd.Series: The lower bounds of all linear constraints in the set. - """ - return _attribute_series( - func=lambda c: c._lower_bound, # pylint: disable=protected-access - values=self._get_linear_constraints(constraints), - ) - - def get_linear_constraint_references(self, name: str) -> pd.Series: - """Gets references to all linear constraints in the set. - - Example usage: - - ``` - # pylint: disable=line-too-long - from ortools.linear_solver.python import pandas_model as pdm - import pandas as pd - - model = pdm.OptimizationModel(name='example') - x = model.create_variables( - name='x', - index=pd.Index(range(3)), - lower_bound=0, - ) - model.create_linear_constraints( - name='c', - bounded_exprs=pd.Series([ - x.dot([10, 4, 5]) <= 600, - x.dot([2, 2, 6]) <= 300, - sum(x) <= 100, - ]), - ) - - model.get_linear_constraint_references(name='c') - ``` - - Args: - name (str): Required. The name of the linear constraint set. - - Returns: - pd.Series: The references of the linear constraints in the set, indexed by - their corresponding dimensions. - - Raises: - KeyError: If name is not found in the set of linear constraints. - """ - return self._linear_constraints[name] - - def get_linear_constraint_upper_bounds( - self, constraints: Optional[_IndexOrSeries] = None - ) -> pd.Series: - """Gets the upper bounds of all linear constraints in the set. - - If `constraints` is a `pd.Index`, then the output will be indexed by the - constraints. If `constraints` is a `pd.Series` indexed by the underlying - dimensions, then the output will be indexed by the same underlying - dimensions. - - Example usage: - - ``` - # pylint: disable=line-too-long - from ortools.linear_solver.python import pandas_model as pdm - import pandas as pd - - model = pdm.OptimizationModel(name='example') - x = model.create_variables( - name='x', - index=pd.Index(range(3)), - lower_bound=0, - ) - model.create_linear_constraints( - name='c', - bounded_exprs=pd.Series([ - x.dot([10, 4, 5]) <= 600, - x.dot([2, 2, 6]) <= 300, - sum(x) <= 100, - ]), - ) - - model.get_linear_constraint_upper_bounds() - ``` - - Args: - constraints (Union[pd.Index, pd.Series]): Optional. The set of linear - constraints. If unspecified, all linear constraints will be in scope. - - Returns: - pd.Series: The upper bounds of all linear constraints in the set. - """ - return _attribute_series( - func=lambda c: c._upper_bound, # pylint: disable=protected-access - values=self._get_linear_constraints(constraints), - ) - - def get_name(self) -> str: - """Returns the name of the model.""" - return self._helper.name() - - def get_objective_expression(self) -> _LinearExpression: - """Returns the objective expression of the model.""" - return _as_flat_linear_expression( - sum( - # pylint: disable=protected-access - variable * self._helper.var_objective_coefficient(variable._index) - for variable in self.get_variables() - ) - + self._helper.objective_offset() - ) - - def get_objective_sense(self) -> ObjectiveSense: - """Returns the objective sense of the model. - - If no objective has been set, it will return MINIMIZE. - """ - if self._helper.maximize(): - return ObjectiveSense.MAXIMIZE - return ObjectiveSense.MINIMIZE - - def get_schema(self) -> pd.DataFrame: - """Returns the schema of the model.""" - result = {"type": [], "name": [], "dimensions": [], "count": []} - for name, variables in self._variables.items(): - result["type"].append("variable") - result["name"].append(name) - result["dimensions"].append(variables.index.names) - result["count"].append(len(variables)) - for name, constraints in self._linear_constraints.items(): - result["type"].append("linear_constraint") - result["name"].append(name) - result["dimensions"].append(constraints.index.names) - result["count"].append(len(constraints)) - return pd.DataFrame(result) - - def get_variables(self, name: Optional[str] = None) -> pd.Index: - """Gets all variables in the set. - - Example usage: - - ``` - # pylint: disable=line-too-long - from ortools.linear_solver.python import pandas_model as pdm - import pandas as pd - - model = pdm.OptimizationModel(name='example') - x = model.create_variables( - name='x', - index=pd.Index(range(3)), - lower_bound=0, - ) - - model.get_variables() - ``` - - Args: - name (str): Optional. The name of the variable set. If unspecified, all - variables will be in scope. - - Returns: - pd.Index: The set of variables in the set. - - Raises: - KeyError: if `name` is not found in the set of variables. - """ - if name: - return pd.Index( - data=self.get_variable_references(name=name).values, name="variable" - ) - return pd.concat( - [ - pd.Series( - dtype=object, - index=pd.Index(variables.values, name="variable"), - ) - for variables in self._variables.values() - ] - ).index - - def get_variable_lower_bounds( - self, variables: Optional[_IndexOrSeries] = None - ) -> pd.Series: - """Gets the lower bounds of all variables in the set. - - 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. - - Example usage: - - ``` - # pylint: disable=line-too-long - from ortools.linear_solver.python import pandas_model as pdm - import pandas as pd - - model = pdm.OptimizationModel(name='example') - x = model.create_variables( - name='x', - index=pd.Index(range(3)), - lower_bound=0, - ) - - model.get_variable_lower_bounds() - ``` - - Args: - variables (Union[pd.Index, pd.Series]): Optional. The set of variables - from which to get the lower bounds. If unspecified, all variables will - be in scope. - - Returns: - pd.Series: The lower bounds of all variables in the set. - """ - return _attribute_series( - func=lambda v: v._lower_bound, # pylint: disable=protected-access - values=self._get_variables(variables), - ) - - def get_variable_references(self, name: str) -> pd.Series: - """Gets all variables in the set. - - Example usage: - - ``` - # pylint: disable=line-too-long - from ortools.linear_solver.python import pandas_model as pdm - import pandas as pd - - model = pdm.OptimizationModel(name='example') - x = model.create_variables( - name='x', - index=pd.Index(range(3)), - lower_bound=0, - ) - - model.get_variable_references(name='x') - ``` - - Args: - name (str): Required. The name of the variable set. - - Returns: - pd.Series: The variable set indexed by its underlying dimensions. - - Raises: - KeyError: if `name` is not found in the set of variables. - """ - if name not in self._variables: - raise KeyError("There is no variable set named {}".format(name)) - return self._variables[name] - - def get_variable_upper_bounds( - self, variables: Optional[_IndexOrSeries] = None - ) -> pd.Series: - """Gets the upper bounds of all variables in the set. - - 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. - - Example usage: - - ``` - # pylint: disable=line-too-long - from ortools.linear_solver.python import pandas_model as pdm - import pandas as pd - - model = pdm.OptimizationModel(name='example') - x = model.create_variables( - name='x', - index=pd.Index(range(3)), - lower_bound=0, - ) - - model.get_variable_upper_bounds() - ``` - - Args: - variables (Union[pd.Index, pd.Series]): Optional. The set of variables - from which to get the upper bounds. If unspecified, all variables will - be in scope. - - Returns: - pd.Series: The upper bounds of all variables in the set. - """ - return _attribute_series( - func=lambda v: v._upper_bound, # pylint: disable=protected-access - values=self._get_variables(variables), - ) - - def minimize(self, expression: _LinearType) -> None: - """Set the objective to minimize the given `expression`. - - This is equivalent to `.set_objective(expression, ObjectiveSense.MINIMIZE)`. - - Example usage: - - ``` - # pylint: disable=line-too-long - from ortools.linear_solver.python import pandas_model as pdm - import pandas as pd - - model = pdm.OptimizationModel(name='example') - x = model.create_variables(name='x', index=pd.Index(range(3))) - - model.minimize(expression=x.dot([10, 6, 4])) - ``` - - To clear the objective of the model, simply set a new objective to - minimize an expression of zero. - - Args: - expression (LinearType): Required. The expression to minimize. - """ - self.set_objective(expression, ObjectiveSense.MINIMIZE) - - def maximize(self, expression: _LinearType) -> None: - """Set the objective to maximize the given `expression`. - - This is equivalent to `.set_objective(expression, ObjectiveSense.MAXIMIZE)`. - - Example usage: - - ``` - # pylint: disable=line-too-long - from ortools.linear_solver.python import pandas_model as pdm - import pandas as pd - - model = pdm.OptimizationModel(name='example') - x = model.create_variables(name='x', index=pd.Index(range(3))) - - model.maximize(expression=x.dot([10, 6, 4])) - ``` - - To clear the objective of the model, simply set a new objective to - minimize an expression of zero. - - Args: - expression (LinearType): Required. The expression to maximize. - """ - self.set_objective(expression, ObjectiveSense.MAXIMIZE) - - def set_objective(self, expression: _LinearType, sense: ObjectiveSense) -> None: - """Sets the objective to maximize or minimize the given `expression`. - - Example usage: - - ``` - # pylint: disable=line-too-long - from ortools.linear_solver.python import pandas_model as pdm - import pandas as pd - - model = pdm.OptimizationModel(name='example') - x = model.create_variables(name='x', index=pd.Index(range(3))) - - model.set_objective( - expression=x.dot([10, 6, 4]), - sense=pdm.ObjectiveSense.MAXIMIZE, - ) - ``` - - To clear the objective of the model, simply set a new objective to - minimize an expression of zero. - - Args: - expression (LinearType): Required. The expression to maximize or minimize. - sense (ObjectiveSense): Required. Either `MAXIMIZE` or `MINIMIZE`. - """ - self._helper.clear_objective() - expr: _LinearExpression = _as_flat_linear_expression(expression) - # pylint: disable=protected-access - self._helper.set_objective_offset(expr._offset) - for variable, coeff in expr._terms.items(): - self._helper.set_var_objective_coefficient(variable._index, coeff) - # pylint: enable=protected-access - self._helper.set_maximize(sense == ObjectiveSense.MAXIMIZE) - - -@dataclasses.dataclass(frozen=True) -class SolveOptions: - """The options for solving the optimization model. - - Attributes: - time_limit_seconds (int): Optional. The time limit (in seconds) for solving - the optimization model. Defaults to `sys.maxsize`. - enable_output (bool): Optional. Whether to enable solver output logging. - Defaults to False. - solver_specific_parameters (str): Optional. The format for specifying the - individual parameters is solver-specific and currently undocumented. - Defaults to an empty string. - """ - - time_limit_seconds: int = sys.maxsize - enable_output: bool = False - solver_specific_parameters: str = "" - - -@enum.unique -class SolveStatus(enum.Enum): - """The status of solving the optimization problem. - - The solve status provides a guarantee for claims that can be made about - the optimization problem. The number of solve statuses might grow with future - versions of this package. - - Attributes: - UNKNOWN: The status of solving the optimization problem is unknown. This is - the default status. - OPTIMAL: The solution is feasible and proven by the solver to be optimal for - the problem's objective. - FEASIBLE: The solution is feasible, but the solver was unable to prove that - it is optimal. (I.e. the solution is feasible for all constraints up to - the underlying solver's tolerances.) - INFEASIBLE: The optimization problem is proven by the solver to be - infeasible. Therefore no solutions can be found. - UNBOUNDED: The optimization problem is feasible, but it has been proven by - the solver to have arbitrarily good solutions (i.e. there are no optimal - solutions). The solver might not provide any feasible solutions. - """ - - UNKNOWN = enum.auto() - OPTIMAL = enum.auto() - FEASIBLE = enum.auto() - INFEASIBLE = enum.auto() - UNBOUNDED = enum.auto() - - -_solve_status: dict[mbh.SolveStatus, SolveStatus] = { - mbh.SolveStatus.OPTIMAL: SolveStatus.OPTIMAL, - mbh.SolveStatus.FEASIBLE: SolveStatus.FEASIBLE, - mbh.SolveStatus.INFEASIBLE: SolveStatus.INFEASIBLE, - mbh.SolveStatus.UNBOUNDED: SolveStatus.UNBOUNDED, -} - - -class _SolveResult: - """The result of solving an optimization model. - - It allows you to query the status of the solution process and inspect the - solution found (if any). In general, the workflow looks like: - - ``` - # pylint: disable=line-too-long - from ortools.linear_solver.python import pandas_model as pdm - - solver = pdm.Solver(solver_type=) - result = solver.solve(model) - - if result.get_status() in (pdm.SolveStatus.OPTIMAL, pdm.SolveStatus.FEASIBLE): - # result.get_objective_value() and result.get_variable_values() will return - # non-NA values for a feasible (if not optimal) solution to the problem. - elif result.get_status() == pdm.SolveStatus.INFEASIBLE: - # result.get_objective_value() and result.get_variable_values() will return - # NA values. - else: - # result.get_objective_value() and result.get_variable_values() are not - # well-defined. - ``` - - (This class is marked internal because it has a constructor [and fields] that - are considered internal. All its public methods will be stable in future - versions from an API perspective.) - """ - - __slots__ = ("_model", "_solver", "_status") - - def __init__( - self, - model: OptimizationModel, - solver: mbh.ModelSolverHelper, - status: mbh.SolveStatus, - ): - self._model = model - self._solver = solver - self._status: SolveStatus = _solve_status.get(status, SolveStatus.UNKNOWN) - - def get_status(self) -> SolveStatus: - return self._status - - def get_variable_values( - self, - variables: Optional[_IndexOrSeries] = None, - ) -> pd.Series: - """Gets the variable values of variables in the set. - - 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. - - Example usage: - - ``` - # pylint: disable=line-too-long - from ortools.linear_solver.python import pandas_model as pdm - import pandas as pd - - model = pdm.OptimizationModel(name='example') - x = model.create_variables( - name='x', - index=pd.Index(range(3)), - lower_bound=0, - ) - model.create_linear_constraints( - name='c', - bounded_exprs=pd.Series([ - x.dot([10, 4, 5]) <= 600, - x.dot([2, 2, 6]) <= 300, - sum(x) <= 100, - ]), - ) - model.set_objective( - expression=x.dot([10, 6, 4]), - sense=pdm.ObjectiveSense.MAXIMIZE, - ) - solver = pdm.Solver(solver_type=pdm.SolverType.GLOP) - run = solver.solve(model) - - run.get_variable_values() - ``` - - Args: - variables (Union[pd.Index, pd.Series]): Optional. The set of variables - from which to get the values. If unspecified, all variables will be in - scope. - - Returns: - pd.Series: The values of all variables in the set. - """ - # pylint: disable=protected-access - model_variables = self._model._get_variables(variables) - if not self._solver.has_solution(): - return _attribute_series(func=lambda v: pd.NA, values=model_variables) - return _attribute_series( - func=lambda v: self._solver.var_value(v._index), - values=model_variables, - ) - - def get_objective_value(self) -> float: - """Gets the objective value of the best primal feasible solution. - - Returns: - float: The objective value of the best feasible solution. It will return - NA if there are no feasible solutions. - """ - if not self._solver.has_solution(): - return pd.NA - return self._solver.objective_value() - - -@enum.unique -class SolverType(enum.Enum): - """The underlying solver to use. - - Attributes: - CP_SAT: Google's CP-SAT solver (first party). Supports problems where all - variables are `is_integer=True` and have finite upper and lower_bounds. - Experimental support is available to automatically rescale and discretize - problems with continuous variables. - GLOP: Google's GLOP linear solver (first party). It supports LP with primal - and dual simplex methods, but does not support problems with variables - where `is_integer=True`. - SCIP: Solving Constraint Integer Programs (SCIP) solver (third party). It - supports linear (LP) and mixed-integer linear (MILP) problems. - """ - - CP_SAT = enum.auto() - GLOP = enum.auto() - SCIP = enum.auto() - - -_solver_type_to_name: dict[SolverType, str] = { - SolverType.CP_SAT: "CP_SAT", - SolverType.GLOP: "GLOP", - SolverType.SCIP: "SCIP", -} - - -@dataclasses.dataclass(frozen=True) -class Solver: - """A solver factory for solvers of the corresponding type. - - The purpose of this class is to search for a solution to the model provided - to the .solve(...) method. It is immutable and does not support incremental - solves. Each call to .solve(model, options) manages its own state. - - In general, the workflow looks like: - - ``` - # pylint: disable=line-too-long - from ortools.linear_solver.python import pandas_model as pdm - - model = ... - solver = pdm.Solver(solver_type=) - result = solver.solve(model=model, options=pdm.SolveOptions(...)) - - if result.get_status() in (pdm.SolveStatus.OPTIMAL, pdm.SolveStatus.FEASIBLE): - # result.get_objective_value() and result.get_variable_values() will return - # non-NA values for a feasible (if not optimal) solution to the problem. - elif result.get_status() == pdm.SolveStatus.INFEASIBLE: - # result.get_objective_value() and result.get_variable_values() will return - # NA values. - else: - # result.get_objective_value() and result.get_variable_values() are not - # well-defined. - ``` - - Attributes: - solver_type (SolverType): The type of solver to use (e.g. GLOP, SCIP). - """ - - solver_type: SolverType - - def solve( - self, - model: OptimizationModel, - options: SolveOptions = SolveOptions(), - ) -> _SolveResult: - """Solves an optimization model. - - It will overwrite the previous state of all variables and constraints with - the results of the solve. - - Example usage: - - ``` - # pylint: disable=line-too-long - from ortools.linear_solver.python import pandas_model as pdm - import pandas as pd - - model = pdm.OptimizationModel(name='example') - x = model.create_variables( - name='x', - index=pd.Index(range(3)), - lower_bound=0, - ) - model.create_linear_constraints( - name='c', - bounded_exprs=pd.Series([ - x.dot([10, 4, 5]) <= 600, - x.dot([2, 2, 6]) <= 300, - sum(x) <= 100, - ]), - ) - model.set_objective( - expression=x.dot([10, 6, 4]), - sense=pdm.ObjectiveSense.MAXIMIZE, - ) - - solver = pdm.Solver(solver_type=pdm.SolverType.GLOP) - solver.solve(model=model) - ``` - - Args: - model (OptimizationModel): Required. The model to be solved. - options (SolveOptions): Optional. The options to set for solving the - model. - - Returns: - SolveResult: The result of solving the model. - - Raises: - ValueError: If `options.solver_specific_parameters` is invalid for the - Solver (based on its `solver_type`). - RuntimeError: On a solve error. - """ - solver = mbh.ModelSolverHelper(_solver_type_to_name[self.solver_type]) - solver.enable_output(options.enable_output) - solver.set_time_limit_in_seconds(options.time_limit_seconds) - if options.solver_specific_parameters: - # This does not panic if the parameters are not recognized by the solver. - solver.set_solver_specific_parameters(options.solver_specific_parameters) - solver.solve(model._helper) # pylint: disable=protected-access - return _SolveResult(model, solver, solver.status()) diff --git a/ortools/linear_solver/python/pandas_model_test.py b/ortools/linear_solver/python/pandas_model_test.py deleted file mode 100644 index 0aca33251f9..00000000000 --- a/ortools/linear_solver/python/pandas_model_test.py +++ /dev/null @@ -1,1747 +0,0 @@ -#!/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. - -import math -from typing import Callable, Union - -from absl.testing import absltest -from absl.testing import parameterized -import pandas as pd - -from google.protobuf import text_format -from ortools.linear_solver import linear_solver_pb2 -from ortools.linear_solver.python import pandas_model as pdm - - -# This test suite should not be depended on as a public API. -class InternalHelperTest(absltest.TestCase): - def test_anonymous_variables(self): - helper = pdm.OptimizationModel(name="test_name")._helper - index = helper.add_var() - variable = pdm._Variable(helper, index) - self.assertEqual(variable._name, f"variable#{index}") - - def test_anonymous_constraints(self): - helper = pdm.OptimizationModel(name="test_name")._helper - index = helper.add_linear_constraint() - constraint = pdm._LinearConstraint(helper, index) - self.assertEqual(constraint._name, f"linear_constraint#{index}") - - -class LinearBaseTest(parameterized.TestCase): - def setUp(self): - super().setUp() - simple_model = pdm.OptimizationModel(name="test_name") - simple_model.create_variables(name="x", index=pd.Index(range(3), name="i")) - simple_model.create_variables(name="y", index=pd.Index(range(5), name="i")) - self.simple_model = simple_model - - @parameterized.named_parameters( - # Variable / Indexing - dict( - testcase_name="x[0]", - expr=lambda x, y: x[0], - expected_repr="x[0]", - ), - dict( - testcase_name="x[1]", - expr=lambda x, y: x[1], - expected_repr="x[1]", - ), - dict( - testcase_name="x[2]", - expr=lambda x, y: x[2], - expected_repr="x[2]", - ), - dict( - testcase_name="y[0]", - expr=lambda x, y: y[0], - expected_repr="y[0]", - ), - dict( - testcase_name="y[4]", - expr=lambda x, y: y[4], - expected_repr="y[4]", - ), - # Sum - dict( - testcase_name="x[0] + 5", - expr=lambda x, y: x[0] + 5, - expected_repr="5.0 + x[0]", - ), - dict( - testcase_name="x[0] - 5", - expr=lambda x, y: x[0] - 5, - expected_repr="-5.0 + x[0]", - ), - dict( - testcase_name="5 - x[0]", - expr=lambda x, y: 5 - x[0], - expected_repr="5.0 - x[0]", - ), - dict( - testcase_name="5 + x[0]", - expr=lambda x, y: 5 + x[0], - expected_repr="5.0 + x[0]", - ), - dict( - testcase_name="x[0] + y[0]", - expr=lambda x, y: x[0] + y[0], - expected_repr="0.0 + x[0] + y[0]", - ), - dict( - testcase_name="x[0] + y[0] + 5", - expr=lambda x, y: x[0] + y[0] + 5, - expected_repr="5.0 + x[0] + y[0]", - ), - dict( - testcase_name="5 + x[0] + y[0]", - expr=lambda x, y: 5 + x[0] + y[0], - expected_repr="5.0 + x[0] + y[0]", - ), - dict( - testcase_name="5 + x[0] - x[0]", - expr=lambda x, y: 5 + x[0] - x[0], - expected_repr="5.0", - ), - dict( - testcase_name="5 + x[0] - y[0]", - expr=lambda x, y: 5 + x[0] - y[0], - expected_repr="5.0 + x[0] - y[0]", - ), - dict( - testcase_name="x.sum()", - expr=lambda x, y: x.sum(), - expected_repr="0.0 + x[0] + x[1] + x[2]", - ), - dict( - testcase_name="x.add(y, fill_value=0).sum() + 5", - expr=lambda x, y: x.add(y, fill_value=0).sum() + 5, - expected_repr="5.0 + x[0] + x[1] + x[2] + y[0] + y[1] + ...", - ), - # Product - dict( - testcase_name="- x.sum()", - expr=lambda x, y: -x.sum(), - expected_repr="0.0 - x[0] - x[1] - x[2]", - ), - dict( - testcase_name="5 - x.sum()", - expr=lambda x, y: 5 - x.sum(), - expected_repr="5.0 - x[0] - x[1] - x[2]", - ), - dict( - testcase_name="x.sum() / 2.0", - expr=lambda x, y: x.sum() / 2.0, - expected_repr="0.0 + 0.5 * x[0] + 0.5 * x[1] + 0.5 * x[2]", - ), - dict( - testcase_name="(3 * x).sum()", - expr=lambda x, y: (3 * x).sum(), - expected_repr="0.0 + 3.0 * x[0] + 3.0 * x[1] + 3.0 * x[2]", - ), - dict( - testcase_name="(x * 3).sum()", - expr=lambda x, y: (x * 3).sum(), - expected_repr="0.0 + 3.0 * x[0] + 3.0 * x[1] + 3.0 * x[2]", - ), - dict( - testcase_name="x.sum() * 3", - expr=lambda x, y: x.sum() * 3, - expected_repr="0.0 + 3.0 * x[0] + 3.0 * x[1] + 3.0 * x[2]", - ), - dict( - testcase_name="3 * x.sum()", - expr=lambda x, y: 3 * x.sum(), - expected_repr="0.0 + 3.0 * x[0] + 3.0 * x[1] + 3.0 * x[2]", - ), - dict( - testcase_name="0 * x.sum() + y.sum()", - expr=lambda x, y: 0 * x.sum() + y.sum(), - expected_repr="0.0 + y[0] + y[1] + y[2] + y[3] + y[4]", - ), - # LinearExpression - dict( - testcase_name="_as_flat_linear_expression(x.sum())", - expr=lambda x, y: pdm._as_flat_linear_expression(x.sum()), - expected_repr="0.0 + x[0] + x[1] + x[2]", - ), - dict( - testcase_name=( - "_as_flat_linear_expression(_as_flat_linear_expression(x.sum()))" - ), - # pylint: disable=g-long-lambda - expr=lambda x, y: pdm._as_flat_linear_expression( - pdm._as_flat_linear_expression(x.sum()) - ), - expected_repr="0.0 + x[0] + x[1] + x[2]", - ), - dict( - testcase_name="""_as_flat_linear_expression(sum([ - _as_flat_linear_expression(x.sum()), - _as_flat_linear_expression(x.sum()), - ]))""", - # pylint: disable=g-long-lambda - expr=lambda x, y: pdm._as_flat_linear_expression( - sum( - [ - pdm._as_flat_linear_expression(x.sum()), - pdm._as_flat_linear_expression(x.sum()), - ] - ) - ), - expected_repr="0.0 + 2.0 * x[0] + 2.0 * x[1] + 2.0 * x[2]", - ), - ) - def test_repr(self, expr, expected_repr): - x = self.simple_model.get_variable_references("x") - y = self.simple_model.get_variable_references("y") - self.assertEqual(repr(expr(x, y)), expected_repr) - - -class LinearBaseErrorsTest(absltest.TestCase): - def test_unknown_linear_type(self): - with self.assertRaisesRegex(TypeError, r"Unrecognized linear expression"): - - class UnknownLinearType(pdm._LinearBase): - pass - - pdm._as_flat_linear_expression(UnknownLinearType()) - - def test_division_by_zero(self): - with self.assertRaises(ZeroDivisionError): - model = pdm.OptimizationModel(name="divide_by_zero") - x = model.create_variables(name="x", index=pd.Index(range(1))) - print(x / 0) - - def test_boolean_expression(self): - with self.assertRaisesRegex( - NotImplementedError, r"LinearExpression as a Boolean value" - ): - model = pdm.OptimizationModel(name="boolean_expression") - x = model.create_variables(name="x", index=pd.Index(range(1))) - bool(x.sum()) - - -class BoundedLinearBaseTest(parameterized.TestCase): - def setUp(self): - super().setUp() - simple_model = pdm.OptimizationModel(name="test_name") - simple_model.create_variables(name="x", index=pd.Index(range(3), name="i")) - simple_model.create_variables(name="y", index=pd.Index(range(5), name="i")) - self.simple_model = simple_model - - @parameterized.product( - lhs=( - lambda x, y: x.sum(), - lambda x, y: -x.sum(), - lambda x, y: x.sum() * 0, - lambda x, y: x.sum() * 3, - lambda x, y: x[0], - lambda x, y: x[1], - lambda x, y: x[2], - lambda x, y: -math.inf, - lambda x, y: -1, - lambda x, y: 0, - lambda x, y: 1, - lambda x, y: 1.1, - lambda x, y: math.inf, - ), - rhs=( - lambda x, y: y.sum(), - lambda x, y: -y.sum(), - lambda x, y: y.sum() * 0, - lambda x, y: y.sum() * 3, - lambda x, y: y[0], - lambda x, y: y[1], - lambda x, y: y[2], - lambda x, y: -math.inf, - lambda x, y: -1, - lambda x, y: 0, - lambda x, y: 1, - lambda x, y: 1.1, - lambda x, y: math.inf, - ), - op=( - lambda lhs, rhs: lhs == rhs, - lambda lhs, rhs: lhs <= rhs, - lambda lhs, rhs: lhs >= rhs, - ), - ) - def test_repr(self, lhs, rhs, op): - x = self.simple_model.get_variable_references("x") - y = self.simple_model.get_variable_references("y") - l: pdm._LinearType = lhs(x, y) - r: pdm._LinearType = rhs(x, y) - result = op(l, r) - if isinstance(l, pdm._LinearBase) or isinstance(r, pdm._LinearBase): - self.assertIsInstance(result, pdm._BoundedLinearBase) - self.assertIn("=", repr(result), msg="is one of ==, <=, or >=") - else: - self.assertIsInstance(result, bool) - - def test_doublesided_bounded_expressions(self): - x = self.simple_model.get_variable_references("x") - self.assertEqual( - "0 <= x[0] <= 1", repr(pdm._BoundedLinearExpression(x[0], 0, 1)) - ) - - def test_free_bounded_expressions(self): - x = self.simple_model.get_variable_references("x") - self.assertEqual( - "x[0] free", - repr(pdm._BoundedLinearExpression(x[0], -math.inf, math.inf)), - ) - - def test_var_eq_var_as_bool(self): - x = self.simple_model.get_variable_references("x") - y = self.simple_model.get_variable_references("y") - self.assertEqual(x[0], x[0]) - self.assertNotEqual(x[0], x[1]) - self.assertNotEqual(x[0], y[0]) - - self.assertEqual(x[1], x[1]) - self.assertNotEqual(x[1], x[0]) - self.assertNotEqual(x[1], y[1]) - - self.assertEqual(y[0], y[0]) - self.assertNotEqual(y[0], y[1]) - self.assertNotEqual(y[0], x[0]) - - self.assertEqual(y[1], y[1]) - self.assertNotEqual(y[1], y[0]) - self.assertNotEqual(y[1], x[1]) - - -class BoundedLinearBaseErrorsTest(absltest.TestCase): - def test_bounded_linear_expression_as_bool(self): - with self.assertRaisesRegex(NotImplementedError, "Boolean value"): - model = pdm.OptimizationModel(name="bounded_linear_expression_as_bool") - x = model.create_variables(name="x", index=pd.Index(range(1))) - bool(pdm._BoundedLinearExpression(x, 0, 1)) - - -class OptimizationModelMetadataTest(absltest.TestCase): - def test_name(self): - model = pdm.OptimizationModel(name="test_name") - self.assertEqual("test_name", model.get_name()) - - def test_schema_empty(self): - model = pdm.OptimizationModel(name="test_name") - self.assertIsInstance(model.get_schema(), pd.DataFrame) - - def test_schema_no_constraints(self): - model = pdm.OptimizationModel(name="test_name") - x = model.create_variables( - name="x", - index=pd.Index(range(3)), - lower_bound=0, - upper_bound=1, - is_integer=True, - ) - y = model.create_variables( - name="y", - index=pd.MultiIndex.from_tuples([(1, 2), (3, 4)], names=["i", "j"]), - lower_bound=0, - ) - z = model.create_variables( - name="z", - index=pd.MultiIndex.from_product(((1, 2), ("a", "b", "c"))), - lower_bound=-5, - upper_bound=10, - is_integer=True, - ) - schema = model.get_schema() - self.assertIsInstance(schema, pd.DataFrame) - self.assertLen(schema, 3) - self.assertSequenceAlmostEqual( - schema.columns, ["type", "name", "dimensions", "count"] - ) - self.assertSequenceAlmostEqual(schema.type, ["variable"] * 3) - self.assertSequenceAlmostEqual(schema.name, ["x", "y", "z"]) - self.assertSequenceAlmostEqual( - schema.dimensions, [(None,), ("i", "j"), (None, None)] - ) - self.assertSequenceAlmostEqual(schema["count"], [len(x), len(y), len(z)]) - self.assertEqual( - repr(model), - """OptimizationModel(name=test_name) with the following schema: - type name dimensions count -0 variable x [None] 3 -1 variable y ['i', 'j'] 2 -2 variable z [None, None] 6""", - ) - - def test_full_schema(self): - model = pdm.OptimizationModel(name="test_name") - x = model.create_variables( - name="x", - index=pd.Index(range(3)), - lower_bound=0, - upper_bound=1, - is_integer=True, - ) - y = model.create_variables( - name="y", - index=pd.MultiIndex.from_tuples([(1, 2), (3, 4)], names=["i", "j"]), - lower_bound=0, - ) - z = model.create_variables( - name="z", - index=pd.MultiIndex.from_product( - ((1, 2), ("a", "b", "c")), names=["i", "k"] - ), - lower_bound=-5, - upper_bound=10, - is_integer=True, - ) - c1 = model.create_linear_constraints( - name="x_sum_le_constant", - bounded_exprs=(x.sum() <= 10), - ) - c2 = model.create_linear_constraints( - name="y_groupbyj_sum_ge_constant", - bounded_exprs=y.groupby("j").sum().apply(lambda expr: expr >= 3), - ) - c3 = model.create_linear_constraints( - name="y_groupbyi_sum_eq_z_groupbyi_sum", - bounded_exprs=y.groupby("i") - .sum() - .sub(z.groupby("i").sum()) - .dropna() - .apply(lambda expr: expr == 0), - ) - schema = model.get_schema() - self.assertIsInstance(schema, pd.DataFrame) - self.assertLen(schema, 6) - self.assertSequenceAlmostEqual( - schema.columns, ["type", "name", "dimensions", "count"] - ) - self.assertSequenceAlmostEqual( - schema.type, ["variable"] * 3 + ["linear_constraint"] * 3 - ) - self.assertSequenceAlmostEqual( - schema.name, - [ - "x", - "y", - "z", - "x_sum_le_constant", - "y_groupbyj_sum_ge_constant", - "y_groupbyi_sum_eq_z_groupbyi_sum", - ], - ) - self.assertSequenceAlmostEqual( - schema.dimensions, - [(None,), ("i", "j"), ("i", "k"), (None,), ("j",), ("i",)], - ) - self.assertSequenceAlmostEqual( - schema["count"], [len(x), len(y), len(z), len(c1), len(c2), len(c3)] - ) - self.assertEqual( - repr(model), - """OptimizationModel(name=test_name) with the following schema: - type name dimensions count -0 variable x [None] 3 -1 variable y ['i', 'j'] 2 -2 variable z ['i', 'k'] 6 -3 linear_constraint x_sum_le_constant [None] 1 -4 linear_constraint y_groupbyj_sum_ge_constant ['j'] 2 -5 linear_constraint y_groupbyi_sum_eq_z_groupbyi_sum ['i'] 3""", - ) - - -class OptimizationModelErrorsTest(absltest.TestCase): - def test_name_errors(self): - with self.assertRaisesRegex(ValueError, r"not a valid identifier"): - pdm.OptimizationModel(name="") - - def test_create_variables_errors(self): - with self.assertRaisesRegex(TypeError, r"Non-index object"): - model = pdm.OptimizationModel(name="test_name") - model.create_variables(name="", index=pd.DataFrame()) - with self.assertRaisesRegex(TypeError, r"invalid type"): - model = pdm.OptimizationModel(name="test_name") - model.create_variables(name="x", index=pd.Index([0]), lower_bound="0") - with self.assertRaisesRegex(TypeError, r"invalid type"): - model = pdm.OptimizationModel(name="test_name") - model.create_variables(name="x", index=pd.Index([0]), upper_bound="0") - with self.assertRaisesRegex(TypeError, r"invalid type"): - model = pdm.OptimizationModel(name="test_name") - model.create_variables(name="x", index=pd.Index([0]), is_integer="True") - with self.assertRaisesRegex(ValueError, r"not a valid identifier"): - model = pdm.OptimizationModel(name="test_name") - model.create_variables(name="", index=pd.Index([0])) - with self.assertRaisesRegex(ValueError, r"already exists"): - model = pdm.OptimizationModel(name="test_name") - model.create_variables(name="x", index=pd.Index([0])) - model.create_variables(name="x", index=pd.Index([0])) - with self.assertRaisesRegex(ValueError, r"is greater than"): - model = pdm.OptimizationModel(name="test_name") - model.create_variables( - name="x", - index=pd.Index([0]), - lower_bound=0.2, - upper_bound=0.1, - ) - with self.assertRaisesRegex(ValueError, r"is greater than"): - model = pdm.OptimizationModel(name="test_name") - model.create_variables( - name="x", - index=pd.Index([0]), - lower_bound=0.1, - upper_bound=0.2, - is_integer=True, - ) - with self.assertRaisesRegex(ValueError, r"index does not match"): - model = pdm.OptimizationModel(name="test_name") - model.create_variables( - name="x", index=pd.Index([0]), lower_bound=pd.Series([1, 2]) - ) - with self.assertRaisesRegex(ValueError, r"index does not match"): - model = pdm.OptimizationModel(name="test_name") - model.create_variables( - name="x", index=pd.Index([0]), upper_bound=pd.Series([1, 2]) - ) - with self.assertRaisesRegex(ValueError, r"index does not match"): - model = pdm.OptimizationModel(name="test_name") - model.create_variables( - name="x", index=pd.Index([0]), is_integer=pd.Series([False, True]) - ) - - def test_get_variables_errors(self): - model = pdm.OptimizationModel(name="test_name") - model.create_variables(name="x", index=pd.Index(range(3))) - with self.assertRaisesRegex(KeyError, r"no variable set named"): - model.get_variables(name="nonexistent_variable") - - def test_get_variable_references_errors(self): - model = pdm.OptimizationModel(name="test_name") - model.create_variables(name="x", index=pd.Index(range(3))) - with self.assertRaisesRegex(KeyError, r"no variable set named"): - model.get_variable_references(None) - with self.assertRaisesRegex(KeyError, r"no variable set named"): - model.get_variable_references(name="") - - def test_create_linear_constraints_errors(self): - with self.assertRaisesRegex(ValueError, r"not a valid identifier"): - model = pdm.OptimizationModel(name="test_name") - x = model.create_variables(name="x", index=pd.Index(range(1))) - model.create_linear_constraints(name="", bounded_exprs=x[0] == 0) - with self.assertRaisesRegex(ValueError, r"already exists"): - model = pdm.OptimizationModel(name="test_name") - x = model.create_variables(name="x", index=pd.Index(range(1))) - model.create_linear_constraints(name="c", bounded_exprs=x[0] <= 0) - model.create_linear_constraints(name="c", bounded_exprs=x[0] >= 0) - with self.assertRaisesRegex(TypeError, r"invalid type"): - model = pdm.OptimizationModel(name="test_name") - model.create_linear_constraints(name="c", bounded_exprs="True") - with self.assertRaisesRegex(TypeError, r"invalid type"): - model = pdm.OptimizationModel(name="test_name") - model.create_linear_constraints(name="c", bounded_exprs=pd.Series(["T"])) - - def test_get_linear_constraint_references_errors(self): - with self.assertRaises(KeyError): - model = pdm.OptimizationModel(name="test_name") - model.get_linear_constraint_references("c") - - -class OptimizationModelVariablesTest(parameterized.TestCase): - _variable_indices = ( - pd.Index(range(3)), - pd.Index(range(5), name="i"), - pd.MultiIndex.from_product(((1, 2), ("a", "b", "c")), names=["i", "j"]), - pd.MultiIndex.from_product((("a", "b"), (1, 2, 3))), - ) - _bounds = ( - lambda index: (-math.inf, -10.5), - lambda index: (-math.inf, -1), - lambda index: (-math.inf, 0), - lambda index: (-math.inf, 10), - lambda index: (-math.inf, math.inf), - lambda index: (-10, -1.1), - lambda index: (-10, 0), - lambda index: (-10, -10), - lambda index: (-10, 3), - lambda index: (-9, math.inf), - lambda index: (-1, 1), - lambda index: (0, 0), - lambda index: (0, 1), - lambda index: (0, math.inf), - lambda index: (1, 1), - lambda index: (1, 10.1), - lambda index: (1, math.inf), - lambda index: (100.1, math.inf), - # pylint: disable=g-long-lambda - lambda index: ( - pd.Series(-math.inf, index=index), - pd.Series(-10.5, index=index), - ), - lambda index: ( - pd.Series(-math.inf, index=index), - pd.Series(-1, index=index), - ), - lambda index: ( - pd.Series(-math.inf, index=index), - pd.Series(0, index=index), - ), - lambda index: ( - pd.Series(-math.inf, index=index), - pd.Series(10, index=index), - ), - lambda index: ( - pd.Series(-math.inf, index=index), - pd.Series(math.inf, index=index), - ), - lambda index: (pd.Series(-10, index=index), pd.Series(-1.1, index=index)), - lambda index: (pd.Series(-10, index=index), pd.Series(0, index=index)), - lambda index: (pd.Series(-10, index=index), pd.Series(-10, index=index)), - lambda index: (pd.Series(-10, index=index), pd.Series(3, index=index)), - lambda index: ( - pd.Series(-9, index=index), - pd.Series(math.inf, index=index), - ), - lambda index: (pd.Series(-1, index=index), pd.Series(1, index=index)), - lambda index: (pd.Series(0, index=index), pd.Series(0, index=index)), - lambda index: (pd.Series(0, index=index), pd.Series(1, index=index)), - lambda index: ( - pd.Series(0, index=index), - pd.Series(math.inf, index=index), - ), - lambda index: (pd.Series(1, index=index), pd.Series(1, index=index)), - lambda index: (pd.Series(1, index=index), pd.Series(10.1, index=index)), - lambda index: ( - pd.Series(1, index=index), - pd.Series(math.inf, index=index), - ), - lambda index: ( - pd.Series(100.1, index=index), - pd.Series(math.inf, index=index), - ), - ) - _is_integer = ( - lambda index: False, - lambda index: True, - lambda index: pd.Series(False, index=index), - lambda index: pd.Series(True, index=index), - ) - - @parameterized.product( - index=_variable_indices, bounds=_bounds, is_integer=_is_integer - ) - def test_create_variables(self, index, bounds, is_integer): - model = pdm.OptimizationModel(name="test_name") - variables = model.create_variables( - name="test_variable", - index=index, - lower_bound=bounds(index)[0], - upper_bound=bounds(index)[1], - is_integer=is_integer(index), - ) - self.assertLen(variables, len(index)) - self.assertLen(set(variables), len(index)) - for i in index: - self.assertEqual(repr(variables[i]), f"test_variable[{i}]") - - @parameterized.named_parameters( - dict(testcase_name="all", variable_name=None, variable_count=3 + 5), - dict(testcase_name="x", variable_name="x", variable_count=3), - dict(testcase_name="y", variable_name="y", variable_count=5), - ) - def test_get_variables(self, variable_name, variable_count): - model = pdm.OptimizationModel(name="test_name") - model.create_variables(name="x", index=pd.Index(range(3))) - model.create_variables(name="y", index=pd.Index(range(5))) - for variables, expected_count in ( - (model.get_variables(), 3 + 5), - (model.get_variables(variable_name), variable_count), - (model.get_variables(name=variable_name), variable_count), - ): - self.assertIsInstance(variables, pd.Index) - self.assertLen(variables, expected_count) - - @parameterized.product( - index=_variable_indices, bounds=_bounds, is_integer=_is_integer - ) - def test_get_variable_lower_bounds(self, index, bounds, is_integer): - lower_bound, upper_bound = bounds(index) - model = pdm.OptimizationModel(name="test_name") - model.create_variables( - name="x", - index=index, - lower_bound=lower_bound, - upper_bound=upper_bound, - is_integer=is_integer(index), - ) - model.create_variables( - name="y", - index=index, - lower_bound=lower_bound, - upper_bound=upper_bound, - is_integer=is_integer(index), - ) - for lower_bounds in ( - model.get_variable_lower_bounds(model.get_variables("x")), - model.get_variable_lower_bounds(model.get_variables("y")), - model.get_variable_lower_bounds(model.get_variable_references("x")), - model.get_variable_lower_bounds(model.get_variable_references("y")), - ): - self.assertSequenceAlmostEqual( - lower_bounds, - pdm._convert_to_series_and_validate_index(lower_bound, index), - ) - self.assertSequenceAlmostEqual( - model.get_variable_lower_bounds(), - pd.concat( - [ - model.get_variable_lower_bounds(model.get_variables("x")), - model.get_variable_lower_bounds(model.get_variables("y")), - ] - ), - ) - for variables in (model.get_variables("x"), model.get_variables("y")): - lower_bounds = model.get_variable_lower_bounds(variables) - self.assertSequenceAlmostEqual(lower_bounds.index, variables) - for variables in ( - model.get_variable_references("x"), - model.get_variable_references("y"), - ): - lower_bounds = model.get_variable_lower_bounds(variables) - self.assertSequenceAlmostEqual(lower_bounds.index, variables.index) - - @parameterized.named_parameters( - dict(testcase_name="x", variable_name="x", variable_count=3), - dict(testcase_name="y", variable_name="y", variable_count=5), - ) - def test_get_variable_references(self, variable_name, variable_count): - model = pdm.OptimizationModel(name="test_name") - model.create_variables(name="x", index=pd.Index(range(3))) - model.create_variables(name="y", index=pd.Index(range(5))) - self.assertLen(model.get_variables(), 3 + 5) - for variables in ( - model.get_variable_references(variable_name), - model.get_variable_references(name=variable_name), - ): - self.assertLen(variables, variable_count) - - @parameterized.product( - index=_variable_indices, bounds=_bounds, is_integer=_is_integer - ) - def test_get_variable_upper_bounds(self, index, bounds, is_integer): - lower_bound, upper_bound = bounds(index) - model = pdm.OptimizationModel(name="test_name") - model.create_variables( - name="x", - index=index, - lower_bound=lower_bound, - upper_bound=upper_bound, - is_integer=is_integer(index), - ) - model.create_variables( - name="y", - index=index, - lower_bound=lower_bound, - upper_bound=upper_bound, - is_integer=is_integer(index), - ) - for upper_bounds in ( - model.get_variable_upper_bounds(model.get_variables("x")), - model.get_variable_upper_bounds(model.get_variables("y")), - model.get_variable_upper_bounds(model.get_variable_references("x")), - model.get_variable_upper_bounds(model.get_variable_references("y")), - ): - self.assertSequenceAlmostEqual( - upper_bounds, - pdm._convert_to_series_and_validate_index(upper_bound, index), - ) - self.assertSequenceAlmostEqual( - model.get_variable_upper_bounds(), - pd.concat( - [ - model.get_variable_upper_bounds(model.get_variables("x")), - model.get_variable_upper_bounds(model.get_variables("y")), - ] - ), - ) - for variables in (model.get_variables("x"), model.get_variables("y")): - upper_bounds = model.get_variable_upper_bounds(variables) - self.assertSequenceAlmostEqual(upper_bounds.index, variables) - for variables in ( - model.get_variable_references("x"), - model.get_variable_references("y"), - ): - upper_bounds = model.get_variable_upper_bounds(variables) - self.assertSequenceAlmostEqual(upper_bounds.index, variables.index) - - -class OptimizationModelLinearConstraintsTest(parameterized.TestCase): - constraint_test_cases = [ - # pylint: disable=g-long-lambda - dict( - testcase_name="True", - name="true", - bounded_exprs=lambda x, y: True, - constraint_count=1, - lower_bounds=[0], - upper_bounds=[0], - expression_terms=lambda x, y: [{}], - expression_offsets=[0], - ), - dict( - testcase_name="False", - name="false", - bounded_exprs=lambda x, y: False, - constraint_count=1, - lower_bounds=[1], - upper_bounds=[1], - expression_terms=lambda x, y: [{}], - expression_offsets=[0], - ), - dict( - testcase_name="x[0] <= 1.5", - name="x0_le_c", - bounded_exprs=lambda x, y: x[0] <= 1.5, - constraint_count=1, - lower_bounds=[-math.inf], - upper_bounds=[1.5], - expression_terms=lambda x, y: [{x[0]: 1}], - expression_offsets=[0], - ), - dict( - testcase_name="x[0] == 1", - name="x0_eq_c", - bounded_exprs=lambda x, y: x[0] == 1, - constraint_count=1, - lower_bounds=[1], - upper_bounds=[1], - expression_terms=lambda x, y: [{x[0]: 1}], - expression_offsets=[0], - ), - dict( - testcase_name="x[0] >= -1", - name="x0_ge_c", - bounded_exprs=lambda x, y: x[0] >= -1, - constraint_count=1, - lower_bounds=[-1], - upper_bounds=[math.inf], - expression_terms=lambda x, y: [{x[0]: 1}], - expression_offsets=[0], - ), - dict( - testcase_name="-1.5 <= x[0]", - name="c_le_x0", - bounded_exprs=lambda x, y: -1.5 <= x[0], - constraint_count=1, - lower_bounds=[-1.5], - upper_bounds=[math.inf], - expression_terms=lambda x, y: [{x[0]: 1}], - expression_offsets=[0], - ), - dict( - testcase_name="0 == x[0]", - name="c_eq_x0", - bounded_exprs=lambda x, y: 0 == x[0], - constraint_count=1, - lower_bounds=[0], - upper_bounds=[0], - expression_terms=lambda x, y: [{x[0]: 1}], - expression_offsets=[0], - ), - dict( - testcase_name="10 >= x[0]", - name="c_ge_x0", - bounded_exprs=lambda x, y: 10 >= x[0], - constraint_count=1, - lower_bounds=[-math.inf], - upper_bounds=[10], - expression_terms=lambda x, y: [{x[0]: 1}], - expression_offsets=[0], - ), - dict( - testcase_name="x[0] <= x[0]", - name="x0_le_x0", - bounded_exprs=lambda x, y: x[0] <= x[0], - constraint_count=1, - lower_bounds=[-math.inf], - upper_bounds=[0], - expression_terms=lambda x, y: [{x[0]: 0}], - expression_offsets=[0], - ), - dict( - testcase_name="x[0] == x[0]", - name="x0_eq_x0", - bounded_exprs=lambda x, y: x[0] == x[0], - constraint_count=1, - lower_bounds=[0], - upper_bounds=[0], - expression_terms=lambda x, y: [{x[0]: 0}], - expression_offsets=[0], - ), - dict( - testcase_name="x[0] >= x[0]", - name="x0_ge_x0", - bounded_exprs=lambda x, y: x[0] >= x[0], - constraint_count=1, - lower_bounds=[0], - upper_bounds=[math.inf], - expression_terms=lambda x, y: [{x[0]: 0}], - expression_offsets=[0], - ), - dict( - # x[0] - x[0] <= 3 - testcase_name="x[0] - 1 <= x[0] + 2", - name="x0c_le_x0c", - bounded_exprs=lambda x, y: pd.Series(x[0] - 1 <= x[0] + 2), - constraint_count=1, - lower_bounds=[-math.inf], - upper_bounds=[3], - expression_terms=lambda x, y: [{x[0]: 0}], - expression_offsets=[0], - ), - dict( - # x[0] - x[0] == 3 - testcase_name="x[0] - 1 == x[0] + 2", - name="x0c_eq_x0c", - bounded_exprs=lambda x, y: pd.Series(x[0] - 1 == x[0] + 2), - constraint_count=1, - lower_bounds=[3], - upper_bounds=[3], - expression_terms=lambda x, y: [{x[0]: 0}], - expression_offsets=[0], - ), - dict( - # x[0] - x[0] >= 3 - testcase_name="x[0] - 1 >= x[0] + 2", - name="x0c_ge_x0c", - bounded_exprs=lambda x, y: pd.Series(x[0] - 1 >= x[0] + 2), - constraint_count=1, - lower_bounds=[3], - upper_bounds=[math.inf], - expression_terms=lambda x, y: [{x[0]: 0}], - expression_offsets=[0], - ), - dict( - testcase_name="x[0] <= x[1]", - name="x0_le_x1", - bounded_exprs=lambda x, y: x[0] <= x[1], - constraint_count=1, - lower_bounds=[-math.inf], - upper_bounds=[0], - expression_terms=lambda x, y: [{x[0]: 1, x[1]: -1}], - expression_offsets=[0], - ), - dict( - testcase_name="x[0] == x[1]", - name="x0_eq_x1", - bounded_exprs=lambda x, y: x[0] == x[1], - constraint_count=1, - lower_bounds=[0], - upper_bounds=[0], - expression_terms=lambda x, y: [{x[0]: 1, x[1]: -1}], - expression_offsets=[0], - ), - dict( - testcase_name="x[0] >= x[1]", - name="x0_ge_x1", - bounded_exprs=lambda x, y: x[0] >= x[1], - constraint_count=1, - lower_bounds=[0], - upper_bounds=[math.inf], - expression_terms=lambda x, y: [{x[0]: 1, x[1]: -1}], - expression_offsets=[0], - ), - dict( - # x[0] - x[1] <= -3 - testcase_name="x[0] + 1 <= x[1] - 2", - name="x0c_le_x1c", - bounded_exprs=lambda x, y: x[0] + 1 <= x[1] - 2, - constraint_count=1, - lower_bounds=[-math.inf], - upper_bounds=[-3], - expression_terms=lambda x, y: [{x[0]: 1, x[1]: -1}], - expression_offsets=[0], - ), - dict( - # x[0] - x[1] == -3 - testcase_name="x[0] + 1 == x[1] - 2", - name="x0c_eq_x1c", - bounded_exprs=lambda x, y: x[0] + 1 == x[1] - 2, - constraint_count=1, - lower_bounds=[-3], - upper_bounds=[-3], - expression_terms=lambda x, y: [{x[0]: 1, x[1]: -1}], - expression_offsets=[0], - ), - dict( - # x[0] - x[1] >= -3 - testcase_name="x[0] + 1 >= x[1] - 2", - name="x0c_ge_x1c", - bounded_exprs=lambda x, y: pd.Series(x[0] + 1 >= x[1] - 2), - constraint_count=1, - lower_bounds=[-3], - upper_bounds=[math.inf], - expression_terms=lambda x, y: [{x[0]: 1, x[1]: -1}], - expression_offsets=[0], - ), - dict( - testcase_name="x <= 0", - name="x_le_c", - bounded_exprs=lambda x, y: x.apply(lambda expr: expr <= 0), - constraint_count=3, - lower_bounds=[-math.inf] * 3, - upper_bounds=[0] * 3, - expression_terms=lambda x, y: [{xi: 1} for xi in x], - expression_offsets=[0] * 3, - ), - dict( - testcase_name="x >= 0", - name="x_ge_c", - bounded_exprs=lambda x, y: x.apply(lambda expr: expr >= 0), - constraint_count=3, - lower_bounds=[0] * 3, - upper_bounds=[math.inf] * 3, - expression_terms=lambda x, y: [{xi: 1} for xi in x], - expression_offsets=[0] * 3, - ), - dict( - testcase_name="x == 0", - name="x_eq_c", - bounded_exprs=lambda x, y: x.apply(lambda expr: expr == 0), - constraint_count=3, - lower_bounds=[0] * 3, - upper_bounds=[0] * 3, - expression_terms=lambda x, y: [{xi: 1} for xi in x], - expression_offsets=[0] * 3, - ), - dict( - testcase_name="y == 0", - name="y_eq_c", - bounded_exprs=(lambda x, y: y.apply(lambda expr: expr == 0)), - constraint_count=2 * 3, - lower_bounds=[0] * 2 * 3, - upper_bounds=[0] * 2 * 3, - expression_terms=lambda x, y: [{yi: 1} for yi in y], - expression_offsets=[0] * 3 * 2, - ), - dict( - testcase_name='y.groupby("i").sum() == 0', - name="ygroupbyi_eq_c", - bounded_exprs=( - lambda x, y: y.groupby("i").sum().apply(lambda expr: expr == 0) - ), - constraint_count=2, - lower_bounds=[0] * 2, - upper_bounds=[0] * 2, - expression_terms=lambda x, y: [ - {y[1, "a"]: 1, y[1, "b"]: 1, y[1, "c"]: 1}, - {y[2, "a"]: 1, y[2, "b"]: 1, y[2, "c"]: 1}, - ], - expression_offsets=[0] * 2, - ), - dict( - testcase_name='y.groupby("j").sum() == 0', - name="ygroupbyj_eq_c", - bounded_exprs=( - lambda x, y: y.groupby("j").sum().apply(lambda expr: expr == 0) - ), - constraint_count=3, - lower_bounds=[0] * 3, - upper_bounds=[0] * 3, - expression_terms=lambda x, y: [ - {y[1, "a"]: 1, y[2, "a"]: 1}, - {y[1, "b"]: 1, y[2, "b"]: 1}, - {y[1, "c"]: 1, y[2, "c"]: 1}, - ], - expression_offsets=[0] * 3, - ), - dict( - testcase_name='3 * x + y.groupby("i").sum() <= 0', - name="broadcast_align_fill", - bounded_exprs=( - lambda x, y: (3 * x) - .add(y.groupby("i").sum(), fill_value=0) - .apply(lambda expr: expr <= 0) - ), - constraint_count=3, - lower_bounds=[-math.inf] * 3, - upper_bounds=[0] * 3, - expression_terms=lambda x, y: [ - {x[0]: 3}, - {x[1]: 3, y[1, "a"]: 1, y[1, "b"]: 1, y[1, "c"]: 1}, - {x[2]: 3, y[2, "a"]: 1, y[2, "b"]: 1, y[2, "c"]: 1}, - ], - expression_offsets=[0] * 3, - ), - ] - - def create_test_model(self, name, bounded_exprs): - model = pdm.OptimizationModel(name="test_name") - x = model.create_variables( - name="x", - index=pd.Index(range(3), name="i"), - ) - y = model.create_variables( - name="y", - index=pd.MultiIndex.from_product( - ((1, 2), ("a", "b", "c")), names=["i", "j"] - ), - ) - model.create_linear_constraints(name=name, bounded_exprs=bounded_exprs(x, y)) - return model - - @parameterized.named_parameters( - # pylint: disable=g-complex-comprehension - { - f: tc[f] - for f in [ - "testcase_name", - "name", - "bounded_exprs", - "constraint_count", - ] - } - for tc in constraint_test_cases - ) - def test_get_linear_constraints( - self, - name, - bounded_exprs, - constraint_count, - ): - model = self.create_test_model(name, bounded_exprs) - for linear_constraints, expected_count in ( - (model.get_linear_constraints(), constraint_count), - (model.get_linear_constraints(name), constraint_count), - (model.get_linear_constraints(name), constraint_count), - ): - self.assertIsInstance(linear_constraints, pd.Index) - self.assertLen(linear_constraints, expected_count) - - def test_get_linear_constraints_empty(self): - linear_constraints = pdm.OptimizationModel( - name="test_name" - ).get_linear_constraints() - self.assertIsInstance(linear_constraints, pd.Index) - self.assertEmpty(linear_constraints) - - @parameterized.named_parameters( - # pylint: disable=g-complex-comprehension - { - f: tc[f] - for f in [ - "testcase_name", - "name", - "bounded_exprs", - "constraint_count", - ] - } - for tc in constraint_test_cases - ) - def test_get_linear_constraint_references( - self, - name, - bounded_exprs, - constraint_count, - ): - model = self.create_test_model(name, bounded_exprs) - for linear_constraints, expected_count in ( - (model.get_linear_constraint_references(name=name), constraint_count), - (model.get_linear_constraint_references(name=name), constraint_count), - ): - self.assertIsInstance(linear_constraints, pd.Series) - self.assertLen(linear_constraints, expected_count) - for i in linear_constraints.index: - self.assertEqual(repr(linear_constraints[i]), f"{name}[{i}]") - - @parameterized.named_parameters( - # pylint: disable=g-complex-comprehension - { - f: tc[f] - for f in [ - "testcase_name", - "name", - "bounded_exprs", - "lower_bounds", - ] - } - for tc in constraint_test_cases - ) - def test_get_linear_constraint_lower_bounds( - self, - name, - bounded_exprs, - lower_bounds, - ): - model = self.create_test_model(name, bounded_exprs) - for linear_constraint_lower_bounds in ( - model.get_linear_constraint_lower_bounds(), - model.get_linear_constraint_lower_bounds(model.get_linear_constraints()), - model.get_linear_constraint_lower_bounds( - model.get_linear_constraint_references(name) - ), - ): - self.assertSequenceAlmostEqual(linear_constraint_lower_bounds, lower_bounds) - - @parameterized.named_parameters( - # pylint: disable=g-complex-comprehension - { - f: tc[f] - for f in [ - "testcase_name", - "name", - "bounded_exprs", - "upper_bounds", - ] - } - for tc in constraint_test_cases - ) - def test_get_linear_constraint_upper_bounds( - self, - name, - bounded_exprs, - upper_bounds, - ): - model = self.create_test_model(name, bounded_exprs) - for linear_constraint_upper_bounds in ( - model.get_linear_constraint_upper_bounds(), - model.get_linear_constraint_upper_bounds(model.get_linear_constraints()), - model.get_linear_constraint_upper_bounds( - model.get_linear_constraint_references(name) - ), - ): - self.assertSequenceAlmostEqual(linear_constraint_upper_bounds, upper_bounds) - - @parameterized.named_parameters( - # pylint: disable=g-complex-comprehension - { - f: tc[f] - for f in [ - "testcase_name", - "name", - "bounded_exprs", - "expression_terms", - "expression_offsets", - ] - } - for tc in constraint_test_cases - ) - def test_get_linear_constraint_expressions( - self, - name, - bounded_exprs, - expression_terms, - expression_offsets, - ): - model = self.create_test_model(name, bounded_exprs) - x = model.get_variable_references(name="x") - y = model.get_variable_references(name="y") - for linear_constraint_expressions in ( - model.get_linear_constraint_expressions(), - model.get_linear_constraint_expressions(model.get_linear_constraints()), - model.get_linear_constraint_expressions( - model.get_linear_constraint_references(name) - ), - ): - expr_terms = expression_terms(x, y) - self.assertLen(linear_constraint_expressions, len(expr_terms)) - for expr, expr_term in zip(linear_constraint_expressions, expr_terms): - self.assertDictEqual(expr._terms, expr_term) - self.assertSequenceAlmostEqual( - [expr._offset for expr in linear_constraint_expressions], - expression_offsets, - ) - - -class OptimizationModelObjectiveTest(parameterized.TestCase): - _expressions = ( - lambda x, y: -3, - lambda x, y: 0, - lambda x, y: 10, - lambda x, y: x[0], - lambda x, y: x[1], - lambda x, y: x[2], - lambda x, y: y[0], - lambda x, y: y[1], - lambda x, y: x[0] + 5, - lambda x, y: -3 + y[1], - lambda x, y: 3 * x[0], - lambda x, y: x[0] * 3 * 5 - 3, - lambda x, y: x.sum(), - lambda x, y: 101 + 2 * 3 * x.sum(), - lambda x, y: x.sum() * 2, - lambda x, y: sum(y), - lambda x, y: x.sum() + 2 * y.sum() + 3, - ) - _variable_indices = ( - pd.Index(range(3)), - pd.Index(range(3), name="i"), - pd.Index(range(10), name="i"), - ) - - def assertLinearExpressionAlmostEqual( - self, - expr1: pdm._LinearExpression, - expr2: pdm._LinearExpression, - ) -> None: - """Test that the two linear expressions are almost equal.""" - for variable, coeff in expr1._terms.items(): - self.assertAlmostEqual(expr2._terms.get(variable, 0), coeff) - for variable, coeff in expr2._terms.items(): - self.assertAlmostEqual(expr1._terms.get(variable, 0), coeff) - self.assertAlmostEqual(expr1._offset, expr2._offset) - - @parameterized.product( - expression=_expressions, - variable_indices=_variable_indices, - sense=(pdm.ObjectiveSense.MINIMIZE, pdm.ObjectiveSense.MAXIMIZE), - ) - def test_set_objective( - self, - expression: Callable[[pd.Series, pd.Series], pdm._LinearType], - variable_indices: pd.Index, - sense: pdm.ObjectiveSense, - ): - model = pdm.OptimizationModel(name="test_name") - x = model.create_variables(name="x", index=variable_indices) - y = model.create_variables(name="y", index=variable_indices) - objective_expression = pdm._as_flat_linear_expression(expression(x, y)) - model.set_objective(expression=objective_expression, sense=sense) - self.assertEqual(model.get_objective_sense(), sense) - got_objective_expression = model.get_objective_expression() - self.assertLinearExpressionAlmostEqual( - got_objective_expression, objective_expression - ) - - def test_set_new_objective(self): - model = pdm.OptimizationModel(name="test_name") - x = model.create_variables(name="x", index=pd.Index(range(3))) - old_objective_expression = 1 - new_objective_expression = pdm._as_flat_linear_expression(x.sum() - 2.3) - - # Set and check for old objective. - model.set_objective( - expression=old_objective_expression, sense=pdm.ObjectiveSense.MAXIMIZE - ) - self.assertEqual(model.get_objective_sense(), pdm.ObjectiveSense.MAXIMIZE) - got_objective_expression = model.get_objective_expression() - for var_coeff in got_objective_expression._terms.values(): - self.assertAlmostEqual(var_coeff, 0) - self.assertAlmostEqual(got_objective_expression._offset, 1) - - # Set to a new objective and check that it is different. - model.set_objective( - expression=new_objective_expression, sense=pdm.ObjectiveSense.MINIMIZE - ) - self.assertEqual(model.get_objective_sense(), pdm.ObjectiveSense.MINIMIZE) - got_objective_expression = model.get_objective_expression() - self.assertLinearExpressionAlmostEqual( - got_objective_expression, new_objective_expression - ) - - @parameterized.product( - expression=_expressions, - variable_indices=_variable_indices, - ) - def test_minimize( - self, - expression: Callable[[pd.Series, pd.Series], pdm._LinearType], - variable_indices: pd.Index, - ): - model = pdm.OptimizationModel(name="test_name") - x = model.create_variables(name="x", index=variable_indices) - y = model.create_variables(name="y", index=variable_indices) - objective_expression = pdm._as_flat_linear_expression(expression(x, y)) - model.minimize(expression=objective_expression) - self.assertEqual(model.get_objective_sense(), pdm.ObjectiveSense.MINIMIZE) - got_objective_expression = model.get_objective_expression() - self.assertLinearExpressionAlmostEqual( - got_objective_expression, objective_expression - ) - - @parameterized.product( - expression=_expressions, - variable_indices=_variable_indices, - ) - def test_maximize( - self, - expression: Callable[[pd.Series, pd.Series], float], - variable_indices: pd.Index, - ): - model = pdm.OptimizationModel(name="test_name") - x = model.create_variables(name="x", index=variable_indices) - y = model.create_variables(name="y", index=variable_indices) - objective_expression = pdm._as_flat_linear_expression(expression(x, y)) - model.maximize(expression=objective_expression) - self.assertEqual(model.get_objective_sense(), pdm.ObjectiveSense.MAXIMIZE) - got_objective_expression = model.get_objective_expression() - self.assertLinearExpressionAlmostEqual( - got_objective_expression, objective_expression - ) - - -class OptimizationModelProtoTest(absltest.TestCase): - def test_to_proto(self): - expected = linear_solver_pb2.MPModelProto() - text_format.Parse( - """ - name: "test_name" - maximize: true - objective_offset: 0 - variable { - lower_bound: 0 - upper_bound: 1000 - objective_coefficient: 1 - is_integer: false - name: "x[0]" - } - variable { - lower_bound: 0 - upper_bound: 1000 - objective_coefficient: 1 - is_integer: false - name: "x[1]" - } - constraint { - var_index: 0 - coefficient: 1 - lower_bound: -inf - upper_bound: 10 - name: "Ct[0]" - } - constraint { - var_index: 1 - coefficient: 1 - lower_bound: -inf - upper_bound: 10 - name: "Ct[1]" - } - """, - expected, - ) - model = pdm.OptimizationModel(name="test_name") - x = model.create_variables("x", pd.Index(range(2)), 0, 1000) - model.create_linear_constraints("Ct", x.apply(lambda expr: expr <= 10)) - model.maximize(expression=x.sum()) - self.assertEqual(str(expected), str(model.to_proto())) - - -class SolverTest(parameterized.TestCase): - _solvers = ( - { - "type": pdm.SolverType.CP_SAT, - "options": pdm.SolveOptions(), - "is_integer": True, # CP-SAT supports only pure integer variables. - }, - { - "type": pdm.SolverType.GLOP, - "options": pdm.SolveOptions( - # Disable GLOP's presolve to correctly trigger unboundedness: - # https://github.com/google/or-tools/issues/3319 - solver_specific_parameters="use_preprocessing: False" - ), - "is_integer": False, # GLOP does not properly support integers. - }, - { - "type": pdm.SolverType.SCIP, - "options": pdm.SolveOptions(), - "is_integer": False, - }, - { - "type": pdm.SolverType.SCIP, - "options": pdm.SolveOptions(), - "is_integer": True, - }, - ) - _variable_indices = ( - pd.Index(range(0)), # No variables. - pd.Index(range(1)), # Single variable. - pd.Index(range(3)), # Multiple variables. - ) - _variable_bounds = (-1, 0, 10.1) - _solve_statuses = ( - pdm.SolveStatus.OPTIMAL, - pdm.SolveStatus.INFEASIBLE, - pdm.SolveStatus.UNBOUNDED, - ) - _set_objectives = (True, False) - _objective_senses = ( - pdm.ObjectiveSense.MAXIMIZE, - pdm.ObjectiveSense.MINIMIZE, - ) - _objective_expressions = ( - lambda x: x.sum(), - lambda x: x.sum() + 5.2, - lambda x: -10.1, - lambda x: 0, - ) - - def _create_model( - self, - variable_indices: pd.Index = pd.Index(range(3)), - variable_bound: float = 0, - is_integer: bool = False, - solve_status: pdm.SolveStatus = pdm.SolveStatus.OPTIMAL, - set_objective: bool = True, - objective_sense: pdm.ObjectiveSense = pdm.ObjectiveSense.MAXIMIZE, - objective_expression: Callable[[pd.Series], float] = lambda x: x.sum(), - ) -> pdm.OptimizationModel: - """Constructs an optimization problem. - - It has the following formulation: - - ``` - objective_sense (MAXIMIZE / MINIMIZE) objective_expression(x) - satisfying constraints - (if solve_status != UNBOUNDED and objective_sense == MAXIMIZE) - x[variable_indices] <= variable_bound - (if solve_status != UNBOUNDED and objective_sense == MINIMIZE) - x[variable_indices] >= variable_bound - x[variable_indices] is_integer - False (if solve_status == INFEASIBLE) - ``` - - Args: - variable_indices (pd.Index): The indices of the variable(s). - variable_bound (float): The upper- or lower-bound(s) of the variable(s). - is_integer (bool): Whether the variables should be integer. - solve_status (pdm.SolveStatus): The solve status to target. - set_objective (bool): Whether to set the objective of the model. - objective_sense (pdm.ObjectiveSense): MAXIMIZE or MINIMIZE, - objective_expression (Callable[[pd.Series], float]): The expression to - maximize or minimize if set_objective=True. - - Returns: - pdm.OptimizationModel: The resulting problem. - """ - model = pdm.OptimizationModel(name="test") - # Variable(s) - x = model.create_variables( - name="x", - index=pd.Index(variable_indices), - is_integer=is_integer, - ) - # Constraint(s) - if solve_status == pdm.SolveStatus.INFEASIBLE: - # Force infeasibility here to test that we get pd.NA later. - model.create_linear_constraints("bool", False) - elif solve_status != pdm.SolveStatus.UNBOUNDED: - if objective_sense == pdm.ObjectiveSense.MAXIMIZE: - model.create_linear_constraints( - "upper_bound", x.apply(lambda xi: xi <= variable_bound) - ) - elif objective_sense == pdm.ObjectiveSense.MINIMIZE: - model.create_linear_constraints( - "lower_bound", x.apply(lambda xi: xi >= variable_bound) - ) - # Objective - if set_objective: - model.set_objective( - expression=objective_expression(x), sense=objective_sense - ) - return model - - @parameterized.product( - solver=_solvers, - variable_indices=_variable_indices, - variable_bound=_variable_bounds, - solve_status=_solve_statuses, - set_objective=_set_objectives, - objective_sense=_objective_senses, - objective_expression=_objective_expressions, - ) - def test_solve_status( - self, - solver: dict[str, Union[pdm.SolverType, pdm.SolveOptions, bool]], - variable_indices: pd.Index, - variable_bound: float, - solve_status: pdm.SolveStatus, - set_objective: bool, - objective_sense: pdm.ObjectiveSense, - objective_expression: Callable[[pd.Series], float], - ): - model = self._create_model( - variable_indices=variable_indices, - variable_bound=variable_bound, - is_integer=solver["is_integer"], - solve_status=solve_status, - set_objective=set_objective, - objective_sense=objective_sense, - objective_expression=objective_expression, - ) - solve_result = pdm.Solver(solver_type=solver["type"]).solve( - model=model, options=solver["options"] - ) - - # pylint: disable=g-explicit-length-test - # (we disable explicit-length-test here because `variable_indices: pd.Index` - # evaluates to an ambiguous boolean value.) - if len(variable_indices) > 0: # Test cases with >=1 variable. - self.assertNotEmpty(variable_indices) - if ( - isinstance( - objective_expression(model.get_variable_references("x")), - (int, float), - ) - and solve_status != pdm.SolveStatus.INFEASIBLE - ): - # Feasibility implies optimality when objective is a constant term. - self.assertEqual(solve_result.get_status(), pdm.SolveStatus.OPTIMAL) - elif not set_objective and solve_status != pdm.SolveStatus.INFEASIBLE: - # Feasibility implies optimality when objective is not set. - self.assertEqual(solve_result.get_status(), pdm.SolveStatus.OPTIMAL) - elif ( - solver["type"] == pdm.SolverType.CP_SAT - and solve_result.get_status() == pdm.SolveStatus.UNKNOWN - ): - # CP_SAT returns unknown for some of the infeasible and unbounded cases. - self.assertIn( - solve_status, - (pdm.SolveStatus.INFEASIBLE, pdm.SolveStatus.UNBOUNDED), - ) - else: - self.assertEqual(solve_result.get_status(), solve_status) - elif solve_status == pdm.SolveStatus.UNBOUNDED: - # Unbounded problems are optimal when there are no variables. - self.assertEqual(solve_result.get_status(), pdm.SolveStatus.OPTIMAL) - else: - self.assertEqual(solve_result.get_status(), solve_status) - - @parameterized.product( - solver=_solvers, - variable_indices=_variable_indices, - variable_bound=_variable_bounds, - solve_status=_solve_statuses, - set_objective=_set_objectives, - objective_sense=_objective_senses, - objective_expression=_objective_expressions, - ) - def test_get_variable_values( - self, - solver: dict[str, Union[pdm.SolverType, pdm.SolveOptions, bool]], - variable_indices: pd.Index, - variable_bound: float, - solve_status: pdm.SolveStatus, - set_objective: bool, - objective_sense: pdm.ObjectiveSense, - objective_expression: Callable[[pd.Series], float], - ): - model = self._create_model( - variable_indices=variable_indices, - variable_bound=variable_bound, - is_integer=solver["is_integer"], - solve_status=solve_status, - set_objective=set_objective, - objective_sense=objective_sense, - objective_expression=objective_expression, - ) - solve_result = pdm.Solver(solver_type=solver["type"]).solve( - model=model, options=solver["options"] - ) - for variables in ( - None, # We get all variables when none is specified. - model.get_variables()[:2], # We can filter to a subset (pd.Index). - model.get_variable_references("x")[:2], # It works for pd.Series. - ): - variable_values = solve_result.get_variable_values(variables) - # Test the type of `variable_values` (we always get pd.Series) - self.assertIsInstance(variable_values, pd.Series) - # Test the index of `variable_values` (match the input variables [if any]) - self.assertSequenceAlmostEqual( - variable_values.index, - pdm._get_index(model._get_variables(variables)), - ) - if solve_result.get_status() not in ( - pdm.SolveStatus.OPTIMAL, - pdm.SolveStatus.FEASIBLE, - ): - # self.assertSequenceAlmostEqual does not work here because we cannot do - # equality comparison for NA values (NAs will propagate and we will get - # 'TypeError: boolean value of NA is ambiguous') - for variable_value in variable_values: - self.assertTrue(pd.isna(variable_value)) - elif set_objective and not isinstance( - objective_expression(model.get_variable_references("x")), - (int, float), - ): - # The variable values are only well-defined when the objective is set - # and depends on the variable(s). - if not solver["is_integer"]: - self.assertSequenceAlmostEqual( - variable_values, [variable_bound] * len(variable_values) - ) - elif objective_sense == pdm.ObjectiveSense.MAXIMIZE: - self.assertTrue(solver["is_integer"]) # Assert a known assumption. - self.assertSequenceAlmostEqual( - variable_values, - [math.floor(variable_bound)] * len(variable_values), - ) - else: - self.assertTrue(solver["is_integer"]) # Assert a known assumption. - self.assertEqual(objective_sense, pdm.ObjectiveSense.MINIMIZE) - self.assertSequenceAlmostEqual( - variable_values, - [math.ceil(variable_bound)] * len(variable_values), - ) - - @parameterized.product( - solver=_solvers, - variable_indices=_variable_indices, - variable_bound=_variable_bounds, - solve_status=_solve_statuses, - set_objective=_set_objectives, - objective_sense=_objective_senses, - objective_expression=_objective_expressions, - ) - def test_get_objective_value( - self, - solver: dict[str, Union[pdm.SolverType, pdm.SolveOptions, bool]], - variable_indices: pd.Index, - variable_bound: float, - solve_status: pdm.SolveStatus, - set_objective: bool, - objective_sense: pdm.ObjectiveSense, - objective_expression: Callable[[pd.Series], float], - ): - model = self._create_model( - variable_indices=variable_indices, - variable_bound=variable_bound, - is_integer=solver["is_integer"], - solve_status=solve_status, - set_objective=set_objective, - objective_sense=objective_sense, - objective_expression=objective_expression, - ) - solve_result = pdm.Solver(solver_type=solver["type"]).solve( - model=model, options=solver["options"] - ) - - # Test objective value - if solve_result.get_status() not in ( - pdm.SolveStatus.OPTIMAL, - pdm.SolveStatus.FEASIBLE, - ): - self.assertTrue(pd.isna(solve_result.get_objective_value())) - return - if set_objective: - variable_values = solve_result.get_variable_values(model.get_variables()) - self.assertAlmostEqual( - solve_result.get_objective_value(), - objective_expression(variable_values), - ) - else: - self.assertAlmostEqual(solve_result.get_objective_value(), 0) - - -if __name__ == "__main__": - absltest.main() diff --git a/ortools/linear_solver/samples/assignment_mb.py b/ortools/linear_solver/samples/assignment_mb.py index 1d60227ef8c..a0a93390625 100644 --- a/ortools/linear_solver/samples/assignment_mb.py +++ b/ortools/linear_solver/samples/assignment_mb.py @@ -15,7 +15,9 @@ """MIP example that solves an assignment problem.""" # [START program] # [START import] -import numpy as np +import io + +import pandas as pd from ortools.linear_solver.python import model_builder # [END import] @@ -24,20 +26,35 @@ def main(): # Data # [START data_model] - costs = np.array( - [ - [90, 80, 75, 70], - [35, 85, 55, 65], - [125, 95, 90, 95], - [45, 110, 95, 115], - [50, 100, 90, 100], - ] - ) - num_workers, num_tasks = costs.shape + 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] - # Solver # Create the model. + # [START model] model = model_builder.ModelBuilder() # [END model] @@ -45,25 +62,23 @@ def main(): # [START variables] # x[i, j] is an array of 0-1 variables, which will be 1 # if worker i is assigned to task j. - x = model.new_bool_var_array( - shape=[num_workers, num_tasks], name="x" - ) # pytype: disable=wrong-arg-types # numpy-scalars + x = model.new_bool_var_series(name="x", index=data.index) # [END variables] # Constraints # [START constraints] # Each worker is assigned to at most 1 task. - for i in range(num_workers): - model.add(np.sum(x[i, :]) <= 1) + for unused_name, tasks in data.groupby("worker"): + model.add(x[tasks.index].sum() <= 1) # Each task is assigned to exactly one worker. - for j in range(num_tasks): - model.add(np.sum(x[:, j]) == 1) + for unused_name, workers in data.groupby("task"): + model.add(x[workers.index].sum() == 1) # [END constraints] # Objective # [START objective] - model.minimize(np.dot(x.flatten(), costs.flatten())) + model.minimize(data.cost.dot(x)) # [END objective] # [START solve] @@ -79,11 +94,9 @@ def main(): or status == model_builder.SolveStatus.FEASIBLE ): print(f"Total cost = {solver.objective_value}\n") - for i in range(num_workers): - for j in range(num_tasks): - # Test if x[i,j] is 1 (with tolerance for floating point arithmetic). - if solver.value(x[i, j]) > 0.5: - print(f"Worker {i} assigned to task {j}." + f" Cost: {costs[i][j]}") + selected = data.loc[solver.values(x).loc[lambda x: x == 1].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/linear_solver/samples/bin_packing_mb.py b/ortools/linear_solver/samples/bin_packing_mb.py index bae4d746d4f..c322186e879 100644 --- a/ortools/linear_solver/samples/bin_packing_mb.py +++ b/ortools/linear_solver/samples/bin_packing_mb.py @@ -15,7 +15,9 @@ """Solve a simple bin packing problem using a MIP solver.""" # [START program] # [START import] -import numpy as np +import io + +import pandas as pd from ortools.linear_solver.python import model_builder # [END import] @@ -25,58 +27,81 @@ # [START data_model] def create_data_model(): """Create the data for the example.""" - data = {} - weights = [48, 30, 19, 36, 36, 27, 42, 42, 36, 24, 30] - data["weights"] = weights - data["items"] = list(range(len(weights))) - data["bins"] = data["items"] - data["bin_capacity"] = 100 - return data -# [END data_model] + 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] - data = create_data_model() - num_items = len(data["items"]) - num_bins = len(data["bins"]) + items, bins = create_data_model() # [END data] # [END program_part1] - # [START solver] + # [START model] # Create the model. model = model_builder.ModelBuilder() - # [END solver] + # [END model] # [START program_part2] # [START variables] # Variables # x[i, j] = 1 if item i is packed in bin j. - x = model.new_bool_var_array( - shape=[num_items, num_bins], name="x" - ) # pytype: disable=wrong-arg-types # numpy-scalars + items_x_bins = pd.MultiIndex.from_product( + [items.index, bins.index], names=["item", "bin"] + ) + x = model.new_bool_var_series(name="x", index=items_x_bins) # y[j] = 1 if bin j is used. - y = model.new_bool_var_array( - shape=[num_bins], name="y" - ) # pytype: disable=wrong-arg-types # numpy-scalars + y = model.new_bool_var_series(name="y", index=bins.index) # [END variables] # [START constraints] # Constraints # Each item must be in exactly one bin. - for i in data["items"]: - model.add(np.sum(x[i, :]) == 1) + for unused_name, all_copies in x.groupby("item"): + model.add(x[all_copies.index].sum() == 1) # The amount packed in each bin cannot exceed its capacity. - for j in data["bins"]: - model.add(np.dot(x[:, j], data["weights"]) <= data["bin_capacity"] * y[j]) + 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(np.sum(y)) + model.minimize(y.sum()) # [END objective] # [START solve] @@ -87,24 +112,23 @@ def main(): # [START print_solution] if status == model_builder.SolveStatus.OPTIMAL: - num_bins = 0.0 - for j in data["bins"]: - if solver.value(y[j]) == 1: - bin_items = [] - bin_weight = 0 - for i in data["items"]: - if solver.value(x[i, j]) > 0: - bin_items.append(i) - bin_weight += data["weights"][i] - if bin_weight > 0: - num_bins += 1 - print("Bin number", j) - print(" Items packed:", bin_items) - print(" Total weight:", bin_weight) - print() + print(f"Number of bins used = {solver.objective_value}") + + x_values = solver.values(x) + y_values = solver.values(y) + active_bins = y_values.loc[lambda x: x == 1].index + + for b in active_bins: + print(f"Bin {b}") + items_in_bin = x_values.xs(b, level="bin").loc[lambda x: x == 1].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("Number of bins used:", num_bins) - print("Time = ", solver.wall_time, " seconds") + print(f"Time = {solver.wall_time} seconds") else: print("The problem does not have an optimal solution.") # [END print_solution] diff --git a/ortools/linear_solver/samples/code_samples.bzl b/ortools/linear_solver/samples/code_samples.bzl index 146b0ae4b6e..134bd6c311c 100644 --- a/ortools/linear_solver/samples/code_samples.bzl +++ b/ortools/linear_solver/samples/code_samples.bzl @@ -46,6 +46,7 @@ def code_sample_py(name): deps = [ requirement("absl-py"), requirement("numpy"), + requirement("pandas"), "//ortools/linear_solver/python:model_builder", ], python_version = "PY3", @@ -63,6 +64,7 @@ def code_sample_py(name): deps = [ requirement("absl-py"), requirement("numpy"), + requirement("pandas"), ], python_version = "PY3", srcs_version = "PY3", diff --git a/ortools/linear_solver/samples/simple_lp_program_mb.py b/ortools/linear_solver/samples/simple_lp_program_mb.py index 12c7ed287e6..07700336e42 100644 --- a/ortools/linear_solver/samples/simple_lp_program_mb.py +++ b/ortools/linear_solver/samples/simple_lp_program_mb.py @@ -29,12 +29,8 @@ def main(): # [START variables] # Create the variables x and y. - x = model.new_num_var( - 0.0, math.inf, "x" - ) # pytype: disable=wrong-arg-types # numpy-scalars - y = model.new_num_var( - 0.0, math.inf, "y" - ) # pytype: disable=wrong-arg-types # numpy-scalars + x = model.new_num_var(0.0, math.inf, "x") + y = model.new_num_var(0.0, math.inf, "y") print("Number of variables =", model.num_variables) # [END variables] diff --git a/ortools/linear_solver/samples/simple_mip_program_mb.py b/ortools/linear_solver/samples/simple_mip_program_mb.py index bc5e61f6d8f..f66d7c406de 100644 --- a/ortools/linear_solver/samples/simple_mip_program_mb.py +++ b/ortools/linear_solver/samples/simple_mip_program_mb.py @@ -29,12 +29,8 @@ def main(): # [START variables] # x and y are integer non-negative variables. - x = model.new_int_var( - 0.0, math.inf, "x" - ) # pytype: disable=wrong-arg-types # numpy-scalars - y = model.new_int_var( - 0.0, math.inf, "y" - ) # pytype: disable=wrong-arg-types # numpy-scalars + x = model.new_int_var(0.0, math.inf, "x") + y = model.new_int_var(0.0, math.inf, "y") print("Number of variables =", model.num_variables) # [END variables]