A More Complicated Example: Nonlinear Circuit
This tutorial will implement the nonlinear circuit example from the OpenMDAO docs in Julia. Along the way, we'll learn
- how to create implicit components with OpenMDAO.jl
- how to create OpenMDAO.jl components with metadata (the equivalent of
options
in a normal Python component) - how to specify default values for component metadata
Preamble
We'll need the OpenMDAOCore package, of course:
using OpenMDAOCore: OpenMDAOCore
An Explicit Component with an Option: The Resistor
Next we'll create an explicit component that models a resistor. The resistor has one option: the resistance, R
. We'll make R
a field in the Julia struct that we'll use for the Resistor
component:
struct Resistor <: OpenMDAOCore.AbstractExplicitComp
R::Float64
end
Now we'd like to use a default value of 1.0
for the resistance. We can do that by creating an outer constructor for the Resistor
struct
with a default keyword value.
# Default value for R.
Resistor(; R=1.0) = Resistor(R)
Main.Resistor
Next, we'll create a setup
function as usual:
function OpenMDAOCore.setup(self::Resistor)
input_data = [OpenMDAOCore.VarData("V_in"; units="V"), OpenMDAOCore.VarData("V_out"; units="V")]
output_data = [OpenMDAOCore.VarData("I"; units="A")]
R = self.R
partials_data = [OpenMDAOCore.PartialsData("I", "V_in", val=1/R),
OpenMDAOCore.PartialsData("I", "V_out", val=-1/R),]
return input_data, output_data, partials_data
end
Since this is a linear resistor, the derivatives are constant, and we can set them via the val
argument in the PartialsData
struct
, just like in OpenMDAO's declare_partials
method. Also notice that we can specify units for each of the inputs and outputs, just like in a Python OpenMDAO component.
Finally, we'll implement the compute!
method:
function OpenMDAOCore.compute!(self::Resistor, inputs, outputs)
deltaV = inputs["V_in"][1] - inputs["V_out"][1]
outputs["I"][1] = deltaV/self.R
return nothing
end
Notice that we have access to the R
field in the Resistor
struct
, named self
here. (We could call it anything, just like in a Python method.)
An Explicit Component with Two Options: The Diode
We need two parameters to characterize the Diode: the saturation current Is
and the thermal voltage Vt
:
struct Diode <: OpenMDAOCore.AbstractExplicitComp
Is::Float64
Vt::Float64
end
Next, we'll create an constructor that sets both options using keyword arguments, and provides default values for both.
# Use Julia's keyword arguments to set default values.
Diode(; Is=1e-15, Vt=0.025875) = Diode(Is, Vt)
Main.Diode
Next, we'll implement the setup
method for the Diode.
function OpenMDAOCore.setup(self::Diode)
input_data = [OpenMDAOCore.VarData("V_in"; units="V"), OpenMDAOCore.VarData("V_out"; units="V")]
output_data = [OpenMDAOCore.VarData("I"; units="A")]
partials_data = [OpenMDAOCore.PartialsData("I", "V_in"), OpenMDAOCore.PartialsData("I", "V_out")]
return input_data, output_data, partials_data
end
Nothing unusual here.
Finally, the compute!
and compute_partials!
methods:
function OpenMDAOCore.compute!(self::Diode, inputs, outputs)
deltaV = inputs["V_in"][1] - inputs["V_out"][1]
Is = self.Is
Vt = self.Vt
outputs["I"][1] = Is * (exp(deltaV / Vt) - 1)
return nothing
end
function OpenMDAOCore.compute_partials!(self::Diode, inputs, J)
deltaV = inputs["V_in"][1] - inputs["V_out"][1]
Is = self.Is
Vt = self.Vt
I = Is * exp(deltaV / Vt)
J["I", "V_in"][1, 1] = I/Vt
J["I", "V_out"][1, 1] = -I/Vt
return nothing
end
Like the Resistor
, we have access to the Is
and Vt
options in the Diode
struct
in both methods.
Our First Implicit Component: The Node
Our final component we need for the circuit is an implicit one: the Node
. Each node can have an arbitrary number of incoming and outgoing currents, so we'll need two integer options to keep track of that:
struct Node <: OpenMDAOCore.AbstractImplicitComp
n_in::Int
n_out::Int
end
We'll have n_in
and n_out
both default to one, though:
Node(; n_in=1, n_out=1) = Node(n_in, n_out)
Main.Node
Next up is the setup
method. We'll need to use a loop to create all the inputs and outputs needed for the component:
function OpenMDAOCore.setup(self::Node)
output_data = [OpenMDAOCore.VarData("V", val=5.0, units="V")]
input_data = Vector{OpenMDAOCore.VarData}()
partials_data = Vector{OpenMDAOCore.PartialsData}()
for i in 0:self.n_in-1
i_name = "I_in:$i"
push!(input_data, OpenMDAOCore.VarData(i_name; units="A"))
push!(partials_data, OpenMDAOCore.PartialsData("V", i_name; val=1.0))
end
for i in 0:self.n_out-1
i_name = "I_out:$i"
push!(input_data, OpenMDAOCore.VarData(i_name; units="A"))
push!(partials_data, OpenMDAOCore.PartialsData("V", i_name; val=-1.0))
end
return input_data, output_data, partials_data
end
We could have done something fancier like an array comprehension to create the VarData
and PartialsData
structs
:
input_data = [OpenMDAOCore.VarData("I_in:$i"; units="A") for i in 0:self.n_in-1]
Also, the derivatives are constant for the node, so we set them in the PartialsData
struct
.
Finally, we just need to write the apply_nonlinear!
method:
function OpenMDAOCore.apply_nonlinear!(self::Node, inputs, outputs, residuals)
residuals["V"][1] = 0.0
for i_conn in 0:self.n_in-1
residuals["V"][1] += inputs["I_in:$i_conn"][1]
end
for i_conn in 0:self.n_out-1
residuals["V"][1] -= inputs["I_out:$i_conn"][1]
end
return nothing
end
We see that the apply_nonlinear!
OpenMDAO.jl method is very similar to the apply_nonlinear
method on a normal Python ImplicitComponent
— its job is to calculate the residual of the implicit equation(s) it is modeling from the inputs
and outputs
.
The Run Script
We're finally ready for the run script! Here it is:
using OpenMDAO: om, make_component
p = om.Problem()
circuit = om.Group()
circuit.add_subsystem("n1", make_component(Node(n_in=1, n_out=2)), promotes_inputs=[("I_in:0", "I_in")])
circuit.add_subsystem("n2", make_component(Node()))
circuit.add_subsystem("R1", make_component(Resistor(R=100.0)), promotes_inputs=[("V_out", "Vg")])
circuit.add_subsystem("R2", make_component(Resistor(R=10000.0)))
circuit.add_subsystem("D1", make_component(Diode()), promotes_inputs=[("V_out", "Vg")])
circuit.connect("n1.V", ["R1.V_in", "R2.V_in"])
circuit.connect("R1.I", "n1.I_out:0")
circuit.connect("R2.I", "n1.I_out:1")
circuit.connect("n2.V", ["R2.V_out", "D1.V_in"])
circuit.connect("R2.I", "n2.I_in:0")
circuit.connect("D1.I", "n2.I_out:0")
circuit.nonlinear_solver = om.NewtonSolver()
circuit.linear_solver = om.DirectSolver()
circuit.nonlinear_solver.options["iprint"] = 2
circuit.nonlinear_solver.options["maxiter"] = 10
circuit.nonlinear_solver.options["solve_subsystems"] = true
circuit.nonlinear_solver.linesearch = om.ArmijoGoldsteinLS()
circuit.nonlinear_solver.linesearch.options["maxiter"] = 10
circuit.nonlinear_solver.linesearch.options["iprint"] = 2
p.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()
println("circuit.n1.V = $(p["circuit.n1.V"]) (should be 9.90804735)")
println("circuit.n2.V = $(p["circuit.n2.V"]) (should be 0.71278185)")
println("circuit.R1.I = $(p["circuit.R1.I"]) (should be 0.09908047)")
println("circuit.R2.I = $(p["circuit.R2.I"]) (should be 0.00091953)")
println("circuit.D1.I = $(p["circuit.D1.I"]) (should be 0.00091953)")
# sanity check: should sum to .1 Amps
println("circuit.R1.I + circuit.D1.I = $(p["circuit.R1.I"] + p["circuit.D1.I"]) (should be 0.1)")
circuit.n1.V = [9.90804735] (should be 9.90804735)
circuit.n2.V = [0.71278185] (should be 0.71278185)
circuit.R1.I = [0.09908047] (should be 0.09908047)
circuit.R2.I = [0.00091953] (should be 0.00091953)
circuit.D1.I = [0.00091953] (should be 0.00091953)
circuit.R1.I + circuit.D1.I = [0.1] (should be 0.1)
Notice that:
- We can use
Groups
andconnect
methods just like a Python OpenMDAO program - Linear and nonlinear solvers, and linesearch objects also work fine
- We get the same answers as the Python example in the OpenMDAO docs! :-)