Repository: Pyomo/pyomo

Issue:
gdp.fix_disjuncts assumes all Disjuncts are set and doesn't die very gracefully if they aren't
## Summary

This is issue is mainly a question, which is: Should the `gdp.fix_disjuncts` transformation be a transformation from a GDP to a MI(N)LP? Historically, this seems to have been assumed: at least in `gdpopt`, after `fix_disjuncts` is called, it is assumed that the NLP subproblem can be solved with a MI(N)LP solver. The alternative would be for it to support `Disjunction` targets, in which case it should complain if not all the Disjuncts' indicator_vars in the Disjunction are set, but it could leave unspecified Disjunctions alone. In the first case, I think it would have a responsibility to transform logical stuff on the model. In the second case, probably not. In either case, it needs an error message that tells the user something more informative than the `ValueError` below.

### Steps to reproduce the issue

<!-- Please include the command run and/or provide an example script that demonstrates the problem. -->

```
from pyomo.environ import *
from pyomo.gdp import *

m = ConcreteModel()
m.d1 = Disjunct()
m.d2 = Disjunct()
m.d = Disjunction(expr=[m.d1, m.d2])
m.another1 = Disjunct()
m.another2 = Disjunct()
m.another = Disjunction(expr=[m.another1, m.another2])

m.d1.indicator_var.set_value(True)
m.d2.indicator_var.set_value(False)
        
TransformationFactory('gdp.fix_disjuncts').apply_to(m)
```

### Error Message

```
ERROR: evaluating object as numeric value: another1.binary_indicator_var
        (object: <class 'pyomo.gdp.disjunct.AutoLinkedBinaryVar'>)
    No value for uninitialized NumericValue object
    another1.binary_indicator_var
Traceback (most recent call last):
  File "partially_fixed.py", line 15, in <module>
    TransformationFactory('gdp.fix_disjuncts').apply_to(m)
  File "/home/esjohn/src/pyomo/pyomo/core/base/transformation.py", line 69, in apply_to
    self._apply_to(model, **kwds)
  File "/home/esjohn/src/pyomo/pyomo/gdp/plugins/fix_disjuncts.py", line 49, in _apply_to
    self._transformContainer(model)
  File "/home/esjohn/src/pyomo/pyomo/gdp/plugins/fix_disjuncts.py", line 66, in _transformContainer
    if fabs(value(_binary) - 1) <= self.config.integer_tolerance:
  File "/home/esjohn/src/pyomo/pyomo/core/expr/numvalue.py", line 143, in value
    raise ValueError(
ValueError: No value for uninitialized NumericValue object another1.binary_indicator_var
```

### Information on your system

Pyomo version: main
Python version: 3.8
Operating system: centos7
How Pyomo was installed (PyPI, conda, source): source
Solver (if applicable): 


Discussion:

Resolving patch:
diff --git a/pyomo/gdp/plugins/fix_disjuncts.py b/pyomo/gdp/plugins/fix_disjuncts.py
--- a/pyomo/gdp/plugins/fix_disjuncts.py
+++ b/pyomo/gdp/plugins/fix_disjuncts.py
@@ -4,7 +4,7 @@
 import logging
 from math import fabs
 
-from pyomo.common.config import ConfigBlock, ConfigValue, NonNegativeFloat
+from pyomo.common.config import ConfigBlock, NonNegativeFloat
 from pyomo.core.base import Transformation, TransformationFactory
 from pyomo.core.base.block import Block
 from pyomo.core.expr.numvalue import value
@@ -16,13 +16,18 @@
 
 @TransformationFactory.register(
     'gdp.fix_disjuncts',
-    doc="Fix disjuncts to their current Boolean values.")
+    doc="""Fix disjuncts to their current Boolean values and transforms any
+    LogicalConstraints and BooleanVars so that the resulting model is a
+    (MI)(N)LP.""")
 class GDP_Disjunct_Fixer(Transformation):
     """Fix disjuncts to their current Boolean values.
 
-    This reclassifies all disjuncts in the passed model instance as ctype Block and deactivates the
-    constraints and disjunctions within inactive disjuncts.
-
+    This reclassifies all disjuncts in the passed model instance as ctype Block
+    and deactivates the constraints and disjunctions within inactive disjuncts.
+    In addition, it transforms relvant LogicalConstraints and BooleanVars so
+    that the resulting model is a (MI)(N)LP (where it is only mixed-integer
+    if the model contains integer-domain Vars or BooleanVars which were not
+    indicator_vars of Disjuncs.
     """
 
     def __init__(self, **kwargs):
@@ -31,38 +36,44 @@ def __init__(self, **kwargs):
         super(GDP_Disjunct_Fixer, self).__init__(**kwargs)
 
     CONFIG = ConfigBlock("gdp.fix_disjuncts")
-    CONFIG.declare('integer_tolerance', ConfigValue(
-        default=1E-6,
-        domain=NonNegativeFloat,
-        description="tolerance on binary variable 0, 1 values"
-    ))
-
     def _apply_to(self, model, **kwds):
-        """Fix all disjuncts in the given model and reclassify them to Blocks."""
+        """Fix all disjuncts in the given model and reclassify them to 
+        Blocks."""
         config = self.config = self.CONFIG(kwds.pop('options', {}))
         config.set_value(kwds)
 
         self._transformContainer(model)
 
         # Reclassify all disjuncts
-        for disjunct_object in model.component_objects(Disjunct, descend_into=(Block, Disjunct)):
-            disjunct_object.parent_block().reclassify_component_type(disjunct_object, Block)
+        for disjunct_object in model.component_objects(Disjunct,
+                                                       descend_into=(Block,
+                                                                     Disjunct)):
+            disjunct_object.parent_block().reclassify_component_type(
+                disjunct_object, Block)
+
+        # Transform any remaining logical stuff
+        TransformationFactory('core.logical_to_linear').apply_to(model)
 
     def _transformContainer(self, obj):
         """Find all disjuncts in the container and transform them."""
-        for disjunct in obj.component_data_objects(ctype=Disjunct, active=True, descend_into=True):
-            _binary = disjunct.binary_indicator_var
-            if fabs(value(_binary) - 1) <= self.config.integer_tolerance:
+        for disjunct in obj.component_data_objects(ctype=Disjunct, active=True,
+                                                   descend_into=True):
+            _bool = disjunct.indicator_var
+            if _bool.value is None:
+                raise GDP_Error("The value of the indicator_var of "
+                                "Disjunct '%s' is None. All indicator_vars "
+                                "must have values before calling "
+                                "'fix_disjuncts'." % disjunct.name)
+            elif _bool.value:
                 disjunct.indicator_var.fix(True)
                 self._transformContainer(disjunct)
-            elif fabs(value(_binary)) <= self.config.integer_tolerance:
-                disjunct.deactivate()
             else:
-                raise ValueError(
-                    'Non-binary indicator variable value %s for disjunct %s'
-                    % (disjunct.name, value(_binary)))
+                # Deactivating fixes the indicator_var to False
+                disjunct.deactivate()
 
-        for disjunction in obj.component_data_objects(ctype=Disjunction, active=True, descend_into=True):
+        for disjunction in obj.component_data_objects(ctype=Disjunction,
+                                                      active=True,
+                                                      descend_into=True):
             self._transformDisjunctionData(disjunction)
 
     def _transformDisjunctionData(self, disjunction):


Test patch:
diff --git a/pyomo/gdp/tests/test_fix_disjuncts.py b/pyomo/gdp/tests/test_fix_disjuncts.py
--- a/pyomo/gdp/tests/test_fix_disjuncts.py
+++ b/pyomo/gdp/tests/test_fix_disjuncts.py
@@ -12,11 +12,14 @@
 # -*- coding: UTF-8 -*-
 """Tests disjunct fixing."""
 import pyomo.common.unittest as unittest
-from pyomo.environ import (Block,
-                           Constraint, ConcreteModel, TransformationFactory,
-                           NonNegativeReals)
+from pyomo.environ import (
+    Block, Constraint, ConcreteModel, TransformationFactory, NonNegativeReals,
+    BooleanVar, LogicalConstraint, SolverFactory, Objective, value, Var,
+    implies)
 from pyomo.gdp import Disjunct, Disjunction, GDP_Error
+from pyomo.opt import check_available_solvers
 
+solvers = check_available_solvers('gurobi')
 
 class TestFixDisjuncts(unittest.TestCase):
     """Tests fixing of disjuncts."""
@@ -28,8 +31,8 @@ def test_fix_disjunct(self):
         m.d2 = Disjunct()
         m.d2.c = Constraint()
         m.d = Disjunction(expr=[m.d1, m.d2])
-        m.d1.indicator_var.set_value(1)
-        m.d2.indicator_var.set_value(0)
+        m.d1.indicator_var.set_value(True)
+        m.d2.indicator_var.set_value(False)
 
         TransformationFactory('gdp.fix_disjuncts').apply_to(m)
         self.assertTrue(m.d1.indicator_var.fixed)
@@ -45,8 +48,8 @@ def test_xor_not_sum_to_1(self):
         m.d1 = Disjunct()
         m.d2 = Disjunct()
         m.d = Disjunction(expr=[m.d1, m.d2], xor=True)
-        m.d1.indicator_var.set_value(1)
-        m.d2.indicator_var.set_value(1)
+        m.d1.indicator_var.set_value(True)
+        m.d2.indicator_var.set_value(True)
         with self.assertRaises(GDP_Error):
             TransformationFactory('gdp.fix_disjuncts').apply_to(m)
 
@@ -55,8 +58,8 @@ def test_disjunction_not_sum_to_1(self):
         m.d1 = Disjunct()
         m.d2 = Disjunct()
         m.d = Disjunction(expr=[m.d1, m.d2], xor=False)
-        m.d1.indicator_var.set_value(0)
-        m.d2.indicator_var.set_value(0)
+        m.d1.indicator_var.set_value(False)
+        m.d2.indicator_var.set_value(False)
         with self.assertRaises(GDP_Error):
             TransformationFactory('gdp.fix_disjuncts').apply_to(m)
 
@@ -70,9 +73,95 @@ def test_disjunct_not_binary(self):
         m.d1.binary_indicator_var.set_value(0.5)
         m.d2.binary_indicator_var.set_value(0.5)
         with self.assertRaisesRegex(
-                ValueError, "Non-binary indicator variable value"):
+                GDP_Error, 
+                "The value of the indicator_var of "
+                "Disjunct 'd1' is None. All indicator_vars "
+                "must have values before calling "
+                "'fix_disjuncts'."):
             TransformationFactory('gdp.fix_disjuncts').apply_to(m)
 
+    def test_disjuncts_partially_fixed(self):
+        m = ConcreteModel()
+        m.d1 = Disjunct()
+        m.d2 = Disjunct()
+        m.d = Disjunction(expr=[m.d1, m.d2])
+        m.another1 = Disjunct()
+        m.another2 = Disjunct()
+        m.another = Disjunction(expr=[m.another1, m.another2])
+
+        m.d1.indicator_var.set_value(True)
+        m.d2.indicator_var.set_value(False)
+
+        with self.assertRaisesRegex(
+                GDP_Error,
+                "The value of the indicator_var of "
+                "Disjunct 'another1' is None. All indicator_vars "
+                "must have values before calling "
+                "'fix_disjuncts'."):
+            TransformationFactory('gdp.fix_disjuncts').apply_to(m)
+
+    @unittest.skipIf('gurobi' not in solvers, "Gurobi solver not available")
+    def test_logical_constraints_transformed(self):
+        """It is expected that the result of this transformation is a MI(N)LP,
+        so check that LogicalConstraints are handeled correctly"""
+        m = ConcreteModel()
+        m.x = Var(bounds=(0, 10))
+        m.d1 = Disjunct()
+        m.d2 = Disjunct()
+        m.d2.c = Constraint()
+        m.d = Disjunction(expr=[m.d1, m.d2])
+        m.another = Disjunction(expr=[[m.x == 3], [m.x == 0]])
+        m.Y = BooleanVar()
+        m.global_logical = LogicalConstraint(expr=m.Y.xor(m.d1.indicator_var))
+        m.d1.logical = LogicalConstraint(
+            expr=implies(~m.Y, m.another.disjuncts[0].indicator_var))
+        m.obj = Objective(expr=m.x)
+
+        m.d1.indicator_var.set_value(True)
+        m.d2.indicator_var.set_value(False)
+        m.another.disjuncts[0].indicator_var.set_value(True)
+        m.another.disjuncts[1].indicator_var.set_value(False)
+
+        TransformationFactory('gdp.fix_disjuncts').apply_to(m)
+
+        # Make sure there are no active LogicalConstraints
+        self.assertEqual(
+            len(list(m.component_data_objects(LogicalConstraint,
+                                              active=True,
+                                              descend_into=(Block,
+                                                            Disjunct)))), 0)
+        # See that it solves as expected
+        SolverFactory('gurobi').solve(m)
+        self.assertTrue(value(m.d1.indicator_var))
+        self.assertFalse(value(m.d2.indicator_var))
+        self.assertTrue(value(m.another.disjuncts[0].indicator_var))
+        self.assertFalse(value(m.another.disjuncts[1].indicator_var))
+        self.assertEqual(value(m.Y.get_associated_binary()), 0)
+        self.assertEqual(value(m.x), 3)
+
+    def test_reclassify_deactivated_disjuncts(self):
+        m = ConcreteModel()
+        m.d = Disjunct([1, 2, 3])
+        m.disjunction = Disjunction(expr=[m.d[1], m.d[2], m.d[3]])
+        m.d[1].deactivate()
+        m.d[2].indicator_var = True
+        m.d[3].indicator_var = False
+        
+        TransformationFactory('gdp.fix_disjuncts').apply_to(m)
+
+        self.assertTrue(m.d[1].indicator_var.fixed)
+        self.assertFalse(value(m.d[1].indicator_var))
+        self.assertFalse(m.d[1].active)
+        self.assertEqual(m.d[1].ctype, Block)
+
+        self.assertTrue(m.d[2].indicator_var.fixed)
+        self.assertTrue(value(m.d[2].indicator_var))
+        self.assertTrue(m.d[2].active)
+        self.assertTrue(m.d[3].indicator_var.fixed)
+        self.assertFalse(value(m.d[3].indicator_var))
+        self.assertFalse(m.d[3].active)
+        self.assertEqual(m.d[1].ctype, Block)
+        self.assertEqual(m.d[2].ctype, Block)
 
 if __name__ == '__main__':
     unittest.main()