diff --git a/pyomo/contrib/cp/repn/docplex_writer.py b/pyomo/contrib/cp/repn/docplex_writer.py index 51c3f66140e..c2687662fe8 100644 --- a/pyomo/contrib/cp/repn/docplex_writer.py +++ b/pyomo/contrib/cp/repn/docplex_writer.py @@ -64,7 +64,7 @@ IndexedBooleanVar, ) from pyomo.core.base.expression import ScalarExpression, _GeneralExpressionData -from pyomo.core.base.param import IndexedParam, ScalarParam +from pyomo.core.base.param import IndexedParam, ScalarParam, _ParamData from pyomo.core.base.var import ScalarVar, _GeneralVarData, IndexedVar import pyomo.core.expr as EXPR from pyomo.core.expr.visitor import StreamBasedExpressionVisitor, identify_variables @@ -805,6 +805,14 @@ def _handle_at_least_node(visitor, node, *args): ) +def _handle_all_diff_node(visitor, node, *args): + return (_GENERAL, cp.all_diff(_get_int_valued_expr(arg) for arg in args)) + + +def _handle_count_if_node(visitor, node, *args): + return (_GENERAL, cp.count((_get_bool_valued_expr(arg) for arg in args), 1)) + + ## CallExpression handllers @@ -932,6 +940,8 @@ class LogicalToDoCplex(StreamBasedExpressionVisitor): EXPR.ExactlyExpression: _handle_exactly_node, EXPR.AtMostExpression: _handle_at_most_node, EXPR.AtLeastExpression: _handle_at_least_node, + EXPR.AllDifferentExpression: _handle_all_diff_node, + EXPR.CountIfExpression: _handle_count_if_node, EXPR.EqualityExpression: _handle_equality_node, EXPR.NotEqualExpression: _handle_not_equal_node, EXPR.InequalityExpression: _handle_inequality_node, @@ -960,6 +970,7 @@ class LogicalToDoCplex(StreamBasedExpressionVisitor): ScalarExpression: _before_named_expression, IndexedParam: _before_indexed_param, # Because of indirection ScalarParam: _before_param, + _ParamData: _before_param, } def __init__(self, cpx_model, symbolic_solver_labels=False): diff --git a/pyomo/contrib/cp/tests/test_docplex_walker.py b/pyomo/contrib/cp/tests/test_docplex_walker.py index 97bc538c827..b897053c93a 100644 --- a/pyomo/contrib/cp/tests/test_docplex_walker.py +++ b/pyomo/contrib/cp/tests/test_docplex_walker.py @@ -21,7 +21,14 @@ from pyomo.core.base.range import NumericRange from pyomo.core.expr.numeric_expr import MinExpression, MaxExpression -from pyomo.core.expr.logical_expr import equivalent, exactly, atleast, atmost +from pyomo.core.expr.logical_expr import ( + equivalent, + exactly, + atleast, + atmost, + all_different, + count_if, +) from pyomo.core.expr.relational_expr import NotEqualExpression from pyomo.environ import ( @@ -401,6 +408,38 @@ def test_atmost_expression(self): expr[1].equals(cp.less_or_equal(cp.count([a[i] == 4 for i in m.I], 1), 3)) ) + def test_all_diff_expression(self): + m = self.get_model() + m.a.domain = Integers + m.a.bounds = (11, 20) + m.c = LogicalConstraint(expr=all_different(m.a)) + + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.body, m.c, 0)) + + a = {} + for i in m.I: + self.assertIn(id(m.a[i]), visitor.var_map) + a[i] = visitor.var_map[id(m.a[i])] + + self.assertTrue(expr[1].equals(cp.all_diff(a[i] for i in m.I))) + + def test_count_if_expression(self): + m = self.get_model() + m.a.domain = Integers + m.a.bounds = (11, 20) + m.c = Constraint(expr=count_if(m.a[i] == i for i in m.I) == 5) + + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.expr, m.c, 0)) + + a = {} + for i in m.I: + self.assertIn(id(m.a[i]), visitor.var_map) + a[i] = visitor.var_map[id(m.a[i])] + + self.assertTrue(expr[1].equals(cp.count((a[i] == i for i in m.I), 1) == 5)) + def test_interval_var_is_present(self): m = self.get_model() m.a.domain = Integers diff --git a/pyomo/contrib/cp/tests/test_docplex_writer.py b/pyomo/contrib/cp/tests/test_docplex_writer.py index d569ef2e696..b563052ef3a 100644 --- a/pyomo/contrib/cp/tests/test_docplex_writer.py +++ b/pyomo/contrib/cp/tests/test_docplex_writer.py @@ -15,10 +15,13 @@ from pyomo.contrib.cp import IntervalVar, Pulse, Step, AlwaysIn from pyomo.contrib.cp.repn.docplex_writer import LogicalToDoCplex from pyomo.environ import ( + all_different, + count_if, ConcreteModel, Set, Var, Integers, + Param, LogicalConstraint, implies, value, @@ -254,3 +257,138 @@ def x_bounds(m, i): self.assertEqual(results.problem.sense, minimize) self.assertEqual(results.problem.lower_bound, 6) self.assertEqual(results.problem.upper_bound, 6) + + def test_matching_problem(self): + m = ConcreteModel() + + m.People = Set(initialize=['P1', 'P2', 'P3', 'P4', 'P5', 'P6', 'P7']) + m.Languages = Set(initialize=['English', 'Spanish', 'Hindi', 'Swedish']) + # People have integer names because we don't have categorical vars yet. + m.Names = Set(initialize=range(len(m.People))) + + m.Observed = Param( + m.Names, + m.Names, + m.Languages, + initialize={ + (0, 1, 'English'): 1, + (1, 0, 'English'): 1, + (0, 2, 'English'): 1, + (2, 0, 'English'): 1, + (0, 3, 'English'): 1, + (3, 0, 'English'): 1, + (0, 4, 'English'): 1, + (4, 0, 'English'): 1, + (0, 5, 'English'): 1, + (5, 0, 'English'): 1, + (0, 6, 'English'): 1, + (6, 0, 'English'): 1, + (1, 2, 'Spanish'): 1, + (2, 1, 'Spanish'): 1, + (1, 5, 'Hindi'): 1, + (5, 1, 'Hindi'): 1, + (1, 6, 'Hindi'): 1, + (6, 1, 'Hindi'): 1, + (2, 3, 'Swedish'): 1, + (3, 2, 'Swedish'): 1, + (3, 4, 'English'): 1, + (4, 3, 'English'): 1, + }, + default=0, + mutable=True, + ) # TODO: shouldn't need to + # be mutable, but waiting + # on #3045 + + m.Expected = Param( + m.People, + m.People, + m.Languages, + initialize={ + ('P1', 'P2', 'English'): 1, + ('P2', 'P1', 'English'): 1, + ('P1', 'P3', 'English'): 1, + ('P3', 'P1', 'English'): 1, + ('P1', 'P4', 'English'): 1, + ('P4', 'P1', 'English'): 1, + ('P1', 'P5', 'English'): 1, + ('P5', 'P1', 'English'): 1, + ('P1', 'P6', 'English'): 1, + ('P6', 'P1', 'English'): 1, + ('P1', 'P7', 'English'): 1, + ('P7', 'P1', 'English'): 1, + ('P2', 'P3', 'Spanish'): 1, + ('P3', 'P2', 'Spanish'): 1, + ('P2', 'P6', 'Hindi'): 1, + ('P6', 'P2', 'Hindi'): 1, + ('P2', 'P7', 'Hindi'): 1, + ('P7', 'P2', 'Hindi'): 1, + ('P3', 'P4', 'Swedish'): 1, + ('P4', 'P3', 'Swedish'): 1, + ('P4', 'P5', 'English'): 1, + ('P5', 'P4', 'English'): 1, + }, + default=0, + mutable=True, + ) # TODO: shouldn't need to be mutable, but + # waiting on #3045 + + m.person_name = Var(m.People, bounds=(0, max(m.Names)), domain=Integers) + + m.one_to_one = LogicalConstraint( + expr=all_different(m.person_name[person] for person in m.People) + ) + + m.obj = Objective( + expr=count_if( + m.Observed[m.person_name[p1], m.person_name[p2], l] + == m.Expected[p1, p2, l] + for p1 in m.People + for p2 in m.People + for l in m.Languages + ), + sense=maximize, + ) + + results = SolverFactory('cp_optimizer').solve(m) + + # we can get one of two perfect matches: + perfect = 7 * 7 * 4 + self.assertEqual(results.problem.lower_bound, perfect) + self.assertEqual(results.problem.upper_bound, perfect) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.optimal + ) + self.assertEqual(value(m.obj), perfect) + m.person_name.pprint() + self.assertEqual(value(m.person_name['P1']), 0) + self.assertEqual(value(m.person_name['P2']), 1) + self.assertEqual(value(m.person_name['P3']), 2) + self.assertEqual(value(m.person_name['P4']), 3) + self.assertEqual(value(m.person_name['P5']), 4) + # We can't distinguish P6 and P7, so they could each have either of + # names 5 and 6 + self.assertTrue( + value(m.person_name['P6']) == 5 or value(m.person_name['P6']) == 6 + ) + self.assertTrue( + value(m.person_name['P7']) == 5 or value(m.person_name['P7']) == 6 + ) + + m.person_name['P6'].fix(5) + m.person_name['P7'].fix(6) + + results = SolverFactory('cp_optimizer').solve(m) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.optimal + ) + self.assertEqual(value(m.obj), perfect) + + m.person_name['P6'].fix(6) + m.person_name['P7'].fix(5) + + results = SolverFactory('cp_optimizer').solve(m) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.optimal + ) + self.assertEqual(value(m.obj), perfect) diff --git a/pyomo/core/__init__.py b/pyomo/core/__init__.py index 5cbebcee9ec..b119c6357d0 100644 --- a/pyomo/core/__init__.py +++ b/pyomo/core/__init__.py @@ -33,6 +33,8 @@ exactly, atleast, atmost, + all_different, + count_if, implies, lnot, xor, diff --git a/pyomo/core/expr/__init__.py b/pyomo/core/expr/__init__.py index 5e30fceeeaa..bd6d1b995a1 100644 --- a/pyomo/core/expr/__init__.py +++ b/pyomo/core/expr/__init__.py @@ -70,6 +70,8 @@ ExactlyExpression, AtMostExpression, AtLeastExpression, + AllDifferentExpression, + CountIfExpression, # land, lnot, @@ -79,6 +81,8 @@ exactly, atleast, atmost, + all_different, + count_if, implies, ) from .numeric_expr import ( diff --git a/pyomo/core/expr/logical_expr.py b/pyomo/core/expr/logical_expr.py index f2d3e110166..48daa79a5b3 100644 --- a/pyomo/core/expr/logical_expr.py +++ b/pyomo/core/expr/logical_expr.py @@ -10,7 +10,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ - import types from itertools import islice @@ -36,6 +35,7 @@ from .base import ExpressionBase from .boolean_value import BooleanValue, BooleanConstant from .expr_common import _and, _or, _equiv, _inv, _xor, _impl, ExpressionType +from .numeric_expr import NumericExpression import operator @@ -183,12 +183,62 @@ def _flattened(args): yield arg +def _flattened_boolean_args(args): + """Flatten any potentially indexed arguments and check that they are + Boolean-valued.""" + for arg in args: + if arg.__class__ in native_types: + myiter = (arg,) + elif isinstance(arg, (types.GeneratorType, list)): + myiter = arg + elif arg.is_indexed(): + myiter = arg.values() + else: + myiter = (arg,) + for _argdata in myiter: + if _argdata.__class__ in native_logical_types: + yield _argdata + elif hasattr(_argdata, 'is_logical_type') and _argdata.is_logical_type(): + yield _argdata + elif isinstance(_argdata, BooleanValue): + yield _argdata + else: + raise ValueError( + "Non-Boolean-valued argument '%s' encountered when constructing " + "expression of Boolean arguments" % arg + ) + + +def _flattened_numeric_args(args): + """Flatten any potentially indexed arguments and check that they are + numeric.""" + for arg in args: + if arg.__class__ in native_types: + myiter = (arg,) + elif isinstance(arg, (types.GeneratorType, list)): + myiter = arg + elif arg.is_indexed(): + myiter = arg.values() + else: + myiter = (arg,) + for _argdata in myiter: + if _argdata.__class__ in native_numeric_types: + yield _argdata + elif hasattr(_argdata, 'is_numeric_type') and _argdata.is_numeric_type(): + yield _argdata + else: + raise ValueError( + "Non-numeric argument '%s' encountered when constructing " + "expression with numeric arguments" % arg + ) + + def land(*args): """ Construct an AndExpression between passed arguments. """ result = AndExpression([]) - for argdata in _flattened(args): + for argdata in _flattened_boolean_args(args): result = result.add(argdata) return result @@ -198,7 +248,7 @@ def lor(*args): Construct an OrExpression between passed arguments. """ result = OrExpression([]) - for argdata in _flattened(args): + for argdata in _flattened_boolean_args(args): result = result.add(argdata) return result @@ -211,7 +261,7 @@ def exactly(n, *args): Usage: exactly(2, m.Y1, m.Y2, m.Y3, ...) """ - result = ExactlyExpression([n] + list(_flattened(args))) + result = ExactlyExpression([n] + list(_flattened_boolean_args(args))) return result @@ -223,7 +273,7 @@ def atmost(n, *args): Usage: atmost(2, m.Y1, m.Y2, m.Y3, ...) """ - result = AtMostExpression([n] + list(_flattened(args))) + result = AtMostExpression([n] + list(_flattened_boolean_args(args))) return result @@ -235,10 +285,30 @@ def atleast(n, *args): Usage: atleast(2, m.Y1, m.Y2, m.Y3, ...) """ - result = AtLeastExpression([n] + list(_flattened(args))) + result = AtLeastExpression([n] + list(_flattened_boolean_args(args))) return result +def all_different(*args): + """Creates a new AllDifferentExpression + + Requires all of the arguments to take on a different value + + Usage: all_different(m.X1, m.X2, ...) + """ + return AllDifferentExpression(list(_flattened_numeric_args(args))) + + +def count_if(*args): + """Creates a new CountIfExpression + + Counts the number of True-valued arguments + + Usage: count_if(m.Y1, m.Y2, ...) + """ + return CountIfExpression(list(_flattened_boolean_args(args))) + + class UnaryBooleanExpression(BooleanExpression): """ Abstract class for single-argument logical expressions. @@ -511,4 +581,54 @@ def _apply_operation(self, result): return sum(result[1:]) >= result[0] +class AllDifferentExpression(NaryBooleanExpression): + """ + Logical expression that all of the N child statements have different values. + All arguments are expected to be discrete-valued. + """ + + __slots__ = () + + PRECEDENCE = None + + def getname(self, *arg, **kwd): + return 'all_different' + + def _to_string(self, values, verbose, smap): + return "all_different(%s)" % (", ".join(values)) + + def _apply_operation(self, result): + last = None + # we know these are integer-valued, so we can just sort them an make + # sure that no adjacent pairs have the same value. + for val in sorted(result): + if last == val: + return False + last = val + return True + + +class CountIfExpression(NumericExpression): + """ + Logical expression that returns the number of True child statements. + All arguments are expected to be Boolean-valued. + """ + + __slots__ = () + PRECEDENCE = None + + # NumericExpression assumes binary operator, so we have to override. + def nargs(self): + return len(self._args_) + + def getname(self, *arg, **kwd): + return 'count_if' + + def _to_string(self, values, verbose, smap): + return "count_if(%s)" % (", ".join(values)) + + def _apply_operation(self, result): + return sum(value(r) for r in result) + + special_boolean_atom_types = {ExactlyExpression, AtMostExpression, AtLeastExpression} diff --git a/pyomo/core/tests/unit/test_logical_expr_expanded.py b/pyomo/core/tests/unit/test_logical_expr_expanded.py index 95ae0494a48..0360e9b4783 100644 --- a/pyomo/core/tests/unit/test_logical_expr_expanded.py +++ b/pyomo/core/tests/unit/test_logical_expr_expanded.py @@ -15,7 +15,7 @@ """ import operator -from itertools import product +from itertools import permutations, product import pyomo.common.unittest as unittest @@ -23,6 +23,8 @@ from pyomo.core.expr.sympy_tools import sympy_available from pyomo.core.expr.visitor import identify_variables from pyomo.environ import ( + all_different, + count_if, land, atleast, atmost, @@ -39,6 +41,8 @@ BooleanVar, lnot, xor, + Var, + Integers, ) @@ -234,12 +238,50 @@ def test_nary_atleast(self): ) self.assertEqual(value(atleast(ntrue, m.Y)), correct_value) + def test_nary_all_diff(self): + m = ConcreteModel() + m.x = Var(range(4), domain=Integers, bounds=(0, 3)) + for vals in permutations(range(4)): + self.assertTrue(value(all_different(*vals))) + for i, v in enumerate(vals): + m.x[i] = v + self.assertTrue(value(all_different(m.x))) + self.assertFalse(value(all_different(1, 1, 2, 3))) + m.x[0] = 1 + m.x[1] = 1 + m.x[2] = 2 + m.x[3] = 3 + self.assertFalse(value(all_different(m.x))) + + def test_count_if(self): + nargs = 3 + m = ConcreteModel() + m.s = RangeSet(nargs) + m.Y = BooleanVar(m.s) + m.x = Var(domain=Integers, bounds=(0, 3)) + for truth_combination in _generate_possible_truth_inputs(nargs): + for ntrue in range(nargs + 1): + m.Y.set_values(dict(enumerate(truth_combination, 1))) + correct_value = sum(truth_combination) + self.assertEqual(value(count_if(*(m.Y[i] for i in m.s))), correct_value) + self.assertEqual(value(count_if(m.Y)), correct_value) + m.x = 2 + self.assertEqual( + value(count_if([m.Y[i] for i in m.s] + [m.x == 3])), correct_value + ) + m.x = 3 + self.assertEqual( + value(count_if([m.Y[i] for i in m.s] + [m.x == 3])), correct_value + 1 + ) + def test_to_string(self): m = ConcreteModel() m.Y1 = BooleanVar() m.Y2 = BooleanVar() m.Y3 = BooleanVar() m.Y4 = BooleanVar() + m.int1 = Var(domain=Integers) + m.int2 = Var(domain=Integers) self.assertEqual(str(land(m.Y1, m.Y2, m.Y3)), "Y1 ∧ Y2 ∧ Y3") self.assertEqual(str(lor(m.Y1, m.Y2, m.Y3)), "Y1 ∨ Y2 ∨ Y3") @@ -249,6 +291,10 @@ def test_to_string(self): self.assertEqual(str(atleast(1, m.Y1, m.Y2)), "atleast(1: [Y1, Y2])") self.assertEqual(str(atmost(1, m.Y1, m.Y2)), "atmost(1: [Y1, Y2])") self.assertEqual(str(exactly(1, m.Y1, m.Y2)), "exactly(1: [Y1, Y2])") + self.assertEqual( + str(all_different(m.int1, m.int2)), "all_different(int1, int2)" + ) + self.assertEqual(str(count_if(m.Y1, m.Y2)), "count_if(Y1, Y2)") # Precedence checks self.assertEqual(str(m.Y1.implies(m.Y2).lor(m.Y3)), "(Y1 --> Y2) ∨ Y3") @@ -266,11 +312,16 @@ def test_node_types(self): m.Y1 = BooleanVar() m.Y2 = BooleanVar() m.Y3 = BooleanVar() + m.int1 = Var(domain=Integers) + m.int2 = Var(domain=Integers) + m.int3 = Var(domain=Integers) self.assertFalse(m.Y1.is_expression_type()) self.assertTrue(lnot(m.Y1).is_expression_type()) self.assertTrue(equivalent(m.Y1, m.Y2).is_expression_type()) self.assertTrue(atmost(1, [m.Y1, m.Y2, m.Y3]).is_expression_type()) + self.assertTrue(all_different(m.int1, m.int2, m.int3).is_expression_type()) + self.assertTrue(count_if(m.Y1, m.Y2, m.Y3).is_expression_type()) def test_numeric_invalid(self): m = ConcreteModel() diff --git a/pyomo/environ/__init__.py b/pyomo/environ/__init__.py index 51c68449247..c3fb3ec4a85 100644 --- a/pyomo/environ/__init__.py +++ b/pyomo/environ/__init__.py @@ -114,6 +114,8 @@ def _import_packages(): exactly, atleast, atmost, + all_different, + count_if, implies, lnot, xor,