Repository: Pyomo/pyomo

Issue:
Scaling transformations on Block structured models
I have been working with the Pyomo scaling transformation in preparation for implementing this within IDAES, and I have run into some unexpected behaviour when working with block-structured models. It appears that only scaling factors declared at the top level of the model are considered by the scaling transformation, and that it does not descend into the block tree to look for additional scaling factors.

I have tested a few cases to try to work out what was happening.

Case 1:

```
import pyomo.environ as pe

###
# create the original unscaled model
###
model = pe.ConcreteModel()

model.b = pe.Block()
model.b.a = pe.Var(initialize=10, bounds=(0,20))

###
# set the scaling parameters
###
model.b.scaling_factor = pe.Suffix(direction=pe.Suffix.EXPORT)
model.b.scaling_factor[model.b.a] = 10

###
# build and solve the scaled model
###
scaled_model = pe.TransformationFactory('core.scale_model').create_using(model)
```

Returns:
`ValueError: ScaleModel transformation called with scaling_method='user', but cannot find the suffix 'scaling_factor' on the model`

This is possibly expected behaviour, but without the `scaling_factor` suffix present at the top level, the transformation returns an error. I do find it strange that the error is a `ValueError` - I would generally expect an `AttributeError` in the case where the attribute was missing completely.

Case 2:

```
import pyomo.environ as pe

###
# create the original unscaled model
###
model = pe.ConcreteModel()

model.b = pe.Block()
model.b.a = pe.Var(initialize=10, bounds=(0,20))

###
# set the scaling parameters
###
model.scaling_factor = pe.Suffix(direction=pe.Suffix.EXPORT)
model.b.scaling_factor = pe.Suffix(direction=pe.Suffix.EXPORT)
model.b.scaling_factor[model.b.a] = 10

###
# build and solve the scaled model
###
scaled_model = pe.TransformationFactory('core.scale_model').create_using(model)

# print the scaled model
scaled_model.b.scaled_a.pprint()
```

Returns:
```
scaled_a : Size=1, Index=None
    Key  : Lower : Value : Upper : Fixed : Stale : Domain
    None :   0.0 :  10.0 :  20.0 : False : False :  Reals
```

The model has been transformed, and the variable `a` replaced by `scaled_a`, but the value and bounds have not been scaled. It appears that the scaling factor(s) defined in child blocks are not being picked up by the transformation.

Case 3:
```
import pyomo.environ as pe

###
# create the original unscaled model
###
model = pe.ConcreteModel()

model.b = pe.Block()
model.b.a = pe.Var(initialize=10, bounds=(0,20))

###
# set the scaling parameters
###
model.scaling_factor = pe.Suffix(direction=pe.Suffix.EXPORT)
model.scaling_factor[model.b.a] = 10

###
# build and solve the scaled model
###
scaled_model = pe.TransformationFactory('core.scale_model').create_using(model)

# print the scaled model
scaled_model.b.scaled_a.pprint()
```

Returns:
```
scaled_a : Size=1, Index=None
    Key  : Lower : Value : Upper : Fixed : Stale : Domain
    None :   0.0 : 100.0 : 200.0 : False : False :  Reals
```

This works now, and the variable value and bounds have been scaled.

It would be good if the scaling transformation would descend into the block structure to look for scaling factors, as it makes more sense to declare these at the same level as the component is declared. This also simplifies modular construction of the scaling factors, as each block can be responsible for its own scaling factors, without having to propagate these up to the top level of the model.


Discussion:
What behavior would you expect if there are inconsistencies in the scaling factors specified at different levels?
Thanks Andrew. I will look into this. I need to figure out what is happening with the suffixes and how they propagate. Thanks for the example.

However, I will also point out that I would *NOT* use the scaling transformation - I would use the user scaling option in IPOPT directly. It uses the same suffixes. The scaling transformation is there if you want to perform scaling with a solver other than IPOPT. (Having said that, it should work, so I will look into it.)
@qtothec I had not considered inconsistencies at different levels, which is a big issue. The expected behaviour (from a naive user) would be to use the most recently specified scaling factor. That would be a nightmare to implement however, so my first response would be to use the scaling factor defined at the same level as the component is declared.

@carldlaird I would agree that using IPOPT's scaling directly would be the best course, but for a general, inexperienced user we would like a common method that works for everything. Advanced users could then use IPOPT's scaling directly if they know how to do so (or any other solver they wish to use).
@carldlaird This is a bit of an aside, but... does that mean that if a user solves with IPOPT and had done the scaling transformation, the model would be scaled twice?
I don't recall if the scaling transformation removes the scaling suffixes or not. However, you also need to turn on an option to IPOPT to use the scaling, so you would need to explicitly ask for (1) a scaling transformation, and (2) for IPOPT to *also* use the scaling parameters.

Resolving patch:
diff --git a/pyomo/core/plugins/transform/scaling.py b/pyomo/core/plugins/transform/scaling.py
--- a/pyomo/core/plugins/transform/scaling.py
+++ b/pyomo/core/plugins/transform/scaling.py
@@ -10,7 +10,10 @@
 #  ___________________________________________________________________________
 
 from pyomo.common.collections import ComponentMap
-from pyomo.core.base import Var, Constraint, Objective, _ConstraintData, _ObjectiveData, Suffix, value
+from pyomo.core.base import (
+    Block, Var, Constraint, Objective, _ConstraintData, _ObjectiveData,
+    Suffix, value,
+)
 from pyomo.core.plugins.transform.hierarchy import Transformation
 from pyomo.core.base import TransformationFactory
 from pyomo.core.expr.current import replace_expressions
@@ -81,16 +84,91 @@ def _create_using(self, original_model, **kwds):
         self._apply_to(scaled_model, **kwds)
         return scaled_model
 
+    def _suffix_finder(self, component_data, suffix_name, root=None):
+        """Find suffix value for a given component data object in model tree
+
+        Suffixes are searched by traversing the model hierarchy in three passes:
+
+        1. Search for a Suffix matching the specific component_data,
+           starting at the `root` and descending down the tree to
+           the component_data.  Return the first match found.
+        2. Search for a Suffix matching the component_data's container,
+           starting at the `root` and descending down the tree to
+           the component_data.  Return the first match found.
+        3. Search for a Suffix with key `None`, starting from the
+           component_data and working up the tree to the `root`.
+           Return the first match found.
+        4. Return None
+
+        Parameters
+        ----------
+        component_data: ComponentDataBase
+
+            Component or component data object to find suffix value for.
+
+        suffix_name: str
+
+            Name of Suffix to search for.
+
+        root: BlockData
+
+            When searching up the block hierarchy, stop at this
+            BlockData instead of traversing all the way to the
+            `component_data.model()` Block.  If the `component_data` is
+            not in the subtree defined by `root`, then the search will
+            proceed up to `component_data.model()`.
+
+        Returns
+        -------
+        The value for Suffix associated with component data if found, else None.
+
+        """
+        # Prototype for Suffix finder
+
+        # We want to *include* the root (if it is not None), so if
+        # it is not None, we want to stop as soon as we get to its
+        # parent.
+        if root is not None:
+            if root.ctype is not Block and not issubclass(root.ctype, Block):
+                raise ValueError("_find_suffix: root must be a BlockData "
+                                 f"(found {root.ctype.__name__}: {root})")
+            if root.is_indexed():
+                raise ValueError(
+                    "_find_suffix: root must be a BlockData "
+                    f"(found {type(root).__name__}: {root})")
+            root = root.parent_block()
+        # Walk parent tree and search for suffixes
+        parent = component_data.parent_block()
+        suffixes = []
+        while parent is not root:
+            s = parent.component(suffix_name)
+            if s is not None and s.ctype is Suffix:
+                suffixes.append(s)
+            parent = parent.parent_block()
+        # Pass 1: look for the component_data, working root to leaf
+        for s in reversed(suffixes):
+            if component_data in s:
+                return s[component_data]
+        # Pass 2: look for the component container, working root to leaf
+        parent_comp = component_data.parent_component()
+        if parent_comp is not component_data:
+            for s in reversed(suffixes):
+                if parent_comp in s:
+                    return s[parent_comp]
+        # Pass 3: look for None, working leaf to root
+        for s in suffixes:
+            if None in s:
+                return s[None]
+        return None
+
     def _get_float_scaling_factor(self, instance, component_data):
-        scaling_factor = None
-        if component_data in instance.scaling_factor:
-            scaling_factor = instance.scaling_factor[component_data]
-        elif component_data.parent_component() in instance.scaling_factor:
-            scaling_factor = instance.scaling_factor[component_data.parent_component()]
+        scaling_factor = self._suffix_finder(component_data, "scaling_factor")
 
+        # If still no scaling factor, return 1.0
         if scaling_factor is None:
             return 1.0
 
+        # Make sure scaling factor is a float
         try:
             scaling_factor = float(scaling_factor)
         except ValueError:


Test patch:
diff --git a/pyomo/core/tests/transform/test_scaling.py b/pyomo/core/tests/transform/test_scaling.py
--- a/pyomo/core/tests/transform/test_scaling.py
+++ b/pyomo/core/tests/transform/test_scaling.py
@@ -13,6 +13,7 @@
 import pyomo.common.unittest as unittest
 import pyomo.environ as pyo
 from pyomo.opt.base.solvers import UnknownSolver
+from pyomo.core.plugins.transform.scaling import ScaleModel
 
 
 class TestScaleModelTransformation(unittest.TestCase):
@@ -208,5 +209,325 @@ def c2_rule(m):
                 scale_factor,
                 )
 
+    def test_scaling_hierarchical(self):
+        m = pyo.ConcreteModel()
+        m.scaling_factor = pyo.Suffix(direction=pyo.Suffix.EXPORT)
+        m.v1 = pyo.Var(initialize=10)
+        m.v2 = pyo.Var(initialize=20)
+        m.v3 = pyo.Var(initialize=30)
+
+        def c1_rule(m):
+            return m.v1 == 1e6
+        m.c1 = pyo.Constraint(rule=c1_rule)
+        def c2_rule(m):
+            return m.v2 == 1e-4
+        m.c2 = pyo.Constraint(rule=c2_rule)
+
+        m.scaling_factor[m.v1] = 1.0
+        m.scaling_factor[m.v2] = 0.5
+        m.scaling_factor[m.v3] = 0.25
+        m.scaling_factor[m.c1] = 1e-5
+        m.scaling_factor[m.c2] = 1e5
+
+        m.b = pyo.Block()
+        m.b.scaling_factor = pyo.Suffix(direction=pyo.Suffix.EXPORT)
+        m.b.v4 = pyo.Var(initialize=10)
+        m.b.v5 = pyo.Var(initialize=20)
+        m.b.v6 = pyo.Var(initialize=30)
+
+        def c3_rule(m):
+            return m.v4 == 1e6
+
+        m.b.c3 = pyo.Constraint(rule=c3_rule)
+
+        def c4_rule(m):
+            return m.v5 == 1e-4
+
+        m.b.c4 = pyo.Constraint(rule=c4_rule)
+
+        m.b.scaling_factor[m.b.v4] = 1.0
+        m.b.scaling_factor[m.b.v5] = 0.5
+        m.b.scaling_factor[m.b.v6] = 0.25
+        m.b.scaling_factor[m.b.c3] = 1e-5
+        m.b.scaling_factor[m.b.c4] = 1e5
+
+        values = {}
+        values[id(m.v1)] = (m.v1.value, m.scaling_factor[m.v1])
+        values[id(m.v2)] = (m.v2.value, m.scaling_factor[m.v2])
+        values[id(m.v3)] = (m.v3.value, m.scaling_factor[m.v3])
+        values[id(m.c1)] = (pyo.value(m.c1.body), m.scaling_factor[m.c1])
+        values[id(m.c2)] = (pyo.value(m.c2.body), m.scaling_factor[m.c2])
+        values[id(m.b.v4)] = (m.b.v4.value, m.b.scaling_factor[m.b.v4])
+        values[id(m.b.v5)] = (m.b.v5.value, m.b.scaling_factor[m.b.v5])
+        values[id(m.b.v6)] = (m.b.v6.value, m.b.scaling_factor[m.b.v6])
+        values[id(m.b.c3)] = (pyo.value(m.b.c3.body), m.b.scaling_factor[m.b.c3])
+        values[id(m.b.c4)] = (pyo.value(m.b.c4.body), m.b.scaling_factor[m.b.c4])
+
+        m.c2_ref = pyo.Reference(m.c2)
+        m.v3_ref = pyo.Reference(m.v3)
+
+        m.b.c4_ref = pyo.Reference(m.b.c4)
+        m.b.v6_ref = pyo.Reference(m.b.v6)
+
+        scale = pyo.TransformationFactory('core.scale_model')
+        scale.apply_to(m, rename=False)
+
+        self.assertTrue(hasattr(m, 'v1'))
+        self.assertTrue(hasattr(m, 'v2'))
+        self.assertTrue(hasattr(m, 'c1'))
+        self.assertTrue(hasattr(m, 'c2'))
+        self.assertTrue(hasattr(m.b, 'v4'))
+        self.assertTrue(hasattr(m.b, 'v5'))
+        self.assertTrue(hasattr(m.b, 'c3'))
+        self.assertTrue(hasattr(m.b, 'c4'))
+
+        orig_val, factor = values[id(m.v1)]
+        self.assertAlmostEqual(
+                m.v1.value,
+                orig_val*factor,
+                )
+
+        orig_val, factor = values[id(m.v2)]
+        self.assertAlmostEqual(
+                m.v2.value,
+                orig_val*factor,
+                )
+
+        orig_val, factor = values[id(m.c1)]
+        self.assertAlmostEqual(
+                pyo.value(m.c1.body),
+                orig_val*factor,
+                )
+
+        orig_val, factor = values[id(m.c2)]
+        self.assertAlmostEqual(
+                pyo.value(m.c2.body),
+                orig_val*factor,
+                )
+
+        orig_val, factor = values[id(m.v3)]
+        self.assertAlmostEqual(
+                m.v3_ref[None].value,
+                orig_val*factor,
+                )
+        # Note that because the model was not renamed,
+        # v3_ref is still intact.
+
+        orig_val, factor = values[id(m.b.v4)]
+        self.assertAlmostEqual(
+            m.b.v4.value,
+            orig_val * factor,
+        )
+
+        orig_val, factor = values[id(m.b.v5)]
+        self.assertAlmostEqual(
+            m.b.v5.value,
+            orig_val * factor,
+        )
+
+        orig_val, factor = values[id(m.b.c3)]
+        self.assertAlmostEqual(
+            pyo.value(m.b.c3.body),
+            orig_val * factor,
+        )
+
+        orig_val, factor = values[id(m.b.c4)]
+        self.assertAlmostEqual(
+            pyo.value(m.b.c4.body),
+            orig_val * factor,
+        )
+
+        orig_val, factor = values[id(m.b.v6)]
+        self.assertAlmostEqual(
+            m.b.v6_ref[None].value,
+            orig_val * factor,
+        )
+        # Note that because the model was not renamed,
+        # v6_ref is still intact.
+
+
+        lhs = m.c2.body
+        monom_factor = lhs.arg(0)
+        scale_factor = (m.scaling_factor[m.c2]/
+                        m.scaling_factor[m.v2])
+        self.assertAlmostEqual(
+                monom_factor,
+                scale_factor,
+                )
+
+        lhs = m.b.c4.body
+        monom_factor = lhs.arg(0)
+        scale_factor = (m.b.scaling_factor[m.b.c4] /
+                        m.b.scaling_factor[m.b.v5])
+        self.assertAlmostEqual(
+            monom_factor,
+            scale_factor,
+        )
+
+    def test_get_float_scaling_factor_top_level(self):
+        m = pyo.ConcreteModel()
+        m.scaling_factor = pyo.Suffix(direction=pyo.Suffix.EXPORT)
+
+        m.b1 = pyo.Block()
+        m.b1.scaling_factor = pyo.Suffix(direction=pyo.Suffix.EXPORT)
+
+        m.b1.b2 = pyo.Block()
+        m.b1.b2.scaling_factor = pyo.Suffix(direction=pyo.Suffix.EXPORT)
+
+        m.v1 = pyo.Var(initialize=10)
+        m.b1.v2 = pyo.Var(initialize=20)
+        m.b1.b2.v3 = pyo.Var(initialize=30)
+
+        m.scaling_factor[m.v1] = 0.1
+        m.scaling_factor[m.b1.v2] = 0.2
+
+        # SF should be 0.1 from top level
+        sf = ScaleModel()._get_float_scaling_factor(m, m.v1)
+        assert sf == float(0.1)
+        # SF should be 0.1 from top level, lower level ignored
+        sf = ScaleModel()._get_float_scaling_factor(m, m.b1.v2)
+        assert sf == float(0.2)
+        # No SF, should return 1
+        sf = ScaleModel()._get_float_scaling_factor(m, m.b1.b2.v3)
+        assert sf == 1.0
+
+    def test_get_float_scaling_factor_local_level(self):
+        m = pyo.ConcreteModel()
+        m.scaling_factor = pyo.Suffix(direction=pyo.Suffix.EXPORT)
+
+        m.b1 = pyo.Block()
+        m.b1.scaling_factor = pyo.Suffix(direction=pyo.Suffix.EXPORT)
+
+        m.b1.b2 = pyo.Block()
+        m.b1.b2.scaling_factor = pyo.Suffix(direction=pyo.Suffix.EXPORT)
+
+        m.v1 = pyo.Var(initialize=10)
+        m.b1.v2 = pyo.Var(initialize=20)
+        m.b1.b2.v3 = pyo.Var(initialize=30)
+
+        m.scaling_factor[m.v1] = 0.1
+        m.b1.scaling_factor[m.b1.v2] = 0.2
+        m.b1.b2.scaling_factor[m.b1.b2.v3] = 0.3
+
+        # Add an intermediate scaling factor - this should take priority
+        m.b1.scaling_factor[m.b1.b2.v3] = 0.4
+
+        # Should get SF from local levels
+        sf = ScaleModel()._get_float_scaling_factor(m, m.v1)
+        assert sf == float(0.1)
+        sf = ScaleModel()._get_float_scaling_factor(m, m.b1.v2)
+        assert sf == float(0.2)
+        sf = ScaleModel()._get_float_scaling_factor(m, m.b1.b2.v3)
+        assert sf == float(0.4)
+
+    def test_get_float_scaling_factor_intermediate_level(self):
+        m = pyo.ConcreteModel()
+        m.scaling_factor = pyo.Suffix(direction=pyo.Suffix.EXPORT)
+
+        m.b1 = pyo.Block()
+        m.b1.scaling_factor = pyo.Suffix(direction=pyo.Suffix.EXPORT)
+
+        m.b1.b2 = pyo.Block()
+        # No suffix at b2 level - this should not cause an issue
+
+        m.b1.b2.b3 = pyo.Block()
+        m.b1.b2.b3.scaling_factor = pyo.Suffix(direction=pyo.Suffix.EXPORT)
+
+        m.v1 = pyo.Var(initialize=10)
+        m.b1.b2.b3.v2 = pyo.Var(initialize=20)
+        m.b1.b2.b3.v3 = pyo.Var(initialize=30)
+
+        # Scale v1 at lowest level - this should not get picked up
+        m.b1.b2.b3.scaling_factor[m.v1] = 0.1
+
+        m.b1.scaling_factor[m.b1.b2.b3.v2] = 0.2
+        m.b1.scaling_factor[m.b1.b2.b3.v3] = 0.3
+
+        m.b1.b2.b3.scaling_factor[m.b1.b2.b3.v3] = 0.4
+
+        # v1 should be unscaled as SF set below variable level
+        sf = ScaleModel()._get_float_scaling_factor(m, m.v1)
+        assert sf == 1.0
+        # v2 should get SF from b1 level
+        sf = ScaleModel()._get_float_scaling_factor(m, m.b1.b2.b3.v2)
+        assert sf == float(0.2)
+        # v2 should get SF from highest level, ignoring b3 level
+        sf = ScaleModel()._get_float_scaling_factor(m, m.b1.b2.b3.v3)
+        assert sf == float(0.3)
+
+    def test_suffix_finder(self):
+        # Build a dummy model
+        m = pyo.ConcreteModel()
+        m.v1 = pyo.Var()
+
+        m.b1 = pyo.Block()
+        m.b1.v2 = pyo.Var()
+
+        m.b1.b2 = pyo.Block()
+        m.b1.b2.v3 = pyo.Var([0])
+
+        xfrm = ScaleModel()
+        _suffix_finder = xfrm._suffix_finder
+
+        # Add Suffixes
+        m.suffix = pyo.Suffix(direction=pyo.Suffix.EXPORT)
+        # No suffix on b1 - make sure we can handle missing suffixes
+        m.b1.b2.suffix = pyo.Suffix(direction=pyo.Suffix.EXPORT)
+
+        # Check for no suffix value
+        assert _suffix_finder(m.b1.b2.v3[0], "suffix") == None
+        assert _suffix_finder(m.b1.b2.v3[0], "suffix", root=m.b1) == None
+
+        # Check finding default values
+        # Add a default at the top level
+        m.suffix[None] = 1
+        assert _suffix_finder(m.b1.b2.v3[0], "suffix") == 1
+        assert _suffix_finder(m.b1.b2.v3[0], "suffix", root=m.b1) == None
+
+        # Add a default suffix at a lower level
+        m.b1.b2.suffix[None] = 2
+        assert _suffix_finder(m.b1.b2.v3[0], "suffix") == 2
+        assert _suffix_finder(m.b1.b2.v3[0], "suffix", root=m.b1) == 2
+
+        # Check for container at lowest level
+        m.b1.b2.suffix[m.b1.b2.v3] = 3
+        assert _suffix_finder(m.b1.b2.v3[0], "suffix") == 3
+        assert _suffix_finder(m.b1.b2.v3[0], "suffix", root=m.b1) == 3
+
+        # Check for container at top level
+        m.suffix[m.b1.b2.v3] = 4
+        assert _suffix_finder(m.b1.b2.v3[0], "suffix") == 4
+        assert _suffix_finder(m.b1.b2.v3[0], "suffix", root=m.b1) == 3
+
+        # Check for specific values at lowest level
+        m.b1.b2.suffix[m.b1.b2.v3[0]] = 5
+        assert _suffix_finder(m.b1.b2.v3[0], "suffix") == 5
+        assert _suffix_finder(m.b1.b2.v3[0], "suffix", root=m.b1) == 5
+
+        # Check for specific values at top level
+        m.suffix[m.b1.b2.v3[0]] = 6
+        assert _suffix_finder(m.b1.b2.v3[0], "suffix") == 6
+        assert _suffix_finder(m.b1.b2.v3[0], "suffix", root=m.b1) == 5
+
+        # Make sure we don't find default suffixes at lower levels
+        assert _suffix_finder(m.b1.v2, "suffix") == 1
+        assert _suffix_finder(m.b1.v2, "suffix", root=m.b1) == None
+
+        # Make sure we don't find specific suffixes at lower levels
+        m.b1.b2.suffix[m.v1] = 5
+        assert _suffix_finder(m.v1, "suffix") == 1
+
+        with self.assertRaisesRegex(
+                ValueError,
+                r"_find_suffix: root must be a BlockData \(found Var: v1\)"):
+            _suffix_finder(m.b1.v2, "suffix", root=m.v1)
+
+        m.bn = pyo.Block([1,2])
+        with self.assertRaisesRegex(
+                ValueError, r"_find_suffix: root must be a BlockData "
+                r"\(found IndexedBlock: bn\)"):
+            _suffix_finder(m.b1.v2, "suffix", root=m.bn)
+
+
 if __name__ == "__main__":
     unittest.main()