Repository: Pyomo/pyomo

Issue:
Discretizing components with multi-dimensional indexing Sets
There is a subtle bug in Pyomo.DAE when discretizing components indexed by multi-dimensional Sets. The following works:

    m = ConcreteModel()
    m.s = Set(initialize=[('A','B'),('C','D')])
    m.t = ContinuousSet(bounds=(0,1))
    m.v = Var(m.t, m.s)
    m.dv = DerivativeVar(m.v)
    TransformationFactory('dae.finite_difference').apply_to(m, nfe=2)

but if the indexing sets are reordered, the discretization fails:

    m = ConcreteModel()
    m.s = Set(initialize=[('A','B'),('C','D')])
    m.t = ContinuousSet(bounds=(0,1))
    m.v = Var(m.s, m.t)
    m.dv = DerivativeVar(m.v)
    TransformationFactory('dae.finite_difference').apply_to(m, nfe=2)

This bug was reported by a user on the Pyomo forum.
Check for unrecognized transformation options in Pyomo.DAE
Unrecognized transformation options are currently ignored in Pyomo.DAE. The discretizations need to be "robustified" by using ConfigBlocks to check specified options or checking for an empty kwds dict after the expected options are processed.


Discussion:

Resolving patch:
diff --git a/pyomo/dae/diffvar.py b/pyomo/dae/diffvar.py
--- a/pyomo/dae/diffvar.py
+++ b/pyomo/dae/diffvar.py
@@ -86,7 +86,10 @@ def __init__(self, sVar, **kwds):
 
         try:
             num_contset = len(sVar._contset)
-        except:
+        except AttributeError:
+            # This dictionary keeps track of where the ContinuousSet appears
+            # in the index. This implementation assumes that every element
+            # in an indexing set has the same dimension.
             sVar._contset = {}
             sVar._derivative = {}
             if sVar.dim() == 0:
@@ -97,9 +100,11 @@ def __init__(self, sVar, **kwds):
                     sVar._contset[sidx_sets] = 0
             else:
                 sidx_sets = sVar._implicit_subsets
+                loc = 0
                 for i, s in enumerate(sidx_sets):
                     if s.type() is ContinuousSet:
-                        sVar._contset[s] = i
+                        sVar._contset[s] = loc
+                    loc += s.dimen
             num_contset = len(sVar._contset)
 
         if num_contset == 0:
diff --git a/pyomo/dae/plugins/colloc.py b/pyomo/dae/plugins/colloc.py
--- a/pyomo/dae/plugins/colloc.py
+++ b/pyomo/dae/plugins/colloc.py
@@ -26,6 +26,8 @@
 from pyomo.dae.misc import get_index_information
 from pyomo.dae.diffvar import DAE_Error
 
+from pyomo.common.config import ConfigBlock, ConfigValue, PositiveInt, In
+
 # If the user has numpy then the collocation points and the a matrix for
 # the Runge-Kutta basis formulation will be calculated as needed.
 # If the user does not have numpy then these values will be read from a
@@ -235,12 +237,39 @@ def calc_afinal(cp):
     return afinal
 
 
-@TransformationFactory.register('dae.collocation', 
-            doc="Discretizes a DAE model using "
-            "orthogonal collocation over finite elements transforming "
-            "the model into an NLP.")
+@TransformationFactory.register('dae.collocation',
+                doc="Discretizes a DAE model using orthogonal collocation over"
+                " finite elements transforming the model into an NLP.")
 class Collocation_Discretization_Transformation(Transformation):
 
+    CONFIG = ConfigBlock("dae.collocation")
+    CONFIG.declare('nfe', ConfigValue(
+        default=10,
+        domain=PositiveInt,
+        description="The desired number of finite element points to be "
+                    "included in the discretization"
+    ))
+    CONFIG.declare('ncp', ConfigValue(
+        default=3,
+        domain=PositiveInt,
+        description="The desired number of collocation points over each "
+                    "finite element"
+    ))
+    CONFIG.declare('wrt', ConfigValue(
+        default=None,
+        description="The ContinuousSet to be discretized",
+        doc="Indicates which ContinuousSet the transformation should be "
+            "applied to. If this keyword argument is not specified then the "
+            "same scheme will be applied to all ContinuousSets."
+    ))
+    CONFIG.declare('scheme', ConfigValue(
+        default='LAGRANGE-RADAU',
+        domain=In(['LAGRANGE-RADAU', 'LAGRANGE-LEGENDRE']),
+        description="Indicates which collocation scheme to apply",
+        doc="Options are 'LAGRANGE-RADAU' and 'LAGRANGE-LEGENDRE'. "
+            "The default scheme is Lagrange polynomials with Radau roots"
+    ))
+
     def __init__(self):
         super(Collocation_Discretization_Transformation, self).__init__()
         self._ncp = {}
@@ -335,17 +364,17 @@ def _apply_to(self, instance, **kwds):
                       should be applied to. If this keyword argument is not
                       specified then the same scheme will be applied to all
                       ContinuousSets.
-        scheme        Indicates which finite difference method to apply.
+        scheme        Indicates which collocation scheme to apply.
                       Options are 'LAGRANGE-RADAU' and 'LAGRANGE-LEGENDRE'. 
                       The default scheme is Lagrange polynomials with Radau
                       roots.
         """
 
-        tmpnfe = kwds.pop('nfe', 10)
-        tmpncp = kwds.pop('ncp', 3)
-        tmpds = kwds.pop('wrt', None)
-        tmpscheme = kwds.pop('scheme', 'LAGRANGE-RADAU')
-        self._scheme_name = tmpscheme.upper()
+        config = self.CONFIG(kwds)
+
+        tmpnfe = config.nfe
+        tmpncp = config.ncp
+        tmpds = config.wrt
 
         if tmpds is not None:
             if tmpds.type() is not ContinuousSet:
@@ -357,13 +386,6 @@ def _apply_to(self, instance, **kwds):
                                  % (tmpds.get_discretization_info()['scheme'],
                                     tmpds.name))
 
-        if tmpnfe <= 0:
-            raise ValueError(
-                "The number of finite elements must be at least 1")
-        if tmpncp <= 0:
-            raise ValueError(
-                "The number of collocation points must be at least 1")
-
         if None in self._nfe:
             raise ValueError(
                 "A general discretization scheme has already been applied to "
@@ -382,12 +404,8 @@ def _apply_to(self, instance, **kwds):
             self._ncp[tmpds.name] = tmpncp
             currentds = tmpds.name
 
+        self._scheme_name = config.scheme
         self._scheme = self.all_schemes.get(self._scheme_name, None)
-        if self._scheme is None:
-            raise ValueError("Unknown collocation scheme '%s' specified using "
-                             "the 'scheme' keyword. Valid schemes are "
-                             "'LAGRANGE-RADAU' and 'LAGRANGE-LEGENDRE'"
-                              % tmpscheme)
 
         if self._scheme_name == 'LAGRANGE-RADAU':
             self._get_radau_constants(currentds)
@@ -403,6 +421,10 @@ def _transformBlock(self, block, currentds):
         self._fe = {}
         for ds in block.component_objects(ContinuousSet, descend_into=True):
             if currentds is None or currentds == ds.name:
+                if 'scheme' in ds.get_discretization_info():
+                    raise DAE_Error("Attempting to discretize ContinuousSet "
+                                    "'%s' after it has already been discretized. "
+                                    % ds.name)
                 generate_finite_elements(ds, self._nfe[currentds])
                 if not ds.get_changed():
                     if len(ds) - 1 > self._nfe[currentds]:
diff --git a/pyomo/dae/plugins/finitedifference.py b/pyomo/dae/plugins/finitedifference.py
--- a/pyomo/dae/plugins/finitedifference.py
+++ b/pyomo/dae/plugins/finitedifference.py
@@ -21,6 +21,8 @@
 from pyomo.dae.misc import block_fully_discretized
 from pyomo.dae.diffvar import DAE_Error
 
+from pyomo.common.config import ConfigBlock, ConfigValue, PositiveInt, In
+
 logger = logging.getLogger('pyomo.dae')
 
 
@@ -117,6 +119,28 @@ class Finite_Difference_Transformation(Transformation):
     DAE, ODE, or PDE models.
     """
 
+    CONFIG = ConfigBlock("dae.finite_difference")
+    CONFIG.declare('nfe', ConfigValue(
+        default=10,
+        domain=PositiveInt,
+        description="The desired number of finite element points to be "
+                    "included in the discretization"
+    ))
+    CONFIG.declare('wrt', ConfigValue(
+        default=None,
+        description="The ContinuousSet to be discretized",
+        doc="Indicates which ContinuousSet the transformation should be "
+            "applied to. If this keyword argument is not specified then the "
+            "same scheme will be applied to all ContinuousSets."
+    ))
+    CONFIG.declare('scheme', ConfigValue(
+        default='BACKWARD',
+        domain=In(['BACKWARD', 'CENTRAL', 'FORWARD']),
+        description="Indicates which finite difference scheme to apply",
+        doc="Options are BACKWARD, CENTRAL, or FORWARD. The default scheme is "
+            "the backward difference method"
+    ))
+
     def __init__(self):
         super(Finite_Difference_Transformation, self).__init__()
         self._nfe = {}
@@ -141,10 +165,10 @@ def _apply_to(self, instance, **kwds):
                       scheme is the backward difference method
         """
 
-        tmpnfe = kwds.pop('nfe', 10)
-        tmpds = kwds.pop('wrt', None)
-        tmpscheme = kwds.pop('scheme', 'BACKWARD')
-        self._scheme_name = tmpscheme.upper()
+        config = self.CONFIG(kwds)
+
+        tmpnfe = config.nfe
+        tmpds = config.wrt
 
         if tmpds is not None:
             if tmpds.type() is not ContinuousSet:
@@ -156,10 +180,6 @@ def _apply_to(self, instance, **kwds):
                                  (tmpds.get_discretization_info()['scheme'],
                                   tmpds.name))
 
-        if tmpnfe < 1:
-            raise ValueError(
-                "The number of finite elements must be at least 1")
-
         if None in self._nfe:
             raise ValueError(
                 "A general discretization scheme has already been applied to "
@@ -175,12 +195,8 @@ def _apply_to(self, instance, **kwds):
             self._nfe[tmpds.name] = tmpnfe
             currentds = tmpds.name
 
+        self._scheme_name = config.scheme
         self._scheme = self.all_schemes.get(self._scheme_name, None)
-        if self._scheme is None:
-            raise ValueError("Unknown finite difference scheme '%s' specified "
-                             "using the 'scheme' keyword. Valid schemes are "
-                             "'BACKWARD', 'CENTRAL', and 'FORWARD'" %
-                             tmpscheme)
 
         self._transformBlock(instance, currentds)
 
@@ -191,6 +207,10 @@ def _transformBlock(self, block, currentds):
         self._fe = {}
         for ds in block.component_objects(ContinuousSet):
             if currentds is None or currentds == ds.name or currentds is ds:
+                if 'scheme' in ds.get_discretization_info():
+                    raise DAE_Error("Attempting to discretize ContinuousSet "
+                                    "'%s' after it has already been discretized. "
+                                    % ds.name)
                 generate_finite_elements(ds, self._nfe[currentds])
                 if not ds.get_changed():
                     if len(ds) - 1 > self._nfe[currentds]:


Test patch:
diff --git a/pyomo/dae/tests/test_colloc.py b/pyomo/dae/tests/test_colloc.py
--- a/pyomo/dae/tests/test_colloc.py
+++ b/pyomo/dae/tests/test_colloc.py
@@ -244,9 +244,11 @@ def test_disc_multi_index(self):
         self.assertTrue(len(m.dv2_disc_eq) == 45)
         self.assertTrue(len(m.v2) == 48)
 
-        expected_tau_points = [0.0, 0.1550510257216822, 0.64494897427831788,
+        expected_tau_points = [0.0, 0.1550510257216822,
+                               0.64494897427831788,
                                1.0]
-        expected_disc_points = [0, 0.310102, 1.289898, 2.0, 2.310102, 3.289898,
+        expected_disc_points = [0, 0.310102, 1.289898, 2.0, 2.310102,
+                                3.289898,
                                 4.0, 4.310102, 5.289898, 6.0, 6.310102,
                                 7.289898, 8.0, 8.310102, 9.289898, 10]
         disc_info = m.t.get_discretization_info()
@@ -259,7 +261,8 @@ def test_disc_multi_index(self):
         for idx, val in enumerate(list(m.t)):
             self.assertAlmostEqual(val, expected_disc_points[idx])
 
-        self.assertTrue(hasattr(m, '_pyomo_dae_reclassified_derivativevars'))
+        self.assertTrue(
+            hasattr(m, '_pyomo_dae_reclassified_derivativevars'))
         self.assertTrue(m.dv1 in m._pyomo_dae_reclassified_derivativevars)
         self.assertTrue(m.dv2 in m._pyomo_dae_reclassified_derivativevars)
 
@@ -294,49 +297,78 @@ def test_disc_multi_index2(self):
         self.assertTrue(m.dv2dt in m._pyomo_dae_reclassified_derivativevars)
         self.assertTrue(m.dv2dt2 in m._pyomo_dae_reclassified_derivativevars)
 
+    # test collocation discretization on var indexed by ContinuousSet
+    # and multi-dimensional Set
+    def test_disc_multidimen_index(self):
+        m = self.m.clone()
+        m.s2 = Set(initialize=[('A', 'B'), ('C', 'D'), ('E', 'F')])
+        m.v2 = Var(m.t, m.s2)
+        m.dv2 = DerivativeVar(m.v2)
+        m.v3 = Var(m.s2, m.t)
+        m.dv3 = DerivativeVar(m.v3)
+
+        disc = TransformationFactory('dae.collocation')
+        disc.apply_to(m, nfe=5, ncp=3)
+
+        self.assertTrue(hasattr(m, 'dv1_disc_eq'))
+        self.assertTrue(hasattr(m, 'dv2_disc_eq'))
+        self.assertTrue(hasattr(m, 'dv3_disc_eq'))
+        self.assertTrue(len(m.dv2_disc_eq) == 45)
+        self.assertTrue(len(m.v2) == 48)
+        self.assertTrue(len(m.dv3_disc_eq) == 45)
+        self.assertTrue(len(m.v3) == 48)
+
+        expected_tau_points = [0.0, 0.1550510257216822,
+                               0.64494897427831788,
+                               1.0]
+        expected_disc_points = [0, 0.310102, 1.289898, 2.0, 2.310102,
+                                3.289898,
+                                4.0, 4.310102, 5.289898, 6.0, 6.310102,
+                                7.289898, 8.0, 8.310102, 9.289898, 10]
+        disc_info = m.t.get_discretization_info()
+
+        self.assertTrue(disc_info['scheme'] == 'LAGRANGE-RADAU')
+
+        for idx, val in enumerate(disc_info['tau_points']):
+            self.assertAlmostEqual(val, expected_tau_points[idx])
+
+        for idx, val in enumerate(list(m.t)):
+            self.assertAlmostEqual(val, expected_disc_points[idx])
+
+        self.assertTrue(
+            hasattr(m, '_pyomo_dae_reclassified_derivativevars'))
+        self.assertTrue(m.dv1 in m._pyomo_dae_reclassified_derivativevars)
+        self.assertTrue(m.dv2 in m._pyomo_dae_reclassified_derivativevars)
+        self.assertTrue(m.dv3 in m._pyomo_dae_reclassified_derivativevars)
+
     # test passing the discretization invalid options
     def test_disc_invalid_options(self):
         m = self.m.clone()
 
-        try:
+        with self.assertRaises(TypeError):
             TransformationFactory('dae.collocation').apply_to(m, wrt=m.s)
-            self.fail('Expected TypeError')
-        except TypeError:
-            pass
 
-        try:
+        with self.assertRaises(ValueError):
             TransformationFactory('dae.collocation').apply_to(m, nfe=-1)
-            self.fail('Expected ValueError')
-        except ValueError:
-            pass
 
-        try:
+        with self.assertRaises(ValueError):
             TransformationFactory('dae.collocation').apply_to(m, ncp=0)
-            self.fail('Expected ValueError')
-        except ValueError:
-            pass
 
-        try:
+        with self.assertRaises(ValueError):
             TransformationFactory('dae.collocation').apply_to(m, scheme='foo')
-            self.fail('Expected ValueError')
-        except ValueError:
-            pass
+
+        with self.assertRaises(ValueError):
+            TransformationFactory('dae.collocation').apply_to(m, foo=True)
 
         TransformationFactory('dae.collocation').apply_to(m, wrt=m.t)
-        try:
+        with self.assertRaises(ValueError):
             TransformationFactory('dae.collocation').apply_to(m, wrt=m.t)
-            self.fail('Expected ValueError')
-        except ValueError:
-            pass
 
         m = self.m.clone()
         disc = TransformationFactory('dae.collocation')
         disc.apply_to(m)
-        try:
+        with self.assertRaises(ValueError):
             disc.apply_to(m)
-            self.fail('Expected ValueError')
-        except ValueError:
-            pass
 
     # test looking up radau collocation points
     def test_lookup_radau_collocation_points(self):
@@ -372,12 +404,9 @@ def test_lookup_radau_collocation_points(self):
             self.assertAlmostEqual(val, expected_disc_points[idx])
 
         m = self.m.clone()
-        try:
+        with self.assertRaises(ValueError):
             disc = TransformationFactory('dae.collocation')
             disc.apply_to(m, ncp=15, scheme='LAGRANGE-RADAU')
-            self.fail('Expected ValueError')
-        except ValueError:
-            pass
 
         # Restore initial flag value
         pyomo.dae.plugins.colloc.numpy_available = colloc_numpy_avail
@@ -416,12 +445,9 @@ def test_lookup_legendre_collocation_points(self):
             self.assertAlmostEqual(val, expected_disc_points[idx])
 
         m = self.m.clone()
-        try:
+        with self.assertRaises(ValueError):
             disc = TransformationFactory('dae.collocation')
             disc.apply_to(m, ncp=15, scheme='LAGRANGE-LEGENDRE')
-            self.fail('Expected ValueError')
-        except ValueError:
-            pass
 
         # Restore initial flag value
         pyomo.dae.plugins.colloc.numpy_available = colloc_numpy_avail
@@ -446,11 +472,8 @@ def test_invalid_derivative(self):
         m.v = Var(m.t)
         m.dv = DerivativeVar(m.v, wrt=(m.t, m.t, m.t))
 
-        try:
+        with self.assertRaises(DAE_Error):
             TransformationFactory('dae.collocation').apply_to(m)
-            self.fail('Expected DAE_Error')
-        except DAE_Error:
-            pass
 
     # test reduce_collocation_points invalid options
     def test_reduce_colloc_invalid(self):
@@ -463,101 +486,74 @@ def test_reduce_colloc_invalid(self):
         disc.apply_to(m, nfe=5, ncp=3)
 
         # No ContinuousSet specified
-        try:
+        with self.assertRaises(TypeError):
             disc.reduce_collocation_points(m, contset=None)
-            self.fail('Expected TypeError')
-        except TypeError:
-            pass
 
         # Component passed in is not a ContinuousSet
-        try:
+        with self.assertRaises(TypeError):
             disc.reduce_collocation_points(m, contset=m.s)
-            self.fail('Expected TypeError')
-        except TypeError:
-            pass
 
         # Call reduce_collocation_points method before applying discretization
-        try:
+        with self.assertRaises(RuntimeError):
             disc2.reduce_collocation_points(m2, contset=m2.t)
-            self.fail('Expected RuntimeError')
-        except RuntimeError:
-            pass
 
         # Call reduce_collocation_points on a ContinuousSet that hasn't been
         #  discretized
         m2.tt = ContinuousSet(bounds=(0, 1))
         disc2.apply_to(m2, wrt=m2.t)
-        try:
+        with self.assertRaises(ValueError):
             disc2.reduce_collocation_points(m2, contset=m2.tt)
-            self.fail('Expected ValueError')
-        except ValueError:
-            pass
 
         # No Var specified
-        try:
+        with self.assertRaises(TypeError):
             disc.reduce_collocation_points(m, contset=m.t, var=None)
-            self.fail('Expected TypeError')
-        except TypeError:
-            pass
 
         # Component passed in is not a Var
-        try:
+        with self.assertRaises(TypeError):
             disc.reduce_collocation_points(m, contset=m.t, var=m.s)
-            self.fail('Expected TypeError')
-        except TypeError:
-            pass
 
         # New ncp not specified
-        try:
+        with self.assertRaises(TypeError):
             disc.reduce_collocation_points(m, contset=m.t, var=m.v1, ncp=None)
-            self.fail('Expected TypeError')
-        except TypeError:
-            pass
 
         # Negative ncp specified
-        try:
+        with self.assertRaises(ValueError):
             disc.reduce_collocation_points(m, contset=m.t, var=m.v1, ncp=-3)
-            self.fail('Expected ValueError')
-        except ValueError:
-            pass
 
         # Too large ncp specified
-        try:
+        with self.assertRaises(ValueError):
             disc.reduce_collocation_points(m, contset=m.t, var=m.v1, ncp=10)
-            self.fail('Expected ValueError')
-        except ValueError:
-            pass
 
         # Passing Vars not indexed by the ContinuousSet
         m.v2 = Var()
         m.v3 = Var(m.s)
         m.v4 = Var(m.s, m.s)
 
-        try:
+        with self.assertRaises(IndexError):
             disc.reduce_collocation_points(m, contset=m.t, var=m.v2, ncp=1)
-            self.fail('Expected IndexError')
-        except IndexError:
-            pass
 
-        try:
+        with self.assertRaises(IndexError):
             disc.reduce_collocation_points(m, contset=m.t, var=m.v3, ncp=1)
-            self.fail('Expected IndexError')
-        except IndexError:
-            pass
 
-        try:
+        with self.assertRaises(IndexError):
             disc.reduce_collocation_points(m, contset=m.t, var=m.v4, ncp=1)
-            self.fail('Expected IndexError')
-        except IndexError:
-            pass
 
         # Calling reduce_collocation_points more than once
         disc.reduce_collocation_points(m, contset=m.t, var=m.u, ncp=1)
-        try:
+        with self.assertRaises(RuntimeError):
             disc.reduce_collocation_points(m, contset=m.t, var=m.u, ncp=1)
-            self.fail('Expected RuntimeError')
-        except RuntimeError:
-            pass
+
+    # test trying to discretize a ContinuousSet twice
+    def test_discretize_twice(self):
+        m = self.m.clone()
+
+        disc1 = TransformationFactory('dae.collocation')
+        disc1.apply_to(m, nfe=5, ncp=3)
+
+        disc2 = TransformationFactory('dae.collocation')
+
+        with self.assertRaises(DAE_Error):
+            disc2.apply_to(m, nfe=5, ncp=3)
 
     # test reduce_collocation_points on var indexed by single ContinuousSet
     def test_reduce_colloc_single_index(self):
diff --git a/pyomo/dae/tests/test_contset.py b/pyomo/dae/tests/test_contset.py
--- a/pyomo/dae/tests/test_contset.py
+++ b/pyomo/dae/tests/test_contset.py
@@ -39,45 +39,25 @@ def test_init(self):
         model.t = ContinuousSet(bounds=(0, 5), initialize=[1, 3, 5])
         del model.t
 
-        try:
+        # Expected ValueError because a ContinuousSet component
+        # must contain at least two values upon construction
+        with self.assertRaises(ValueError):
             model.t = ContinuousSet()
-            self.fail("Expected ValueError because a ContinuousSet component"
-                      " must contain at least two values upon construction")
-        except ValueError:
-            pass
 
     # test bad keyword arguments
     def test_bad_kwds(self):
         model = ConcreteModel()
-        try:
+        with self.assertRaises(TypeError):
             model.t = ContinuousSet(bounds=(0, 1), filter=True)
-            self.fail("Expected TypeError")
-        except TypeError:
-            pass
-        
-        # try:
-        #     model.t = ContinuousSet(bounds=(0,1),within=NonNegativeReals)
-        #     self.fail("Expected TypeError")
-        # except TypeError:
-        #     pass
-        
-        try:
+
+        with self.assertRaises(TypeError):
             model.t = ContinuousSet(bounds=(0, 1), dimen=2)
-            self.fail("Expected TypeError")
-        except TypeError:
-            pass
-        
-        try:
+
+        with self.assertRaises(TypeError):
             model.t = ContinuousSet(bounds=(0, 1), virtual=True)
-            self.fail("Expected TypeError")
-        except TypeError:
-            pass
 
-        try:
+        with self.assertRaises(TypeError):
             model.t = ContinuousSet(bounds=(0, 1), validate=True)
-            self.fail("Expected TypeError")
-        except TypeError:
-            pass
 
     # test valid declarations
     def test_valid_declaration(self):
@@ -123,47 +103,26 @@ def test_invalid_declaration(self):
         model = ConcreteModel()
         model.s = Set(initialize=[1, 2, 3])
 
-        try:
+        with self.assertRaises(TypeError):
             model.t = ContinuousSet(model.s, bounds=(0, 1))
-            self.fail("Expected TypeError")
-        except TypeError:
-            pass
 
-        try:
+        with self.assertRaises(ValueError):
             model.t = ContinuousSet(bounds=(0, 0))
-            self.fail("Expected ValueError")
-        except ValueError:
-            pass
 
-        try:
+        with self.assertRaises(ValueError):
             model.t = ContinuousSet(initialize=[1])
-            self.fail("Expected ValueError")
-        except ValueError:
-            pass
 
-        try:
+        with self.assertRaises(ValueError):
             model.t = ContinuousSet(bounds=(None, 1))
-            self.fail("Expected ValueError")
-        except ValueError:
-            pass
 
-        try:
+        with self.assertRaises(ValueError):
             model.t = ContinuousSet(bounds=(0, None))
-            self.fail("Expected ValueError")
-        except ValueError:
-            pass
 
-        try:
+        with self.assertRaises(ValueError):
             model.t = ContinuousSet(initialize=[(1, 2), (3, 4)])
-            self.fail("Expected ValueError")
-        except ValueError:
-            pass
 
-        try:
+        with self.assertRaises(ValueError):
             model.t = ContinuousSet(initialize=['foo', 'bar'])
-            self.fail("Expected ValueError")
-        except ValueError:
-            pass
 
     # test the get_changed method
     def test_get_changed(self):
@@ -182,11 +141,8 @@ def test_set_changed(self):
         model.t.set_changed(False)
         self.assertFalse(model.t._changed)
 
-        try:
+        with self.assertRaises(ValueError):
             model.t.set_changed(3)
-            self.fail("Expected a ValueError")
-        except ValueError:
-            pass
 
     # test get_upper_element_boundary
     def test_get_upper_element_boundary(self):
@@ -286,14 +242,13 @@ def test_io6(self):
         OUTPUT.write("set B := 1;\n")
         OUTPUT.write("end;\n")
         OUTPUT.close()
+
+        # Expected ValueError because data set has only one value and no
+        # bounds are specified
         self.model.B = ContinuousSet()
-        try:
+        with self.assertRaises(ValueError):
             self.instance = self.model.create_instance("diffset.dat")
-            self.fail("Expected ValueError because data set has only one value"
-                      " and no bounds are specified")
-        except ValueError:
-            pass
-            
+
     def test_io7(self):
         OUTPUT = open("diffset.dat", "w")
         OUTPUT.write("data;\n")
diff --git a/pyomo/dae/tests/test_diffvar.py b/pyomo/dae/tests/test_diffvar.py
--- a/pyomo/dae/tests/test_diffvar.py
+++ b/pyomo/dae/tests/test_diffvar.py
@@ -107,80 +107,47 @@ def test_invalid(self):
         m.y = Var()
 
         # Not passing a Var as the first positional argument
-        try:
+        with self.assertRaises(DAE_Error):
             m.ds = DerivativeVar(m.s)
-            self.fail('Expected DAE_Error')
-        except DAE_Error:
-            pass
 
         # Specifying both option aliases
-        try:
+        with self.assertRaises(TypeError):
             m.dv = DerivativeVar(m.v, wrt=m.t, withrespectto=m.t)
-            self.fail('Expected TypeError')
-        except TypeError:
-            pass
 
         # Passing in Var not indexed by a ContinuousSet
-        try:
+        with self.assertRaises(DAE_Error):
             m.dy = DerivativeVar(m.y)
-            self.fail('Expected DAE_Error')
-        except DAE_Error:
-            pass
 
         # Not specifying 'wrt' when Var indexed by multiple ContinuousSets
-        try:
+        with self.assertRaises(DAE_Error):
             m.dv3 = DerivativeVar(m.v3)
-            self.fail('Expected DAE_Error')
-        except DAE_Error:
-            pass
 
         # 'wrt' is not a ContinuousSet
-        try:
+        with self.assertRaises(DAE_Error):
             m.dv2 = DerivativeVar(m.v2, wrt=m.s)
-            self.fail('Expected DAE_Error')
-        except DAE_Error:
-            pass
 
-        try:
+        with self.assertRaises(DAE_Error):
             m.dv2 = DerivativeVar(m.v2, wrt=(m.t, m.s))
-            self.fail('Expected DAE_Error')
-        except DAE_Error:
-            pass
 
         # Specified ContinuousSet does not index the Var
-        try:
+        with self.assertRaises(DAE_Error):
             m.dv = DerivativeVar(m.v, wrt=m.x)
-            self.fail('Expected DAE_Error')
-        except DAE_Error:
-            pass
 
-        try:
+        with self.assertRaises(DAE_Error):
             m.dv2 = DerivativeVar(m.v2, wrt=[m.t, m.x])
-            self.fail('Expected DAE_Error')
-        except DAE_Error:
-            pass
 
         # Declaring the same derivative twice
         m.dvdt = DerivativeVar(m.v)
-        try:
+        with self.assertRaises(DAE_Error):
             m.dvdt2 = DerivativeVar(m.v)
-            self.fail('Expected DAE_Error')
-        except DAE_Error:
-            pass
 
         m.dv2dt = DerivativeVar(m.v2, wrt=m.t)
-        try:
+        with self.assertRaises(DAE_Error):
             m.dv2dt2 = DerivativeVar(m.v2, wrt=m.t)
-            self.fail('Expected DAE_Error')
-        except DAE_Error:
-            pass
 
         m.dv3 = DerivativeVar(m.v3, wrt=(m.x, m.x))
-        try:
+        with self.assertRaises(DAE_Error):
             m.dv4 = DerivativeVar(m.v3, wrt=(m.x, m.x))
-            self.fail('Expected DAE_Error')
-        except DAE_Error:
-            pass
 
     # test DerivativeVar reclassification after discretization
     def test_reclassification(self):
diff --git a/pyomo/dae/tests/test_finite_diff.py b/pyomo/dae/tests/test_finite_diff.py
--- a/pyomo/dae/tests/test_finite_diff.py
+++ b/pyomo/dae/tests/test_finite_diff.py
@@ -269,44 +269,62 @@ def test_disc_multi_index2(self):
         for idx, val in enumerate(list(m.t2)):
             self.assertAlmostEqual(val, expected_t2_disc_points[idx])
 
+    # test collocation discretization on var indexed by ContinuousSet and
+    # multi-dimensional Set
+    def test_disc_multidimen_index(self):
+        m = self.m.clone()
+        m.s2 = Set(initialize=[('A', 'B'), ('C', 'D'), ('E', 'F')])
+        m.v2 = Var(m.t, m.s2)
+        m.dv2 = DerivativeVar(m.v2)
+        m.v3 = Var(m.s2, m.t)
+        m.dv3 = DerivativeVar(m.v3)
+
+        disc = TransformationFactory('dae.finite_difference')
+        disc.apply_to(m, nfe=5)
+
+        self.assertTrue(hasattr(m, 'dv1_disc_eq'))
+        self.assertTrue(hasattr(m, 'dv2_disc_eq'))
+        self.assertTrue(hasattr(m, 'dv3_disc_eq'))
+        self.assertTrue(len(m.dv2_disc_eq) == 15)
+        self.assertTrue(len(m.v2) == 18)
+        self.assertTrue(len(m.dv3_disc_eq) == 15)
+        self.assertTrue(len(m.v3) == 18)
+
+        expected_disc_points = [0, 2.0, 4.0, 6.0, 8.0, 10]
+        disc_info = m.t.get_discretization_info()
+
+        self.assertTrue(disc_info['scheme'] == 'BACKWARD Difference')
+
+        for idx, val in enumerate(list(m.t)):
+            self.assertAlmostEqual(val, expected_disc_points[idx])
+
     # test passing the discretization invalid options
     def test_disc_invalid_options(self):
         m = self.m.clone()
 
-        try:
+        with self.assertRaises(TypeError):
             TransformationFactory('dae.finite_difference').apply_to(m, wrt=m.s)
-            self.fail('Expected TypeError')
-        except TypeError:
-            pass
 
-        try:
+        with self.assertRaises(ValueError):
             TransformationFactory('dae.finite_difference').apply_to(m, nfe=-1)
-            self.fail('Expected ValueError')
-        except ValueError:
-            pass
 
-        try:
+        with self.assertRaises(ValueError):
+            TransformationFactory('dae.finite_difference').apply_to(m,
+                                                                    scheme='foo')
+
+        with self.assertRaises(ValueError):
             TransformationFactory('dae.finite_difference').apply_to(m,
-                                                                scheme='foo')
-            self.fail('Expected ValueError')
-        except ValueError:
-            pass
+                                                                    foo=True)
 
         TransformationFactory('dae.finite_difference').apply_to(m, wrt=m.t)
-        try:
+        with self.assertRaises(ValueError):
             TransformationFactory('dae.finite_difference').apply_to(m, wrt=m.t)
-            self.fail('Expected ValueError')
-        except ValueError:
-            pass
 
         m = self.m.clone()
         disc = TransformationFactory('dae.finite_difference')
         disc.apply_to(m)
-        try:
+        with self.assertRaises(ValueError):
             disc.apply_to(m)
-            self.fail('Expected ValueError')
-        except ValueError:
-            pass
 
     # test discretization using fewer points than ContinuousSet initialized
     # with
@@ -328,11 +346,21 @@ def test_invalid_derivative(self):
         m.v = Var(m.t)
         m.dv = DerivativeVar(m.v, wrt=(m.t, m.t, m.t))
 
-        try:
+        with self.assertRaises(DAE_Error):
             TransformationFactory('dae.finite_difference').apply_to(m)
-            self.fail('Expected DAE_Error')
-        except DAE_Error:
-            pass
+
+    # test trying to discretize a ContinuousSet twice
+    def test_discretize_twice(self):
+        m = self.m.clone()
+
+        disc1 = TransformationFactory('dae.finite_difference')
+        disc1.apply_to(m, nfe=5)
+
+        disc2 = TransformationFactory('dae.finite_difference')
+
+        with self.assertRaises(DAE_Error):
+            disc2.apply_to(m, nfe=5)
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/pyomo/dae/tests/test_integral.py b/pyomo/dae/tests/test_integral.py
--- a/pyomo/dae/tests/test_integral.py
+++ b/pyomo/dae/tests/test_integral.py
@@ -115,53 +115,32 @@ def _int3(m, s, t):
             return m.v2[s,t]
 
         # Integrals must be indexed by a ContinuousSet
-        try:
+        with self.assertRaises(ValueError):
             m.int = Integral(rule=_int)
-            self.fail('Expected ValueError')
-        except ValueError:
-            pass
 
         # Specifying multiple aliases of same option
-        try:
+        with self.assertRaises(TypeError):
             m.int = Integral(m.t, wrt=m.t, withrespectto=m.t, rule=_int)
-            self.fail('Expected TypeError')
-        except TypeError:
-            pass
 
         # No ContinuousSet specified
-        try:
+        with self.assertRaises(ValueError):
             m.int2 = Integral(m.x, m.t, rule= _int2)
-            self.fail('Expected ValueError')
-        except ValueError:
-            pass
 
         # 'wrt' is not a ContinuousSet
-        try:
+        with self.assertRaises(ValueError):
             m.int = Integral(m.s, m.t, wrt=m.s, rule=_int2)
-            self.fail('Expected ValueError')
-        except ValueError:
-            pass
 
         # 'wrt' is not in argument list
-        try:
+        with self.assertRaises(ValueError):
             m.int = Integral(m.t, wrt=m.x, rule=_int)
-            self.fail('Expected ValueError')
-        except ValueError:
-            pass
 
         # 'bounds' not supported
-        try:
+        with self.assertRaises(DAE_Error):
             m.int = Integral(m.t, wrt=m.t, rule=_int, bounds=(0,0.5))
-            self.fail('Expected DAE_Error')
-        except DAE_Error:
-            pass
 
         # No rule specified
-        try:
+        with self.assertRaises(ValueError):
             m.int = Integral(m.t, wrt=m.t)
-            self.fail('Expected ValueError')
-        except ValueError:
-            pass
 
             # test DerivativeVar reclassification after discretization
 
diff --git a/pyomo/dae/tests/test_misc.py b/pyomo/dae/tests/test_misc.py
--- a/pyomo/dae/tests/test_misc.py
+++ b/pyomo/dae/tests/test_misc.py
@@ -111,14 +111,10 @@ def _rule2(m, i):
         m.p5 = Param(m.t, initialize=_rule1, default=_rule2)
 
         generate_finite_elements(m.t, 5)
-        
-        try:
+        # Expected ValueError because no default value was specified
+        with self.assertRaises(ValueError):
             for i in m.t:
                 m.p1[i]
-            self.fail("Expected ValueError because no default value was "
-                      "specified")
-        except ValueError:
-            pass
 
         for i in m.t:
             self.assertEqual(m.p2[i], 2)
@@ -154,13 +150,12 @@ def _rule3(m, i, j, k):
 
         generate_finite_elements(m.t, 5)
 
-        try:
+        # Expected TypeError because a function with the wrong number of
+        # arguments was specified as the default
+
+        with self.assertRaises(TypeError):
             for i in m.p1:
                 m.p1[i]
-            self.fail("Expected TypeError because a function with the wrong "
-                      "number of arguments was specified as the default")
-        except TypeError:
-            pass
 
         for i in m.p2:
             self.assertEqual(m.p2[i], 5)
@@ -747,13 +742,10 @@ def test_update_contset_indexed_component_unsupported_single(self):
         generate_finite_elements(m.t, 5)
         expansion_map = ComponentMap()
 
-        try:
+        # Expected TypeError because Set is not a component that supports
+        # indexing by a ContinuousSet
+        with self.assertRaises(TypeError):
             update_contset_indexed_component(m.s, expansion_map)
-            self.fail("Expected TypeError because Set is not a component "
-                      "that supports indexing by a ContinuousSet")
-
-        except TypeError:
-            pass
 
     # test unsupported components indexed by multiple sets
     def test_update_contset_indexed_component_unsupported_multiple(self):
@@ -764,13 +756,10 @@ def test_update_contset_indexed_component_unsupported_multiple(self):
         generate_finite_elements(m.t, 5)
         expansion_map = ComponentMap()
 
-        try:
+        # Expected TypeError because Set is not a component that supports
+        # indexing by a ContinuousSet
+        with self.assertRaises(TypeError):
             update_contset_indexed_component(m.s, expansion_map)
-            self.fail("Expected TypeError because Set is not a component "
-                      "that supports indexing by a ContinuousSet")
-
-        except TypeError:
-            pass  
 
     def test_update_block_derived(self):
         class Foo(Block):