Skip to content

Commit

Permalink
Merge pull request #2835 from jsiirola/infix-boolean-ops
Browse files Browse the repository at this point in the history
Add support for infix Boolean logical operators
  • Loading branch information
mrmundt authored May 22, 2023
2 parents dca0388 + 744dfcd commit e3232d2
Show file tree
Hide file tree
Showing 4 changed files with 172 additions and 37 deletions.
16 changes: 8 additions & 8 deletions doc/OnlineDocs/modeling_extensions/gdp/modeling.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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])` |
+--------------+------------------------+-----------------------------------+--------------------------------+
Expand All @@ -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``.
Expand Down Expand Up @@ -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
Expand Down
96 changes: 88 additions & 8 deletions pyomo/core/expr/boolean_value.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,41 +118,121 @@ 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 XorExpression using 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)

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):
"""
Expand Down
24 changes: 11 additions & 13 deletions pyomo/core/expr/logical_expr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -310,7 +308,7 @@ class XorExpression(BinaryBooleanExpression):

__slots__ = ()

PRECEDENCE = 5
PRECEDENCE = 4

def getname(self, *arg, **kwd):
return 'xor'
Expand Down Expand Up @@ -392,7 +390,7 @@ class AndExpression(NaryBooleanExpression):

__slots__ = ()

PRECEDENCE = 4
PRECEDENCE = 3

def getname(self, *arg, **kwd):
return 'and'
Expand All @@ -419,7 +417,7 @@ class OrExpression(NaryBooleanExpression):

__slots__ = ()

PRECEDENCE = 4
PRECEDENCE = 5

def getname(self, *arg, **kwd):
return 'or'
Expand Down
73 changes: 65 additions & 8 deletions pyomo/core/tests/unit/test_logical_expr_expanded.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -134,27 +134,27 @@ 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()
m.Y1 = BooleanVar()
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
Expand Down Expand Up @@ -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")
Expand All @@ -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()
Expand All @@ -269,22 +278,70 @@ 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()

Expand Down

0 comments on commit e3232d2

Please sign in to comment.