From d0f6b6db748e4d075d0bbfc46fcf7a7776da1526 Mon Sep 17 00:00:00 2001 From: Laurent Perron Date: Tue, 28 Feb 2023 10:55:51 +0400 Subject: [PATCH] add typing info to model_builder/python; add numpy variable support; update samples --- ortools/linear_solver/python/CMakeLists.txt | 3 +- ortools/linear_solver/python/model_builder.py | 1178 +++++++++++------ .../python/model_builder_helper.py | 35 +- .../python/model_builder_helper_test.py | 2 +- .../python/model_builder_test.py | 320 ++++- .../python/pywrap_model_builder_helper.cc | 92 +- .../linear_solver/samples/assignment_mb.py | 17 +- .../linear_solver/samples/bin_packing_mb.py | 20 +- 8 files changed, 1166 insertions(+), 501 deletions(-) diff --git a/ortools/linear_solver/python/CMakeLists.txt b/ortools/linear_solver/python/CMakeLists.txt index e4aaf97cf4..ac25c9bddd 100644 --- a/ortools/linear_solver/python/CMakeLists.txt +++ b/ortools/linear_solver/python/CMakeLists.txt @@ -13,7 +13,8 @@ set_property(SOURCE linear_solver.i PROPERTY CPLUSPLUS ON) set_property(SOURCE linear_solver.i PROPERTY SWIG_MODULE_NAME pywraplp) -set_property(SOURCE linear_solver.i PROPERTY COMPILE_DEFINITIONS ${OR_TOOLS_COMPILE_DEFINITIONS} ABSL_MUST_USE_RESULT=) +set_property(SOURCE linear_solver.i PROPERTY COMPILE_DEFINITIONS + ${OR_TOOLS_COMPILE_DEFINITIONS} ABSL_MUST_USE_RESULT=) swig_add_library(pywraplp TYPE MODULE LANGUAGE python diff --git a/ortools/linear_solver/python/model_builder.py b/ortools/linear_solver/python/model_builder.py index 5e8599f52c..9e90a7675d 100644 --- a/ortools/linear_solver/python/model_builder.py +++ b/ortools/linear_solver/python/model_builder.py @@ -32,11 +32,27 @@ rather than for solving specific optimization problems. """ import math +import numbers +from typing import Any, Callable, Dict, List, Optional, Union, Sequence, Tuple import numpy as np +from numpy import typing as npt +from numpy.lib import mixins from ortools.linear_solver.python import model_builder_helper as mbh from ortools.linear_solver.python import pywrap_model_builder_helper as pwmb +# Custom types. +NumberT = Union[numbers.Number, np.number, int, float] +IntegerT = Union[numbers.Integral, np.integer] +LinearExprT = Union['LinearExpr', NumberT] +ConstraintT = Union['VarCompVar', 'BoundedLinearExpression', bool] +ShapeT = Union[IntegerT, Sequence[IntegerT]] +NumpyFuncT = Callable[[ + 'VariableContainer', + Optional[np.double], + Union[npt.NDArray[np.double], Sequence[NumberT], None], +], LinearExprT,] + # Forward solve statuses. SolveStatus = pwmb.SolveStatus @@ -74,67 +90,142 @@ class LinearExpr: """ @classmethod - def sum(cls, expressions): - """Creates the expression sum(expressions).""" - if len(expressions) == 1: + def sum(cls, + expressions: Sequence[LinearExprT], + *, + constant: NumberT = 0.0) -> LinearExprT: + """Creates `sum(expressions) + constant`. + + It can perform simple simplifications and returns different objects, + including the input. + + Args: + expressions: a sequence of linear expressions or constants. + constant: a numerical constant. + + Returns: + a LinearExpr instance or a numerical constant. + """ + checked_constant: np.double = mbh.assert_is_a_number(constant) + if not expressions: + return checked_constant + if len(expressions) == 1 and mbh.is_zero(checked_constant): return expressions[0] - return _SumArray(expressions) + + return LinearExpr.weighted_sum(expressions, + np.ones(len(expressions)), + constant=checked_constant) @classmethod - def weighted_sum(cls, expressions, coefficients): - """Creates the expression sum(expressions[i] * coefficients[i]).""" - if LinearExpr.is_empty_or_null(coefficients): + def weighted_sum( + cls, + expressions: Sequence[LinearExprT], + coefficients: Sequence[NumberT], + *, + constant: NumberT = 0.0, + ) -> LinearExprT: + """Creates `sum(expressions[i] * coefficients[i]) + constant`. + + It can perform simple simplifications and returns different object, + including the input. + + Args: + expressions: a sequence of linear expressions or constants. + coefficients: a sequence of numerical constants. + constant: a numerical constant. + + Returns: + a LinearExpr instance or a numerical constant. + """ + if len(expressions) != len(coefficients): + raise ValueError( + 'LinearExpr.weighted_sum: expressions and coefficients have' + ' different lengths') + checked_constant: np.double = mbh.assert_is_a_number(constant) + if not expressions: + return checked_constant + + # Collect sub-arrays to concatenate. + indices = [] + coeffs = [] + for e, c in zip(expressions, coefficients): + if mbh.is_zero(c): + continue + + if mbh.is_a_number(e): + checked_constant += np.double(c * e) + elif isinstance(e, Variable): + indices.append(np.array([e.index], dtype=np.int32)) + coeffs.append(np.array([c], dtype=np.double)) + elif isinstance(e, _WeightedSum): + checked_constant += np.double(c * e.constant) + indices.append(e.variable_indices) + coeffs.append(e.coefficients * c) + + if indices: + return _WeightedSum( + variable_indices=np.concatenate(indices, axis=0), + coefficients=np.concatenate(coeffs, axis=0), + constant=checked_constant, + ) + return checked_constant + + @classmethod + def term( + cls, + expression: LinearExprT, + coefficient: NumberT, + *, + constant: NumberT = 0.0, + ) -> LinearExprT: + """Creates `expression * coefficient + constant`. + + It can perform simple simplifications and returns different object, + including the input. + Args: + expression: a linear expression or a constant. + coefficient: a numerical constant. + constant: a numerical constant. + + Returns: + a LinearExpr instance or a numerical constant. + """ + checked_coefficient: np.double = mbh.assert_is_a_number(coefficient) + checked_constant: np.double = mbh.assert_is_a_number(constant) + + if mbh.is_zero(checked_coefficient): + return checked_constant + if mbh.is_one(checked_coefficient) and mbh.is_zero(checked_constant): + return expression + if mbh.is_a_number(expression): + return np.double( + expression) * checked_coefficient + checked_constant + if isinstance(expression, Variable): + return _WeightedSum( + 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( + variable_indices=np.copy(expression.variable_indices), + coefficients=expression.coefficients * checked_coefficient, + constant=expression.constant * checked_coefficient + + checked_constant, + ) + raise TypeError( + f'Unknown expression {expression!r} of type {type(expression)}') + + @classmethod + def length(cls, expression: LinearExprT) -> int: + if mbh.is_a_number(expression): return 0 - elif len(expressions) == 1: - return expressions[0] * coefficients[0] - else: - return _WeightedSum(expressions, coefficients) - - @classmethod - def term(cls, expression, coefficient): - """Creates `expression * coefficient`.""" - if mbh.is_zero(coefficient): - return 0 - else: - return expression * coefficient - - @classmethod - def is_empty_or_null(cls, coefficients): - for c in coefficients: - if not mbh.is_zero(c): - return False - return True - - def get_var_value_map(self): - """Scans the expression. Returns (var_coef_map, constant).""" - coeffs = {} - constant = 0.0 - to_process = [(self, 1.0)] - while to_process: # Flatten to avoid recursion. - expr, coeff = to_process.pop() - if mbh.is_a_number(expr): - constant += coeff * mbh.assert_is_a_number(expr) - elif isinstance(expr, _ProductCst): - to_process.append((expr.expression, coeff * expr.coefficient)) - elif isinstance(expr, _Sum): - to_process.append((expr.left, coeff)) - to_process.append((expr.right, coeff)) - elif isinstance(expr, _SumArray): - for e in expr.expressions: - to_process.append((e, coeff)) - constant += expr.constant * coeff - elif isinstance(expr, _WeightedSum): - for e, c in zip(expr.expressions, expr.coefficients): - to_process.append((e, coeff * c)) - constant += expr.constant * coeff - elif isinstance(expr, Variable): - if expr in coeffs: - coeffs[expr] += coeff - else: - coeffs[expr] = coeff - else: - raise TypeError('Unrecognized linear expression: ' + str(expr)) - return coeffs, constant + elif isinstance(expression, Variable): + return 1 + elif isinstance(expression, _WeightedSum): + return expression.variable_indices.size + raise TypeError( + f'Unknown expression {expression!r} of type {type(expression)}') def __hash__(self): return object.__hash__(self) @@ -142,36 +233,39 @@ class LinearExpr: def __abs__(self): return NotImplemented - def __add__(self, arg): - if mbh.is_zero(arg): - return self - return _Sum(self, arg) + def __add__(self, arg: LinearExprT) -> LinearExprT: + if mbh.is_a_number(arg): + return LinearExpr.sum([self], constant=arg) + return LinearExpr.weighted_sum([self, arg], [1.0, 1.0], constant=0.0) - def __radd__(self, arg): + def __radd__(self, arg: LinearExprT): return self.__add__(arg) - def __sub__(self, arg): - if mbh.is_zero(arg): - return self - return _Sum(self, -arg) + def __sub__(self, arg: LinearExprT): + if mbh.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) - def __rsub__(self, arg): - return _Sum(-self, arg) + def __rsub__(self, arg: LinearExprT): + return LinearExpr.weighted_sum([self, arg], [-1.0, 1.0], constant=0.0) - def __mul__(self, arg): + def __mul__(self, arg: NumberT): arg = mbh.assert_is_a_number(arg) if mbh.is_one(arg): return self elif mbh.is_zero(arg): - return 0 - return _ProductCst(self, arg) + return 0.0 + return self.multiply_by(arg) - def __rmul__(self, arg): + def multiply_by(self, arg: NumberT) -> LinearExprT: + raise NotImplementedError('LinearExpr.multiply_by') + + def __rmul__(self, arg: NumberT): return self.__mul__(arg) - def __div__(self, arg): + def __div__(self, arg: NumberT): coeff = mbh.assert_is_a_number(arg) - if coeff == 0.0: + if mbh.is_zero(coeff): raise ValueError( 'Cannot call the division operator with a zero divisor') return self.__mul__(1.0 / coeff) @@ -201,13 +295,15 @@ class LinearExpr: return NotImplemented def __neg__(self): - return _ProductCst(self, -1) + return self.__mul__(-1.0) def __bool__(self): raise NotImplementedError( f'Cannot use a LinearExpr {self} as a Boolean value') - def __eq__(self, arg): + def __eq__( + self, arg: Optional[LinearExprT] + ) -> Union[bool, 'BoundedLinearExpression']: if arg is None: return False if mbh.is_a_number(arg): @@ -216,189 +312,104 @@ class LinearExpr: else: return BoundedLinearExpression(self - arg, 0, 0) - def __ge__(self, arg): + def __ge__(self, arg: LinearExprT) -> 'BoundedLinearExpression': if mbh.is_a_number(arg): arg = mbh.assert_is_a_number(arg) return BoundedLinearExpression(self, arg, math.inf) else: return BoundedLinearExpression(self - arg, 0, math.inf) - def __le__(self, arg): + def __le__(self, arg: LinearExprT) -> 'BoundedLinearExpression': if mbh.is_a_number(arg): arg = mbh.assert_is_a_number(arg) return BoundedLinearExpression(self, -math.inf, arg) else: return BoundedLinearExpression(self - arg, -math.inf, 0) - def __ne__(self, arg): + def __ne__(self, arg: LinearExprT): return NotImplemented - def __lt__(self, arg): + def __lt__(self, arg: LinearExprT): return NotImplemented - def __gt__(self, arg): + def __gt__(self, arg: LinearExprT): return NotImplemented -class _Sum(LinearExpr): - """Represents the sum of two LinearExprs.""" - - def __init__(self, left, right): - for x in [left, right]: - if not mbh.is_a_number(x) and not isinstance(x, LinearExpr): - raise TypeError('Not an linear expression: ' + str(x)) - self.__left = left - self.__right = right - - @property - def left(self): - return self.__left - - @property - def right(self): - return self.__right - - def __str__(self): - return f'({self.__left} + {self.__right})' - - def __repr__(self): - return f'Sum({repr(self.__left)}, {repr(self.__right)})' - - -class _ProductCst(LinearExpr): - """Represents the product of a LinearExpr by a constant.""" - - def __init__(self, expr, coeff): - coeff = mbh.assert_is_a_number(coeff) - if isinstance(expr, _ProductCst): - self.__expr = expr.expression - self.__coef = expr.coefficient * coeff - else: - self.__expr = expr - self.__coef = coeff - - def __str__(self): - if self.__coef == -1: - return '-' + str(self.__expr) - else: - return '(' + str(self.__coef) + ' * ' + str(self.__expr) + ')' - - def __repr__(self): - return 'ProductCst(' + repr(self.__expr) + ', ' + repr( - self.__coef) + ')' - - @property - def coefficient(self): - return self.__coef - - @property - def expression(self): - return self.__expr - - -class _SumArray(LinearExpr): - """Represents the sum of a list of LinearExpr and a constant.""" - - def __init__(self, expressions, constant=0): - self.__expressions = [] - self.__constant = constant - for x in expressions: - if mbh.is_a_number(x): - if mbh.is_zero(x): - continue - x = mbh.assert_is_a_number(x) - self.__constant += x - elif isinstance(x, LinearExpr): - self.__expressions.append(x) - else: - raise TypeError('Not an linear expression: ' + str(x)) - - def __str__(self): - if self.__constant == 0: - return '({})'.format(' + '.join(map(str, self.__expressions))) - else: - return '({} + {})'.format(' + '.join(map(str, self.__expressions)), - self.__constant) - - def __repr__(self): - return 'SumArray({}, {})'.format( - ', '.join(map(repr, self.__expressions)), self.__constant) - - @property - def expressions(self): - return self.__expressions - - @property - def constant(self): - return self.__constant - - class _WeightedSum(LinearExpr): """Represents sum(ai * xi) + b.""" - def __init__(self, expressions, coefficients, constant=0.0): - self.__expressions = [] - self.__coefficients = [] - self.__constant = constant - if len(expressions) != len(coefficients): - raise TypeError( - 'In the LinearExpr.weighted_sum method, the expression array and the ' - ' coefficient array must have the same length.') - for e, c in zip(expressions, coefficients): - c = mbh.assert_is_a_number(c) - if mbh.is_zero(c): - continue - if mbh.is_a_number(e): - e = mbh.assert_is_a_number(e) - self.__constant += e * c - elif isinstance(e, LinearExpr): - self.__expressions.append(e) - self.__coefficients.append(c) - else: - raise TypeError('Not an linear expression: ' + str(e)) + def __init__( + self, + *, + variable_indices: npt.NDArray[np.int32], + coefficients: npt.NDArray[np.double], + constant: np.double = np.double(0.0), + ): + LinearExpr.__init__(self) + self.__variable_indices: npt.NDArray[np.int32] = variable_indices + self.__coefficients: npt.NDArray[ + np.double] = mbh.assert_is_a_number_array(coefficients) + self.__constant: np.double = constant - def __str__(self): - output = None - for expr, coeff in zip(self.__expressions, self.__coefficients): - if not output and mbh.is_one(coeff): - output = str(expr) - elif not output and mbh.is_minus_one(coeff): - output = '-' + str(expr) - elif not output: - output = '{} * {}'.format(coeff, str(expr)) - elif mbh.is_one(coeff): - output += ' + {}'.format(str(expr)) - elif mbh.is_minus_one(coeff): - output += ' - {}'.format(str(expr)) - elif coeff > 1: - output += ' + {} * {}'.format(coeff, str(expr)) - elif coeff < -1: - output += ' - {} * {}'.format(-coeff, str(expr)) - if self.__constant > 0: - output += ' + {}'.format(self.__constant) - elif self.__constant < 0: - output += ' - {}'.format(-self.__constant) - if output is None: - output = '0' - return output - - def __repr__(self): - return 'WeightedSum([{}], [{}], {})'.format( - ', '.join(map(repr, self.__expressions)), - ', '.join(map(repr, self.__coefficients)), self.__constant) + def multiply_by(self, arg: NumberT) -> LinearExprT: + if mbh.is_zero(arg): + return 0.0 + if self.__variable_indices.size > 0: + return _WeightedSum( + variable_indices=np.copy(self.__variable_indices), + coefficients=self.__coefficients * arg, + constant=self.__constant * arg, + ) + else: + return self.constant * arg @property - def expressions(self): - return self.__expressions + def variable_indices(self) -> npt.NDArray[np.int32]: + return self.__variable_indices @property - def coefficients(self): + def coefficients(self) -> npt.NDArray[np.double]: return self.__coefficients @property - def constant(self): + def constant(self) -> np.double: return self.__constant + def pretty_string(self, helper: pwmb.ModelBuilderHelper) -> 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}' + + if not output and mbh.is_one(coeff): + output = var_name + elif not output and mbh.is_minus_one(coeff): + output = f'-{var_name}' + elif not output: + output = f'{coeff} * {var_name}' + elif mbh.is_one(coeff): + output += f' + {var_name}' + elif mbh.is_minus_one(coeff): + output += f' - {var_name}' + elif coeff > 0.0: + output += f' + {coeff} * {var_name}' + elif coeff < 0.0: + output += ' - {-coeff} * {var_name}' + if self.constant > 0: + output += f' + {self.constant}' + elif self.constant < 0: + output += f' - {-self.constant}' + if not output: + output = '0.0' + return output + + def __repr__(self): + return (f'WeightedSum(indices = {self.variable_indices}, coefficients =' + f' {self.coefficients}, constant = {self.constant})') + class Variable(LinearExpr): """A variable (continuous or integral). @@ -413,9 +424,17 @@ class Variable(LinearExpr): model is feasible, or optimal if you provided an objective function. """ - def __init__(self, helper, lb, ub, is_integral, name): + def __init__( + self, + helper: pwmb.ModelBuilderHelper, + lb: NumberT, + ub: Optional[NumberT], + is_integral: Optional[bool], + name: Optional[str], + ): """See ModelBuilder.new_var below.""" - self.__helper = helper + LinearExpr.__init__(self) + self.__helper: pwmb.ModelBuilderHelper = helper # Python do not support multiple __init__ methods. # This method is only called from the ModelBuilder class. # We hack the parameter to support the two cases: @@ -426,12 +445,12 @@ class Variable(LinearExpr): # helper is a ModelBuilderHelper, lb is an index (int), ub is None, # is_integral is None, and name is None. if mbh.is_integral(lb) and ub is None and is_integral is None: - self.__index = np.int32(lb) - self.__helper = helper + self.__index: np.int32 = np.int32(lb) + self.__helper: pwmb.ModelBuilderHelper = helper else: - index = helper.add_var() - self.__index = np.int32(index) - self.__helper = helper + index: np.int32 = helper.add_var() + self.__index: np.int32 = np.int32(index) + self.__helper: pwmb.ModelBuilderHelper = helper helper.set_var_lower_bound(index, lb) helper.set_var_upper_bound(index, ub) helper.set_var_integrality(index, is_integral) @@ -439,22 +458,22 @@ class Variable(LinearExpr): helper.set_var_name(index, name) @property - def index(self): + def index(self) -> np.int32: """Returns the index of the variable in the helper.""" return self.__index @property - def helper(self): + def helper(self) -> pwmb.ModelBuilderHelper: """Returns the underlying ModelBuilderHelper.""" return self.__helper - def is_equal_to(self, other): + def is_equal_to(self, other: LinearExprT) -> bool: """Returns true if self == other in the python sense.""" if not isinstance(other, Variable): return False - return self.index == other.index + return self.index == other.index and self.helper == other.helper - def __str__(self): + def __str__(self) -> str: name = self.__helper.var_name(self.__index) if not name: if self.__helper.VarIsInteger(self.__index): @@ -463,7 +482,7 @@ class Variable(LinearExpr): return 'unnamed_num_var_%i' % self.__index return name - def __repr__(self): + def __repr__(self) -> str: index = self.__index name = self.__helper.var_name(index) lb = self.__helper.var_lower_bound(index) @@ -481,53 +500,54 @@ class Variable(LinearExpr): return f'unnamed_var(index={index}, lb={lb}, ub={ub})' @property - def name(self): + def name(self) -> str: + """Returns the name of the variable.""" return self.__helper.var_name(self.__index) @name.setter - def name(self, name): + def name(self, name: str) -> None: """Sets the name of the variable.""" self.__helper.set_var_name(self.__index, name) @property - def lower_bound(self): + def lower_bound(self) -> np.double: """Returns the lower bound of the variable.""" return self.__helper.var_lower_bound(self.__index) @lower_bound.setter - def lower_bound(self, bound): + def lower_bound(self, bound: NumberT) -> None: """Sets the lower bound of the variable.""" self.__helper.set_var_lower_bound(self.__index, bound) @property - def upper_bound(self): + def upper_bound(self) -> np.double: """Returns the upper bound of the variable.""" return self.__helper.var_upper_bound(self.__index) @upper_bound.setter - def upper_bound(self, bound): + def upper_bound(self, bound: NumberT) -> None: """Sets the upper bound of the variable.""" self.__helper.set_var_upper_bound(self.__index, bound) @property - def is_integral(self): + def is_integral(self) -> bool: """Returns whether the variable is integral.""" return self.__helper.var_is_integral(self.__index) @is_integral.setter - def integrality(self, is_integral): + def integrality(self, is_integral: bool) -> None: """Sets the integrality of the variable.""" self.__helper.set_var_integrality(self.__index, is_integral) @property - def objective_coefficient(self): + def objective_coefficient(self) -> NumberT: return self.__helper.var_objective_coefficient(self.__index) @objective_coefficient.setter - def objective_coefficient(self, coeff): - return self.__helper.set_var_objective_coefficient(self.__index, coeff) + def objective_coefficient(self, coeff: NumberT) -> None: + self.__helper.set_var_objective_coefficient(self.__index, coeff) - def __eq__(self, arg): + def __eq__(self, arg: Optional[LinearExprT]) -> ConstraintT: if arg is None: return False if isinstance(arg, Variable): @@ -537,9 +557,9 @@ class Variable(LinearExpr): arg = mbh.assert_is_a_number(arg) return BoundedLinearExpression(self, arg, arg) else: - return BoundedLinearExpression(self - arg, 0, 0) + return BoundedLinearExpression(self - arg, 0.0, 0.0) - def __ne__(self, arg): + def __ne__(self, arg: LinearExprT) -> ConstraintT: if arg is None: return True if isinstance(arg, Variable): @@ -549,95 +569,235 @@ class Variable(LinearExpr): def __hash__(self): return hash((self.__helper, self.__index)) + def multiply_by(self, arg: NumberT) -> LinearExprT: + return LinearExpr.weighted_sum([self], [arg], constant=0.0) -class VariableContainer: + +# TODO(user): Type slices. +_REGISTERED_NUMPY_UFUNCS: Dict[Any, NumpyFuncT] = {} + + +class VariableContainer(mixins.NDArrayOperatorsMixin): """Variable container.""" - def __init__(self, helper, indices): - self.__helper = helper - self.__indices = indices + def __init__(self, helper: pwmb.ModelBuilderHelper, + indices: npt.NDArray[np.int32]): + self.__helper: pwmb.ModelBuilderHelper = helper + self.__variable_indices: npt.NDArray[np.int32] = indices - def __getitem__(self, pos): - index_or_slice = self.__indices[pos] + @property + def variable_indices(self) -> npt.NDArray[np.int32]: + return self.__variable_indices + + def __getitem__( + self, + pos: Union[slice, int, List[int], Tuple[Union[int, slice, List[int]], + ...]], + ) -> Union['VariableContainer', Variable]: + index_or_slice = self.__variable_indices[pos] if mbh.is_integral(index_or_slice): - return Variable(self.__helper, self.__indices[pos], None, None, - None) + return Variable(self.__helper, self.__variable_indices[pos], None, + None, None) else: return VariableContainer(self.__helper, index_or_slice) - def index_at(self, pos): + def index_at( + self, + pos: Union[slice, int, List[int], Tuple[Union[int, slice, List[int]], + ...]], + ) -> Union[np.int32, npt.NDArray[np.int32]]: """Returns the index of the variable at the position 'pos'.""" - return self.__indices[pos] + return self.__variable_indices[pos] # pylint: disable=invalid-name @property - def T(self): + def T(self) -> 'VariableContainer': """Returns a view upon the transposed numpy array of variables.""" - return VariableContainer(self.__helper, self.__indices.T) + return VariableContainer(self.__helper, self.__variable_indices.T) # pylint: enable=invalid-name @property - def shape(self): + def shape(self) -> Sequence[int]: """Returns the shape of the numpy array.""" - return self.__indices.shape + return self.__variable_indices.shape @property - def size(self): + def size(self) -> int: """Returns the number of variables in the numpy array.""" - return self.__indices.size + return self.__variable_indices.size @property - def ravel(self): + def ravel(self) -> 'VariableContainer': """returns the flattened array of variables.""" - return VariableContainer(self.__helper, self.__indices.ravel()) + return VariableContainer(self.__helper, self.__variable_indices.ravel()) @property - def flatten(self): + def flatten(self) -> 'VariableContainer': """returns the flattened array of variables.""" - return VariableContainer(self.__helper, self.__indices.flatten()) + return VariableContainer(self.__helper, + self.__variable_indices.flatten()) - def __str__(self): - return f'VariableContainer({self.__indices})' + def __str__(self) -> str: + return f'VariableContainer({self.__variable_indices})' - def __repr__(self): - return f'VariableContainer({self.__helper}, {repr(self.__indices)})' + def __repr__(self) -> str: + return ( + f'VariableContainer({self.__helper}, {repr(self.__variable_indices)})' + ) + + def __len__(self): + return self.__variable_indices.shape[0] + + def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): + if method == '__call__': + if ufunc not in _REGISTERED_NUMPY_UFUNCS: + return NotImplemented + container: Optional[VariableContainer] = None + scalar: Optional[NumberT] = None + coeffs: npt.NDArray[np.double] = None + + for arg in inputs: + if mbh.is_a_number(arg): + scalar = mbh.assert_is_a_number(arg) + elif isinstance(arg, self.__class__): + container = arg + elif isinstance(arg, (Sequence, np.ndarray)): + coeffs = np.array(arg, dtype=np.double) + else: + print(f'not recognized {arg}') + return NotImplemented + + if container is None: + return NotImplemented + if coeffs is not None: + assert container.shape == coeffs.shape + function = _REGISTERED_NUMPY_UFUNCS[ufunc] + return function(container, scalar, coeffs) + else: + return NotImplemented + + def __array_function__(self, func: Any, types: Any, args: Any, + kwargs: Any) -> LinearExprT: + if func not in _REGISTERED_NUMPY_UFUNCS: + return NotImplemented + return _REGISTERED_NUMPY_UFUNCS[func](*args, **kwargs) + + +def implements(np_function: Any) -> Callable[[NumpyFuncT], NumpyFuncT]: + """Register an __array_function__ implementation for VariableContainer objects.""" + + def decorator(func: NumpyFuncT) -> NumpyFuncT: + _REGISTERED_NUMPY_UFUNCS[np_function] = func + return func + + return decorator + + +@implements(np.sum) +def sum_variable_container( + container: VariableContainer, + scalar: Optional[np.double] = None, + coeffs: Optional[npt.NDArray[np.double]] = None, +) -> LinearExprT: + """Implementation of np.sum for VariableContainer objects.""" + assert coeffs is None + indices: npt.NDArray[np.int32] = container.variable_indices + constant = scalar if scalar is not None else np.double(0.0) + return _WeightedSum( + variable_indices=indices.flatten(), + coefficients=np.ones(indices.size), + constant=constant, + ) + + +@implements(np.multiply) +def multiply_variable_container( + container: VariableContainer, + scalar: Optional[np.double] = None, + coeffs: Optional[npt.NDArray[np.double]] = None, +) -> LinearExprT: + """Implementation of np.multiply for VariableContainer objects.""" + indices: npt.NDArray[np.int32] = container.variable_indices + if scalar is not None: + assert coeffs is None + return _WeightedSum( + variable_indices=indices.flatten(), + coefficients=np.full(indices.size, scalar), + constant=0.0, + ) + if coeffs is not None: + assert container.shape == coeffs.shape + return _WeightedSum( + variable_indices=indices.flatten(), + coefficients=coeffs.flatten(), + constant=0.0, + ) + + raise TypeError('Cannot call multiply_variable_container without argument') + + +@implements(np.dot) +def dot_variable_container( + container: VariableContainer, + scalar: Optional[np.double] = None, + coeffs: Optional[npt.NDArray[np.double]] = None, +) -> LinearExprT: + """Implementation of np.dot for VariableContainer objects.""" + indices: npt.NDArray[np.int32] = container.variable_indices + if coeffs is not None: + assert scalar is None + assert container.shape == coeffs.shape + return _WeightedSum( + variable_indices=indices.flatten(), + coefficients=coeffs.flatten(), + constant=0.0, + ) + if scalar is not None: + return _WeightedSum( + variable_indices=indices.flatten(), + coefficients=np.full(indices.size, scalar), + constant=0.0, + ) + + raise TypeError('Cannot call dot_variable_container without argument') class VarCompVar: """Represents var == /!= var.""" - def __init__(self, left, right, is_equality): - self.__left = left - self.__right = right - self.__is_equality = is_equality + def __init__(self, left: Variable, right: Variable, is_equality: bool): + self.__left: Variable = left + self.__right: Variable = right + self.__is_equality: bool = is_equality - def __str__(self): + def __str__(self) -> str: if self.__is_equality: return f'{self.__left} == {self.__right}' else: return f'{self.__left} != {self.__right}' - def __repr__(self): + def __repr__(self) -> str: return f'VarCompVar({self.__left}, {self.__right}, {self.__is_equality})' @property - def left(self): + def left(self) -> Variable: return self.__left @property - def right(self): + def right(self) -> Variable: return self.__right @property - def is_equality(self): + def is_equality(self) -> bool: return self.__is_equality - def __bool__(self): + def __bool__(self) -> bool: return bool( self.__left.index == self.__right.index) == self.__is_equality +# TODO(user): investigate storing left and right expressions. class BoundedLinearExpression: """Represents a linear constraint: `lb <= linear expression <= ub`. @@ -647,12 +807,12 @@ class BoundedLinearExpression: model.Add(x + 2 * y -1 >= z) """ - def __init__(self, expr, lb, ub): - self.__expr = expr - self.__lb = lb - self.__ub = ub + def __init__(self, expr: LinearExprT, lb: NumberT, ub: NumberT): + self.__expr: LinearExprT = expr + self.__lb: np.double = mbh.assert_is_a_number(lb) + self.__ub: np.double = mbh.assert_is_a_number(ub) - def __str__(self): + def __str__(self) -> str: if self.__lb > -math.inf and self.__ub < math.inf: if self.__lb == self.__ub: return str(self.__expr) + ' == ' + str(self.__lb) @@ -667,18 +827,18 @@ class BoundedLinearExpression: return 'True (unbounded expr ' + str(self.__expr) + ')' @property - def expression(self): + def expression(self) -> LinearExprT: return self.__expr @property - def lower_bound(self): + def lower_bound(self) -> np.double: return self.__lb @property - def upper_bound(self): + def upper_bound(self) -> np.double: return self.__ub - def __bool__(self): + def __bool__(self) -> bool: raise NotImplementedError( f'Cannot use a BoundedLinearExpression {self} as a Boolean value') @@ -687,52 +847,51 @@ class LinearConstraint: """Stores a linear equation. Example: - x = model.new_num_var(0, 10, 'x') y = model.new_num_var(0, 10, 'y') - model.add(x + 2 * y == 5) + linear_constraint = model.add(x + 2 * y == 5) """ - def __init__(self, helper): - self.__index = helper.add_linear_constraint() - self.__helper = helper + def __init__(self, helper: pwmb.ModelBuilderHelper): + self.__index: np.int32 = helper.add_linear_constraint() + self.__helper: pwmb.ModelBuilderHelper = helper @property - def index(self): + def index(self) -> np.int32: """Returns the index of the constraint in the helper.""" return self.__index @property - def helper(self): + def helper(self) -> pwmb.ModelBuilderHelper: """Returns the ModelBuilderHelper instance.""" return self.__helper @property - def lower_bound(self): + def lower_bound(self) -> np.double: return self.__helper.constraint_lower_bound(self.__index) @lower_bound.setter - def lower_bound(self, bound): + def lower_bound(self, bound: NumberT) -> None: self.__helper.set_constraint_lower_bound(self.__index, bound) @property - def upper_bound(self): + def upper_bound(self) -> np.double: return self.__helper.constraint_upper_bound(self.__index) @upper_bound.setter - def upper_bound(self, bound): + def upper_bound(self, bound: NumberT) -> None: self.__helper.set_constraint_upper_bound(self.__index, bound) @property - def name(self): + def name(self) -> str: return self.__helper.constraint_name(self.__index) @name.setter - def name(self, name): + def name(self, name: str) -> None: return self.__helper.set_constraint_name(self.__index, name) - def add_term(self, var, coeff): + def add_term(self, var: Variable, coeff: NumberT) -> None: self.__helper.add_term_to_constraint(self.__index, var.index, coeff) @@ -746,16 +905,17 @@ class ModelBuilder: """ def __init__(self): - self.__helper = pwmb.ModelBuilderHelper() + self.__helper: pwmb.ModelBuilderHelper = pwmb.ModelBuilderHelper() # Integer variable. - def new_var(self, lb, ub, is_integer, name): + def new_var(self, lb: NumberT, ub: NumberT, is_integer: bool, + name: Optional[str]) -> Variable: """Create an integer variable with domain [lb, ub]. Args: - lb: Lower bound for the variable. - ub: Upper bound for the variable. + lb: Lower bound of the variable. + ub: Upper bound of the variable. is_integer: Indicates if the variable must take integral values. name: The name of the variable. @@ -765,12 +925,15 @@ class ModelBuilder: return Variable(self.__helper, lb, ub, is_integer, name) - def new_int_var(self, lb, ub, name): + def new_int_var(self, + lb: NumberT, + ub: NumberT, + name: Optional[str] = None) -> Variable: """Create an integer variable with domain [lb, ub]. Args: - lb: Lower bound for the variable. - ub: Upper bound for the variable. + lb: Lower bound of the variable. + ub: Upper bound of the variable. name: The name of the variable. Returns: @@ -779,12 +942,15 @@ class ModelBuilder: return self.new_var(lb, ub, True, name) - def new_num_var(self, lb, ub, name): + def new_num_var(self, + lb: NumberT, + ub: NumberT, + name: Optional[str] = None) -> Variable: """Create an integer variable with domain [lb, ub]. Args: - lb: Lower bound for the variable. - ub: Upper bound for the variable. + lb: Lower bound of the variable. + ub: Upper bound of the variable. name: The name of the variable. Returns: @@ -793,103 +959,242 @@ class ModelBuilder: return self.new_var(lb, ub, False, name) - def new_bool_var(self, name): + def new_bool_var(self, name: Optional[str] = None) -> Variable: """Creates a 0-1 variable with the given name.""" return self.new_var(0, 1, True, name) - def new_constant(self, value): + def new_constant(self, value: NumberT) -> Variable: """Declares a constant variable.""" - return self.new_var(value, value, False, '') + return self.new_var(value, value, False, None) - def new_var_ndarray_with_bounds(self, lbs, ubs, ints, name): - """Creates a vector of continuous variables from two vector of bounds.""" - if np.shape(lbs) != np.shape(ubs): - raise ValueError('The lbs and ubs vectors must have the same size') - if np.shape(lbs) != np.shape(ints): - raise ValueError('The lbs and ints vectors must have the same size') - var_indices = self.__helper.add_var_ndarray_with_bounds( - lbs, ubs, ints, name) - return VariableContainer(self.__helper, var_indices) - - def new_num_var_ndarray(self, shape, lb, ub, name): - """Creates a vector of continuous variables with the same bounds.""" - if mbh.is_integral(shape): + def new_var_array( + 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] - var_indices = self.__helper.add_var_ndarray(shape, lb, ub, False, name) + + if not np.isscalar(lower_bounds): + if shape is None: + shape = np.shape(lower_bounds) + elif shape != np.shape(lower_bounds): + raise ValueError( + 'ModelBuilder.new_var_array: lower_bounds must be compatible' + ' with shape') + + if not np.isscalar(upper_bounds): + if shape is None: + shape = np.shape(upper_bounds) + elif shape != np.shape(upper_bounds): + raise ValueError( + 'ModelBuilder.new_var_array: ubs must be compatible with shape' + ' or lower_bounds') + + if not np.isscalar(is_integral): + if shape is None: + shape = np.shape(is_integral) + elif shape != np.shape(is_integral): + raise ValueError( + 'ModelBuilder.new_var_array: is_integral must be compatible' + ' with shape, lower_bounds, or upper_bounds') + + if shape is None: + raise ValueError( + 'ModelBuilder.new_var_array: a shape must be defined') + + if name is None: + name = '' + + if (np.isscalar(lower_bounds) and np.isscalar(upper_bounds) and + np.isscalar(is_integral)): + var_indices = self.__helper.add_var_array(shape, lower_bounds, + upper_bounds, is_integral, + 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_ndarray_with_bounds(self, lbs, ubs, name): - """Creates a vector of continuous variables from two vector of bounds.""" - if np.shape(lbs) != np.shape(ubs): - raise ValueError('The lbs and ubs vectors must have the same size') - var_indices = self.__helper.add_var_ndarray_with_bounds( - lbs, ubs, np.zeros(len(lbs), dtype=bool), name) - return VariableContainer(self.__helper, var_indices) - - def new_int_var_ndarray(self, shape, lb, ub, name): - """Creates a vector of integer variables with the same bounds.""" - if mbh.is_integral(shape): + 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] - var_indices = self.__helper.add_var_ndarray(shape, lb, ub, True, name) + + if not np.isscalar(lower_bounds): + if shape is None: + shape = np.shape(lower_bounds) + elif shape != np.shape(lower_bounds): + raise ValueError( + 'ModelBuilder.new_num_var_array: lower_bounds must be compatible' + ' with shape') + + if not np.isscalar(upper_bounds): + if shape is None: + shape = np.shape(upper_bounds) + elif shape != np.shape(upper_bounds): + raise ValueError( + 'ModelBuilder.new_num_var_array: ubs must be compatible with shape' + ' or lower_bounds') + + if shape is None: + raise ValueError( + 'ModelBuilder.new_num_var_array: a shape must be defined') + + if name is None: + name = '' + + 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) return VariableContainer(self.__helper, var_indices) - def new_int_var_ndarray_with_bounds(self, lbs, ubs, name): - """Creates a vector of integer variables from two vector of bounds.""" - if np.shape(lbs) != np.shape(ubs): - raise ValueError('The lbs and ubs vectors must have the same size') - var_indices = self.__helper.add_var_ndarray_with_bounds( - lbs, ubs, np.ones(len(lbs), dtype=bool), name) + def new_int_var_array( + 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( + 'ModelBuilder.new_int_var_array: lower_bounds must be compatible' + ' with shape') + + if not np.isscalar(upper_bounds): + if shape is None: + shape = np.shape(upper_bounds) + elif shape != np.shape(upper_bounds): + raise ValueError( + 'ModelBuilder.new_int_var_array: upper_bounds must be compatible' + ' with shape or lower_bounds') + + if shape is None: + raise ValueError( + 'ModelBuilder.new_int_var_array: a shape must be defined') + + if name is None: + name = '' + + 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) + + # 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.ones(shape, dtype=bool), name) return VariableContainer(self.__helper, var_indices) - def new_bool_var_ndarray(self, shape, name): + def new_bool_var_array( + self, + shape: ShapeT, + name: Optional[str] = None, + ) -> VariableContainer: """Creates a vector of Boolean variables.""" if mbh.is_integral(shape): shape = [shape] - var_indices = self.__helper.add_var_ndarray(shape, 0.0, 1.0, True, name) + + if name is None: + name = '' + + var_indices = self.__helper.add_var_array(shape, 0.0, 1.0, True, name) return VariableContainer(self.__helper, var_indices) - def var_from_index(self, index): + def var_from_index(self, index: IntegerT) -> Variable: """Rebuilds a variable object from the model and its index.""" return Variable(self.__helper, index, None, None, None) @property - def num_variables(self): + def num_variables(self) -> int: """Returns the number of variables in the model.""" return self.__helper.num_variables() # Linear constraints. - def add_linear_constraint(self, - linear_expr, - lb=-math.inf, - ub=math.inf, - name=None): + def add_linear_constraint( + self, + linear_expr: LinearExprT, + lb: NumberT = -math.inf, + ub: NumberT = math.inf, + name: Optional[str] = None, + ) -> LinearConstraint: """Adds the constraint: `lb <= linear_expr <= ub` with the given name.""" ct = LinearConstraint(self.__helper) - index = ct.index - coeffs_map = {} - constant = 0.0 - if isinstance(linear_expr, LinearExpr): - coeffs_map, constant = linear_expr.get_var_value_map() - elif mbh.is_a_number(linear_expr): - constant = mbh.assert_is_a_number(linear_expr) + if name: + self.__helper.set_constraint_name(ct.index, name) + if mbh.is_a_number(linear_expr): + self.__helper.set_constraint_lower_bound(ct.index, lb - linear_expr) + self.__helper.set_constraint_upper_bound(ct.index, ub - linear_expr) + elif isinstance(linear_expr, Variable): + self.__helper.set_constraint_lower_bound(ct.index, lb) + self.__helper.set_constraint_upper_bound(ct.index, ub) + self.__helper.add_term_to_constraint(ct.index, linear_expr.index, + 1.0) + elif isinstance(linear_expr, _WeightedSum): + self.__helper.set_constraint_lower_bound(ct.index, + lb - linear_expr.constant) + self.__helper.set_constraint_upper_bound(ct.index, + ub - linear_expr.constant) + self.__helper.add_terms_to_constraint(ct.index, + linear_expr.variable_indices, + linear_expr.coefficients) else: raise TypeError( - 'Not supported: ModelBuilder.add_linear_constraint(' + - f'{lb} <= {linear_expr} <= {ub})') - - for t in coeffs_map.items(): - if not isinstance(t[0], Variable): - raise TypeError('Wrong argument' + str(t)) - c = mbh.assert_is_a_number(t[1]) - self.__helper.add_term_to_constraint(index, t[0].index, c) - self.__helper.set_constraint_lower_bound(index, lb - constant) - self.__helper.set_constraint_upper_bound(index, ub - constant) - if name: - self.__helper.set_constraint_name(index, name) + f'Not supported: ModelBuilder.add_linear_constraint({linear_expr})' + f' with type {type(linear_expr)}') return ct - def add(self, ct, name=None): + def add(self, + ct: ConstraintT, + name: Optional[str] = None) -> LinearConstraint: """Adds a `BoundedLinearExpression` to the model. Args: @@ -922,79 +1227,74 @@ class ModelBuilder: raise TypeError('Not supported: ModelBuilder.Add(' + str(ct) + ')') @property - def num_constraints(self): + def num_constraints(self) -> int: return self.__helper.num_constraints() # Objective. - def minimize(self, linear_expr): + def minimize(self, linear_expr: LinearExprT) -> None: self.__optimize(linear_expr, False) - def maximize(self, linear_expr): + def maximize(self, linear_expr: LinearExprT) -> None: self.__optimize(linear_expr, True) - def __optimize(self, linear_expr, maximize): + def __optimize(self, linear_expr: LinearExprT, maximize: bool) -> None: """Defines the objective.""" self.helper.clear_objective() - coeffs_map = {} - # constant = 0.0 - if isinstance(linear_expr, LinearExpr): - coeffs_map, constant = linear_expr.get_var_value_map() - elif mbh.is_a_number(linear_expr): - constant = mbh.assert_is_a_number(linear_expr) + self.__helper.set_maximize(maximize) + if mbh.is_a_number(linear_expr): + self.helper.set_objective_offset(linear_expr) + elif isinstance(linear_expr, Variable): + 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( + linear_expr.variable_indices, linear_expr.coefficients) else: raise TypeError( f'Not supported: ModelBuilder.minimize/maximize({linear_expr})') - for t in coeffs_map.items(): - if not isinstance(t[0], Variable): - raise TypeError('Wrong argument' + str(t)) - c = mbh.assert_is_a_number(t[1]) - self.__helper.set_var_objective_coefficient(t[0].index, c) - self.__helper.set_objective_offset(constant) - self.__helper.set_maximize(maximize) - @property - def objective_offset(self): + def objective_offset(self) -> np.double: return self.__helper.objective_offset() @objective_offset.setter - def objective_offset(self, value): + def objective_offset(self, value: NumberT) -> None: self.__helper.set_objective_offset(value) # Input/Output - def export_to_lp_string(self, obfuscate=False): - options = pwmb.MPModelExportOptions() + def export_to_lp_string(self, obfuscate: bool = False) -> str: + options: pwmb.MPModelExportOptions = pwmb.MPModelExportOptions() options.obfuscate = obfuscate return self.__helper.export_to_lp_string(options) - def export_to_mps_string(self, obfuscate=False): - options = pwmb.MPModelExportOptions() + def export_to_mps_string(self, obfuscate: bool = False) -> str: + options: pwmb.MPModelExportOptions = pwmb.MPModelExportOptions() options.obfuscate = obfuscate return self.__helper.export_to_mps_string(options) - def import_from_mps_string(self, mps_string): + def import_from_mps_string(self, mps_string: str) -> bool: return self.__helper.import_from_mps_string(mps_string) - def import_from_mps_file(self, mps_file): + def import_from_mps_file(self, mps_file: str) -> bool: return self.__helper.import_from_mps_file(mps_file) - def import_from_lp_string(self, lp_string): + def import_from_lp_string(self, lp_string: str) -> bool: return self.__helper.import_from_lp_string(lp_string) - def import_from_lp_file(self, lp_file): + def import_from_lp_file(self, lp_file: str) -> bool: return self.__helper.import_from_lp_file(lp_file) # Utilities @property - def name(self): + def name(self) -> str: return self.__helper.name() @name.setter - def name(self, name): + def name(self, name: str): self.__helper.set_name(name) @property - def helper(self): + def helper(self) -> pwmb.ModelBuilderHelper: """Returns the model builder helper.""" return self.__helper @@ -1010,28 +1310,29 @@ class ModelSolver: procedure. """ - def __init__(self, solver_name): - self.__solve_helper = pwmb.ModelSolverHelper(solver_name) - self.log_callback = None + def __init__(self, solver_name: str): + self.__solve_helper: pwmb.ModelSolverHelper = pwmb.ModelSolverHelper( + solver_name) + self.log_callback: Optional[Callable[[str], None]] = None - def solver_is_supported(self): + def solver_is_supported(self) -> bool: """Checks whether the requested solver backend was found.""" return self.__solve_helper.solver_is_supported() # Solver backend and parameters. - def set_time_limit_in_seconds(self, limit): + def set_time_limit_in_seconds(self, limit: NumberT) -> None: """Sets a time limit for the solve() call.""" self.__solve_helper.set_time_limit_in_seconds(limit) - def set_solver_specific_parameters(self, parameters): + def set_solver_specific_parameters(self, parameters: str) -> None: """Sets parameters specific to the solver backend.""" self.__solve_helper.set_solver_specific_parameters(parameters) - def enable_output(self, enabled): + def enable_output(self, enabled: bool) -> None: """Controls the solver backend logs.""" self.__solve_helper.enable_output(enabled) - def solve(self, model): + def solve(self, model: ModelBuilder) -> SolveStatus: """Solves a problem and passes each solution to the callback if not null.""" if self.log_callback is not None: self.__solve_helper.set_log_callback(self.log_callback) @@ -1040,7 +1341,7 @@ class ModelSolver: self.__solve_helper.solve(model.helper) return SolveStatus(self.__solve_helper.status()) - def __check_has_feasible_solution(self): + 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( @@ -1050,35 +1351,44 @@ class ModelSolver: """Stops the current search asynchronously.""" self.__solve_helper.interrupt_solve() - def value(self, var): + def value(self, expr: LinearExprT) -> np.double: """Returns the value of a linear expression after solve.""" self.__check_has_feasible_solution() - return self.__solve_helper.var_value(var.index) + if mbh.is_a_number(expr): + return expr + elif isinstance(expr, Variable): + return self.__solve_helper.var_value(expr.index) + elif isinstance(expr, _WeightedSum): + return self.__solve_helper.expression_value(expr.variable_indices, + expr.coefficients, + expr.constant) + else: + raise TypeError(f'Unknown expression {expr!r} of type {type(expr)}') - def reduced_cost(self, var): + def reduced_cost(self, var: Variable) -> np.double: """Returns the reduced cost of a linear expression after solve.""" self.__check_has_feasible_solution() return self.__solve_helper.reduced_cost(var.index) - def dual_value(self, ct): + def dual_value(self, ct: LinearConstraint) -> np.double: """Returns the dual value of a linear constraint after solve.""" self.__check_has_feasible_solution() return self.__solve_helper.dual_value(ct.index) @property - def objective_value(self): + def objective_value(self) -> np.double: """Returns the value of the objective after solve.""" self.__check_has_feasible_solution() return self.__solve_helper.objective_value() @property - def best_objective_bound(self): + def best_objective_bound(self) -> np.double: """Returns the best lower (upper) bound found when min(max)imizing.""" self.__check_has_feasible_solution() return self.__solve_helper.best_objective_bound() @property - def status_string(self): + def status_string(self) -> str: """Returns additional information of the last solve. It can describe why the model is invalid. @@ -1086,9 +1396,9 @@ class ModelSolver: return self.__solve_helper.status_string() @property - def wall_time(self): + def wall_time(self) -> np.double: return self.__solve_helper.wall_time() @property - def user_time(self): + def user_time(self) -> np.double: return self.__solve_helper.user_time() diff --git a/ortools/linear_solver/python/model_builder_helper.py b/ortools/linear_solver/python/model_builder_helper.py index cdc24ad33d..41c6177514 100644 --- a/ortools/linear_solver/python/model_builder_helper.py +++ b/ortools/linear_solver/python/model_builder_helper.py @@ -13,43 +13,56 @@ """helpers methods for the cp_model_builder module.""" import numbers +from typing import Any, Sequence, Union import numpy as np +import numpy.typing as npt + +# Custom types. +NumberT = Union[numbers.Number, np.number] -def is_integral(x): +def is_integral(x: Any) -> bool: """Checks if x has either a number.Integral or a np.integer type.""" return isinstance(x, numbers.Integral) or isinstance(x, np.integer) -def is_a_number(x): +def is_a_number(x: Any) -> bool: """Checks if x has either a number.Number or a np.double type.""" return isinstance(x, numbers.Number) or isinstance( x, np.double) or isinstance(x, np.integer) -def is_zero(x): +def is_zero(x: Any) -> bool: """Checks if the x is 0 or 0.0.""" return (is_integral(x) and int(x) == 0) or (is_a_number(x) and float(x) == 0.0) -def is_one(x): +def is_one(x: Any) -> bool: """Checks if x is 1 or 1.0.""" return (is_integral(x) and int(x) == 1) or (is_a_number(x) and float(x) == 1.0) -def is_minus_one(x): +def is_minus_one(x: Any) -> bool: """Checks if x is -1 or -1.0.""" return (is_integral(x) and int(x) == -1) or (is_a_number(x) and float(x) == -1.0) -def assert_is_a_number(x): - """Asserts that x is a number and returns it.""" +def assert_is_a_number(x: NumberT) -> np.double: + """Asserts that x is a number and converts to a np.double.""" if not is_a_number(x): raise TypeError('Not a number: %s' % x) - elif is_integral(x): - return int(x) - else: - return float(x) + return np.double(x) + + +def assert_is_a_number_array(x: Sequence[NumberT]) -> npt.NDArray[np.double]: + """Asserts x is a list of numbers and converts it to np.array(np.double).""" + result = np.empty(len(x), dtype=np.double) + pos = 0 + for c in x: + result[pos] = assert_is_a_number(c) + pos += 1 + assert pos == len(x) + return result diff --git a/ortools/linear_solver/python/model_builder_helper_test.py b/ortools/linear_solver/python/model_builder_helper_test.py index fc0585cbcb..4d1d25e087 100644 --- a/ortools/linear_solver/python/model_builder_helper_test.py +++ b/ortools/linear_solver/python/model_builder_helper_test.py @@ -168,7 +168,7 @@ class PywrapModelBuilderHelperTest(unittest.TestCase): self.assertEqual([0], model.constraint_var_indices(1)) self.assertEqual([2.0], model.constraint_coefficients(1)) - var_array = model.add_var_ndarray([10], 1.0, 5.0, True, 'var_') + var_array = model.add_var_array([10], 1.0, 5.0, True, 'var_') self.assertEqual(1, var_array.ndim) self.assertEqual(10, var_array.size) self.assertEqual((10,), var_array.shape) diff --git a/ortools/linear_solver/python/model_builder_test.py b/ortools/linear_solver/python/model_builder_test.py index efbd095e50..2ae0c1fa30 100644 --- a/ortools/linear_solver/python/model_builder_test.py +++ b/ortools/linear_solver/python/model_builder_test.py @@ -11,13 +11,14 @@ # 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. -"""Tests for model_builder.""" +"""Tests for ModelBuilder.""" import math import numpy as np +import numpy.testing as np_testing import os -from ortools.linear_solver.python import model_builder +from ortools.linear_solver.python import model_builder as mb import unittest @@ -29,7 +30,7 @@ class ModelBuilderTest(unittest.TestCase): # pylint: disable=too-many-statements def run_minimal_linear_example(self, solver_name): """Minimal Linear Example.""" - model = model_builder.ModelBuilder() + model = mb.ModelBuilder() model.name = 'minimal_linear_example' x1 = model.new_num_var(0.0, math.inf, 'x1') x2 = model.new_num_var(0.0, math.inf, 'x2') @@ -54,13 +55,18 @@ class ModelBuilderTest(unittest.TestCase): c2 = model.add(2.0 * x1 + 2.0 * x2 + 6.0 * x3 <= 300.0) self.assertEqual(-math.inf, c2.lower_bound) - solver = model_builder.ModelSolver(solver_name) - self.assertEqual(model_builder.SolveStatus.OPTIMAL, solver.solve(model)) + solver = mb.ModelSolver(solver_name) + self.assertEqual(mb.SolveStatus.OPTIMAL, solver.solve(model)) # The problem has an optimal solution. self.assertAlmostEqual(733.333333 + model.objective_offset, solver.objective_value, places=self.NUM_PLACES) + self.assertAlmostEqual( + solver.value(10.0 * x1 + 6 * x2 + 4.0 * x3 - 5.5), + solver.objective_value, + places=self.NUM_PLACES, + ) self.assertAlmostEqual(33.333333, solver.value(x1), places=self.NUM_PLACES) @@ -120,14 +126,14 @@ BOUNDS UP BOUND X_ONE 4 ENDATA """ - model = model_builder.ModelBuilder() + model = mb.ModelBuilder() self.assertTrue(model.import_from_mps_string(mps_data)) self.assertEqual(model.name, 'SupportedMaximizationProblem') def test_import_from_mps_file(self): path = os.path.dirname(__file__) mps_path = f'{path}/../testdata/maximization.mps' - model = model_builder.ModelBuilder() + model = mb.ModelBuilder() self.assertTrue(model.import_from_mps_file(mps_path)) self.assertEqual(model.name, 'SupportedMaximizationProblem') @@ -140,7 +146,7 @@ ENDATA 4 y + b2 - 3 b3 <= 2; constraint_num2: -4 b1 + b2 - 3 z <= -2; """ - model = model_builder.ModelBuilder() + model = mb.ModelBuilder() self.assertTrue(model.import_from_lp_string(lp_data)) self.assertEqual(6, model.num_variables) self.assertEqual(3, model.num_constraints) @@ -151,7 +157,7 @@ ENDATA def test_import_from_lp_file(self): path = os.path.dirname(__file__) lp_path = f'{path}/../testdata/small_model.lp' - model = model_builder.ModelBuilder() + model = mb.ModelBuilder() self.assertTrue(model.import_from_lp_file(lp_path)) self.assertEqual(6, model.num_variables) self.assertEqual(3, model.num_constraints) @@ -159,8 +165,77 @@ ENDATA self.assertEqual(42, model.var_from_index(0).upper_bound) self.assertEqual('x', model.var_from_index(0).name) + def test_class_api(self): + model = mb.ModelBuilder() + x = model.new_int_var(0, 10, 'x') + y = model.new_int_var(1, 10, 'y') + z = model.new_int_var(2, 10, 'z') + t = model.new_int_var(3, 10, 't') + + e1 = mb.LinearExpr.sum([x, y, z]) + expected_vars = np.array([0, 1, 2], dtype=np.int32) + np_testing.assert_array_equal(expected_vars, e1.variable_indices) + np_testing.assert_array_equal(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') + + e2 = mb.LinearExpr.sum([e1, 4.0]) + np_testing.assert_array_equal(expected_vars, e2.variable_indices) + np_testing.assert_array_equal(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') + + e3 = mb.LinearExpr.term(e2, 2) + np_testing.assert_array_equal(expected_vars, e3.variable_indices) + np_testing.assert_array_equal(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') + + e4 = mb.LinearExpr.weighted_sum([x, t], [-1, 1], constant=2) + np_testing.assert_array_equal(np.array([0, 3], dtype=np.int32), + e4.variable_indices) + np_testing.assert_array_equal(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') + + e4b = e4 * 3.0 + np_testing.assert_array_equal(np.array([0, 3], dtype=np.int32), + e4b.variable_indices) + np_testing.assert_array_equal(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') + + e5 = mb.LinearExpr.sum([e1, -3, e4]) + np_testing.assert_array_equal(np.array([0, 1, 2, 0, 3], dtype=np.int32), + e5.variable_indices) + np_testing.assert_array_equal( + 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') + + e6 = mb.LinearExpr.term(x, 2.0, constant=1.0) + np_testing.assert_array_equal(np.array([0], dtype=np.int32), + e6.variable_indices) + np_testing.assert_array_equal(np.array([2], dtype=np.double), + e6.coefficients) + self.assertEqual(e6.constant, 1.0) + + e7 = mb.LinearExpr.term(x, 1.0, constant=0.0) + self.assertEqual(x, e7) + + e8 = mb.LinearExpr.term(2, 3, constant=4) + self.assertEqual(e8, 10) + def test_variables(self): - model = model_builder.ModelBuilder() + model = mb.ModelBuilder() x = model.new_int_var(0.0, 4.0, 'x') self.assertEqual(0, x.index) self.assertEqual(0.0, x.lower_bound) @@ -180,32 +255,59 @@ ENDATA self.assertNotEqual(x, y) # array - xs = model.new_int_var_ndarray(10, 0.0, 5.0, 'xs_') + 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 = np.array([3.0, 4.0, 5.0]) - ys = model.new_int_var_ndarray_with_bounds(lbs, ubs, 'ys_') + 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_ndarray_with_bounds([1.0, 2.0, 3], [4, 4, 4], - 'zs_') + 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_ndarray([4, 5], 'bs_') + 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.assertEqual((20,), bs.ravel.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)) + times_bs = np.multiply(bs, 4) + np_testing.assert_array_equal(times_bs.variable_indices, + bs.variable_indices.flatten()) + np_testing.assert_array_equal(times_bs.coefficients, np.full(20, 4.0)) + times_bs_rev = np.multiply(4, bs) + np_testing.assert_array_equal(times_bs_rev.variable_indices, + bs.variable_indices.flatten()) + np_testing.assert_array_equal(times_bs_rev.coefficients, + np.full(20, 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) @@ -213,15 +315,187 @@ ENDATA 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 = model_builder.ModelBuilder() + model = mb.ModelBuilder() x = model.new_int_var(0.0, 4.0, 'x') y = model.new_int_var(0.0, 4.0, 'y') z = model.new_int_var(0.0, 4.0, 'z') model.add(x + 2 * y == x - z) model.minimize(x + y + z) - solver = model_builder.ModelSolver('scip') - self.assertEqual(model_builder.SolveStatus.OPTIMAL, solver.solve(model)) + solver = mb.ModelSolver('scip') + self.assertEqual(mb.SolveStatus.OPTIMAL, solver.solve(model)) def test_issue_3614(self): total_number_of_choices = 5 + 1 @@ -235,7 +509,7 @@ ENDATA bundle_start_idx = len(standalone_features) # Model - model = model_builder.ModelBuilder() + model = mb.ModelBuilder() y = {} v = {} for i in range(total_number_of_choices): @@ -248,12 +522,12 @@ ENDATA (feature_bundle_incidence_matrix[(i, 0)] * y[bundle_start_idx]))) - solver = model_builder.ModelSolver('scip') + solver = mb.ModelSolver('scip') status = solver.solve(model) - self.assertEqual(model_builder.SolveStatus.OPTIMAL, status) + self.assertEqual(mb.SolveStatus.OPTIMAL, status) def test_varcompvar(self): - model = model_builder.ModelBuilder() + model = mb.ModelBuilder() x = model.new_int_var(0.0, 4.0, 'x') y = model.new_int_var(0.0, 4.0, 'y') ct = x == y diff --git a/ortools/linear_solver/python/pywrap_model_builder_helper.cc b/ortools/linear_solver/python/pywrap_model_builder_helper.cc index de3bce8e6a..5e14e327a2 100644 --- a/ortools/linear_solver/python/pywrap_model_builder_helper.cc +++ b/ortools/linear_solver/python/pywrap_model_builder_helper.cc @@ -13,13 +13,19 @@ // A pybind11 wrapper for model_builder_helper. +#include +#include +#include #include #include #include +#include +#include #include #include "Eigen/Core" #include "Eigen/SparseCore" +#include "absl/log/check.h" #include "absl/strings/str_cat.h" #include "absl/strings/string_view.h" #include "ortools/linear_solver/linear_solver.pb.h" @@ -106,6 +112,38 @@ void BuildModelFromSparseData( } } +std::vector> SortedGroupedTerms( + const std::vector& indices, const std::vector& coefficients) { + CHECK_EQ(indices.size(), coefficients.size()); + std::vector> terms; + terms.reserve(indices.size()); + for (int i = 0; i < indices.size(); ++i) { + terms.emplace_back(indices[i], coefficients[i]); + } + std::sort( + terms.begin(), terms.end(), + [](const std::pair& a, const std::pair& b) { + if (a.first != b.first) return a.first < b.first; + if (std::abs(a.second) != std::abs(b.second)) { + return std::abs(a.second) < std::abs(b.second); + } + return a.second < b.second; + }); + int pos = 0; + for (int i = 0; i < terms.size(); ++i) { + if (i == 0 || terms[i].first != terms[i - 1].first) { + if (i != pos) { + terms[pos] = terms[i]; + } + pos++; + } else { + terms[pos].second += terms[i].second; + } + } + terms.resize(pos); + return terms; +} + PYBIND11_MODULE(pywrap_model_builder_helper, m) { py::class_(m, "MPModelExportOptions") .def(py::init<>()) @@ -151,7 +189,7 @@ PYBIND11_MODULE(pywrap_model_builder_helper, m) { arg("objective_coefficients"), arg("constraint_lower_bounds"), arg("constraint_upper_bounds"), arg("constraint_matrix")) .def("add_var", &ModelBuilderHelper::AddVar) - .def("add_var_ndarray", + .def("add_var_array", [](ModelBuilderHelper* helper, std::vector shape, double lb, double ub, bool is_integral, absl::string_view name_prefix) { int size = shape[0]; @@ -174,17 +212,13 @@ PYBIND11_MODULE(pywrap_model_builder_helper, m) { } return result; }) - .def("add_var_ndarray_with_bounds", + .def("add_var_array_with_bounds", [](ModelBuilderHelper* helper, py::array_t lbs, py::array_t ubs, py::array_t are_integral, absl::string_view name_prefix) { py::buffer_info buf_lbs = lbs.request(); py::buffer_info buf_ubs = ubs.request(); py::buffer_info buf_are_integral = are_integral.request(); - if (buf_lbs.ndim != 1 || buf_ubs.ndim != 1 || - buf_are_integral.ndim != 1) { - throw std::runtime_error("Number of dimensions must be one"); - } const int size = buf_lbs.size; if (size != buf_ubs.size || size != buf_are_integral.size) { throw std::runtime_error("Input sizes must match"); @@ -222,6 +256,14 @@ PYBIND11_MODULE(pywrap_model_builder_helper, m) { .def("set_var_objective_coefficient", &ModelBuilderHelper::SetVarObjectiveCoefficient, arg("var_index"), arg("coeff")) + .def("set_objective_coefficients", + [](ModelBuilderHelper* helper, const std::vector& indices, + const std::vector& coefficients) { + for (const auto& [i, c] : + SortedGroupedTerms(indices, coefficients)) { + helper->SetVarObjectiveCoefficient(i, c); + } + }) .def("set_var_name", &ModelBuilderHelper::SetVarName, arg("var_index"), arg("name")) .def("add_linear_constraint", &ModelBuilderHelper::AddLinearConstraint) @@ -233,6 +275,15 @@ PYBIND11_MODULE(pywrap_model_builder_helper, m) { arg("ub")) .def("add_term_to_constraint", &ModelBuilderHelper::AddConstraintTerm, arg("ct_index"), arg("var_index"), arg("coeff")) + .def("add_terms_to_constraint", + [](ModelBuilderHelper* helper, int ct_index, + const std::vector& indices, + const std::vector& coefficients) { + for (const auto& [i, c] : + SortedGroupedTerms(indices, coefficients)) { + helper->AddConstraintTerm(ct_index, i, c); + } + }) .def("set_constraint_name", &ModelBuilderHelper::SetConstraintName, arg("ct_index"), arg("name")) .def("num_variables", &ModelBuilderHelper::num_variables) @@ -339,7 +390,10 @@ PYBIND11_MODULE(pywrap_model_builder_helper, m) { .def("dual_value", &ModelSolverHelper::dual_value, arg("ct_index")) .def("variable_values", [](const ModelSolverHelper& helper) { - if (!helper.has_response()) return Eigen::VectorXd(); + if (!helper.has_response()) { + throw std::logic_error( + "Accessing a solution value when none has been found."); + } const MPSolutionResponse& response = helper.response(); Eigen::VectorXd vec(response.variable_value_size()); for (int i = 0; i < response.variable_value_size(); ++i) { @@ -347,9 +401,26 @@ PYBIND11_MODULE(pywrap_model_builder_helper, m) { } return vec; }) + .def("expression_value", + [](const ModelSolverHelper& helper, const std::vector& indices, + const std::vector& coefficients, double constant) { + if (!helper.has_response()) { + throw std::logic_error( + "Accessing a solution value when none has been found."); + } + const MPSolutionResponse& response = helper.response(); + for (int i = 0; i < indices.size(); ++i) { + constant += + response.variable_value(indices[i]) * coefficients[i]; + } + return constant; + }) .def("reduced_costs", [](const ModelSolverHelper& helper) { - if (!helper.has_response()) return Eigen::VectorXd(); + if (!helper.has_response()) { + throw std::logic_error( + "Accessing a solution value when none has been found."); + } const MPSolutionResponse& response = helper.response(); Eigen::VectorXd vec(response.reduced_cost_size()); for (int i = 0; i < response.reduced_cost_size(); ++i) { @@ -358,7 +429,10 @@ PYBIND11_MODULE(pywrap_model_builder_helper, m) { return vec; }) .def("dual_values", [](const ModelSolverHelper& helper) { - if (!helper.has_response()) return Eigen::VectorXd(); + if (!helper.has_response()) { + throw std::logic_error( + "Accessing a solution value when none has been found."); + } const MPSolutionResponse& response = helper.response(); Eigen::VectorXd vec(response.dual_value_size()); for (int i = 0; i < response.dual_value_size(); ++i) { diff --git a/ortools/linear_solver/samples/assignment_mb.py b/ortools/linear_solver/samples/assignment_mb.py index a413f9c1c0..6504540606 100644 --- a/ortools/linear_solver/samples/assignment_mb.py +++ b/ortools/linear_solver/samples/assignment_mb.py @@ -14,6 +14,8 @@ """MIP example that solves an assignment problem.""" # [START program] # [START import] +import numpy as np + from ortools.linear_solver.python import model_builder # [END import] @@ -41,30 +43,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 = {} - for i in range(num_workers): - for j in range(num_tasks): - x[i, j] = model.new_bool_var(f'x_{i}_{j}') + x = model.new_bool_var_array(shape=[num_workers, num_tasks], name='x') # [END variables] # Constraints # [START constraints] # Each worker is assigned to at most 1 task. for i in range(num_workers): - model.add(sum(x[i, j] for j in range(num_tasks)) <= 1) + model.add(np.sum(x[i, :]) <= 1) # Each task is assigned to exactly one worker. for j in range(num_tasks): - model.add(sum(x[i, j] for i in range(num_workers)) == 1) + model.add(np.sum(x[:, j]) == 1) # [END constraints] # Objective # [START objective] - objective_expr = 0 - for i in range(num_workers): - for j in range(num_tasks): - objective_expr += costs[i][j] * x[i, j] - model.minimize(objective_expr) + model.minimize(np.multiply(x, costs)) # [END objective] # [START solve] diff --git a/ortools/linear_solver/samples/bin_packing_mb.py b/ortools/linear_solver/samples/bin_packing_mb.py index 40678a3ad4..499aa84c6b 100644 --- a/ortools/linear_solver/samples/bin_packing_mb.py +++ b/ortools/linear_solver/samples/bin_packing_mb.py @@ -14,6 +14,8 @@ """Solve a simple bin packing problem using a MIP solver.""" # [START program] # [START import] +import numpy as np + from ortools.linear_solver.python import model_builder # [END import] @@ -36,6 +38,8 @@ def create_data_model(): def main(): # [START data] data = create_data_model() + num_items = len(data['items']) + num_bins = len(data['bins']) # [END data] # [END program_part1] @@ -48,33 +52,27 @@ def main(): # [START variables] # Variables # x[i, j] = 1 if item i is packed in bin j. - x = {} - for i in data['items']: - for j in data['bins']: - x[(i, j)] = model.new_bool_var(f'x_{i}_{j}') + x = model.new_bool_var_array(shape=[num_items, num_bins], name='x') # y[j] = 1 if bin j is used. - y = {} - for j in data['bins']: - y[j] = model.new_bool_var(f'y_{j}') + y = model.new_bool_var_array(shape=[num_bins], name='y') # [END variables] # [START constraints] # Constraints # Each item must be in exactly one bin. for i in data['items']: - model.add(sum(x[i, j] for j in data['bins']) == 1) + model.add(np.sum(x[i, :]) == 1) # The amount packed in each bin cannot exceed its capacity. for j in data['bins']: model.add( - sum(x[(i, j)] * data['weights'][i] for i in data['items']) <= y[j] * - data['bin_capacity']) + np.dot(x[:, j], data['weights']) <= y[j] * data['bin_capacity']) # [END constraints] # [START objective] # Objective: minimize the number of bins used. - model.minimize(model_builder.LinearExpr.sum([y[j] for j in data['bins']])) + model.minimize(np.sum(y)) # [END objective] # [START solve]