Solutions: Debugging solvers – a hands-on set of problems

Level: Advanced

Throughout this notebook I will mark specific important changes to code using a # Solution! comment.

Circuit example with a Newton solver

In this first case, we had a nonlinear solver added but no linear solver! Because we are using a Newton solver, you need a linear solver to compute the derivative information to be used during the nonlinear solve. Here I’ve added a DirectSolver.

That alone does not make the model converge; we also need to add a linesearch to the Newton method to help move through the solution space correctly. With these two changes we see convergence.

In the original problem setup, you might’ve noticed the Newton solver wasn’t able to reduce the residuals even from the beginning. This is sometimes a sign that the derivative information is incorrect (unsolved linear system) because the solver doesn’t know where to move in the solution space.

import numpy as np

import openmdao.api as om


class Resistor(om.ExplicitComponent):
    """Computes current across a resistor using Ohm's law."""

    def initialize(self):
        self.options.declare('R', default=1., desc='Resistance in Ohms')

    def setup(self):
        self.add_input('V_in', units='V')
        self.add_input('V_out', units='V')
        self.add_output('I', units='A')

        # partial derivs are constant, so we can assign their values in setup
        R = self.options['R']
        self.declare_partials('I', 'V_in', val=1 / R)
        self.declare_partials('I', 'V_out', val=-1 / R)

    def compute(self, inputs, outputs):
        deltaV = inputs['V_in'] - inputs['V_out']
        outputs['I'] = deltaV / self.options['R']

class Diode(om.ExplicitComponent):
    """Computes current across a diode using the Shockley diode equation."""

    def initialize(self):
        self.options.declare('Is', default=1e-15, desc='Saturation current in Amps')
        self.options.declare('Vt', default=.025875, desc='Thermal voltage in Volts')

    def setup(self):
        self.add_input('V_in', units='V')
        self.add_input('V_out', units='V')
        self.add_output('I', units='A')

        # non-linear component, so we'll declare the partials here but compute them in compute_partials
        self.declare_partials('I', 'V_in')
        self.declare_partials('I', 'V_out')

    def compute(self, inputs, outputs):
        deltaV = inputs['V_in'] - inputs['V_out']
        Is = self.options['Is']
        Vt = self.options['Vt']
        outputs['I'] = Is * (np.exp(deltaV / Vt) - 1)

    def compute_partials(self, inputs, J):
        deltaV = inputs['V_in'] - inputs['V_out']
        Is = self.options['Is']
        Vt = self.options['Vt']
        I = Is * np.exp(deltaV / Vt)

        J['I', 'V_in'] = I/Vt
        J['I', 'V_out'] = -I/Vt

class Node(om.ImplicitComponent):
    """Computes voltage residual across a node based on incoming and outgoing current."""

    def initialize(self):
        self.options.declare('n_in', default=1, types=int, desc='number of connections with + assumed in')
        self.options.declare('n_out', default=1, types=int, desc='number of current connections + assumed out')

    def setup(self):
        self.add_output('V', val=5., units='V')

        for i in range(self.options['n_in']):
            i_name = 'I_in:{}'.format(i)
            self.add_input(i_name, units='A')
            self.declare_partials('V', i_name, val=1)

        for i in range(self.options['n_out']):
            i_name = 'I_out:{}'.format(i)
            self.add_input(i_name, units='A')
            self.declare_partials('V', i_name, val=-1)

            # note: we don't declare any partials wrt `V` here,
            #      because the residual doesn't directly depend on it

    def apply_nonlinear(self, inputs, outputs, residuals):
        residuals['V'] = 0.
        for i_conn in range(self.options['n_in']):
            residuals['V'] += inputs['I_in:{}'.format(i_conn)]
        for i_conn in range(self.options['n_out']):
            residuals['V'] -= inputs['I_out:{}'.format(i_conn)]

class Circuit(om.Group):

    def setup(self):
        self.add_subsystem('n1', Node(n_in=1, n_out=2), promotes_inputs=[('I_in:0', 'I_in')])
        self.add_subsystem('n2', Node())  # leaving defaults

        self.add_subsystem('R1', Resistor(R=100.), promotes_inputs=[('V_out', 'Vg')])
        self.add_subsystem('R2', Resistor(R=10000.))
        self.add_subsystem('D1', Diode(), promotes_inputs=[('V_out', 'Vg')])

        self.connect('n1.V', ['R1.V_in', 'R2.V_in'])
        self.connect('R1.I', 'n1.I_out:0')
        self.connect('R2.I', 'n1.I_out:1')

        self.connect('n2.V', ['R2.V_out', 'D1.V_in'])
        self.connect('R2.I', 'n2.I_in:0')
        self.connect('D1.I', 'n2.I_out:0')

        self.nonlinear_solver = om.NewtonSolver()
        self.linear_solver = om.DirectSolver() # Solution!

        self.nonlinear_solver.options['iprint'] = 2
        self.nonlinear_solver.options['maxiter'] = 10
        self.nonlinear_solver.options['solve_subsystems'] = True
        self.nonlinear_solver.linesearch = om.ArmijoGoldsteinLS() # Solution!
        self.nonlinear_solver.linesearch.options['maxiter'] = 10
        self.nonlinear_solver.linesearch.options['iprint'] = 2


p = om.Problem()
model = p.model

model.add_subsystem('circuit', Circuit())

p.setup()

p.set_val('circuit.I_in', 0.1)
p.set_val('circuit.Vg', 0.)

# set some initial guesses
p.set_val('circuit.n1.V', 10.)
p.set_val('circuit.n2.V', 1e-3)

p.run_model()
=======
circuit
=======
NL: Newton 0 ; 0.00141407214 1
|  LS: AG 1 ; 6.9707252e+152 1
|  LS: AG 2 ; 8.51199079e+68 0.5
|  LS: AG 3 ; 9.40605982e+26 0.25
|  LS: AG 4 ; 988771.709 0.125
|  LS: AG 5 ; 0.00130322117 0.0625
NL: Newton 1 ; 0.00130322117 0.921608691
|  LS: AG 1 ; 5580826.07 1
|  LS: AG 2 ; 13.3748871 0.5
|  LS: AG 3 ; 0.0198015756 0.25
|  LS: AG 4 ; 0.000828003297 0.125
NL: Newton 2 ; 0.000828003297 0.585545301
NL: Newton 3 ; 7.02986117e-06 0.00497135964
NL: Newton 4 ; 2.64550002e-08 1.87083809e-05
NL: Newton 5 ; 3.78431444e-13 2.67618202e-10
NL: Newton Converged

Simple implicit calculation doesn’t converge

In this problem the linear solver we were using (Linear Block Gauss-Seidel) doesn’t correctly solve the linear system. We saw some print-outs saying that the linear solver didn’t converge. By changing to a different linear solver, we’re able to converge the system.

import numpy as np

import openmdao.api as om

prob = om.Problem()

bal = om.BalanceComp()

bal.add_balance(name='E', val=0.0, units='rad', eq_units='rad', rhs_name='M')

# Use M (mean anomaly) as the initial guess for E (eccentric anomaly)
def guess_function(inputs, outputs, residuals):
    if np.abs(residuals['E']) > 1.0E-2:
        outputs['E'] = inputs['M']

bal.options['guess_func'] = guess_function

# ExecComp used to compute the LHS of Kepler's equation.
lhs_comp = om.ExecComp('lhs=E - ecc * sin(E)',
                       lhs={'units': 'rad'},
                       E={'units': 'rad'},
                       ecc={'units': None})

prob.model.set_input_defaults('M', 85.0, units='deg')

prob.model.add_subsystem(name='lhs_comp', subsys=lhs_comp,
                         promotes_inputs=['E', 'ecc'])

prob.model.add_subsystem(name='balance', subsys=bal,
                         promotes_inputs=['M'],
                         promotes_outputs=['E'])

# Explicit connections
prob.model.connect('lhs_comp.lhs', 'balance.lhs:E')

# Set up solvers
prob.model.linear_solver = om.ScipyKrylov()  # Solution!
prob.model.nonlinear_solver = om.NewtonSolver(solve_subsystems=False, maxiter=10, iprint=2)

prob.setup()

prob.set_val('M', 85.0, units='deg')
prob.set_val('E', 85.0, units='deg')
prob.set_val('ecc', 0.6)

prob.run_model()

M = prob.get_val('M')
E = prob.get_val('E')
print(f'M = {M}')
print(f'E = {E}')
NL: Newton 0 ; 0.3321557 1
NL: Newton 1 ; 0.117134648 0.352649822
NL: Newton 2 ; 0.00208780933 0.00628563452
NL: Newton 3 ; 7.36738647e-07 2.2180521e-06
NL: Newton 4 ; 9.19264664e-14 2.76757155e-13
NL: Newton Converged
M = [1.48352986]
E = [2.02317564]

Propulsor Newton solver doesn’t converge

For this next case, it turns out we just needed to change the initial guess. The pyCycle model is quite touchy and heavily relies on good initial guesses to converge. By changing W (the mass flow rate) through the engine, we’re able to converge this system correctly.

import openmdao.api as om
import pycycle.api as pyc


class Propulsor(pyc.Cycle):
    def setup(self):
        design = self.options['design']

        USE_TABULAR = True
        if USE_TABULAR: 
            self.options['thermo_method'] = 'TABULAR'
            self.options['thermo_data'] = pyc.AIR_JETA_TAB_SPEC
        else: 
            self.options['thermo_method'] = 'CEA'
            self.options['thermo_data'] = pyc.species_data.janaf
            FUEL_TYPE = 'JP-7'

        self.add_subsystem('fc', pyc.FlightConditions())

        self.add_subsystem('inlet', pyc.Inlet())
        self.add_subsystem('fan', pyc.Compressor(map_data=pyc.FanMap, map_extrap=True))
        self.add_subsystem('nozz', pyc.Nozzle())
        
        self.add_subsystem('perf', pyc.Performance(num_nozzles=1, num_burners=0))

        balance = om.BalanceComp()
        if design:
            self.add_subsystem('shaft', om.IndepVarComp('Nmech', 1., units='rpm'))
            self.connect('shaft.Nmech', 'fan.Nmech')

            balance.add_balance('W', units='lbm/s', eq_units='hp', val=50., lower=1., upper=500.)
            self.add_subsystem('balance', balance,
                               promotes_inputs=[('rhs:W', 'pwr_target')])
            self.connect('fan.power', 'balance.lhs:W')

        else:
            # vary mass flow till the nozzle area matches the design values
            balance.add_balance('W', units='lbm/s', eq_units='inch**2', val=50, lower=1., upper=500.)
            self.connect('nozz.Throat:stat:area', 'balance.lhs:W')

            balance.add_balance('Nmech', val=1., units='rpm', lower=0.1, upper=2.0, eq_units='hp')
            self.connect('balance.Nmech', 'fan.Nmech')
            self.connect('fan.power', 'balance.lhs:Nmech')

            self.add_subsystem('balance', balance,
                               promotes_inputs=[('rhs:Nmech', 'pwr_target')])

        self.pyc_connect_flow('fc.Fl_O', 'inlet.Fl_I')
        self.pyc_connect_flow('inlet.Fl_O', 'fan.Fl_I')
        self.pyc_connect_flow('fan.Fl_O', 'nozz.Fl_I')


        self.connect('fc.Fl_O:stat:P', 'nozz.Ps_exhaust')
        self.connect('inlet.Fl_O:tot:P', 'perf.Pt2')
        self.connect('fan.Fl_O:tot:P', 'perf.Pt3')
        self.connect('inlet.F_ram', 'perf.ram_drag')
        self.connect('nozz.Fg', 'perf.Fg_0')

        self.connect('balance.W', 'fc.W')

        newton = self.nonlinear_solver = om.NewtonSolver()
        newton.options['atol'] = 1e-12
        newton.options['rtol'] = 1e-12
        newton.options['iprint'] = 2
        newton.options['maxiter'] = 10
        newton.options['solve_subsystems'] = True
        newton.options['max_sub_solves'] = 10
        newton.options['reraise_child_analysiserror'] = False
        newton.linesearch = om.BoundsEnforceLS()
        self.linear_solver = om.DirectSolver()

        # base_class setup should be called as the last thing in your setup
        super().setup()

class MPpropulsor(pyc.MPCycle):

    def setup(self):

        design = self.pyc_add_pnt('design', Propulsor(design=True, thermo_method='CEA'))
        self.pyc_add_cycle_param('pwr_target', 100.)

        # define the off-design conditions we want to run
        self.od_pts = ['off_design']
        self.od_MNs = [0.8,]
        self.od_alts = [15000,]
        self.od_Rlines = [2.2,]

        for i, pt in enumerate(self.od_pts):
            self.pyc_add_pnt(pt, Propulsor(design=False, thermo_method='CEA'))

            self.set_input_defaults(pt+'.fc.MN', val=self.od_MNs[i])
            self.set_input_defaults(pt+'.fc.alt', val=self.od_alts, units='m') 
            self.set_input_defaults(pt+'.fan.map.RlineMap', val=self.od_Rlines[i])        

        self.pyc_use_default_des_od_conns()

        self.pyc_connect_des_od('nozz.Throat:stat:area', 'balance.rhs:W')

        super().setup()

Next, let’s run the propulser through a design and off-design case to see what happens.

import numpy as np

prob = om.Problem()

prob.model = mp_propulsor = MPpropulsor()

prob.setup()

#Define the design point
prob.set_val('design.fc.alt', 10000, units='m')
prob.set_val('design.fc.MN', 0.8)
prob.set_val('design.inlet.MN', 0.6)
prob.set_val('design.fan.PR', 1.2)
prob.set_val('pwr_target', -3486.657, units='hp')
prob.set_val('design.fan.eff', 0.96)

# Set initial guesses for balances
prob['design.balance.W'] = 500.

for i, pt in enumerate(mp_propulsor.od_pts):
    # initial guesses
    prob['off_design.fan.PR'] = 3.
    prob['off_design.balance.W'] = 100. # Solution!
    prob['off_design.balance.Nmech'] = 20. # normalized value

prob.set_solver_print(level=-1)
prob.set_solver_print(level=2, depth=2)
prob.model.design.nonlinear_solver.options['atol'] = 1e-6
prob.model.design.nonlinear_solver.options['rtol'] = 1e-6

prob.model.off_design.nonlinear_solver.options['atol'] = 1e-6
prob.model.off_design.nonlinear_solver.options['rtol'] = 1e-6


prob.run_model()
======
design
======
NL: Newton 0 ; 0.468905936 1
|  LS: BCHK 0 ; 50.7119874 108.149596
NL: Newton 1 ; 0.0900132352 0.191964376
|  LS: BCHK 0 ; 0.00611530407 0.0679378322
NL: Newton 2 ; 0.00611400388 0.0130388707
|  LS: BCHK 0 ; 3.68697569e-05 0.00603037839
NL: Newton 3 ; 3.6869756e-05 7.86293225e-05
|  LS: BCHK 0 ; 1.35988258e-09 3.68834169e-05
NL: Newton 4 ; 1.35927833e-09 2.89882943e-09
NL: Newton Converged

==========
off_design
==========
NL: Newton 0 ; 16.0863489 1
|  LS: BCHK 0 ; 13215486.8 821534.263
NL: Newton 1 ; 0.480750635 0.0298856278
|  LS: BCHK 0 ; 612567.884 1274190.48
NL: Newton 2 ; 0.0511605572 0.00318037098
|  LS: BCHK 0 ; 38285.792 748345.877
NL: Newton 3 ; 0.00128938347 8.01538918e-05
|  LS: BCHK 0 ; 187.538455 145448.161
NL: Newton 4 ; 0.000731094371 4.54481235e-05
|  LS: BCHK 0 ; 22.2153148 30386.3847
NL: Newton 5 ; 1.23203982e-05 7.65891516e-07
NL: Newton Converged