Multidisciplinary Design Feasible (MDF)ΒΆ

In a Multidisciplinary Design Feasible (MDF) problem, the disciplines are directly coupled via some kind of solver, and the design variables are optimized in a single loop. The following diagram illustrates the data flow for MDF applied to the Sellar problem.

Diagram consists of boxes and arrows to show data flow for MDF applied to the Sellar problem.

Data Flow for MDF Applied to the Sellar Problem

This diagram introduces a component called a Broadcaster. A Broadcaster is a component that enables a design variable to be set to the same value at multiple locations. If you recall, a driver such as the CONMIN optimizer contains a list of parameters, where each parameter is a location in OpenMDAO’s data hierarchy. Each parameter is a single design variable, and there is no way to indicate that one design variable might be needed at multiple component inputs in the model. We can overcome this by creating a component that passes an input to its output. Thus, CONMIN can set the design variable in this Broadcaster, and when the Broadcaster executes, the new value gets passed to all of the components that need it.

OpenMDAO doesn’t have a built-in Broadcaster, so we need to make our own. It’s a simple component with some inputs, some outputs, and an execute function that passes the inputs to the outputs.

from openmdao.main.api import Component
from openmdao.lib.datatypes.api import Float


class Broadcaster(Component):
    """Component that holds some design variables.
    This is only needed because we can't hook an optimizer up to multiple
    locations of the same design variable"""

    # pylint: disable-msg=E1101
    z1_in = Float(0.0, iotype='in', desc='Global Design Variable')
    z2_in = Float(0.0, iotype='in', desc='Global Design Variable')
    x1_in = Float(0.0, iotype='in', desc='Local Design Variable for CO')
    y1_in = Float(0.0, iotype='in', desc='Coupling Variable')
    y2_in = Float(0.0, iotype='in', desc='Coupling Variable')
    z1 = Float(0.0, iotype='out', desc='Global Design Variable')
    z2 = Float(0.0, iotype='out', desc='Global Design Variable')
    x1 = Float(0.0, iotype='out', desc='Local Design Variable for CO')
    y1 = Float(0.0, iotype='out', desc='Coupling Variable')
    y2 = Float(0.0, iotype='out', desc='Coupling Variable')

    def execute(self):
        """ Pass everything through"""
        self.z1 = self.z1_in
        self.z2 = self.z2_in
        self.x1 = self.x1_in
        self.y1 = self.y1_in
        self.y2 = self.y2_in

We’ve added the coupling variables in our Broadcaster as well, foreseeing the need for them in some of the other MDAO architectures.

The diagram also shows a solver that takes the output of the component dataflow and feeds it back into the input. OpenMDAO presently has two solvers: FixedPointIterator and BroydenSolver. The FixedPointIterator is a solver that performs fixed point iteration, which means that it keeps driving x_new = f(x_old) until convergence is achieved. In other words, y2 is passed from the output of SellarDiscipline2 to the input of SellarDiscipline1, and the loop keeps executing until the change in the value of y2 between iterations is smaller than a tolerance. The BroydenSolver is a solver based on a quasi-Newton-Raphson algorithm that uses a Broyden update to approximate the Jacobian. This solver reads the output and calculates a new input each iteration. Convergence is achieved when the residual between the output and input is driven to zero.

The major difference between the MDF problem and previous examples is the presence of nested drivers. Drivers can be nested in OpenMDAO using WorkFlows in the iteration hierarchy. A WorkFlow is an object that determines execution order for a group of Components. Each driver contains a single WorkFlow. For each iteration, a Driver will execute one pass through the WorkFlow, executing the components contained therein in the order the WorkFlow prescribes. Although in many cases a WorkFlow contains just Components, it can also contain Drivers. This allows nested iterative processes to be created. The following diagram shows an iteration hierarchy for the MDF problem.

Diagram showing the Optimizer, workflow for the Optimizer, and workflow for the Solver

Iteration Hierarchy for the MDF Problem

In the top left of this diagram, the gray box labeled Optimizer is the top level (or outermost) driver. This driver has a workflow that contains two objects – the Broadcaster and a Solver – so each time the optimizer runs an iteration, both of these components run. The Solver also has a workflow which contains the two discipline components. With the nesting of the drivers we get the behavior we want, namely, that for each optimizer iteration, the solver runs the discipline components until they converge. We now have a nested driver loop.

The execution order is determined by the components’ dataflow. Here, the broadcaster feeds the design variables to the discipline components, which are contained in the solver’s workflow, so the broadcaster must run first. Also, the data connection between the two discipline components means that SellarDiscipline1 runs before SellarDiscipline2. Sometimes a workflow may contain components that are not directly connected and can be run concurrently. Future tutorials will demonstrate this.

Now, let’s create the assembly for the MDF problem. First, we’ll create the top level optimization loop.

from openmdao.examples.mdao.disciplines import SellarDiscipline1, \
                                               SellarDiscipline2
from openmdao.examples.mdao.broadcaster import Broadcaster

from openmdao.main.api import Assembly, set_as_top
from openmdao.lib.drivers.api import CONMINdriver, FixedPointIterator

class SellarMDF(Assembly):
    """ Optimization of the Sellar problem using MDF
    Disciplines coupled with FixedPointIterator.
    """

    def __init__(self):
        """ Creates a new Assembly with this problem

        Optimal Design at (1.9776, 0, 0)

        Optimal Objective = 3.18339"""

        # pylint: disable-msg=E1101
        super(SellarMDF, self).__init__()

        # create Optimizer instance
        self.add('driver', CONMINdriver())

        # Outer Loop - Global Optimization
        self.add('bcastr', Broadcaster())
        self.add('fixed_point_iterator', FixedPointIterator())
        self.driver.workflow.add(['bcastr', 'fixed_point_iterator'])

So far nothing is really new in terms of syntax. Note that the top level driver is always named ‘driver’. However, all other drivers can be given any valid name. For this model, we’ve chosen to use the FixedPointIterator.

Next, we need to create the workflow for the solver. We create instances of SellarDiscipline1 and SellarDiscipline2 and add them to the assembly. Then, instead of adding them to the workflow of 'driver', we add them to the workflow of 'fixed_point_iterator'.

# Inner Loop - Full Multidisciplinary Solve via fixed point iteration
self.add('dis1', SellarDiscipline1())
self.add('dis2', SellarDiscipline2())
self.fixed_point_iterator.workflow.add(['dis1', 'dis2'])

Now the iteration hierarchy is finished. We still need to hook up the data connections and set up the CONMIN optimization and the fixed point iteration.

We need one connection between 'dis1' and 'dis2'. We also need to hook up 'bcastr' so that the design variables carry through to the discipline components.

self.connect('bcastr.z1','dis1.z1')
self.connect('bcastr.z1','dis2.z1')
self.connect('bcastr.z2','dis1.z2')
self.connect('bcastr.z2','dis2.z2')
self.connect('dis1.y1','dis2.y1')

Next, the parameters for the fixed point iterator must be set. FixedPointIterator is a specialized solver that is applicable only to single-input/single-output problems. As such, it does not conform to the standard driver interface. The output from SellarDiscipline2 is 'dis2.y2'. During iteration, this is the variable that is going to be sent to the input of SellarDiscipline1, which is 'dis1y2'. The parameter x_out takes the output variable while the parameter x_in takes the input variable. These are expression strings, but fixed point iteration doesn’t make sense using anything other than a single input and output. We also set the maximum number of iterations and a convergence tolerance.

# Iteration loop
self.fixed_point_iterator.x_out = 'dis2.y2'
self.fixed_point_iterator.x_in = 'dis1.y2'
self.fixed_point_iterator.max_iteration = 1000
self.fixed_point_iterator.tolerance = .0001

Finally, the CONIM optimization is set up.

# Optimization parameters
self.driver.add_objective('(dis1.x1)**2 + bcastr.z2 + dis1.y1 + math.exp(-dis2.y2)')

self.driver.add_parameter('bcastr.z1_in', low = -10.0, high = 10.0)
self.driver.add_parameter('bcastr.z2_in', low = 0.0,   high = 10.0)
self.driver.add_parameter('dis1.x1',      low = 0.0,   high = 10.0)

self.driver.add_constraint('3.16 < dis1.y1')
self.driver.add_constraint('dis2.y2 < 24.0')

self.driver.cons_is_linear = [1, 1]
self.driver.iprint = 0
self.driver.itmax = 30
self.driver.fdch = .001
self.driver.fdchm = .001
self.driver.delfun = .0001
self.driver.dabfun = .000001
self.driver.ctlmin = 0.0001

The process of getting the optimizer to reach a value close enough to the correct optimum required the use of more of CONMIN’s settings. The fdchm parameter is the minimum absolute step size that the finite difference uses, and fdch is the step size relative to the design variable. Dabfun is the absolute change in the objective function to indicate convergence (i.e., if the objective function changes by less than dabfun, then the problem is converged). Similarly, delfun is the relative change of the objective function with respect to the value at the previous step. Finally, ctlmin is the minimum constraint thickness for the linear constraints. We also use cons_is_linear to let CONMIN know that both constraints are linear. This can speed up the algorithm, though it hardly matters here.

As before, the add_constraint method is used to add our constraints. This time however, we used a more general expression for the first constraint. Expression strings in OpenMDAO can also be parsed as inequalities, so all of the following are equivalent ways of defining this constraint:

self.driver.add_constraint('3.16 - dis1.y1 < 0')
self.driver.add_constraint('3.16 < dis1.y1')
self.driver.add_constraint('-3.16 > -dis1.y1')

Finally, putting it all together gives:

from openmdao.examples.mdao.disciplines import SellarDiscipline1, \
                                               SellarDiscipline2
from openmdao.examples.mdao.broadcaster import Broadcaster

from openmdao.main.api import Assembly, set_as_top
from openmdao.lib.drivers.api import CONMINdriver, FixedPointIterator

class SellarMDF(Assembly):
    """ Optimization of the Sellar problem using MDF
    Disciplines coupled with FixedPointIterator.
    """

    def __init__(self):
        """ Creates a new Assembly with this problem

        Optimal Design at (1.9776, 0, 0)

        Optimal Objective = 3.18339"""

        # pylint: disable-msg=E1101
        super(SellarMDF, self).__init__()

        # create Optimizer instance
        self.add('driver', CONMINdriver())

        # Outer Loop - Global Optimization
        self.add('bcastr', Broadcaster())
        self.add('fixed_point_iterator', FixedPointIterator())
        self.driver.workflow.add(['bcastr', 'fixed_point_iterator'])

        # Inner Loop - Full Multidisciplinary Solve via fixed point iteration
        self.add('dis1', SellarDiscipline1())
        self.add('dis2', SellarDiscipline2())
        self.fixed_point_iterator.workflow.add(['dis1', 'dis2'])

        # Make all connections
        self.connect('bcastr.z1','dis1.z1')
        self.connect('bcastr.z1','dis2.z1')
        self.connect('bcastr.z2','dis1.z2')
        self.connect('bcastr.z2','dis2.z2')
        self.connect('dis1.y1','dis2.y1')

        # Iteration loop
        self.fixed_point_iterator.x_out = 'dis2.y2'
        self.fixed_point_iterator.x_in = 'dis1.y2'
        self.fixed_point_iterator.max_iteration = 1000
        self.fixed_point_iterator.tolerance = .0001

        # Optimization parameters
        self.driver.add_objective('(dis1.x1)**2 + bcastr.z2 + dis1.y1 + math.exp(-dis2.y2)')

        self.driver.add_parameter('bcastr.z1_in', low = -10.0, high = 10.0)
        self.driver.add_parameter('bcastr.z2_in', low = 0.0,   high = 10.0)
        self.driver.add_parameter('dis1.x1',      low = 0.0,   high = 10.0)

        self.driver.add_constraint('3.16 < dis1.y1')
        self.driver.add_constraint('dis2.y2 < 24.0')

        self.driver.cons_is_linear = [1, 1]
        self.driver.iprint = 0
        self.driver.itmax = 30
        self.driver.fdch = .001
        self.driver.fdchm = .001
        self.driver.delfun = .0001
        self.driver.dabfun = .000001
        self.driver.ctlmin = 0.0001

This problem is contained in sellar_MDF.py. Executing it at the command line should produce output that resembles this:

$ python sellar_MDF.py
CONMIN Iterations:  12
Minimum found at (1.977657, 0.000000, 0.000000)
Couping vars: 3.160068, 3.755315
Minimum objective:  3.18346116811
Elapsed time:  0.121051073074 seconds

We can also replace the fixed point iterator with a better solver. Fixed point iteration works for some problems, including this one, but may not converge to a solution for other problems. OpenMDAO also contains a Broyden solver called BroydenSolver. This solver is based on a quasi-Newton-Raphson algorithm found in scipy.nonlinear. It uses a Broyden update to approximate the Jacobian. If we replace FixedPointIterator with BroydenSolver, the optimizer’s workflow looks like this:

# Don't forget to put the import in your header
from openmdao.lib.drivers.api import BroydenSolver

# Outer Loop - Global Optimization
self.add('bcastr', Broadcaster())
self.add('solver', BroydenSolver())
self.driver.workflow.add(['bcastr', 'solver'])

Next, we set up our parameters for the inner loop. The Broyden solver can be connected using the standard driver interface. It can take multiple inputs and outputs though we only have one input and one output in this example.

# Iteration loop
self.solver.add_parameter('dis1.y2', low=-9.e99, high=9.e99)
self.solver.add_constraint('dis2.y2 = dis1.y2')
self.solver.itmax = 10
self.solver.alpha = .4
self.solver.tol = .0000001
self.solver.algorithm = "broyden2"

The input is selected using add_parameter. You might also be familiar with the term independent used to describe this. Here, we’ve given a low and a high attribute, but we’ve set them to very large negative and positive values as the Broyden solver doesn’t use either of these. The output is specified by adding an equality constraint. A solver essentially tries to drive something to zero. In this case, we want to drive the residual error in the coupled variable y2 to zero. An equality constraint is defined with an expression string which is parsed for the equals sign, so the following constraints are equivalent:

# Iteration loop
self.solver.add_constraint('dis2.y2 = dis1.y2')
self.solver.add_constraint('dis2.y2 - dis1.y2 = 0')

Equality constraints may also be available for some optimizers, but you should verify that they are supported. CONMIN does not support equality constraints.

OpenMDAO Home

Previous topic

Introduction

Next topic

Individual Design Feasible (IDF)

This Page