From 94a37c676d9d44187c3ae9dd862da36547921cf0 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Sat, 20 May 2023 22:58:25 -0600 Subject: [PATCH 1/6] Add support for infix boolean operators: &, |, and ^ --- pyomo/core/expr/boolean_value.py | 96 +++++++++++++++++-- pyomo/core/expr/logical_expr.py | 18 ++-- .../tests/unit/test_logical_expr_expanded.py | 55 +++++++++-- 3 files changed, 144 insertions(+), 25 deletions(-) diff --git a/pyomo/core/expr/boolean_value.py b/pyomo/core/expr/boolean_value.py index 8cea5c0f61e..01dba02259b 100644 --- a/pyomo/core/expr/boolean_value.py +++ b/pyomo/core/expr/boolean_value.py @@ -118,33 +118,108 @@ def is_numeric_type(self): def is_logical_type(self): return True + def __invert__(self): + """ + Construct a NotExpression using operator '~' + """ + return _generate_logical_proposition(_inv, self, None) + def equivalent_to(self, other): """ Construct an EquivalenceExpression between this BooleanValue and its operand. """ - return _generate_logical_proposition(_equiv, self, other) + ans = _generate_logical_proposition(_equiv, self, other) + if ans is NotImplemented: + raise TypeError( + "unsupported operand type for equivalent_to(): " + f"'{type(other).__name__}'" + ) + return ans def land(self, other): """ - Construct an AndExpression (Logical And) between this BooleanValue and its operand. + Construct an AndExpression (Logical And) between this BooleanValue and `other`. + """ + ans = _generate_logical_proposition(_and, self, other) + if ans is NotImplemented: + raise TypeError( + f"unsupported operand type for land(): '{type(other).__name__}'" + ) + return ans + + def __and__(self, other): + """ + Construct an AndExpression using the '&' operator + """ + return _generate_logical_proposition(_and, self, other) + + def __rand__(self, other): + """ + Construct an AndExpression using the '&' operator + """ + return _generate_logical_proposition(_and, other, self) + + def __iand__(self, other): + """ + Construct an AndExpression using the '&' operator """ return _generate_logical_proposition(_and, self, other) def lor(self, other): """ - Construct an OrExpression (Logical OR) between this BooleanValue and its operand. + Construct an OrExpression (Logical OR) between this BooleanValue and `other`. + """ + ans = _generate_logical_proposition(_or, self, other) + if ans is NotImplemented: + raise TypeError( + f"unsupported operand type for lor(): '{type(other).__name__}'" + ) + return ans + + def __or__(self, other): + """ + Construct an OrExpression using the '|' operator """ return _generate_logical_proposition(_or, self, other) - def __invert__(self): + def __ror__(self, other): """ - Construct a NotExpression using operator '~' + Construct an OrExpression using the '|' operator """ - return _generate_logical_proposition(_inv, self, None) + return _generate_logical_proposition(_or, other, self) + + def __ior__(self, other): + """ + Construct an OrExpression using the '|' operator + """ + return _generate_logical_proposition(_or, self, other) def xor(self, other): """ - Construct an EquivalenceExpression using method "xor" + Construct an XorExpressionusing method "xor" + """ + ans = _generate_logical_proposition(_xor, self, other) + if ans is NotImplemented: + raise TypeError( + f"unsupported operand type for xor(): '{type(other).__name__}'" + ) + return ans + + def __xor__(self, other): + """ + Construct an XorExpression using the '^' operator + """ + return _generate_logical_proposition(_xor, self, other) + + def __rxor__(self, other): + """ + Construct an XorExpression using the '^' operator + """ + return _generate_logical_proposition(_xor, other, self) + + def __ixor__(self, other): + """ + Construct an XorExpression using the '^' operator """ return _generate_logical_proposition(_xor, self, other) @@ -152,7 +227,12 @@ def implies(self, other): """ Construct an ImplicationExpression using method "implies" """ - return _generate_logical_proposition(_impl, self, other) + ans = _generate_logical_proposition(_impl, self, other) + if ans is NotImplemented: + raise TypeError( + f"unsupported operand type for implies(): '{type(other).__name__}'" + ) + return ans def to_string(self, verbose=None, labeler=None, smap=None, compute_values=False): """ diff --git a/pyomo/core/expr/logical_expr.py b/pyomo/core/expr/logical_expr.py index a8f05a84bcf..a5e98a5c85e 100644 --- a/pyomo/core/expr/logical_expr.py +++ b/pyomo/core/expr/logical_expr.py @@ -67,18 +67,16 @@ def _generate_logical_proposition(etype, lhs, rhs): - if lhs.__class__ in native_types and lhs.__class__ not in native_logical_types: - raise TypeError( - "Cannot create Logical expression with lhs of type '%s'" % lhs.__class__ - ) if ( - rhs.__class__ in native_types - and rhs.__class__ not in native_logical_types - and rhs is not None + lhs.__class__ in native_types and lhs.__class__ not in native_logical_types + ) and not isinstance(lhs, BooleanValue): + return NotImplemented + if ( + (rhs.__class__ in native_types and rhs.__class__ not in native_logical_types) + and not isinstance(rhs, BooleanValue) + and not (rhs is None and etype == _inv) ): - raise TypeError( - "Cannot create Logical expression with rhs of type '%s'" % rhs.__class__ - ) + return NotImplemented if etype == _equiv: return EquivalenceExpression((lhs, rhs)) diff --git a/pyomo/core/tests/unit/test_logical_expr_expanded.py b/pyomo/core/tests/unit/test_logical_expr_expanded.py index cb14fb596f2..4fbcaca9270 100644 --- a/pyomo/core/tests/unit/test_logical_expr_expanded.py +++ b/pyomo/core/tests/unit/test_logical_expr_expanded.py @@ -102,13 +102,13 @@ def test_binary_xor(self): m.Y2 = BooleanVar() op_static = xor(m.Y1, m.Y2) op_class = m.Y1.xor(m.Y2) - # op_operator = m.Y1 ^ m.Y2 + op_operator = m.Y1 ^ m.Y2 for truth_combination in _generate_possible_truth_inputs(2): m.Y1.value, m.Y2.value = truth_combination[0], truth_combination[1] correct_value = operator.xor(*truth_combination) self.assertEqual(value(op_static), correct_value) self.assertEqual(value(op_class), correct_value) - # self.assertEqual(value(op_operator), correct_value) + self.assertEqual(value(op_operator), correct_value) def test_binary_implies(self): m = ConcreteModel() @@ -134,13 +134,13 @@ def test_binary_and(self): m.Y2 = BooleanVar() op_static = land(m.Y1, m.Y2) op_class = m.Y1.land(m.Y2) - # op_operator = m.Y1 & m.Y2 + op_operator = m.Y1 & m.Y2 for truth_combination in _generate_possible_truth_inputs(2): m.Y1.value, m.Y2.value = truth_combination[0], truth_combination[1] correct_value = all(truth_combination) self.assertEqual(value(op_static), correct_value) self.assertEqual(value(op_class), correct_value) - # self.assertEqual(value(op_operator), correct_value) + self.assertEqual(value(op_operator), correct_value) def test_binary_or(self): m = ConcreteModel() @@ -148,13 +148,13 @@ def test_binary_or(self): m.Y2 = BooleanVar() op_static = lor(m.Y1, m.Y2) op_class = m.Y1.lor(m.Y2) - # op_operator = m.Y1 | m.Y2 + op_operator = m.Y1 | m.Y2 for truth_combination in _generate_possible_truth_inputs(2): m.Y1.value, m.Y2.value = truth_combination[0], truth_combination[1] correct_value = any(truth_combination) self.assertEqual(value(op_static), correct_value) self.assertEqual(value(op_class), correct_value) - # self.assertEqual(value(op_operator), correct_value) + self.assertEqual(value(op_operator), correct_value) def test_nary_and(self): nargs = 3 @@ -269,22 +269,63 @@ def test_numeric_invalid(self): m.Y2 = BooleanVar() m.Y3 = BooleanVar() + def iadd(): + m.Y3 += 2 + def isub(): + m.Y3 -= 2 + def imul(): + m.Y3 *= 2 + def idiv(): + m.Y3 /= 2 + def ipow(): + m.Y3 **= 2 + def iand(): + m.Y3 &= 2 + def ior(): + m.Y3 |= 2 + def ixor(): + m.Y3 ^= 2 + def invalid_expression_generator(): yield lambda: m.Y1 + m.Y2 yield lambda: m.Y1 - m.Y2 yield lambda: m.Y1 * m.Y2 yield lambda: m.Y1 / m.Y2 yield lambda: m.Y1**m.Y2 + yield lambda: m.Y1.land(0) + yield lambda: m.Y1.lor(0) + yield lambda: m.Y1.xor(0) + yield lambda: m.Y1.equivalent_to(0) + yield lambda: m.Y1.implies(0) yield lambda: 0 + m.Y2 yield lambda: 0 - m.Y2 yield lambda: 0 * m.Y2 yield lambda: 0 / m.Y2 yield lambda: 0**m.Y2 + yield lambda: 0 & m.Y2 + yield lambda: 0 | m.Y2 + yield lambda: 0 ^ m.Y2 + yield lambda: m.Y3 + 2 + yield lambda: m.Y3 - 2 + yield lambda: m.Y3 * 2 + yield lambda: m.Y3 / 2 + yield lambda: m.Y3**2 + yield lambda: m.Y3 & 2 + yield lambda: m.Y3 | 2 + yield lambda: m.Y3 ^ 2 + yield iadd + yield isub + yield imul + yield idiv + yield ipow + yield iand + yield ior + yield ixor numeric_error_msg = ( "(?:(?:unsupported operand type)|(?:operands do not support))" ) - for invalid_expr_fcn in invalid_expression_generator(): + for i, invalid_expr_fcn in enumerate(invalid_expression_generator()): with self.assertRaisesRegex(TypeError, numeric_error_msg): _ = invalid_expr_fcn() From a4979d61e81bee1aa71927608bbe787d72502dbe Mon Sep 17 00:00:00 2001 From: John Siirola Date: Sat, 20 May 2023 22:58:53 -0600 Subject: [PATCH 2/6] Update docs to reflect infix operators are allowed --- .../modeling_extensions/gdp/modeling.rst | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/doc/OnlineDocs/modeling_extensions/gdp/modeling.rst b/doc/OnlineDocs/modeling_extensions/gdp/modeling.rst index 671575d5775..278581353e1 100644 --- a/doc/OnlineDocs/modeling_extensions/gdp/modeling.rst +++ b/doc/OnlineDocs/modeling_extensions/gdp/modeling.rst @@ -112,7 +112,7 @@ Using these Boolean variables, we can define ``LogicalConstraint`` objects, anal .. doctest:: - >>> m.p = LogicalConstraint(expr=m.Y[1].implies(land(m.Y[2], m.Y[3])).lor(m.Y[4])) + >>> m.p = LogicalConstraint(expr=m.Y[1].implies(m.Y[2] & m.Y[3])) | m.Y[4]) >>> m.p.pprint() p : Size=1, Index=None, Active=True Key : Body : Active @@ -126,13 +126,13 @@ Pyomo.GDP logical expression system supported operators and their usage are list +--------------+------------------------+-----------------------------------+--------------------------------+ | Operator | Operator | Method | Function | +==============+========================+===================================+================================+ -| Conjunction | | :code:`Y[1].land(Y[2])` | :code:`land(Y[1],Y[2])` | +| Negation | :code:`~Y[1]` | | :code:`lnot(Y[1])` | +--------------+------------------------+-----------------------------------+--------------------------------+ -| Disjunction | | :code:`Y[1].lor(Y[2])` | :code:`lor(Y[1],Y[2])` | +| Conjunction | :code:`Y[1] & Y[2]` | :code:`Y[1].land(Y[2])` | :code:`land(Y[1],Y[2])` | +--------------+------------------------+-----------------------------------+--------------------------------+ -| Negation | :code:`~Y[1]` | | :code:`lnot(Y[1])` | +| Disjunction | :code:`Y[1] | Y[2]` | :code:`Y[1].lor(Y[2])` | :code:`lor(Y[1],Y[2])` | +--------------+------------------------+-----------------------------------+--------------------------------+ -| Exclusive OR | | :code:`Y[1].xor(Y[2])` | :code:`xor(Y[1], Y[2])` | +| Exclusive OR | :code:`Y[1] ^ Y[2]` | :code:`Y[1].xor(Y[2])` | :code:`xor(Y[1], Y[2])` | +--------------+------------------------+-----------------------------------+--------------------------------+ | Implication | | :code:`Y[1].implies(Y[2])` | :code:`implies(Y[1], Y[2])` | +--------------+------------------------+-----------------------------------+--------------------------------+ @@ -141,7 +141,7 @@ Pyomo.GDP logical expression system supported operators and their usage are list .. note:: - We omit support for most infix operators, e.g. :code:`Y[1] >> Y[2]`, due to concerns about non-intuitive Python operator precedence. + We omit support for some infix operators, e.g. :code:`Y[1] >> Y[2]`, due to concerns about non-intuitive Python operator precedence. That is :code:`Y[1] | Y[2] >> Y[3]` would translate to :math:`Y_1 \lor (Y_2 \Rightarrow Y_3)` rather than :math:`(Y_1 \lor Y_2) \Rightarrow Y_3` In addition, the following constraint-programming-inspired operators are provided: ``exactly``, ``atmost``, and ``atleast``. @@ -289,8 +289,8 @@ Composition of standard operators .. code:: - m.p = LogicalConstraint(expr=lor(m.Y[1], m.Y[2]).implies( - land(m.Y[3], ~m.Y[4], m.Y[5].lor(m.Y[6]))) + m.p = LogicalConstraint(expr=(m.Y[1] | m.Y[2]).implies( + m.Y[3] & ~m.Y[4] & (m.Y[5] | m.Y[6]))) ) Expressions within CP-type operators From c68bd7a5997143efe84aa1327d97b26d632ee4af Mon Sep 17 00:00:00 2001 From: John Siirola Date: Sat, 20 May 2023 23:17:24 -0600 Subject: [PATCH 3/6] Fix &, ^, | precedence; verify with tests --- pyomo/core/expr/logical_expr.py | 6 +++--- pyomo/core/tests/unit/test_logical_expr_expanded.py | 11 ++++++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/pyomo/core/expr/logical_expr.py b/pyomo/core/expr/logical_expr.py index a5e98a5c85e..e5a2f411a6e 100644 --- a/pyomo/core/expr/logical_expr.py +++ b/pyomo/core/expr/logical_expr.py @@ -308,7 +308,7 @@ class XorExpression(BinaryBooleanExpression): __slots__ = () - PRECEDENCE = 5 + PRECEDENCE = 4 def getname(self, *arg, **kwd): return 'xor' @@ -390,7 +390,7 @@ class AndExpression(NaryBooleanExpression): __slots__ = () - PRECEDENCE = 4 + PRECEDENCE = 3 def getname(self, *arg, **kwd): return 'and' @@ -417,7 +417,7 @@ class OrExpression(NaryBooleanExpression): __slots__ = () - PRECEDENCE = 4 + PRECEDENCE = 5 def getname(self, *arg, **kwd): return 'or' diff --git a/pyomo/core/tests/unit/test_logical_expr_expanded.py b/pyomo/core/tests/unit/test_logical_expr_expanded.py index 4fbcaca9270..8ec0390d80d 100644 --- a/pyomo/core/tests/unit/test_logical_expr_expanded.py +++ b/pyomo/core/tests/unit/test_logical_expr_expanded.py @@ -239,6 +239,7 @@ def test_to_string(self): m.Y1 = BooleanVar() m.Y2 = BooleanVar() m.Y3 = BooleanVar() + m.Y4 = BooleanVar() 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,8 +250,16 @@ def test_to_string(self): 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])") - # Precedence check + # Precedence checks self.assertEqual(str(m.Y1.implies(m.Y2).lor(m.Y3)), "(Y1 --> Y2) ∨ Y3") + self.assertEqual(str(m.Y1 & m.Y2 | m.Y3 ^ m.Y4), "Y1 ∧ Y2 ∨ Y3 ⊻ Y4") + self.assertEqual(str(m.Y1 & (m.Y2 | m.Y3) ^ m.Y4), "Y1 ∧ (Y2 ∨ Y3) ⊻ Y4") + self.assertEqual(str(m.Y1 & m.Y2 ^ m.Y3 | m.Y4), "Y1 ∧ Y2 ⊻ Y3 ∨ Y4") + self.assertEqual(str(m.Y1 & m.Y2 ^ (m.Y3 | m.Y4)), "Y1 ∧ Y2 ⊻ (Y3 ∨ Y4)") + self.assertEqual(str(m.Y1 & (m.Y2 ^ (m.Y3 | m.Y4))), "Y1 ∧ (Y2 ⊻ (Y3 ∨ Y4))") + self.assertEqual(str(m.Y1 | m.Y2 ^ m.Y3 & m.Y4), "Y1 ∨ Y2 ⊻ Y3 ∧ Y4") + self.assertEqual(str((m.Y1 | m.Y2) ^ m.Y3 & m.Y4), "(Y1 ∨ Y2) ⊻ Y3 ∧ Y4") + self.assertEqual(str(((m.Y1 | m.Y2) ^ m.Y3) & m.Y4), "((Y1 ∨ Y2) ⊻ Y3) ∧ Y4") def test_node_types(self): m = ConcreteModel() From 01e076c77834f80a7a3da8f18d5dd7d0bfe74ace Mon Sep 17 00:00:00 2001 From: John Siirola Date: Sat, 20 May 2023 23:17:53 -0600 Subject: [PATCH 4/6] NFC: apply black --- pyomo/core/tests/unit/test_logical_expr_expanded.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pyomo/core/tests/unit/test_logical_expr_expanded.py b/pyomo/core/tests/unit/test_logical_expr_expanded.py index 8ec0390d80d..f5b86d59cbd 100644 --- a/pyomo/core/tests/unit/test_logical_expr_expanded.py +++ b/pyomo/core/tests/unit/test_logical_expr_expanded.py @@ -280,18 +280,25 @@ def test_numeric_invalid(self): def iadd(): m.Y3 += 2 + def isub(): m.Y3 -= 2 + def imul(): m.Y3 *= 2 + def idiv(): m.Y3 /= 2 + def ipow(): m.Y3 **= 2 + def iand(): m.Y3 &= 2 + def ior(): m.Y3 |= 2 + def ixor(): m.Y3 ^= 2 From a60b696d1035d49b633522a2498d1977279f00ba Mon Sep 17 00:00:00 2001 From: John Siirola Date: Sun, 21 May 2023 00:08:33 -0600 Subject: [PATCH 5/6] NFC: fixing doc typos --- doc/OnlineDocs/modeling_extensions/gdp/modeling.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/OnlineDocs/modeling_extensions/gdp/modeling.rst b/doc/OnlineDocs/modeling_extensions/gdp/modeling.rst index 278581353e1..b70e37d5935 100644 --- a/doc/OnlineDocs/modeling_extensions/gdp/modeling.rst +++ b/doc/OnlineDocs/modeling_extensions/gdp/modeling.rst @@ -112,7 +112,7 @@ Using these Boolean variables, we can define ``LogicalConstraint`` objects, anal .. doctest:: - >>> m.p = LogicalConstraint(expr=m.Y[1].implies(m.Y[2] & m.Y[3])) | m.Y[4]) + >>> m.p = LogicalConstraint(expr=m.Y[1].implies(m.Y[2] & m.Y[3]) | m.Y[4]) >>> m.p.pprint() p : Size=1, Index=None, Active=True Key : Body : Active @@ -290,7 +290,7 @@ Composition of standard operators .. code:: m.p = LogicalConstraint(expr=(m.Y[1] | m.Y[2]).implies( - m.Y[3] & ~m.Y[4] & (m.Y[5] | m.Y[6]))) + m.Y[3] & ~m.Y[4] & (m.Y[5] | m.Y[6])) ) Expressions within CP-type operators From 744dfcd3b4f69fb0b16cf5d2c95bba10ed174ee0 Mon Sep 17 00:00:00 2001 From: Bethany Nicholson Date: Sun, 21 May 2023 12:35:11 -0600 Subject: [PATCH 6/6] Update boolean_value.py NFC: Fixing typo --- pyomo/core/expr/boolean_value.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/core/expr/boolean_value.py b/pyomo/core/expr/boolean_value.py index 01dba02259b..b9c8ece29c8 100644 --- a/pyomo/core/expr/boolean_value.py +++ b/pyomo/core/expr/boolean_value.py @@ -196,7 +196,7 @@ def __ior__(self, other): def xor(self, other): """ - Construct an XorExpressionusing method "xor" + Construct an XorExpression using method "xor" """ ans = _generate_logical_proposition(_xor, self, other) if ans is NotImplemented: