Repository: Pyomo/pyomo

Issue:
DeveloperError: Internal Pyomo implementation error
Dear support team,

I have tried to run the following code. The first part is compiled without any issues, but the last shows the following note:

```
DeveloperError: Internal Pyomo implementation error:
	"sympy expression type '<class 'sympy.core.relational.LessThan'>' not found in the operator map"
	Please report this to the Pyomo Developers.
```

The code:
```
ml = ConcreteModel()
ml.I = RangeSet(3)
ml.M = RangeSet(2)
ml.PAIRS = Set(initialize = ml.I * ml.I, dimen=2, filter=lambda ml, i, j : i < j)

ml.x = BooleanVar(ml.I, ml.I)
ml.start = Var(ml.I, bounds=(0, 10))
ml.finish = Var(ml.I, bounds=(0, 10))

ml.c1 = LogicalConstraint(ml.PAIRS, ml.M, rule=lambda ml, i, j, m: land(ml.x[i,j], ml.x[j,m]).implies(lor(ml.finish[j]<=ml.start[i], ml.finish[i]<=ml.start[j])))
ml.c1.pprint()

# where the issue is occurred: 
TransformationFactory('core.logical_to_linear').apply_to(ml)
ml.logic_to_linear.transformed_constraints.pprint()
```

Would you please, how can I fix this?
Regards
DeveloperError: Internal Pyomo implementation error
Dear support team,

I have tried to run the following code. The first part is compiled without any issues, but the last shows the following note:

```
DeveloperError: Internal Pyomo implementation error:
	"sympy expression type '<class 'sympy.core.relational.LessThan'>' not found in the operator map"
	Please report this to the Pyomo Developers.
```

The code:
```
ml = ConcreteModel()
ml.I = RangeSet(3)
ml.M = RangeSet(2)
ml.PAIRS = Set(initialize = ml.I * ml.I, dimen=2, filter=lambda ml, i, j : i < j)

ml.x = BooleanVar(ml.I, ml.I)
ml.start = Var(ml.I, bounds=(0, 10))
ml.finish = Var(ml.I, bounds=(0, 10))

ml.c1 = LogicalConstraint(ml.PAIRS, ml.M, rule=lambda ml, i, j, m: land(ml.x[i,j], ml.x[j,m]).implies(lor(ml.finish[j]<=ml.start[i], ml.finish[i]<=ml.start[j])))
ml.c1.pprint()

# where the issue is occurred: 
TransformationFactory('core.logical_to_linear').apply_to(ml)
ml.logic_to_linear.transformed_constraints.pprint()
```

Would you please, how can I fix this?
Regards


Discussion:
Thanks for the report, @abb-omidi . Please provide the following information:

Pyomo version:
Python version:
Operating system:
How Pyomo was installed (PyPI, conda, source):
Solver (if applicable):

For future reference, please use the Bug report or Feature/enhancement request templates we supply. They ask all of the information we need to get started on debugging or prioritizing requests.
Thanks @mrmundt for your attention. Actually, I am working with Pyomo with Google-Colab. But, as far as I can see:

Pyomo version: 6.5.0
Python version: 3.9
Operating system: Google-colab
How Pyomo was installed (PyPI, conda, source): !pip install Pyomo
Solver (if applicable): - 

I hope this would be helpful.
 
Regards
Abbas
In this case, it is correct that you are getting an error, but we could be generating a better (different) message.  I'm working on a PR to improve the error message.

The crux of the problem is that you are mixing Logical and Relational expressions.  That is, you are putting a relational expression as a node in a logical expression.  The `core.logical_to_linear` transformation was designed for convert purely logical expressions (i.e., combinations of BooleanVars with and, or, xor, implies, equivalent, atleast, atmost, exactly expressions).  We are in the process of moving away from `core.logical_to_linear` and to a new transformation (`contrib.logical_to_disjunctive`) that is significantly faster and doesn't rely on sympy.  However, that transformation does not yet support "mixed" logical expressions, either (although it is on the relatively short list to add).  The challenge here is how to convert the relational expression to a boolean variable.  The simple approach is to add a disjunction for each term.  That is, convert:
```
(ml.x[i,j] & ml.x[j,m]) --> (ml.finish[j]<=ml.start[i] | ml.finish[i]<=ml.start[j])`
```
to (pardon the abuse of notation)
```
[ Y1[i,j]; ml.finish[j]<=ml.start[i] ] V [ ~Y1[i,j] ]
[ Y2[i,j]; ml.finish[i]<=ml.start[j] ] V [ ~Y2[i,j] ]
(ml.x[i,j] & ml.x[j,m]) --> (Y1[i,j] | Y2[i,j])
```
...unfortunately, while easy, that is a pretty poor relaxation.  A better approach is to convert the inner `lor` to a disjunction:
```
(ml.x[i,j] & ml.x[j,m]) --> ([ ml.finish[j]<=ml.start[i] ] V [ ml.finish[j]<=ml.start[i] ])
```
The relaxation here is better, but the transformation is more complicated (you have to recognize the inner disjunction as a nested disjunction and relax the implicit "or constraint' by a new Boolean variable.  That is, something like:
```
[ Y1; ml.finish[j]<=ml.start[i] ] V [ Y2; ml.finish[j]<=ml.start[i] ]
Y3 = Y1 | Y2
(ml.x[i,j] & ml.x[j,m]) --> Y3
```

OK, so how to work around this right now?  The "easiest" thing is to explicitly write the nested disjunction:
```
[Y1; [ ml.finish[j]<=ml.start[i] ] V [ ml.finish[j]<=ml.start[i] ]] V [ ~Y1 ]
(ml.x[i,j] & ml.x[j,m]) --> Y1
```
In Pyomo, we could do it with something like:
```python
from pyomo.gdp import Disjunction

@ml.Disjunct(ml.PAIRS)
def enforce_no_overlap(b, i, j):
    ml = b.model()
    @b.Disjunction()
    def no_overlap(b):
        return [ml.finish[i]<=ml.start[j], ml.finish[j]<=ml.start[i]]

@ml.Disjunction(ml.PAIRS)
def disj(ml, i, j):
    return [ml.enforce_no_overlap[i,j], []]

@ml.LogicalConstraint(ml.PAIRS, ml.M)
def c1(ml, i, j, m):
    return land(ml.x[i,j], ml.x[j,m]).implies(ml.enforce_no_overlap[i,j].indicator_var)
```

To solve the problem, you would need to convert the whole thing to a MIP, e.g., using a BigM relaxation (which will call `contrib.logical_to_disjunctive` for you):
```python
TransformationFactory('gdp.bigm').apply_to(ml)
```
Dear @jsiirola,

Many thanks for your detailed explanation. I confirm that the above form works, at least without throwing any error, but I need a hard time debugging what really is happening as I am pretty new to using Pyomo. Let's say, I have worked on some variants of the parallel machine scheduling problem. The mentioned logical expression, as you correctly pointed out, came back to well-known machine_no_overlap_constraints. This can be written as:

```
comp[j] <= start[i] + M*(1-x[i,m]) + M*(1-x[j,m]) + M*(alpha[i,j]),    forall m, i, j : i < j;
comp[i] <= start[j] + M*(1-x[i,m]) + M*(1-x[j,m]) + M*(1- alpha[i,j]),    forall m, i, j : i < j;
```
It is also, very similar to the implementation of Pyomo scheduling books. And it can be easily transformed into its equivalent GDF. But, it produces a weak relaxation. Now, I am seeking a tighter form of the above transformation. I have tried the following derivations:

```
Iff (x[i,m] and x[j,m]) => (c[j]<=s[i] or c[i]<=s[j]) => 
Iff (x[i,m] and x[j,m]) => (w_1[i,j] <=> c[j]<=s[i] or w_2[i,j] <=>c[i]<=s[j]) =>
1) (1-x[i,m]) + (1-x[j,m]) + w_1[i,j] + w_2[i,j] >= 1;
2) c[j] <= s[i] + M*(1-w_1[i,j]);
3) c[j] >= s[i] -M*w_1[i,j];
4) c[i] <= s[j] + M*(1-w_2[i,j]);
5) c[i] >= s[j] -M*w_2[i,j];
```
After solving the model with both approaches, I found that the second one can be solved so faster that the first one. The first transformation takes around 28 sec, while the second just takes around 2 sec. In both cases, the optimal solution is the same and the results are reasonable. 
1) At the moment what I am willing to know is, does my second transformation and your last formulation implies the same thing?
2) Would you pleaes, how can I write my second derivations as a GDF? I tried something like this, but it cannot implies no_overlap_constraint:

```
m.z = Var(m.J, m.M, domain=Binary)
m.y = Var(m.PAIRS, domain=Binary)    
BigM = max([JOBS[j]['release'] for j in m.J]) + sum([JOBS[j]['duration'] for j in m.J])
m.w_1 = Var(m.PAIRS, domain=Binary)
m.w_2 = Var(m.PAIRS, domain=Binary)

m.c7 = LogicalConstraint(m.PAIRS, m.M, rule=lambda m, i, j, mach: land(m.z[i,mach], m.z[j,mach]).implies(lor(m.w_1[i,j], m.w_2[i,j])))
m.c8 = Disjunction(m.PAIRS, rule=lambda m, j, k: [ m.start[j] + JOBS[j]['duration'] <= m.start[k] + BigM*(1- m.w_1[j,k]), m.start[j] + JOBS[j]['duration'] >= m.start[k] - (BigM)*(m.w_1[j,k]) ])
m.c9 = Disjunction(m.PAIRS, rule=lambda m, j, k: [ m.start[k] + JOBS[k]['duration'] <= m.start[j] + BigM*(1- m.w_2[j,k]), m.start[k] + JOBS[k]['duration'] >= m.start[j] - (BigM)*(m.w_1[j,k]) ])
```

Best regards
Abbas

Thanks for the report, @abb-omidi . Please provide the following information:

Pyomo version:
Python version:
Operating system:
How Pyomo was installed (PyPI, conda, source):
Solver (if applicable):

For future reference, please use the Bug report or Feature/enhancement request templates we supply. They ask all of the information we need to get started on debugging or prioritizing requests.
Thanks @mrmundt for your attention. Actually, I am working with Pyomo with Google-Colab. But, as far as I can see:

Pyomo version: 6.5.0
Python version: 3.9
Operating system: Google-colab
How Pyomo was installed (PyPI, conda, source): !pip install Pyomo
Solver (if applicable): - 

I hope this would be helpful.
 
Regards
Abbas
In this case, it is correct that you are getting an error, but we could be generating a better (different) message.  I'm working on a PR to improve the error message.

The crux of the problem is that you are mixing Logical and Relational expressions.  That is, you are putting a relational expression as a node in a logical expression.  The `core.logical_to_linear` transformation was designed for convert purely logical expressions (i.e., combinations of BooleanVars with and, or, xor, implies, equivalent, atleast, atmost, exactly expressions).  We are in the process of moving away from `core.logical_to_linear` and to a new transformation (`contrib.logical_to_disjunctive`) that is significantly faster and doesn't rely on sympy.  However, that transformation does not yet support "mixed" logical expressions, either (although it is on the relatively short list to add).  The challenge here is how to convert the relational expression to a boolean variable.  The simple approach is to add a disjunction for each term.  That is, convert:
```
(ml.x[i,j] & ml.x[j,m]) --> (ml.finish[j]<=ml.start[i] | ml.finish[i]<=ml.start[j])`
```
to (pardon the abuse of notation)
```
[ Y1[i,j]; ml.finish[j]<=ml.start[i] ] V [ ~Y1[i,j] ]
[ Y2[i,j]; ml.finish[i]<=ml.start[j] ] V [ ~Y2[i,j] ]
(ml.x[i,j] & ml.x[j,m]) --> (Y1[i,j] | Y2[i,j])
```
...unfortunately, while easy, that is a pretty poor relaxation.  A better approach is to convert the inner `lor` to a disjunction:
```
(ml.x[i,j] & ml.x[j,m]) --> ([ ml.finish[j]<=ml.start[i] ] V [ ml.finish[j]<=ml.start[i] ])
```
The relaxation here is better, but the transformation is more complicated (you have to recognize the inner disjunction as a nested disjunction and relax the implicit "or constraint' by a new Boolean variable.  That is, something like:
```
[ Y1; ml.finish[j]<=ml.start[i] ] V [ Y2; ml.finish[j]<=ml.start[i] ]
Y3 = Y1 | Y2
(ml.x[i,j] & ml.x[j,m]) --> Y3
```

OK, so how to work around this right now?  The "easiest" thing is to explicitly write the nested disjunction:
```
[Y1; [ ml.finish[j]<=ml.start[i] ] V [ ml.finish[j]<=ml.start[i] ]] V [ ~Y1 ]
(ml.x[i,j] & ml.x[j,m]) --> Y1
```
In Pyomo, we could do it with something like:
```python
from pyomo.gdp import Disjunction

@ml.Disjunct(ml.PAIRS)
def enforce_no_overlap(b, i, j):
    ml = b.model()
    @b.Disjunction()
    def no_overlap(b):
        return [ml.finish[i]<=ml.start[j], ml.finish[j]<=ml.start[i]]

@ml.Disjunction(ml.PAIRS)
def disj(ml, i, j):
    return [ml.enforce_no_overlap[i,j], []]

@ml.LogicalConstraint(ml.PAIRS, ml.M)
def c1(ml, i, j, m):
    return land(ml.x[i,j], ml.x[j,m]).implies(ml.enforce_no_overlap[i,j].indicator_var)
```

To solve the problem, you would need to convert the whole thing to a MIP, e.g., using a BigM relaxation (which will call `contrib.logical_to_disjunctive` for you):
```python
TransformationFactory('gdp.bigm').apply_to(ml)
```
Dear @jsiirola,

Many thanks for your detailed explanation. I confirm that the above form works, at least without throwing any error, but I need a hard time debugging what really is happening as I am pretty new to using Pyomo. Let's say, I have worked on some variants of the parallel machine scheduling problem. The mentioned logical expression, as you correctly pointed out, came back to well-known machine_no_overlap_constraints. This can be written as:

```
comp[j] <= start[i] + M*(1-x[i,m]) + M*(1-x[j,m]) + M*(alpha[i,j]),    forall m, i, j : i < j;
comp[i] <= start[j] + M*(1-x[i,m]) + M*(1-x[j,m]) + M*(1- alpha[i,j]),    forall m, i, j : i < j;
```
It is also, very similar to the implementation of Pyomo scheduling books. And it can be easily transformed into its equivalent GDF. But, it produces a weak relaxation. Now, I am seeking a tighter form of the above transformation. I have tried the following derivations:

```
Iff (x[i,m] and x[j,m]) => (c[j]<=s[i] or c[i]<=s[j]) => 
Iff (x[i,m] and x[j,m]) => (w_1[i,j] <=> c[j]<=s[i] or w_2[i,j] <=>c[i]<=s[j]) =>
1) (1-x[i,m]) + (1-x[j,m]) + w_1[i,j] + w_2[i,j] >= 1;
2) c[j] <= s[i] + M*(1-w_1[i,j]);
3) c[j] >= s[i] -M*w_1[i,j];
4) c[i] <= s[j] + M*(1-w_2[i,j]);
5) c[i] >= s[j] -M*w_2[i,j];
```
After solving the model with both approaches, I found that the second one can be solved so faster that the first one. The first transformation takes around 28 sec, while the second just takes around 2 sec. In both cases, the optimal solution is the same and the results are reasonable. 
1) At the moment what I am willing to know is, does my second transformation and your last formulation implies the same thing?
2) Would you pleaes, how can I write my second derivations as a GDF? I tried something like this, but it cannot implies no_overlap_constraint:

```
m.z = Var(m.J, m.M, domain=Binary)
m.y = Var(m.PAIRS, domain=Binary)    
BigM = max([JOBS[j]['release'] for j in m.J]) + sum([JOBS[j]['duration'] for j in m.J])
m.w_1 = Var(m.PAIRS, domain=Binary)
m.w_2 = Var(m.PAIRS, domain=Binary)

m.c7 = LogicalConstraint(m.PAIRS, m.M, rule=lambda m, i, j, mach: land(m.z[i,mach], m.z[j,mach]).implies(lor(m.w_1[i,j], m.w_2[i,j])))
m.c8 = Disjunction(m.PAIRS, rule=lambda m, j, k: [ m.start[j] + JOBS[j]['duration'] <= m.start[k] + BigM*(1- m.w_1[j,k]), m.start[j] + JOBS[j]['duration'] >= m.start[k] - (BigM)*(m.w_1[j,k]) ])
m.c9 = Disjunction(m.PAIRS, rule=lambda m, j, k: [ m.start[k] + JOBS[k]['duration'] <= m.start[j] + BigM*(1- m.w_2[j,k]), m.start[k] + JOBS[k]['duration'] >= m.start[j] - (BigM)*(m.w_1[j,k]) ])
```

Best regards
Abbas


Resolving patch:
diff --git a/pyomo/core/expr/cnf_walker.py b/pyomo/core/expr/cnf_walker.py
--- 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
--- 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
--- 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:


Test patch:
diff --git a/pyomo/core/tests/unit/test_logical_to_linear.py b/pyomo/core/tests/unit/test_logical_to_linear.py
--- 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
--- 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
--- 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',