Repository: Pyomo/pyomo

Issue:
Allow Constraint.Infeasible to be used to disable a Disjunct
### Rationale
When generating indexed Disjuncts, it may be that some disjuncts contain constraints that are impossible due to the input data. The natural thing to do in that case is to return Constraint.Infeasible in a rule.

### Description
Currently, this throws a ValueError at pyomo/core/base/constraint.py:539 . One alternative for the user is to fix the disjunct's indicator variable to False and deactivate it. It would be nice for Constraint.Infeasible to have the same effect when used on a Disjunct. 

### Additional information
There is a TODO mentioning that this would be good to have, but I figured I would make an issue anyways.



Discussion:

Patch:
diff --git a/pyomo/common/numeric_types.py b/pyomo/common/numeric_types.py
index 96fd228c43f..e336acdc1e8 100644
--- a/pyomo/common/numeric_types.py
+++ b/pyomo/common/numeric_types.py
@@ -382,7 +382,8 @@ def value(obj, exception=True):
             tmp = obj(exception=True)
             if tmp is None:
                 raise ValueError(
-                    "No value for uninitialized NumericValue object %s" % (obj.name,)
+                    "No value for uninitialized %s object %s"
+                    % (type(obj).__name__, obj.name)
                 )
             return tmp
         except TemplateExpressionError:
diff --git a/pyomo/contrib/cp/transform/logical_to_disjunctive_walker.py b/pyomo/contrib/cp/transform/logical_to_disjunctive_walker.py
index 157f02b6803..6bece6a5319 100644
--- a/pyomo/contrib/cp/transform/logical_to_disjunctive_walker.py
+++ b/pyomo/contrib/cp/transform/logical_to_disjunctive_walker.py
@@ -29,6 +29,10 @@
 from pyomo.gdp.disjunct import AutoLinkedBooleanVar, Disjunct, Disjunction
 
 
+def _dispatch_boolean_const(visitor, node):
+    return False, 1 if node.value else 0
+
+
 def _dispatch_boolean_var(visitor, node):
     if node not in visitor.boolean_to_binary_map:
         binary = node.get_associated_binary()
@@ -197,6 +201,7 @@ def _dispatch_atmost(visitor, node, *args):
 _operator_dispatcher[EXPR.AtMostExpression] = _dispatch_atmost
 
 _before_child_dispatcher = {}
+_before_child_dispatcher[EXPR.BooleanConstant] = _dispatch_boolean_const
 _before_child_dispatcher[BV.ScalarBooleanVar] = _dispatch_boolean_var
 _before_child_dispatcher[BV.BooleanVarData] = _dispatch_boolean_var
 _before_child_dispatcher[AutoLinkedBooleanVar] = _dispatch_boolean_var
@@ -264,5 +269,9 @@ def finalizeResult(self, result):
         # This LogicalExpression must evaluate to True (but note that we cannot
         # fix this variable to 1 since this logical expression could be living
         # on a Disjunct and later need to be relaxed.)
-        self.constraints.add(result >= 1)
+        expr = result >= 1
+        if expr.__class__ is bool:
+            self.constraints.add(Constraint.Feasible if expr else Constraint.Infeasible)
+        else:
+            self.constraints.add(expr)
         return result
diff --git a/pyomo/contrib/incidence_analysis/incidence.py b/pyomo/contrib/incidence_analysis/incidence.py
index 3cfd82ad3d7..903207b632a 100644
--- a/pyomo/contrib/incidence_analysis/incidence.py
+++ b/pyomo/contrib/incidence_analysis/incidence.py
@@ -58,7 +58,7 @@ def _get_incident_via_standard_repn(
         except ValueError as err:
             # Catch error evaluating expression with uninitialized variables
             # TODO: Suppress logged error?
-            if "No value for uninitialized NumericValue" not in str(err):
+            if "No value for uninitialized VarData" not in str(err):
                 raise err
             value = None
         if value != 0:
diff --git a/pyomo/core/base/constraint.py b/pyomo/core/base/constraint.py
index e21f3fe2aaa..d9338ab1f70 100644
--- a/pyomo/core/base/constraint.py
+++ b/pyomo/core/base/constraint.py
@@ -39,6 +39,7 @@
     RangedExpression,
 )
 from pyomo.core.expr.expr_common import _type_check_exception_arg
+from pyomo.core.expr.relational_expr import TrivialRelationalExpression
 from pyomo.core.expr.template_expr import templatize_constraint
 from pyomo.core.base.component import ActiveComponentData, ModelComponentFactory
 from pyomo.core.base.global_set import UnindexedComponent_index
@@ -63,10 +64,11 @@
 
 _inf = float('inf')
 _nonfinite_values = {_inf, -_inf}
-_known_relational_expressions = {
+_known_relational_expression_types = {
     EqualityExpression,
     InequalityExpression,
     RangedExpression,
+    TrivialRelationalExpression,
 }
 _strict_relational_exprs = {True, (False, True), (True, False), (True, True)}
 _rule_returned_none_error = """Constraint '%s': rule returned None.
@@ -382,9 +384,7 @@ def get_value(self):
 
     def set_value(self, expr):
         """Set the expression on this constraint."""
-        # Clear any previously-cached normalized constraint
-        self._expr = None
-        if expr.__class__ in _known_relational_expressions:
+        if expr.__class__ in _known_relational_expression_types:
             if getattr(expr, 'strict', False) in _strict_relational_exprs:
                 raise ValueError(
                     "Constraint '%s' encountered a strict "
@@ -393,6 +393,7 @@ def set_value(self, expr):
                     "using '<=', '>=', or '=='." % (self.name,)
                 )
             self._expr = expr
+            return
 
         elif expr.__class__ is tuple:  # or expr_type is list:
             for arg in expr:
@@ -407,7 +408,7 @@ def set_value(self, expr):
                     "Constraint expressions expressed as tuples must "
                     "contain native numeric types or Pyomo NumericValue "
                     "objects. Tuple %s contained invalid type, %s"
-                    % (self.name, expr, arg.__class__.__name__)
+                    % (self.name, expr, type(arg).__name__)
                 )
             if len(expr) == 2:
                 #
@@ -420,6 +421,7 @@ def set_value(self, expr):
                         "cannot contain None [received %s]" % (self.name, expr)
                     )
                 self._expr = EqualityExpression(expr)
+                return
             elif len(expr) == 3:
                 #
                 # Form (ranged) inequality expression
@@ -430,6 +432,7 @@ def set_value(self, expr):
                     self._expr = InequalityExpression(expr[:2], False)
                 else:
                     self._expr = RangedExpression(expr, False)
+                return
             else:
                 raise ValueError(
                     "Constraint '%s' does not have a proper value. "
@@ -442,25 +445,9 @@ def set_value(self, expr):
         #
         # Ignore an 'empty' constraint
         #
-        elif expr.__class__ is type:
+        if expr is Constraint.Skip:
             del self.parent_component()[self.index()]
-            if expr is Constraint.Skip:
-                return
-            elif expr is Constraint.Infeasible:
-                # TODO: create a trivial infeasible constraint.  This
-                # could be useful in the case of GDP where certain
-                # disjuncts are trivially infeasible, but we would still
-                # like to express the disjunction.
-                # del self.parent_component()[self.index()]
-                raise ValueError("Constraint '%s' is always infeasible" % (self.name,))
-            else:
-                raise ValueError(
-                    "Constraint '%s' does not have a proper "
-                    "value. Found '%s'\nExpecting a tuple or "
-                    "relational expression. Examples:"
-                    "\n   sum(model.costs) == model.income"
-                    "\n   (0, model.price[item], 50)" % (self.name, str(expr))
-                )
+            return
 
         elif expr is None:
             raise ValueError(_rule_returned_none_error % (self.name,))
@@ -479,17 +466,18 @@ def set_value(self, expr):
             try:
                 if expr.is_expression_type(ExpressionType.RELATIONAL):
                     self._expr = expr
+                    return
             except AttributeError:
                 pass
-            if self._expr is None:
-                msg = (
-                    "Constraint '%s' does not have a proper "
-                    "value. Found '%s'\nExpecting a tuple or "
-                    "relational expression. Examples:"
-                    "\n   sum(model.costs) == model.income"
-                    "\n   (0, model.price[item], 50)" % (self.name, str(expr))
-                )
-                raise ValueError(msg)
+
+        raise ValueError(
+            "Constraint '%s' does not have a proper "
+            "value. Found %s '%s'\nExpecting a tuple or "
+            "relational expression. Examples:"
+            "\n   sum(model.costs) == model.income"
+            "\n   (0, model.price[item], 50)"
+            % (self.name, type(expr).__name__, str(expr))
+        )
 
     def lslack(self):
         """
@@ -619,10 +607,9 @@ class Constraint(ActiveIndexedComponent):
 
     _ComponentDataClass = ConstraintData
 
-    class Infeasible(object):
-        pass
+    Infeasible = TrivialRelationalExpression('Infeasible', (1, 0))
+    Feasible = TrivialRelationalExpression('Feasible', (0, 0))
 
-    Feasible = ActiveIndexedComponent.Skip
     NoConstraint = ActiveIndexedComponent.Skip
     Violated = Infeasible
     Satisfied = Feasible
@@ -640,11 +627,11 @@ def __new__(cls: Type[IndexedConstraint], *args, **kwds) -> IndexedConstraint: .
 
     def __new__(cls, *args, **kwds):
         if cls != Constraint:
-            return super(Constraint, cls).__new__(cls)
+            return super().__new__(cls)
         if not args or (args[0] is UnindexedComponent_set and len(args) == 1):
-            return super(Constraint, cls).__new__(AbstractScalarConstraint)
+            return super().__new__(AbstractScalarConstraint)
         else:
-            return super(Constraint, cls).__new__(IndexedConstraint)
+            return super().__new__(IndexedConstraint)
 
     @overload
     def __init__(self, *indexes, expr=None, rule=None, name=None, doc=None): ...
@@ -901,7 +888,7 @@ def set_value(self, expr):
         """Set the expression on this constraint."""
         if not self._data:
             self._data[None] = self
-        return super(ScalarConstraint, self).set_value(expr)
+        return super().set_value(expr)
 
     #
     # Leaving this method for backward compatibility reasons.
@@ -928,6 +915,7 @@ class SimpleConstraint(metaclass=RenamedClass):
         'add',
         'set_value',
         'to_bounded_expression',
+        'expr',
         'body',
         'lower',
         'upper',
@@ -981,7 +969,7 @@ def __init__(self, **kwargs):
         _rule = kwargs.pop('rule', None)
         self._starting_index = kwargs.pop('starting_index', 1)
 
-        super(ConstraintList, self).__init__(Set(dimen=1), **kwargs)
+        super().__init__(Set(dimen=1), **kwargs)
 
         self.rule = Initializer(
             _rule, treat_sequences_as_mappings=False, allow_generators=True
diff --git a/pyomo/core/base/logical_constraint.py b/pyomo/core/base/logical_constraint.py
index ffaf545848e..62300d61740 100644
--- a/pyomo/core/base/logical_constraint.py
+++ b/pyomo/core/base/logical_constraint.py
@@ -9,7 +9,6 @@
 #  This software is distributed under the 3-clause BSD License.
 #  ___________________________________________________________________________
 
-import inspect
 import sys
 import logging
 from weakref import ref as weakref_ref
@@ -18,23 +17,24 @@
 from pyomo.common.formatting import tabular_writer
 from pyomo.common.log import is_debug_set
 from pyomo.common.modeling import NOTSET
+from pyomo.common.pyomo_typing import overload
 from pyomo.common.timing import ConstructionTimer
 
 from pyomo.core.expr.expr_common import _type_check_exception_arg
 from pyomo.core.expr.boolean_value import as_boolean, BooleanConstant
 from pyomo.core.expr.numvalue import native_types, native_logical_types
 from pyomo.core.base.component import ActiveComponentData, ModelComponentFactory
+from pyomo.core.base.disable_methods import disable_methods
 from pyomo.core.base.global_set import UnindexedComponent_index
 from pyomo.core.base.indexed_component import (
     ActiveIndexedComponent,
     UnindexedComponent_set,
-    _get_indexed_component_data_name,
 )
-from pyomo.core.base.misc import apply_indexed_rule
+from pyomo.core.base.initializer import Initializer
 from pyomo.core.base.set import Set
 
-logger = logging.getLogger('pyomo.core')
-
+logger = logging.getLogger(__name__)
+_known_logical_expression_types = set()
 _rule_returned_none_error = """LogicalConstraint '%s': rule returned None.
 
 logical constraint rules must return a valid logical proposition.
@@ -49,7 +49,7 @@ class LogicalConstraintData(ActiveComponentData):
 
     Constructor arguments:
         component
-            The LogicalStatement object that owns this data.
+            The LogicalConstraint object that owns this data.
         expr
             The Pyomo expression stored in this logical constraint.
 
@@ -88,9 +88,7 @@ def __init__(self, expr=None, component=None):
     def __call__(self, exception=NOTSET):
         """Compute the value of the body of this logical constraint."""
         exception = _type_check_exception_arg(self, exception)
-        if self.body is None:
-            return None
-        return self.body(exception=exception)
+        return self.expr(exception=exception)
 
     #
     # Abstract Interface
@@ -99,36 +97,63 @@ def __call__(self, exception=NOTSET):
     @property
     def body(self):
         """Access the body of a logical constraint expression."""
-        return self._expr
+        return self.expr
 
     @property
     def expr(self):
         """Return the expression associated with this logical constraint."""
-        return self.get_value()
+        return self._expr
 
     def set_value(self, expr):
         """Set the expression on this logical constraint."""
+        if expr.__class__ in _known_logical_expression_types:
+            self._expr = expr
+            return
+        #
+        # Ignore an 'empty' constraint
+        #
+        if expr is LogicalConstraint.Skip:
+            del self.parent_component()[self.index()]
+            return
 
-        if expr is None:
-            self._expr = BooleanConstant(True)
+        elif expr.__class__ in native_logical_types:
+            self._expr = as_boolean(expr)
             return
 
-        expr_type = type(expr)
-        if expr_type in native_types and expr_type not in native_logical_types:
-            msg = (
-                "LogicalStatement '%s' does not have a proper value. "
-                "Found '%s'.\n"
-                "Expecting a logical expression or Boolean value. Examples:"
-                "\n   (m.Y1 & m.Y2).implies(m.Y3)"
-                "\n   atleast(1, m.Y1, m.Y2)"
-            )
-            raise ValueError(msg)
+        elif expr is None:
+            raise ValueError(_rule_returned_none_error % (self.name,))
 
-        self._expr = as_boolean(expr)
+        elif expr.__class__ not in native_types:
+            try:
+                if expr.is_logical_type():
+                    self._expr = expr
+                    _known_logical_expression_types.add(expr.__class__)
+                    return
+
+                # FIXME: we should extend the templating system to
+                # handle things like IntervalVar types so that
+                # indirection and things like CallExpression can
+                # properly propagate the expression type.  In the
+                # meantime, we will just assume that all template
+                # expressions are acceptable (which, while not correct,
+                # is consistent with prior behavior)
+                if hasattr(expr, '_resolve_template'):
+                    self._expr = expr
+                    return
+            except (AttributeError, TypeError):
+                pass
+
+        raise ValueError(
+            "Assigning improper value to LogicalConstraint '%s'. "
+            "Found %s '%s'.\n"
+            "Expecting a logical expression or Boolean value. Examples:"
+            "\n   (m.Y1 & m.Y2).implies(m.Y3)"
+            "\n   atleast(1, m.Y1, m.Y2)" % (self.name, type(expr).__name__, str(expr))
+        )
 
     def get_value(self):
         """Get the expression on this logical constraint."""
-        return self._expr
+        return self.expr
 
 
 class _LogicalConstraintData(metaclass=RenamedClass):
@@ -185,121 +210,94 @@ class LogicalConstraint(ActiveIndexedComponent):
 
     _ComponentDataClass = LogicalConstraintData
 
-    class Infeasible(object):
-        pass
+    Infeasible = BooleanConstant(False, 'Infeasible')
+    Feasible = BooleanConstant(True, 'Feasible')
 
-    Feasible = ActiveIndexedComponent.Skip
     NoConstraint = ActiveIndexedComponent.Skip
     Violated = Infeasible
     Satisfied = Feasible
 
     def __new__(cls, *args, **kwds):
         if cls != LogicalConstraint:
-            return super(LogicalConstraint, cls).__new__(cls)
+            return super().__new__(cls)
         if not args or (args[0] is UnindexedComponent_set and len(args) == 1):
-            return ScalarLogicalConstraint.__new__(ScalarLogicalConstraint)
+            return super().__new__(AbstractScalarLogicalConstraint)
         else:
-            return IndexedLogicalConstraint.__new__(IndexedLogicalConstraint)
+            return super().__new__(IndexedLogicalConstraint)
 
     def __init__(self, *args, **kwargs):
-        self.rule = kwargs.pop('rule', None)
-        self._init_expr = kwargs.pop('expr', None)
+        _init = self._pop_from_kwargs('Constraint', kwargs, ('rule', 'expr'), None)
+        self.rule = Initializer(_init)
+
         kwargs.setdefault('ctype', LogicalConstraint)
         ActiveIndexedComponent.__init__(self, *args, **kwargs)
 
-    #
-    # TODO: Ideally we would not override these methods and instead add
-    # the contents of _check_skip_add to the set_value() method.
-    # Unfortunately, until IndexedComponentData objects know their own
-    # index, determining the index is a *very* expensive operation.  If
-    # we refactor things so that the Data objects have their own index,
-    # then we can remove these overloads.
-    #
-
-    def _setitem_impl(self, index, obj, value):
-        if self._check_skip_add(index, value) is None:
-            del self[index]
-            return None
-        else:
-            obj.set_value(value)
-            return obj
-
-    def _setitem_when_not_present(self, index, value):
-        if self._check_skip_add(index, value) is None:
-            return None
-        else:
-            return super(LogicalConstraint, self)._setitem_when_not_present(
-                index=index, value=value
-            )
-
     def construct(self, data=None):
         """
         Construct the expression(s) for this logical constraint.
         """
-        if is_debug_set(logger):
-            logger.debug("Constructing logical constraint %s" % self.name)
         if self._constructed:
             return
-        timer = ConstructionTimer(self)
         self._constructed = True
 
+        timer = ConstructionTimer(self)
+        if is_debug_set(logger):
+            logger.debug("Constructing logical constraint %s" % self.name)
+
         if self._anonymous_sets is not None:
             for _set in self._anonymous_sets:
                 _set.construct()
 
-        _init_expr = self._init_expr
-        _init_rule = self.rule
-        #
-        # We no longer need these
-        #
-        self._init_expr = None
-        # Utilities like DAE assume this stays around
-        # self.rule = None
+        rule = self.rule
+        try:
+            # We do not (currently) accept data for constructing LogicalConstraints
+            index = None
+            assert data is None
 
-        if (_init_rule is None) and (_init_expr is None):
-            # No construction role or expression specified.
-            return
-
-        _self_parent = self._parent()
-        if not self.is_indexed():
-            #
-            # Scalar component
-            #
-            if _init_rule is None:
-                tmp = _init_expr
-            else:
-                try:
-                    tmp = _init_rule(_self_parent)
-                except Exception:
-                    err = sys.exc_info()[1]
-                    logger.error(
-                        "Rule failed when generating expression for "
-                        "logical constraint %s:\n%s: %s"
-                        % (self.name, type(err).__name__, err)
-                    )
-                    raise
-            self._setitem_when_not_present(None, tmp)
+            if rule is None:
+                # If there is no rule, then we are immediately done.
+                return
 
-        else:
-            if _init_expr is not None:
+            if rule.constant() and self.is_indexed():
                 raise IndexError(
                     "LogicalConstraint '%s': Cannot initialize multiple indices "
-                    "of a logical constraint with a single expression" % (self.name,)
+                    "of a constraint with a single expression" % (self.name,)
                 )
 
-            for ndx in self._index_set:
-                try:
-                    tmp = apply_indexed_rule(self, _init_rule, _self_parent, ndx)
-                except Exception:
-                    err = sys.exc_info()[1]
-                    logger.error(
-                        "Rule failed when generating expression for "
-                        "logical constraint %s with index %s:\n%s: %s"
-                        % (self.name, str(ndx), type(err).__name__, err)
-                    )
-                    raise
-                self._setitem_when_not_present(ndx, tmp)
-        timer.report()
+            block = self.parent_block()
+            if rule.contains_indices():
+                # The index is coming in externally; we need to validate it
+                for index in rule.indices():
+                    self[index] = rule(block, index)
+            elif not self.index_set().isfinite():
+                # If the index is not finite, then we cannot iterate
+                # over it.  Since the rule doesn't provide explicit
+                # indices, then there is nothing we can do (the
+                # assumption is that the user will trigger specific
+                # indices to be created at a later time).
+                pass
+            else:
+                # Bypass the index validation and create the member directly
+                for index in self.index_set():
+                    self._setitem_when_not_present(index, rule(block, index))
+        except Exception:
+            err = sys.exc_info()[1]
+            logger.error(
+                "Rule failed when generating expression for "
+                "LogicalConstraint %s with index %s:\n%s: %s"
+                % (self.name, str(index), type(err).__name__, err)
+            )
+            raise
+        finally:
+            timer.report()
+
+    def _getitem_when_not_present(self, idx):
+        if self.rule is None:
+            raise KeyError(idx)
+        con = self._setitem_when_not_present(idx, self.rule(self.parent_block(), idx))
+        if con is None:
+            raise KeyError(idx)
+        return con
 
     def _pprint(self):
         """
@@ -339,43 +337,6 @@ def display(self, prefix="", ostream=None):
             lambda k, v: [v.body()],
         )
 
-    #
-    # Checks flags like Constraint.Skip, etc. before actually creating a
-    # constraint object. Returns the ConstraintData object when it should be
-    #  added to the _data dict; otherwise, None is returned or an exception
-    # is raised.
-    #
-    def _check_skip_add(self, index, expr):
-        _expr_type = expr.__class__
-        if expr is None:
-            raise ValueError(
-                _rule_returned_none_error
-                % (_get_indexed_component_data_name(self, index),)
-            )
-
-        if expr is True:
-            raise ValueError(
-                "LogicalConstraint '%s' is always True."
-                % (_get_indexed_component_data_name(self, index),)
-            )
-        if expr is False:
-            raise ValueError(
-                "LogicalConstraint '%s' is always False."
-                % (_get_indexed_component_data_name(self, index),)
-            )
-
-        if _expr_type is tuple and len(expr) == 1:
-            if expr is LogicalConstraint.Skip:
-                # Note: LogicalConstraint.Feasible is Skip
-                return None
-            if expr is LogicalConstraint.Infeasible:
-                raise ValueError(
-                    "LogicalConstraint '%s' cannot be passed 'Infeasible' as a value."
-                    % (_get_indexed_component_data_name(self, index),)
-                )
-
-        return expr
-
 
 class ScalarLogicalConstraint(LogicalConstraintData, LogicalConstraint):
     """
@@ -394,22 +355,16 @@ def __init__(self, *args, **kwds):
     #
 
     @property
-    def body(self):
+    def expr(self):
         """Access the body of a logical constraint."""
-        if self._constructed:
-            if len(self._data) == 0:
-                raise ValueError(
-                    "Accessing the body of ScalarLogicalConstraint "
-                    "'%s' before the LogicalConstraint has been assigned "
-                    "an expression. There is currently "
-                    "nothing to access." % self.name
-                )
-            return LogicalConstraintData.body.fget(self)
-        raise ValueError(
-            "Accessing the body of logical constraint '%s' "
-            "before the LogicalConstraint has been constructed (there "
-            "is currently no value to return)." % self.name
-        )
+        if not self._data:
+            raise ValueError(
+                "Accessing the expr of ScalarLogicalConstraint "
+                "'%s' before the LogicalConstraint has been assigned "
+                "an expression. There is currently "
+                "nothing to access." % self.name
+            )
+        return LogicalConstraintData.expr.fget(self)
 
     #
     # Singleton logical constraints are strange in that we want them to be
@@ -424,19 +379,9 @@ def body(self):
 
     def set_value(self, expr):
         """Set the expression on this logical constraint."""
-        if not self._constructed:
-            raise ValueError(
-                "Setting the value of logical constraint '%s' "
-                "before the LogicalConstraint has been constructed (there "
-                "is currently no object to set)." % self.name
-            )
-
-        if len(self._data) == 0:
+        if not self._data:
             self._data[None] = self
-        if self._check_skip_add(None, expr) is None:
-            del self[None]
-            return None
-        return super(ScalarLogicalConstraint, self).set_value(expr)
+        return super().set_value(expr)
 
     #
     # Leaving this method for backward compatibility reasons.
@@ -458,6 +403,11 @@ class SimpleLogicalConstraint(metaclass=RenamedClass):
     __renamed__version__ = '6.0'
 
 
+@disable_methods({'add', 'set_value', 'expr', 'body'})
+class AbstractScalarLogicalConstraint(ScalarLogicalConstraint):
+    pass
+
+
 class IndexedLogicalConstraint(LogicalConstraint):
     #
     # Leaving this method for backward compatibility reasons
@@ -470,6 +420,11 @@ def add(self, index, expr):
         """Add a logical constraint with a given index."""
         return self.__setitem__(index, expr)
 
+    @overload
+    def __getitem__(self, index) -> LogicalConstraintData: ...
+
+    __getitem__ = ActiveIndexedComponent.__getitem__  # type: ignore
+
 
 @ModelComponentFactory.register("A list of logical constraints.")
 class LogicalConstraintList(IndexedLogicalConstraint):
@@ -479,13 +434,21 @@ class LogicalConstraintList(IndexedLogicalConstraint):
     added an index value is not specified.
     """
 
-    End = (1003,)
+    class End(object):
+        pass
 
     def __init__(self, **kwargs):
         """Constructor"""
         if 'expr' in kwargs:
             raise ValueError("LogicalConstraintList does not accept the 'expr' keyword")
-        LogicalConstraint.__init__(self, Set(dimen=1), **kwargs)
+        _rule = kwargs.pop('rule', None)
+        self._starting_index = kwargs.pop('starting_index', 1)
+
+        super().__init__(Set(dimen=1), **kwargs)
+
+        self.rule = Initializer(
+            _rule, treat_sequences_as_mappings=False, allow_generators=True
+        )
 
     def construct(self, data=None):
         """
@@ -495,61 +458,24 @@ def construct(self, data=None):
             return
         self._constructed = True
 
-        generate_debug_messages = is_debug_set(logger)
-        if generate_debug_messages:
-            logger.debug("Constructing logical constraint list %s" % self.name)
+        if is_debug_set(logger):
+            logger.debug("Constructing logical constraint list %s" % (self.name))
 
         if self._anonymous_sets is not None:
             for _set in self._anonymous_sets:
                 _set.construct()
 
-        assert self._init_expr is None
-        _init_rule = self.rule
-
-        #
-        # We no longer need these
-        #
-        self._init_expr = None
-        # Utilities like DAE assume this stays around
-        # self.rule = None
-
-        if _init_rule is None:
-            return
-
-        _generator = None
-        _self_parent = self._parent()
-        if inspect.isgeneratorfunction(_init_rule):
-            _generator = _init_rule(_self_parent)
-        elif inspect.isgenerator(_init_rule):
-            _generator = _init_rule
-        if _generator is None:
-            while True:
-                val = len(self._index_set) + 1
-                if generate_debug_messages:
-                    logger.debug("   Constructing logical constraint index " + str(val))
-                expr = apply_indexed_rule(self, _init_rule, _self_parent, val)
-                if expr is None:
-                    raise ValueError(
-                        "LogicalConstraintList '%s': rule returned None "
-                        "instead of LogicalConstraintList.End" % (self.name,)
-                    )
-                if (expr.__class__ is tuple) and (expr == LogicalConstraintList.End):
-                    return
-                self.add(expr)
-
-        else:
-            for expr in _generator:
-                if expr is None:
-                    raise ValueError(
-                        "LogicalConstraintList '%s': generator returned None "
-                        "instead of LogicalConstraintList.End" % (self.name,)
-                    )
-                if (expr.__class__ is tuple) and (expr == LogicalConstraintList.End):
-                    return
-                self.add(expr)
+        if self.rule is not None:
+            _rule = self.rule(self.parent_block(), ())
+            for cc in iter(_rule):
+                if cc is LogicalConstraintList.End:
+                    break
+                if cc is LogicalConstraint.Skip:
+                    continue
+                self.add(cc)
 
     def add(self, expr):
         """Add a logical constraint with an implicit index."""
-        next_idx = len(self._index_set) + 1
+        next_idx = len(self._index_set) + self._starting_index
         self._index_set.add(next_idx)
         return self.__setitem__(next_idx, expr)
diff --git a/pyomo/core/expr/boolean_value.py b/pyomo/core/expr/boolean_value.py
index d35ae953b3c..fbfa58694e5 100644
--- a/pyomo/core/expr/boolean_value.py
+++ b/pyomo/core/expr/boolean_value.py
@@ -30,17 +30,20 @@ def _generate_logical_proposition(etype, _self, _other):
 
 
 def as_boolean(obj):
-    """
-    A function that creates a BooleanConstant object that
-    wraps Python Boolean values.
+    """A function that converts its argument to a Pyomo Boolean (logical) object.
+
+    If `obj` is a Pyomo logical value (usually a BooleanValue subclass),
+    then `obj` is returned.  If `obj` is in `native_logical_types`, then
+    the value is wrapped in a :py:class:`BooleanConstant` and returned.
 
-    Args:
-        obj: The logical value that may be wrapped.
+    Parameters
+    ----------
+    obj: The value to process and return / convert.
 
-    Raises: TypeError if the object is in native_types and not in
-        native_logical_types
+    Raises
+    ------
+    TypeError: if `obj` is not a logical value
 
-    Returns: A true or false BooleanConstant or the original object
     """
     if obj.__class__ in native_logical_types:
         return BooleanConstant(obj)
@@ -48,8 +51,8 @@ def as_boolean(obj):
     # Ignore objects that are duck types to work with Pyomo expressions
     #
     try:
-        obj.is_expression_type()
-        return obj
+        if obj.is_logical_type():
+            return obj
     except AttributeError:
         pass
     #
@@ -57,9 +60,13 @@ def as_boolean(obj):
     #
     if obj.__class__ in native_types:
         raise TypeError(f"Cannot treat the value '{obj}' as a logical constant")
+    try:
+        _name = obj.name
+    except AttributeError:
+        _name = str(obj)
     raise TypeError(
-        "Cannot treat the value '%s' as a logical constant because it has "
-        "unknown type '%s'" % (str(obj), type(obj).__name__)
+        "The '%s' object '%s' is not a valid type for Pyomo "
+        "logical expressions" % (type(obj).__name__, _name)
     )
 
 
@@ -271,14 +278,32 @@ class BooleanConstant(BooleanValue):
         value           The initial value.
     """
 
-    __slots__ = ('value',)
-
-    def __init__(self, value):
-        if value not in native_logical_values:
-            raise TypeError(
-                'Not a valid BooleanValue. Unable to create a logical constant'
-            )
-        self.value = value
+    __slots__ = ('value', '_name')
+    singleton = {}
+
+    def __new__(cls, value, name=None):
+        if name is None:
+            name = value
+        if name not in cls.singleton:
+            if value not in native_logical_values:
+                raise TypeError(
+                    'Not a valid BooleanValue. Unable to create a logical constant'
+                )
+            cls.singleton[name] = super().__new__(cls)
+            cls.singleton[name].value = value
+            cls.singleton[name]._name = name
+        return cls.singleton[name]
+
+    def __deepcopy__(self, memo):
+        # Prevent deepcopy from duplicating this object
+        return self
+
+    def __reduce__(self):
+        return self.__class__, (self._name, self._args_)
+
+    def __init__(self, value, name=None):
+        # note that the meat of __init__ is called as part of __new__ above.
+        assert self.value == value
 
     def is_constant(self):
         return True
@@ -290,7 +315,7 @@ def is_potentially_variable(self):
         return False
 
     def __str__(self):
-        return str(self.value)
+        return str(self._name)
 
     def __nonzero__(self):
         return self.value
diff --git a/pyomo/core/expr/logical_expr.py b/pyomo/core/expr/logical_expr.py
index f481a00efe4..d2d25e8ded4 100644
--- a/pyomo/core/expr/logical_expr.py
+++ b/pyomo/core/expr/logical_expr.py
@@ -471,11 +471,14 @@ def _apply_operation(self, result):
         return all(result)
 
     def add(self, new_arg):
+        # FIXME: we should probably NOT perform this simplification
+        # here, and instead use a dispatcher-based expression generation
+        # system
         if new_arg.__class__ in native_logical_types:
-            if new_arg is False:
-                return BooleanConstant(False)
-            elif new_arg is True:
+            if new_arg:
                 return self
+            else:
+                return BooleanConstant(False)
         return _add_to_and_or_expression(self, new_arg)
 
 
@@ -498,11 +501,14 @@ def _apply_operation(self, result):
         return any(result)
 
     def add(self, new_arg):
+        # FIXME: we should probably NOT perform this simplification
+        # here, and instead use a dispatcher-based expression generation
+        # system
         if new_arg.__class__ in native_logical_types:
-            if new_arg is False:
-                return self
-            elif new_arg is True:
+            if new_arg:
                 return BooleanConstant(True)
+            else:
+                return self
         return _add_to_and_or_expression(self, new_arg)
 
 
diff --git a/pyomo/core/expr/relational_expr.py b/pyomo/core/expr/relational_expr.py
index 1704abec254..f72149e8ee2 100644
--- a/pyomo/core/expr/relational_expr.py
+++ b/pyomo/core/expr/relational_expr.py
@@ -307,6 +307,43 @@ def strict(self):
         return self._strict
 
 
+class TrivialRelationalExpression(InequalityExpression):
+    """A trivial relational expression
+
+    Note that an inequality is sufficient to induce infeasibility and is
+    simpler to relax (in the Big-M sense) than an equality.
+
+    Note that we do not want to provide a trivial equality constraint as
+    that can confuse solvers like Ipopt into believing that the model
+    has fewer degrees of freedom than it actually has.
+
+    """
+
+    __slots__ = ('_name',)
+    singleton = {}
+
+    def __new__(cls, name, args):
+        if name not in cls.singleton:
+            cls.singleton[name] = super().__new__(cls)
+            super().__init__(cls.singleton[name], args, False)
+            cls.singleton[name]._name = name
+        return cls.singleton[name]
+
+    def __init__(self, name, args):
+        # note that the meat of __init__ is called as part of __new__ above.
+        assert args == self.args
+
+    def __deepcopy__(self, memo):
+        # Prevent deepcopy from duplicating this object
+        return self
+
+    def __reduce__(self):
+        return self.__class__, (self._name, self._args_)
+
+    def _to_string(self, values, verbose, smap):
+        return self._name
+
+
 def inequality(lower=None, body=None, upper=None, strict=False):
     """
     A utility function that can be used to declare inequality and
diff --git a/pyomo/core/plugins/transform/logical_to_linear.py b/pyomo/core/plugins/transform/logical_to_linear.py
index 2d04ea7c5fb..849ea9fdda2 100644
--- a/pyomo/core/plugins/transform/logical_to_linear.py
+++ b/pyomo/core/plugins/transform/logical_to_linear.py
@@ -263,12 +263,14 @@ def _cnf_to_linear_constraint_list(cnf_expr, indicator_var=None, binary_varlist=
     # Screen for constants
     if type(cnf_expr) in native_types or cnf_expr.is_constant():
         if value(cnf_expr) is True:
+            # Trivially feasible: no constraints
             return []
         else:
-            raise ValueError(
-                "Cannot build linear constraint for logical expression with "
-                "constant value False: %s" % cnf_expr
-            )
+            # Trivially infeasible: we will return an infeasible
+            # constant expression, because if we are nested within
+            # something like a Disjunct, the model may still be feasible
+            # (only this disjunct is not feasible).
+            return [InequalityExpression((1, 0), False)]
     if cnf_expr.is_expression_type():
         return CnfToLinearVisitor(indicator_var, binary_varlist).walk_expression(
             cnf_expr
diff --git a/pyomo/gdp/disjunct.py b/pyomo/gdp/disjunct.py
index 16e2e4dca2f..311edc77388 100644
--- a/pyomo/gdp/disjunct.py
+++ b/pyomo/gdp/disjunct.py
@@ -27,8 +27,10 @@
     ModelComponentFactory,
     Binary,
     Block,
+    Constraint,
     ConstraintList,
     Any,
+    LogicalConstraint,
     LogicalConstraintList,
     BooleanValue,
     ScalarBooleanVar,
@@ -575,19 +577,32 @@ def set_value(self, expr):
             # The user was lazy and gave us a single constraint
             # expression or an iterable of expressions
             expressions = []
-            for _tmpe in e_iter:
+            propositions = []
+            for _tmp_e in e_iter:
                 try:
-                    if _tmpe.is_expression_type():
-                        expressions.append(_tmpe)
+                    if _tmp_e.is_expression_type(ExpressionType.RELATIONAL):
+                        expressions.append(_tmp_e)
+                        continue
+                    if _tmp_e.is_expression_type(ExpressionType.LOGICAL):
+                        propositions.append(_tmp_e)
                         continue
                 except AttributeError:
                     pass
+                # Note: Constraint.(In)Feasible is now a full relational
+                # expression, so we don't need to check for it.
+                # LogicalConstraint.(In)Feasible is a BooleanConstant,
+                # so is not an expression and needs to be explicitly
+                # checked for.
+                if _tmp_e in (LogicalConstraint.Feasible, LogicalConstraint.Infeasible):
+                    propositions.append(_tmp_e)
+                    continue
                 msg = " in '%s'" % (type(e).__name__,) if e_iter is e else ""
                 raise ValueError(
-                    "Unexpected term for Disjunction '%s'.\n"
-                    "    Expected a Disjunct object, relational expression, "
-                    "or iterable of\n    relational expressions but got '%s'%s"
-                    % (self.name, type(_tmpe).__name__, msg)
+                    "Unexpected term for Disjunction '%s'.\n    "
+                    "Expected a Disjunct object, relational or logical "
+                    "expression, or\n    iterable of relational/logical "
+                    "expressions but got '%s'%s"
+                    % (self.name, type(_tmp_e).__name__, msg)
                 )
 
             comp = self.parent_component()
@@ -603,19 +618,8 @@ def set_value(self, expr):
                 # happen automatically.
                 comp._autodisjuncts.construct()
             disjunct = comp._autodisjuncts[len(comp._autodisjuncts)]
-            disjunct.constraint = c = ConstraintList()
-            disjunct.propositions = p = LogicalConstraintList()
-            for e in expressions:
-                if e.is_expression_type(ExpressionType.RELATIONAL):
-                    c.add(e)
-                elif e.is_expression_type(ExpressionType.LOGICAL):
-                    p.add(e)
-                else:
-                    raise RuntimeError(
-                        "Unsupported expression type on Disjunct "
-                        f"{disjunct.name}: expected either relational or "
-                        f"logical expression, found {e.__class__.__name__}"
-                    )
+            disjunct.constraint = ConstraintList(rule=expressions)
+            disjunct.propositions = LogicalConstraintList(rule=propositions)
             self.disjuncts.append(disjunct)
 
 


Test patch:
diff --git a/pyomo/contrib/incidence_analysis/tests/test_incidence.py b/pyomo/contrib/incidence_analysis/tests/test_incidence.py
index 5e576754de8..1018a77c139 100644
--- a/pyomo/contrib/incidence_analysis/tests/test_incidence.py
+++ b/pyomo/contrib/incidence_analysis/tests/test_incidence.py
@@ -33,7 +33,7 @@ def test_uninitialized_value_error_message(self):
         m = pyo.ConcreteModel()
         m.x = pyo.Var([1, 2])
         m.x[1].set_value(5)
-        msg = "No value for uninitialized NumericValue"
+        msg = "No value for uninitialized VarData"
         with self.assertRaisesRegex(ValueError, msg):
             pyo.value(1 + m.x[1] * m.x[2])
 
diff --git a/pyomo/core/tests/unit/kernel/test_variable.py b/pyomo/core/tests/unit/kernel/test_variable.py
index 49ff290ebe9..14ef15c38ae 100644
--- a/pyomo/core/tests/unit/kernel/test_variable.py
+++ b/pyomo/core/tests/unit/kernel/test_variable.py
@@ -283,7 +283,7 @@ def test_domain(self):
         self.assertIs(v.lower, lb)
         self.assertIs(v.upper, ub)
         with self.assertRaisesRegex(
-            ValueError, 'No value for uninitialized NumericValue object None'
+            ValueError, 'No value for uninitialized parameter object None'
         ):
             v.bounds
 
diff --git a/pyomo/core/tests/unit/test_con.py b/pyomo/core/tests/unit/test_con.py
index af2fc879479..8c58d8663a7 100644
--- a/pyomo/core/tests/unit/test_con.py
+++ b/pyomo/core/tests/unit/test_con.py
@@ -816,7 +816,7 @@ def test_set_expr_undefined_univariate(self):
         model.c = Constraint(expr=ans)
 
         with self.assertRaisesRegex(
-            ValueError, "No value for uninitialized NumericValue object x"
+            ValueError, "No value for uninitialized ScalarVar object x"
         ):
             value(model.c)
         model.x = 2
@@ -1375,26 +1375,43 @@ def f(model):
 class MiscConTests(unittest.TestCase):
     def test_infeasible(self):
         m = ConcreteModel()
-        with self.assertRaisesRegex(ValueError, "Constraint 'c' is always infeasible"):
-            m.c = Constraint(expr=Constraint.Infeasible)
-        self.assertEqual(m.c._data, {})
+        m.c = Constraint(expr=Constraint.Infeasible)
+        self.assertIn(None, m.c._data)
+        self.assertEqual(m.c.lb, None)
+        self.assertEqual(m.c.body, 1)
+        self.assertEqual(m.c.ub, 0)
+
+        m.c = (0, 1, 2)
+        self.assertIn(None, m.c._data)
+        self.assertEqual(m.c.lb, 0)
+        self.assertEqual(m.c.body, 1)
+        self.assertEqual(m.c.ub, 2)
 
-        with self.assertRaisesRegex(ValueError, "Constraint 'c' is always infeasible"):
-            m.c = Constraint.Infeasible
-        self.assertEqual(m.c._data, {})
-        self.assertIsNone(m.c.expr)
+        m.c = Constraint.Infeasible
+        self.assertIn(None, m.c._data)
+        self.assertEqual(m.c.lb, None)
+        self.assertEqual(m.c.body, 1)
+        self.assertEqual(m.c.ub, 0)
+
+    def test_feasible(self):
+        m = ConcreteModel()
+        m.c = Constraint(expr=Constraint.Feasible)
+        self.assertIn(None, m.c._data)
+        self.assertEqual(m.c.lb, None)
+        self.assertEqual(m.c.body, 0)
+        self.assertEqual(m.c.ub, 0)
 
         m.c = (0, 1, 2)
         self.assertIn(None, m.c._data)
         self.assertEqual(m.c.lb, 0)
+        self.assertEqual(m.c.body, 1)
         self.assertEqual(m.c.ub, 2)
 
-        with self.assertRaisesRegex(ValueError, "Constraint 'c' is always infeasible"):
-            m.c = Constraint.Infeasible
-        self.assertEqual(m.c._data, {})
-        self.assertIsNone(m.c.expr)
+        m.c = Constraint.Feasible
+        self.assertIn(None, m.c._data)
         self.assertEqual(m.c.lb, None)
-        self.assertEqual(m.c.ub, None)
+        self.assertEqual(m.c.body, 0)
+        self.assertEqual(m.c.ub, 0)
 
     def test_slack_methods(self):
         model = ConcreteModel()
@@ -1859,11 +1876,11 @@ def test_potentially_variable_bounds(self):
         self.assertIs(m.c.lower, m.l)
         self.assertIs(m.c.upper, m.u)
         with self.assertRaisesRegex(
-            ValueError, 'No value for uninitialized NumericValue object l'
+            ValueError, 'No value for uninitialized ScalarExpression object l'
         ):
             m.c.lb
         with self.assertRaisesRegex(
-            ValueError, 'No value for uninitialized NumericValue object u'
+            ValueError, 'No value for uninitialized ScalarExpression object u'
         ):
             m.c.ub
 
diff --git a/pyomo/core/tests/unit/test_logical_constraint.py b/pyomo/core/tests/unit/test_logical_constraint.py
index 939178f618d..84174a16925 100644
--- a/pyomo/core/tests/unit/test_logical_constraint.py
+++ b/pyomo/core/tests/unit/test_logical_constraint.py
@@ -9,22 +9,32 @@
 #  This software is distributed under the 3-clause BSD License.
 #  ___________________________________________________________________________
 
+import io
+import logging
+
 import pyomo.common.unittest as unittest
 
+from pyomo.common.log import LoggingIntercept
+from pyomo.common.tee import capture_output
+from pyomo.core.expr import InequalityExpression
 from pyomo.core.expr.sympy_tools import sympy_available
 from pyomo.environ import (
     AbstractModel,
+    BooleanConstant,
     BooleanVar,
     ConcreteModel,
     LogicalConstraint,
+    LogicalConstraintList,
     TransformationFactory,
     Constraint,
+    Param,
+    NonNegativeIntegers,
 )
 from pyomo.repn import generate_standard_repn
 from pyomo.gdp import Disjunction
 
 
-class TestLogicalConstraintCreation(unittest.TestCase):
+class TestLogicalConstraint(unittest.TestCase):
     def create_model(self, abstract=False):
         if abstract is True:
             model = AbstractModel()
@@ -35,16 +45,277 @@ def create_model(self, abstract=False):
         model.z = BooleanVar()
         return model
 
+    def test_abstract_and_empty(self):
+        m = self.create_model(True)
+
+        @m.LogicalConstraint()
+        def p(m):
+            return LogicalConstraint.Skip
+
+        with self.assertRaisesRegex(
+            RuntimeError,
+            "Cannot access property 'expr' on AbstractScalarLogicalConstraint "
+            "'p' before it has been constructed",
+        ):
+            m.p.expr
+
+        i = m.create_instance()
+        with self.assertRaisesRegex(
+            ValueError,
+            "Accessing the expr of ScalarLogicalConstraint "
+            "'p' before the LogicalConstraint has been assigned "
+            "an expression.",
+        ):
+            i.p.body
+
     def test_construct(self):
         model = self.create_model()
 
-        def rule(model):
+        @model.LogicalConstraint()
+        def p(model):
             return model.x
 
-        model.p = LogicalConstraint(rule=rule)
-
         self.assertIs(model.p.body, model.x)
 
+        with LoggingIntercept() as LOG:
+            with self.assertRaisesRegex(
+                IndexError,
+                "LogicalConstraint 'q': Cannot initialize multiple indices of "
+                "a constraint with a single expression",
+            ):
+                model.q = LogicalConstraint(range(2), expr=model.x)
+        self.assertEqual(
+            LOG.getvalue().strip(),
+            """
+Rule failed when generating expression for LogicalConstraint q with index None:
+IndexError: LogicalConstraint 'q': Cannot initialize multiple indices of a constraint with a single expression
+Constructing component 'q' from data=None failed:
+    IndexError: LogicalConstraint 'q': Cannot initialize multiple indices of a constraint with a single expression
+""".strip(),
+        )
+
+        with LoggingIntercept(level=logging.DEBUG, module='pyomo.core.base') as LOG:
+            model.r = LogicalConstraint()
+        self.assertEqual(LOG.getvalue(), 'Constructing logical constraint r\n')
+
+        with LoggingIntercept(level=logging.DEBUG, module='pyomo.core.base') as LOG:
+            model.r.construct()
+        self.assertEqual(LOG.getvalue(), "")
+
+    def test_indexed_constructor(self):
+        m = self.create_model()
+        m.p = LogicalConstraint(
+            range(7),
+            rule=[
+                LogicalConstraint.Feasible,
+                LogicalConstraint.Infeasible,
+                LogicalConstraint.Skip,
+                m.x.implies(m.y),
+                m.x,
+                True,
+                False,
+            ],
+        )
+
+        self.assertExpressionsStructurallyEqual(m.p[0].expr, LogicalConstraint.Feasible)
+        self.assertExpressionsStructurallyEqual(
+            m.p[1].expr, LogicalConstraint.Infeasible
+        )
+        self.assertNotIn(2, m.p)
+        with self.assertRaises(KeyError):
+            m.p[2].expr
+        self.assertExpressionsStructurallyEqual(m.p[3].expr, m.x.implies(m.y))
+        self.assertExpressionsStructurallyEqual(m.p[4].expr, m.x)
+        self.assertExpressionsStructurallyEqual(m.p[5].expr, BooleanConstant(True))
+        self.assertExpressionsStructurallyEqual(m.p[6].expr, BooleanConstant(False))
+
+    def test_display(self):
+        m = self.create_model()
+        m.p = LogicalConstraint(
+            range(7),
+            rule=[
+                LogicalConstraint.Feasible,
+                LogicalConstraint.Infeasible,
+                LogicalConstraint.Skip,
+                m.x.implies(m.y),
+                m.x,
+                True,
+                False,
+            ],
+        )
+
+        ref = """p : Size=6
+    Key : Body
+      0 :  True
+      1 : False
+      3 :  None
+      4 :  None
+      5 :  True
+      6 : False
+"""
+        OUT = io.StringIO()
+        m.p.display(ostream=OUT)
+        self.assertEqual(ref, OUT.getvalue())
+
+        with capture_output() as OUT:
+            m.p.display()
+        self.assertEqual(ref, OUT.getvalue())
+
+        m.x = True
+        m.y = False
+
+        OUT = io.StringIO()
+        m.p.display(ostream=OUT)
+        self.assertEqual(
+            """p : Size=6
+    Key : Body
+      0 :  True
+      1 : False
+      3 : False
+      4 :  True
+      5 :  True
+      6 : False
+""",
+            OUT.getvalue(),
+        )
+
+        m.p.deactivate()
+
+        OUT = io.StringIO()
+        m.p.display(ostream=OUT)
+        self.assertEqual("", OUT.getvalue())
+
+    def test_pprint(self):
+        m = self.create_model()
+        m.p = LogicalConstraint(
+            range(7),
+            rule=[
+                LogicalConstraint.Feasible,
+                LogicalConstraint.Infeasible,
+                LogicalConstraint.Skip,
+                m.x.implies(m.y),
+                m.x,
+                True,
+                False,
+            ],
+        )
+
+        OUT = io.StringIO()
+        m.p.pprint(ostream=OUT)
+        self.assertEqual(
+            """p : Size=6, Index={0, 1, 2, 3, 4, 5, 6}, Active=True
+    Key : Body       : Active
+      0 :   Feasible :   True
+      1 : Infeasible :   True
+      3 :    x --> y :   True
+      4 :          x :   True
+      5 :       True :   True
+      6 :      False :   True
+""",
+            OUT.getvalue(),
+        )
+
+    def test_indexed_deferred_constructor(self):
+        m = self.create_model()
+
+        def p_rule(m, i):
+            return [
+                LogicalConstraint.Feasible,
+                LogicalConstraint.Infeasible,
+                LogicalConstraint.Skip,
+                m.x.implies(m.y),
+                m.x,
+                True,
+                False,
+            ][i]
+
+        m.p = LogicalConstraint(NonNegativeIntegers, rule=p_rule)
+
+        self.assertExpressionsStructurallyEqual(m.p[0].expr, LogicalConstraint.Feasible)
+        self.assertExpressionsStructurallyEqual(
+            m.p[1].expr, LogicalConstraint.Infeasible
+        )
+        self.assertNotIn(2, m.p)
+        with self.assertRaises(KeyError):
+            m.p[2].expr
+        self.assertExpressionsStructurallyEqual(m.p[3].expr, m.x.implies(m.y))
+        self.assertExpressionsStructurallyEqual(m.p[4].expr, m.x)
+        self.assertExpressionsStructurallyEqual(m.p[5].expr, BooleanConstant(True))
+        self.assertExpressionsStructurallyEqual(m.p[6].expr, BooleanConstant(False))
+
+        m.q = LogicalConstraint(NonNegativeIntegers)
+        with self.assertRaises(KeyError):
+            m.q[2].expr
+
+    def test_add(self):
+        m = self.create_model()
+        m.p = LogicalConstraint()
+        expr = m.x.implies(m.y)
+        with self.assertRaisesRegex(
+            ValueError,
+            "ScalarLogicalConstraint object 'p' does not accept "
+            "index values other than None. Invalid value: 1",
+        ):
+            m.p.add(1, expr)
+        self.assertEqual(len(m.p), 0)
+        m.p.add(None, expr)
+        self.assertEqual(len(m.p), 1)
+        self.assertIs(m.p.expr, expr)
+
+        m.q = LogicalConstraint(range(2))
+        m.q.add(0, expr)
+        self.assertEqual(len(m.q), 1)
+        self.assertIs(m.q[0].expr, expr)
+        m.q.add(1, expr)
+        self.assertEqual(len(m.q), 2)
+        self.assertIs(m.q[1].expr, expr)
+
+    def test_set_value(self):
+        m = self.create_model()
+
+        m.p = LogicalConstraint()
+        self.assertEqual(len(m.p), 0)
+
+        expr = m.x.implies(m.y)
+        m.p = expr
+        self.assertIs(m.p.expr, expr)
+
+        with self.assertRaisesRegex(
+            ValueError, "LogicalConstraint 'p': rule returned None"
+        ):
+            m.p = None
+        # (test that the expr is unchanged on error)
+        self.assertIs(m.p.expr, expr)
+
+        m.p = LogicalConstraint.Skip
+        self.assertEqual(len(m.p), 0)
+
+        m.p = LogicalConstraint.Feasible
+        self.assertExpressionsStructurallyEqual(m.p.expr, LogicalConstraint.Feasible)
+
+        m.p = LogicalConstraint.Infeasible
+        self.assertExpressionsStructurallyEqual(m.p.expr, LogicalConstraint.Infeasible)
+
+        with self.assertRaisesRegex(
+            ValueError, "Assigning improper value to LogicalConstraint 'p'."
+        ):
+            m.p = LogicalConstraint
+
+        with self.assertRaisesRegex(
+            ValueError, "Assigning improper value to LogicalConstraint 'p'."
+        ):
+            m.p = {}
+
+        with self.assertRaisesRegex(
+            ValueError, "Assigning improper value to LogicalConstraint 'p'."
+        ):
+            m.p = Param(mutable=True) + 1
+
+    def test_get_value(self):
+        m = self.create_model()
+        m.p = LogicalConstraint(expr=m.x.implies(m.y))
+        self.assertIs(m.p.get_value(), m.p.expr)
+
     def check_lor_on_disjunct(self, model, disjunct, x1, x2):
         x1 = x1.get_associated_binary()
         x2 = x2.get_associated_binary()
@@ -89,5 +360,49 @@ def test_statement_in_Disjunct_with_logical_to_linear(self):
     # TODO look to test_con.py for inspiration
 
 
+class TestLogicalConstraintList(unittest.TestCase):
+    def create_model(self, abstract=False):
+        if abstract is True:
+            model = AbstractModel()
+        else:
+            model = ConcreteModel()
+        model.x = BooleanVar()
+        model.y = BooleanVar()
+        model.z = BooleanVar()
+        return model
+
+    def test_construct(self):
+        m = self.create_model()
+
+        @m.LogicalConstraintList(starting_index=0)
+        def p(m):
+            yield m.x
+            yield m.x.implies(m.y)
+            yield True
+            yield LogicalConstraintList.Skip
+            yield False
+            yield LogicalConstraintList.End
+            yield m.y
+
+        self.assertEqual(len(m.p), 4)
+        self.assertExpressionsStructurallyEqual(m.p[0].expr, m.x)
+        self.assertExpressionsStructurallyEqual(m.p[1].expr, m.x.implies(m.y))
+        self.assertExpressionsStructurallyEqual(m.p[2].expr, BooleanConstant(True))
+        self.assertExpressionsStructurallyEqual(m.p[3].expr, BooleanConstant(False))
+
+        with self.assertRaisesRegex(
+            ValueError, "LogicalConstraintList does not accept the 'expr' keyword"
+        ):
+            m.q = LogicalConstraintList(expr=[m.x])
+
+        with LoggingIntercept(level=logging.DEBUG, module='pyomo.core.base') as LOG:
+            m.r = LogicalConstraintList()
+        self.assertEqual(LOG.getvalue(), 'Constructing logical constraint list r\n')
+
+        with LoggingIntercept(level=logging.DEBUG, module='pyomo.core.base') as LOG:
+            m.r.construct()
+        self.assertEqual(LOG.getvalue(), "")
+
+
 if __name__ == "__main__":
     unittest.main()
diff --git a/pyomo/core/tests/unit/test_logical_to_linear.py b/pyomo/core/tests/unit/test_logical_to_linear.py
index 23e3fca3927..789ef622984 100644
--- a/pyomo/core/tests/unit/test_logical_to_linear.py
+++ b/pyomo/core/tests/unit/test_logical_to_linear.py
@@ -14,12 +14,14 @@
 from pyomo.common.log import LoggingIntercept
 import logging
 
+from pyomo.core.expr import InequalityExpression
 from pyomo.core.expr.sympy_tools import sympy_available
 from pyomo.core.plugins.transform.logical_to_linear import (
     update_boolean_vars_from_binary,
 )
 from pyomo.environ import (
     ConcreteModel,
+    BooleanConstant,
     BooleanVar,
     LogicalConstraint,
     TransformationFactory,
@@ -147,12 +149,27 @@ def test_literal(self):
 
     def test_constant_True(self):
         m = ConcreteModel()
-        with self.assertRaisesRegex(
-            ValueError, "LogicalConstraint 'p' is always True."
-        ):
-            m.p = LogicalConstraint(expr=True)
-            TransformationFactory('core.logical_to_linear').apply_to(m)
-        self.assertIsNone(m.component('logic_to_linear'))
+        m.p = LogicalConstraint(expr=True)
+        self.assertExpressionsStructurallyEqual(m.p.expr, BooleanConstant(True))
+        TransformationFactory('core.logical_to_linear').apply_to(m)
+        self.assertIsNotNone(m.component('logic_to_linear'))
+        self.assertEqual(0, len(m.logic_to_linear.augmented_vars_asbinary))
+        self.assertEqual(0, len(m.logic_to_linear.transformed_constraints))
+        self.assertEqual(0, len(m.logic_to_linear.augmented_vars))
+
+    def test_constant_False(self):
+        m = ConcreteModel()
+        m.p = LogicalConstraint(expr=False)
+        self.assertExpressionsStructurallyEqual(m.p.expr, BooleanConstant(False))
+        TransformationFactory('core.logical_to_linear').apply_to(m)
+        self.assertIsNotNone(m.component('logic_to_linear'))
+        self.assertEqual(0, len(m.logic_to_linear.augmented_vars_asbinary))
+        self.assertEqual(1, len(m.logic_to_linear.transformed_constraints))
+        self.assertExpressionsStructurallyEqual(
+            m.logic_to_linear.transformed_constraints[1].expr,
+            InequalityExpression((1, 0), False),
+        )
+        self.assertEqual(0, len(m.logic_to_linear.augmented_vars))
 
     def test_nothing_to_do(self):
         m = ConcreteModel()
diff --git a/pyomo/core/tests/unit/test_numeric_expr_api.py b/pyomo/core/tests/unit/test_numeric_expr_api.py
index e6258cf29b8..9c5775432e9 100644
--- a/pyomo/core/tests/unit/test_numeric_expr_api.py
+++ b/pyomo/core/tests/unit/test_numeric_expr_api.py
@@ -311,7 +311,7 @@ def test_pow(self):
         self.assertEqual(e.polynomial_degree(), None)
         self.assertEqual(is_fixed(e), False)
         with self.assertRaisesRegex(
-            ValueError, 'No value for uninitialized NumericValue object y'
+            ValueError, 'No value for uninitialized ScalarVar object y'
         ):
             self.assertEqual(value(e), None)
         self.assertEqual(str(e), "y**x")
@@ -429,7 +429,7 @@ def test_prod(self):
         self.assertEqual(e.polynomial_degree(), 2)
         self.assertEqual(is_fixed(e), False)
         with self.assertRaisesRegex(
-            ValueError, 'No value for uninitialized NumericValue object y'
+            ValueError, 'No value for uninitialized ScalarVar object y'
         ):
             self.assertEqual(value(e), None)
         self.assertEqual(str(e), "y*x")
@@ -441,7 +441,7 @@ def test_prod(self):
         self.assertEqual(e.polynomial_degree(), 0)
         self.assertEqual(is_fixed(e), True)
         with self.assertRaisesRegex(
-            ValueError, 'No value for uninitialized NumericValue object y'
+            ValueError, 'No value for uninitialized ScalarVar object y'
         ):
             self.assertEqual(value(e), None)
         self.assertEqual(str(e), "y*x")
@@ -452,7 +452,7 @@ def test_prod(self):
         self.assertEqual(e.polynomial_degree(), 1)
         self.assertEqual(is_fixed(e), False)
         with self.assertRaisesRegex(
-            ValueError, 'No value for uninitialized NumericValue object y'
+            ValueError, 'No value for uninitialized ScalarVar object y'
         ):
             self.assertEqual(value(e), None)
         self.assertEqual(str(e), "y*x")
@@ -464,7 +464,7 @@ def test_prod(self):
         self.assertEqual(e.polynomial_degree(), None)
         self.assertEqual(is_fixed(e), False)
         with self.assertRaisesRegex(
-            ValueError, 'No value for uninitialized NumericValue object x'
+            ValueError, 'No value for uninitialized ScalarVar object x'
         ):
             self.assertEqual(value(e), None)
         self.assertEqual(str(e), "1/y*x")
diff --git a/pyomo/gdp/tests/common_tests.py b/pyomo/gdp/tests/common_tests.py
index 008d288082b..72b06847baa 100644
--- a/pyomo/gdp/tests/common_tests.py
+++ b/pyomo/gdp/tests/common_tests.py
@@ -40,6 +40,7 @@
 import pyomo.opt
 
 linear_solvers = pyomo.opt.check_available_solvers('glpk', 'cbc', 'gurobi', 'cplex')
+nonlinear_solvers = pyomo.opt.check_available_solvers('ipopt')
 
 # utility functions
 
@@ -1854,6 +1855,31 @@ def check_untransformed_network_raises_GDPError(self, transformation, **kwargs):
     )
 
 
+def check_trivial_constraints(self, solver, transformation, **kwds):
+    m = models.makeTrivialGDP()
+    TransformationFactory('gdp.%s' % transformation).apply_to(m, **kwds)
+    results = SolverFactory(solver).solve(m)
+    self.assertEqual(results.solver.termination_condition, TerminationCondition.optimal)
+    self.assertTrue(m.numeric.disjuncts[0].indicator_var.value)
+    self.assertFalse(m.numeric.disjuncts[1].indicator_var.value)
+    self.assertTrue(m.logical.disjuncts[0].indicator_var.value)
+    self.assertFalse(m.logical.disjuncts[1].indicator_var.value)
+
+    m.numeric.disjuncts[0].indicator_var.fix(False)
+    results = SolverFactory(solver).solve(m)
+    self.assertEqual(
+        results.solver.termination_condition, TerminationCondition.infeasible
+    )
+    m.numeric.disjuncts[0].indicator_var.unfix()
+
+    m.logical.disjuncts[0].indicator_var.fix(False)
+    results = SolverFactory(solver).solve(m)
+    self.assertEqual(
+        results.solver.termination_condition, TerminationCondition.infeasible
+    )
+    m.logical.disjuncts[0].indicator_var.unfix()
+
+
 def check_network_disjuncts(self, minimize, transformation, **kwds):
     m = models.makeExpandedNetworkDisjunction(minimize=minimize)
     TransformationFactory('gdp.%s' % transformation).apply_to(m, **kwds)
diff --git a/pyomo/gdp/tests/models.py b/pyomo/gdp/tests/models.py
index 18e5bf02f17..a0ba4d1988d 100644
--- a/pyomo/gdp/tests/models.py
+++ b/pyomo/gdp/tests/models.py
@@ -862,6 +862,16 @@ def makeAnyIndexedDisjunctionOfDisjunctDatas():
     return m
 
 
+def makeTrivialGDP():
+    m = ConcreteModel()
+
+    m.numeric = Disjunction(expr=[Constraint.Feasible, Constraint.Infeasible])
+    m.logical = Disjunction(
+        expr=[LogicalConstraint.Feasible, LogicalConstraint.Infeasible]
+    )
+    return m
+
+
 def makeNetworkDisjunction(minimize=True):
     """creates a GDP model with pyomo.network components"""
     m = ConcreteModel()
diff --git a/pyomo/gdp/tests/test_bigm.py b/pyomo/gdp/tests/test_bigm.py
index ddf0863ceef..571087b8822 100644
--- a/pyomo/gdp/tests/test_bigm.py
+++ b/pyomo/gdp/tests/test_bigm.py
@@ -2697,6 +2697,18 @@ def test_tighter_Ms_when_vars_fixed_forever(self):
         ct.check_linear_coef(self, repn, promise.d.indicator_var, 7)
 
 
+class TrivialDisjuncts(unittest.TestCase):
+    @unittest.skipIf(not ct.linear_solvers, "No linear solver available")
+    def test_trivial_disjuncts_linear(self):
+        ct.check_trivial_constraints(self, ct.linear_solvers[0], transformation='bigm')
+
+    @unittest.skipIf(not ct.nonlinear_solvers, "No linear solver available")
+    def test_trivial_disjuncts_nonlinear(self):
+        ct.check_trivial_constraints(
+            self, ct.nonlinear_solvers[0], transformation='bigm'
+        )
+
+
 class NetworkDisjuncts(unittest.TestCase, CommonTests):
     @unittest.skipIf(not ct.linear_solvers, "No linear solver available")
     def test_solution_maximize(self):
diff --git a/pyomo/gdp/tests/test_disjunct.py b/pyomo/gdp/tests/test_disjunct.py
index 2248531bf53..c8e5dfa2b02 100644
--- a/pyomo/gdp/tests/test_disjunct.py
+++ b/pyomo/gdp/tests/test_disjunct.py
@@ -114,22 +114,25 @@ def test_construct_invalid_component(self):
         with self.assertRaisesRegex(
             ValueError,
             "Unexpected term for Disjunction 'dd'.\n    "
-            "Expected a Disjunct object, relational expression, or iterable of\n"
-            "    relational expressions but got 'IndexedDisjunct'",
+            "Expected a Disjunct object, relational or logical "
+            "expression, or\n    iterable of relational/logical "
+            "expressions but got 'IndexedDisjunct'",
         ):
             m.dd = Disjunction(expr=[m.d])
         with self.assertRaisesRegex(
             ValueError,
             "Unexpected term for Disjunction 'ee'.\n    "
-            "Expected a Disjunct object, relational expression, or iterable of\n"
-            "    relational expressions but got 'str' in 'list'",
+            "Expected a Disjunct object, relational or logical "
+            "expression, or\n    iterable of relational/logical "
+            "expressions but got 'str' in 'list'",
         ):
             m.ee = Disjunction(expr=[['a']])
         with self.assertRaisesRegex(
             ValueError,
             "Unexpected term for Disjunction 'ff'.\n    "
-            "Expected a Disjunct object, relational expression, or iterable of\n"
-            "    relational expressions but got 'str'",
+            "Expected a Disjunct object, relational or logical "
+            "expression, or\n    iterable of relational/logical "
+            "expressions but got 'str'",
         ):
             m.ff = Disjunction(expr=['a'])