diff --git a/pyomo/core/expr/cnf_walker.py b/pyomo/core/expr/cnf_walker.py index 7cd64d6f313..a7bf61bef5a 100644 --- a/pyomo/core/expr/cnf_walker.py +++ b/pyomo/core/expr/cnf_walker.py @@ -9,166 +9,43 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ - from pyomo.common import DeveloperError from pyomo.common.collections import ComponentMap from pyomo.common.dependencies import attempt_import -from pyomo.core.expr.logical_expr import ( - AndExpression, - EquivalenceExpression, - equivalent, - ImplicationExpression, - implies, - land, - lnot, - lor, - NotExpression, - OrExpression, - special_boolean_atom_types, - XorExpression, -) +from pyomo.core.expr.logical_expr import special_boolean_atom_types from pyomo.core.expr.numvalue import native_types, value -from pyomo.core.expr.visitor import StreamBasedExpressionVisitor - -_operatorMap = {} -_pyomo_operator_map = {} - - -def _configure_sympy(sympy, available): - if not available: - return - - _operatorMap.update( - { - sympy.Or: lor, - sympy.And: land, - sympy.Implies: implies, - sympy.Equivalent: equivalent, - sympy.Not: lnot, - } - ) - - _pyomo_operator_map.update( - { - AndExpression: sympy.And, - OrExpression: sympy.Or, - ImplicationExpression: sympy.Implies, - EquivalenceExpression: sympy.Equivalent, - XorExpression: sympy.Xor, - NotExpression: sympy.Not, - } - ) - - -sympy, _sympy_available = attempt_import('sympy', callback=_configure_sympy) - - -class _PyomoSympyLogicalBimap(object): - def __init__(self): - self.pyomo2sympy = ComponentMap() - self.sympy2pyomo = {} - self.i = 0 - - def getPyomoSymbol(self, sympy_object, default=None): - return self.sympy2pyomo.get(sympy_object, default) - - def getSympySymbol(self, pyomo_object): - if pyomo_object in self.pyomo2sympy: - return self.pyomo2sympy[pyomo_object] - # Pyomo currently ONLY supports Real variables (not complex - # variables). If that ever changes, then we will need to - # revisit hard-coding the symbol type here - sympy_obj = sympy.Symbol("x%d" % self.i, real=True) - self.i += 1 - self.pyomo2sympy[pyomo_object] = sympy_obj - self.sympy2pyomo[sympy_obj] = pyomo_object - return sympy_obj - - def sympyVars(self): - return self.sympy2pyomo.keys() +from pyomo.core.expr.sympy_tools import ( + Pyomo2SympyVisitor, + PyomoSympyBimap, + sympy, + sympy2pyomo_expression, +) -class _Pyomo2SympyVisitor(StreamBasedExpressionVisitor): +class CNF_Pyomo2SympyVisitor(Pyomo2SympyVisitor): def __init__(self, object_map, bool_varlist): - sympy.Add # this ensures _configure_sympy gets run - super(_Pyomo2SympyVisitor, self).__init__() - self.object_map = object_map + super().__init__(object_map) self.boolean_variable_list = bool_varlist self.special_atom_map = ComponentMap() - def exitNode(self, node, values): - _op = _pyomo_operator_map.get(node.__class__, None) - if _op is None: - if node.__class__ in special_boolean_atom_types: - raise ValueError( - "Encountered special atom class '%s' in root node" % node.__class__ - ) - return node._apply_operation(values) - else: - return _op(*tuple(values)) - def beforeChild(self, node, child, child_idx): - # - # Don't replace native or sympy types - # - if type(child) in native_types: - return False, child - # - # We will descend into all expressions... - # - if child.is_expression_type(): + descend, result = super().beforeChild(node, child, child_idx) + if descend: if child.__class__ in special_boolean_atom_types: indicator_var = self.boolean_variable_list.add() self.special_atom_map[indicator_var] = child return False, self.object_map.getSympySymbol(indicator_var) - else: - return True, None - # - # Replace pyomo variables with sympy variables - # - if child.is_potentially_variable(): - return False, self.object_map.getSympySymbol(child) - # - # Everything else is a constant... - # - return False, value(child) - - -class _Sympy2PyomoVisitor(StreamBasedExpressionVisitor): - def __init__(self, object_map): - sympy.Add # this ensures _configure_sympy gets run - super(_Sympy2PyomoVisitor, self).__init__() - self.object_map = object_map - - def enterNode(self, node): - return (node.args, []) - - def exitNode(self, node, values): - """Visit nodes that have been expanded""" - _sympyOp = node - _op = _operatorMap.get(type(_sympyOp), None) - if _op is None: - raise DeveloperError( - "sympy expression type '%s' not found in the operator " - "map" % type(_sympyOp) - ) - return _op(*tuple(values)) - - def beforeChild(self, node, child, child_idx): - if not child.args: - item = self.object_map.getPyomoSymbol(child, None) - if item is None: - item = float(child.evalf()) - return False, item - return True, None + return descend, result def to_cnf(expr, bool_varlist=None, bool_var_to_special_atoms=None): """Converts a Pyomo logical constraint to CNF form. - Note: the atoms AtMostExpression, AtLeastExpression, and ExactlyExpression - require special treatment if they are not the root node, or if their children are not atoms, - e.g. atmost(2, Y1, Y1 | Y2, Y2, Y3) + Note: the atoms AtMostExpression, AtLeastExpression, and + ExactlyExpression require special treatment if they are not the root + node, or if their children are not atoms, e.g. + + atmost(2, Y1, Y1 | Y2, Y2, Y3) As a result, the model may need to be augmented with additional boolean indicator variables and logical propositions. @@ -199,34 +76,35 @@ def to_cnf(expr, bool_varlist=None, bool_var_to_special_atoms=None): # While performing conversion to sympy, substitute new boolean variables for # non-root special atoms. - pyomo_sympy_map = _PyomoSympyLogicalBimap() + pyomo_sympy_map = PyomoSympyBimap() bool_var_to_special_atoms = ( ComponentMap() if bool_var_to_special_atoms is None else bool_var_to_special_atoms ) - visitor = _Pyomo2SympyVisitor(pyomo_sympy_map, bool_varlist) + visitor = CNF_Pyomo2SympyVisitor(pyomo_sympy_map, bool_varlist) sympy_expr = visitor.walk_expression(expr) new_statements = [] - # If visitor encountered any special atoms in non-root node, ensure that their children are literals: + # If visitor encountered any special atoms in non-root node, ensure + # that their children are literals: for indicator_var, special_atom in visitor.special_atom_map.items(): atom_cnf = _convert_children_to_literals( special_atom, bool_varlist, bool_var_to_special_atoms ) bool_var_to_special_atoms[indicator_var] = atom_cnf[0] new_statements.extend(atom_cnf[1:]) - cnf_form = sympy.to_cnf(sympy_expr) return [ - _sympy2pyomo_expression(cnf_form, pyomo_sympy_map) + sympy2pyomo_expression(cnf_form, pyomo_sympy_map) ] + new_statements # additional statements def _convert_children_to_literals( special_atom, bool_varlist, bool_var_to_special_atoms ): - """If the child logical constraints are not literals, substitute augmented boolean variables. + """If the child logical constraints are not literals, substitute + augmented boolean variables. Same return types as to_cnf() function. @@ -261,11 +139,3 @@ def _convert_children_to_literals( return [new_atom_with_literals] + new_statements else: return [special_atom] - - -def _sympy2pyomo_expression(expr, object_map): - visitor = _Sympy2PyomoVisitor(object_map) - is_expr, ans = visitor.beforeChild(None, expr, None) - if not is_expr: - return ans - return visitor.walk_expression(expr) diff --git a/pyomo/core/expr/sympy_tools.py b/pyomo/core/expr/sympy_tools.py index 8f18599ba11..1743f6be672 100644 --- a/pyomo/core/expr/sympy_tools.py +++ b/pyomo/core/expr/sympy_tools.py @@ -8,12 +8,14 @@ # rights in this software. # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +import operator +import sys from pyomo.common import DeveloperError from pyomo.common.collections import ComponentMap from pyomo.common.dependencies import attempt_import from pyomo.common.errors import NondifferentiableError -from pyomo.core.expr import current as EXPR, native_types +from pyomo.core.expr import current as EXPR, logical_expr as LEXPR, native_types from pyomo.core.expr.numvalue import value # @@ -32,38 +34,55 @@ def _configure_sympy(sympy, available): _operatorMap.update( { - sympy.Add: _sum, + sympy.Add: sum, sympy.Mul: _prod, - sympy.Pow: lambda x, y: x**y, - sympy.exp: lambda x: EXPR.exp(x), - sympy.log: lambda x: EXPR.log(x), - sympy.sin: lambda x: EXPR.sin(x), - sympy.asin: lambda x: EXPR.asin(x), - sympy.sinh: lambda x: EXPR.sinh(x), - sympy.asinh: lambda x: EXPR.asinh(x), - sympy.cos: lambda x: EXPR.cos(x), - sympy.acos: lambda x: EXPR.acos(x), - sympy.cosh: lambda x: EXPR.cosh(x), - sympy.acosh: lambda x: EXPR.acosh(x), - sympy.tan: lambda x: EXPR.tan(x), - sympy.atan: lambda x: EXPR.atan(x), - sympy.tanh: lambda x: EXPR.tanh(x), - sympy.atanh: lambda x: EXPR.atanh(x), - sympy.ceiling: lambda x: EXPR.ceil(x), - sympy.floor: lambda x: EXPR.floor(x), - sympy.sqrt: lambda x: EXPR.sqrt(x), - sympy.Abs: lambda x: abs(x), + sympy.Pow: lambda x: operator.pow(*x), + sympy.exp: lambda x: EXPR.exp(*x), + sympy.log: lambda x: EXPR.log(*x), + sympy.sin: lambda x: EXPR.sin(*x), + sympy.asin: lambda x: EXPR.asin(*x), + sympy.sinh: lambda x: EXPR.sinh(*x), + sympy.asinh: lambda x: EXPR.asinh(*x), + sympy.cos: lambda x: EXPR.cos(*x), + sympy.acos: lambda x: EXPR.acos(*x), + sympy.cosh: lambda x: EXPR.cosh(*x), + sympy.acosh: lambda x: EXPR.acosh(*x), + sympy.tan: lambda x: EXPR.tan(*x), + sympy.atan: lambda x: EXPR.atan(*x), + sympy.tanh: lambda x: EXPR.tanh(*x), + sympy.atanh: lambda x: EXPR.atanh(*x), + sympy.ceiling: lambda x: EXPR.ceil(*x), + sympy.floor: lambda x: EXPR.floor(*x), + sympy.sqrt: lambda x: EXPR.sqrt(*x), + sympy.Abs: lambda x: abs(*x), sympy.Derivative: _nondifferentiable, - sympy.Tuple: lambda *x: x, + sympy.Tuple: lambda x: x, + sympy.Or: lambda x: LEXPR.lor(*x), + sympy.And: lambda x: LEXPR.land(*x), + sympy.Implies: lambda x: LEXPR.implies(*x), + sympy.Equivalent: lambda x: LEXPR.equivalents(*x), + sympy.Not: lambda x: LEXPR.lnot(*x), + sympy.LessThan: lambda x: operator.le(*x), + sympy.StrictLessThan: lambda x: operator.lt(*x), + sympy.GreaterThan: lambda x: operator.ge(*x), + sympy.StrictGreaterThan: lambda x: operator.gt(*x), + sympy.Equality: lambda x: operator.eq(*x), } ) _pyomo_operator_map.update( { EXPR.SumExpression: sympy.Add, + EXPR.LinearExpression: sympy.Add, EXPR.ProductExpression: sympy.Mul, - EXPR.NPV_ProductExpression: sympy.Mul, EXPR.MonomialTermExpression: sympy.Mul, + EXPR.ExternalFunctionExpression: _external_fcn, + LEXPR.AndExpression: sympy.And, + LEXPR.OrExpression: sympy.Or, + LEXPR.ImplicationExpression: sympy.Implies, + LEXPR.EquivalenceExpression: sympy.Equivalent, + LEXPR.XorExpression: sympy.Xor, + LEXPR.NotExpression: sympy.Not, } ) @@ -94,18 +113,19 @@ def _configure_sympy(sympy, available): sympy, sympy_available = attempt_import('sympy', callback=_configure_sympy) -def _prod(*x): - ans = x[0] - for i in x[1:]: - ans *= i - return ans +if sys.version_info[:2] < (3, 8): + def _prod(args): + ans = 1 + for arg in args: + ans *= arg + return ans -def _sum(*x): - return sum(x_ for x_ in x) +else: + from math import prod as _prod -def _nondifferentiable(*x): +def _nondifferentiable(x): if type(x[1]) is tuple: # sympy >= 1.3 returns tuples (var, order) wrt = x[1][0] @@ -117,6 +137,13 @@ def _nondifferentiable(*x): ) +def _external_fcn(*x): + raise ValueError( + "Expressions containing external functions are not convertible to " + f"sympy expressions (found 'f{x}')" + ) + + class PyomoSympyBimap(object): def __init__(self): self.pyomo2sympy = ComponentMap() @@ -172,15 +199,16 @@ def beforeChild(self, node, child, child_idx): if type(child) in native_types: return False, child # - # We will descend into all expressions... - # - if child.is_expression_type(): - return True, None - # # Replace pyomo variables with sympy variables # if child.is_potentially_variable(): - return False, self.object_map.getSympySymbol(child) + # + # We will descend into all expressions... + # + if child.is_expression_type(): + return True, None + else: + return False, self.object_map.getSympySymbol(child) # # Everything else is a constant... # @@ -197,21 +225,19 @@ def initializeWalker(self, expr): return self.beforeChild(None, expr, None) def enterNode(self, node): - return (node._args, []) + return (node.args, []) def exitNode(self, node, values): """Visit nodes that have been expanded""" - _sympyOp = node - _op = _operatorMap.get(type(_sympyOp), None) + _op = _operatorMap.get(node.func, None) if _op is None: raise DeveloperError( - "sympy expression type %s not found in the operator " - "map" % type(_sympyOp) + f"sympy expression type {node.func} not found in the operator map" ) - return _op(*tuple(values)) + return _op(tuple(values)) def beforeChild(self, node, child, child_idx): - if not child._args: + if not child.args: item = self.object_map.getPyomoSymbol(child, None) if item is None: item = float(child.evalf()) diff --git a/pyomo/core/plugins/transform/logical_to_linear.py b/pyomo/core/plugins/transform/logical_to_linear.py index 26a73a34004..c970e950c5b 100644 --- a/pyomo/core/plugins/transform/logical_to_linear.py +++ b/pyomo/core/plugins/transform/logical_to_linear.py @@ -1,6 +1,7 @@ """Transformation from BooleanVar and LogicalConstraint to Binary and Constraints.""" from pyomo.common.collections import ComponentMap +from pyomo.common.errors import MouseTrap, DeveloperError from pyomo.common.modeling import unique_component_name from pyomo.common.config import ConfigBlock, ConfigValue from pyomo.contrib.fbbt.fbbt import compute_bounds_on_expr @@ -335,7 +336,17 @@ def exitNode(self, node, values): + (-(rhs_lb - 1) + num_args) * (1 - less_than_binary), sum_values >= values[0] + 1 - (rhs_ub + 1) * (1 - more_than_binary), ] - pass + if type(node) in _numeric_relational_types: + raise MouseTrap( + "core.logical_to_linear does not support transforming " + "LogicalConstraints with embedded relational expressions. " + f"Found '{node}'." + ) + else: + raise DeveloperError( + f"Unsupported node type {type(node)} encountered when " + f"transforming a CNF expression to its linear equivalent ({node})." + ) def beforeChild(self, node, child, child_idx): if type(node) in special_boolean_atom_types and child is node.args[0]: @@ -349,7 +360,13 @@ def beforeChild(self, node, child, child_idx): return True, None # Only thing left should be _BooleanVarData - return False, child.get_associated_binary() + # + # TODO: After the expr_multiple_dispatch is merged, this should + # be switched to using as_numeric. + if hasattr(child, 'get_associated_binary'): + return False, child.get_associated_binary() + else: + return False, child def finalizeResult(self, result): if type(result) is list: diff --git a/pyomo/core/tests/unit/test_logical_to_linear.py b/pyomo/core/tests/unit/test_logical_to_linear.py index a7ad10da9eb..16367eb746f 100644 --- a/pyomo/core/tests/unit/test_logical_to_linear.py +++ b/pyomo/core/tests/unit/test_logical_to_linear.py @@ -10,6 +10,7 @@ # ___________________________________________________________________________ import pyomo.common.unittest as unittest +from pyomo.common.errors import MouseTrap, DeveloperError from pyomo.common.log import LoggingIntercept import logging @@ -21,14 +22,16 @@ ConcreteModel, BooleanVar, LogicalConstraint, - lor, TransformationFactory, RangeSet, Var, Constraint, + ExternalFunction, ComponentMap, value, BooleanSet, + land, + lor, atleast, atmost, exactly, @@ -827,6 +830,36 @@ def test_target_with_unrecognized_type(self): ): TransformationFactory('core.logical_to_linear').apply_to(m, targets=1) + def test_mixed_logical_relational_expressions(self): + m = ConcreteModel() + m.x = Var() + m.y = BooleanVar([1, 2]) + m.c = LogicalConstraint(expr=(land(m.y[1], m.y[2]).implies(m.x >= 0))) + with self.assertRaisesRegex( + MouseTrap, + "core.logical_to_linear does not support transforming " + "LogicalConstraints with embedded relational expressions. " + "Found '0.0 <= x'.", + normalize_whitespace=True, + ): + TransformationFactory('core.logical_to_linear').apply_to(m) + + def test_external_function(self): + def _fcn(*args): + raise RuntimeError('unreachable') + + m = ConcreteModel() + m.x = Var() + m.f = ExternalFunction(_fcn) + m.y = BooleanVar() + m.c = LogicalConstraint(expr=(m.y.implies(m.f(m.x)))) + with self.assertRaisesRegex( + ValueError, + "Expressions containing external functions are not convertible " + r"to sympy expressions \(found 'f\(x1", + ): + TransformationFactory('core.logical_to_linear').apply_to(m) + @unittest.skipUnless(sympy_available, "Sympy not available") class TestLogicalToLinearBackmap(unittest.TestCase): diff --git a/pyomo/core/tests/unit/test_symbolic.py b/pyomo/core/tests/unit/test_symbolic.py index 7a839cf33c5..bbac4599363 100644 --- a/pyomo/core/tests/unit/test_symbolic.py +++ b/pyomo/core/tests/unit/test_symbolic.py @@ -397,7 +397,8 @@ def test_errors(self): class bogus(object): def __init__(self): - self._args = (obj_map.getSympySymbol(m.x),) + self.args = (obj_map.getSympySymbol(m.x),) + self.func = type(self) self.assertRaisesRegex( DeveloperError, diff --git a/pyomo/environ/tests/test_environ.py b/pyomo/environ/tests/test_environ.py index 726c3b8eb68..e22fce7546c 100644 --- a/pyomo/environ/tests/test_environ.py +++ b/pyomo/environ/tests/test_environ.py @@ -144,6 +144,7 @@ def test_tpl_import_time(self): '__future__', 'argparse', 'ast', # Imported on Windows + 'backports_abc', # Imported by cython on Linux 'base64', # Imported on Windows 'cPickle', 'csv',