Is it possible to add an "explicit output" to an implicit component without extra computational effort compared to an explicit component? - openmdao

While trying to figure out if the code can be simplified to avoid some duplication, I was wondering if it is possible to add an explicit output to an implicit component without adding extra computation effort compared to an explicit component. Explicit output may not be a fully correct term here though, since it depends on another output that is determined implicitly. Taking the node implicit component example from the docs:
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')
for i in range(self.options['n_out']):
i_name = 'I_out:{}'.format(i)
self.add_input(i_name, units='A')
def setup_partials(self):
#note: we don't declare any partials wrt `V` here,
# because the residual doesn't directly depend on it
self.declare_partials('V', 'I*', method='fd')
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)]
When we would like to calculate the power going through the node, one option would be to create an explicit component that takes the node voltage and each of the node current in and outs as inputs to calculate the power, and group it with the implicit component. However, since all of the parameters are already available inside the implicit component, and this approach duplicates some current in/out loops between the components, I was wondering if this can be done directly within the implicit component. Since the docs example mentions "The solve_nonlinear method provides a way to explicitly define an output within an implicit component":
def solve_nonlinear(self, inputs, outputs):
total_abs_current = 0
for i_conn in range(self.options['n_in']):
total_abs_current += np.abs(inputs['I_in:{}'.format(i_conn)])
for i_conn in range(self.options['n_out']):
total_abs_current += np.abs(inputs['I_out:{}'.format(i_conn)])
outputs['P_total'] = total_abs_current * outputs['V'] / 2
Reading on further, the docs say it is still necesarry to also add a power residual under the apply_nonlinear() method. Hence, something like:
def apply_nonlinear(self, inputs, outputs, residuals):
residuals['V'] = 0
total_abs_current = 0
for i_conn in range(self.options['n_in']):
residuals['V'] += inputs['I_in:{}'.format(i_conn)]
total_abs_current += np.abs(inputs['I_in:{}'.format(i_conn)])
for i_conn in range(self.options['n_out']):
residuals['V'] -= inputs['I_out:{}'.format(i_conn)]
total_abs_current += np.abs(inputs['I_out:{}'.format(i_conn)])
residuals['P_total'] = outputs['P_total'] - total_abs_current * outputs['V'] / 2
But will the component actually use this function to "solve" for the power, even when solve_linear() specifies/calculates the power already explicitely? Will this implementation then therefore require more computational resources compared to the explicit component approach? And when specifying the partials through the linearize() method, should they follow the apply_nonlinear() or solve_nonlinear() calculation?

I typically call this kind of situation a pseudo-implicit output. You have an analytic expression so you don't really need it to be implicit, but you want to stick the calculation in with a bunch of other implicit stuff. You have the basic layout right. You write a solve_nonlinear method that does the calculation for you, and you add the residual form in the apply_nonlinear.
But will the component actually use this function to "solve" for the
power, even when solve_linear() specifies/calculates the power already
explicitly?
Yes .. and no :) The simple answer is that (in most cases) the solve_nonlinear method will ultimately provide the value for the pseudo-implicit output as part of the whole global nonlinear solve. The residual form will effectively always return 0 for that particular variable. This holds true if you are using a block gauss-seidel solver, or a newton solver with solve_subsystems turned on.
The more subtle situation happens if you use a pure newton method (without solve_subsystem). In that case the residual form is actually driving the entire calculation, and the solve_nonlinear method of any implicit component is not ever getting called. This is not a super common mode of running the newton solver, but it does come up often enough.
I would say that the pseudo-implicit output gives you the flexibility to work either way with no real loss of performance. As I'll discuss below, there isn't any practical difference between this and just breaking it out into an explicit component anyway.
Will this implementation then therefore require more computational
resources compared to the explicit component approach?
The short answer is no, at least not by any meaningful amount. The long answer requires diving into the math of newton solvers and understanding how OpenMDAO really does ExplicitComponents. For all the details, you should check out section 5.1 of the OpenMDAO paper along with the implicit transformation that OpenMDAO does internally for all ExplicitComponents.
In summary, explicit components in OpenMDAO do the exact same thing that you did in the apply_linear is what OpenMDAO does internally anyway when it needs to compute a residual. So your implementation doesn't really add anything more or less than OpenMDAO already does in the background.
residuals['P_total'] = outputs['P_total'] - total_abs_current * outputs['V'] / 2
There is one caveat here though. I'll exaggerate to make the situation clear. Lets say you had a single scaler implicit relationship in a component, that you then add 1e6 pseudo-implicit outputs to it as well. In that case, you are better of splitting them up because you're making the newton system a lot larger and more expensive. But generally, adding a few extra pseudo-explicit outputs won't make much of a difference at all.
When specifying the partials through the linearize() method,
should they follow the apply_nonlinear() or solve_nonlinear()
calculation?
Differentiate the apply_nonlinear. Don't worry about what you did in the solve_nonlinear at all in the context of derivatives for implicit components!

Related

openMDAO: Does the use of ExecComp maximum() interfere with constraints not being affected by design variables?

When running the optimization driver on a large model I recieve:
DerivativesWarning:Constraints or objectives [('max_current.current_constraint.current_constraint', inds=[0]), ('max_current.continuous_current_constraint.continuous_current_constraint', inds=[0])] cannot be impacted by the design variables of the problem.
I read the answer to a similar question posed here.
The values do change as the design variables change, and the two constraints are satisfied during the course of optimization.
I had assumed this was due to those components' ExecComp using a maximum(), as this is the only place in my model I use a maximum function, however when setting up a simple problem with a maximum() function in a similar manner I do not receive an error.
My model uses explicit components that are looped, there are connections in the bottom left of the N2 diagram and NLBGS is converging the whole model. I currently am thinking it is due to the use of only explicit components and the NLBGS instead of implicit components.
Thank you for any insight you can give in resolving this warning.
Below is a simple script using maximum() that does not report errors. (I was so sure that was it) As I create a minimum working example that gives the error in a similar way to my larger model I will upload it.
import openmdao.api as om
prob=om.Problem()
prob.driver = om.ScipyOptimizeDriver()
prob.driver.options['optimizer'] = 'SLSQP'
prob.driver.options['tol'] = 1e-6
prob.driver.options['maxiter'] = 80
prob.driver.options['disp'] = True
indeps = prob.model.add_subsystem('indeps', om.IndepVarComp())
indeps.add_output('x', val=2.0, units=None)
prob.model.promotes('indeps', outputs=['*'])
prob.model.add_subsystem('y_func_1',
om.ExecComp('y_func_1 = x'),
promotes_inputs=['x'],
promotes_outputs=['y_func_1'])
prob.model.add_subsystem('y_func_2',
om.ExecComp('y_func_2 = x**2'),
promotes_inputs=['x'],
promotes_outputs=['y_func_2'])
prob.model.add_subsystem('y_max',
om.ExecComp('y_max = maximum( y_func_1 , y_func_2 )'),
promotes_inputs=['y_func_1',
'y_func_2'],
promotes_outputs=['y_max'])
prob.model.add_subsystem('y_check',
om.ExecComp('y_check = y_max - 1.1'),
promotes_inputs=['*'],
promotes_outputs=['*'])
prob.model.add_constraint('y_check', lower=0.0)
prob.model.add_design_var('x', lower=0.0, upper=2.0)
prob.model.add_objective('x')
prob.setup()
prob.run_driver()
print(prob.get_val('x'))
There is a problem with the maximum function in this context. Technically a maximum function is not differentiable; at least not when the index of which value is max is subject to change. If the maximum value is not subject to change, then it is differentiable... but you didn't need the max function anyway.
One correct, differentiable way to handle a max when doing gradient based things is to use a KS function. OpenMDAO provides the KSComp which implements it. There are other kinds of functions (like p-norm that you could use as well).
However, even though maximum is not technically differentiable ... you can sort-of/kind-of get away with it. At least, numpy (which ExecComp uses) lets you apply complex-step differentiation to the maximum function and it seems to give a non-zero derivative. So while its not technically correct, you can maybe get rid of it. At least, its not likely to be the core of your problem.
You mention using NLBGS, and that you have components which are looped. Your test case is purely feed forward though (here is the N2 from your test case).
. That is an important difference.
The problem here is with your derivatives, not with the maximum function. Since you have a nonlinear solver, you need to do something to get the derivatives right. In the example Sellar optimization, the model uses this line: prob.model.approx_totals(), which tells OpenMDAO to finite-difference across the whole model (including the nonlinear solver). This is simple and keeps the example compact. It also works regardless of whether your components define derivatives or not. It is however, slow and suffers from numerical difficulties. So use on "real" problems at your own risk.
If you don't include that (and your above example does not, so I assume your real problem does not either) then you're basically telling OpenMDAO that you want to use analytic derivatives (yay! they are so much more awesome). That means that you need to have a Linear solver to match your nonlinear one. For most problems that you start out with, you can simply put a DirectSolver right at the top of the model and it will all work out. For more advanced models, you need a more complex linear solver structure... but thats a different question.
Give this a try:
prob.model.linear_solver = om.DirectSolver()
That should give you non-zero total derivatives regardless of whether you have coupling (loops) or not.

Approximating the whole problem with Mixed Analytical strategy

I have problem where I have implemented analytical derivatives for some components and I'm using complex step for the rest. There is a cyclic dependency between them so I also use a solver to converge them. It converges when I use NonlinearBlockGS. But when I use NewtonSolver in combination with a linear solver the optimization fails (Iteration limit exceeded), even with high iteration count. But I found that it converges easily and works perfectly when I use prob.model.approx_totals(). I read that approx_totals uses fd or cs to find the model gradients. So I have two questions.
In general, Will I lose the benefits from the mixed-analytical approach when I use approx_totals()? Is there a way to find the derivatives of whole model (or group) with mixed analytical strategy ? (Anyway In my case the explicitcomponents which are coupled use 'complex step`. But I'm just curious about this.)
In general (not in this scenario), will Openmdao automatically detect the mixed strategy or should I specify it some how ?
I will also be grateful, if you could point me to some examples where mixed derivatives are used. I didnt have any luck finding them myself.
Edit:Adding Example. I am not able to reproduce the issue in a sample code. Also I dont want to waste your time with my code(there more than 30 ExplicitComponents and 7 Groups). So I made a simple structure below to explain it better. In this there are 7 components A to G and only F and G doesn't have analytical derivatives and uses FD.
import openmdao.api as om
import numpy as np
class ComponentA_withDerivatives(om.ExplicitComponent):
def setup(self):
#setup inputs and outputs
def setup_partials(self):
#partial declaration
def compute(self, inputs, outputs):
def compute_partials(self, inputs, J):
#Partial definition
class ComponentB_withDerivatives(om.ExplicitComponent):
.....
class ComponentC_withDerivatives(om.ExplicitComponent):
......
class ComponentD_withDerivatives(om.ExplicitComponent):
......
class ComponentE_withDerivatives(om.ExplicitComponent):
......
class ComponentF(om.ExplicitComponent):
def setup(self):
#setup inputs and outputs
self.declare_partials(of='*', wrt='*', method='fd')
def compute(self,inputs,outputs):
# Computation
class ComponentG(om.ExplicitComponent):
def setup(self):
#setup inputs and outputs
self.declare_partials(of='*', wrt='*', method='fd')
def compute(self,inputs,outputs):
# Computation
class GroupAB(om.Group):
def setup(self):
self.add_subsystem('A', ComponentA_withDerivatives(), promotes_inputs=['x','y'], promotes_outputs=['z'])
self.add_subsystem('B', ComponentB_withDerivatives(), promotes_inputs=['x','y','w','u'], promotes_outputs=['k'])
class GroupCD(om.Group):
def setup(self):
self.add_subsystem('C', ComponentC_withDerivatives(), .....)
self.add_subsystem('D', ComponentD_withDerivatives(), ...)
class Final(om.Group):
def setup(self):
cycle1 = self.add_subsystem('cycle1', om.Group(), promotes=['*'])
cycle1.add_subsystem('GroupAB', GroupAB())
cycle1.add_subsystem('ComponentF', ComponentF())
cycle1.linear_solver = om.DirectSolver()
cycle1.nonlinear_solver = om.NewtonSolver(solve_subsystems=True)
cycle2 = self.add_subsystem('cycle2', om.Group(), promotes=['*'])
cycle2.add_subsystem('GroupCD', GroupCD())
cycle2.add_subsystem('ComponentE_withDerivatives', ComponentE_withDerivatives())
cycle2.linear_solver = om.DirectSolver()
cycle2.nonlinear_solver = om.NewtonSolver(solve_subsystems=True)
self.add_subsystem('ComponentG', ComponentG(), promotes_inputs=['a1','a2','a3'], promotes_outputs=['b1'])
prob = om.Problem()
prob.model = Final()
prob.driver = om.pyOptSparseDriver()
prob.driver.options['optimizer'] = 'SNOPT'
prob.driver.options['print_results']= True
## Design Variables
## Costraints
## Objectives
# Setup
prob.setup()
##prob.model.approx_totals(method='fd')
prob.run_model()
prob.run_driver()
Here this doesn't work. The cycle1 doesn't converge. The code works when I completely remove cycle1 or use NonlinearBlockGS instead of Newton or if I uncomment prob.model.approx_total(method='FD'). (no problem with cycle2. Work with Newton)
So if I don't use approx_totals(), I am assuming Openmdao uses a mixed strategy. Or should I manually mention it somehow ? And when I do use approx_totals() , will I lose the benefits from the analytical derivatives that I do have?
The code example you provided isn't runnable, so I'll have to make a few guesses. You call both run_model() and run_driver(). You bothered to include an optimizer in your sample code though, and you've show approx_totals to be called at the top of the model hierarchy.
So when you say it does not work, I will assume you mean that the optimizer doesn't converge.
You have understood the behavior of approx_totals correctly. When you set that at the top of your model, then OpenMDAO will FD the relevant variables from the group level. In this case, that means you will also be FD-ing across the solver itself. You say that this seems to work, but the mixed analytic approach does not.
In general, Will I lose the benefits from the mixed-analytical approach when I use approx_totals()?
Yes. You are no long using a mixed approach. You are just FD-ing across the model monolithically.
Is there a way to find the derivatives of whole model (or group) with mixed analytical strategy ?
OpenMDAO is computing total derivatives with a mixed strategy when you don't use approx_totals. The issue is that for your model, it seems not to be working.
In general (not in this scenario), will Openmdao automatically detect the mixed strategy?
It will "detect" it (it doesn't actually detect anything, but the underlying algorithms will use a mixed strategy UNLESS you tell it not to with approx_totals. Again, the issue is not that a mixed strategy is not being used, but that it is not working.
So why isn't the mixed strategy working?
I can only guess, since I can't run the code... so YMMV.
You mention that you are using complex-step for partials of your explicit components. Complex-step is a much more accurate approximation scheme than FD, but it is not without its own flaws. Not every computation is complex-safe. Some can be re-written to be complex-safe, others can not.
By "complex-safe" I mean that the computation correctly handles the complex-part to give a derivatives.
Two commonly used-complex-safe methods are np.linalg.norm and np.abs. Both will happily accept complex-numbers and give you an answer, but it is not the correct answer for when you need derivatives.
Because of this, OpenMDAO ships with a set of custom functions that are cs-safe --- custom norm and abs are provided.
What typically happens with non cs-safe methods is that the complex-part somehow gets dropped off and you get 0 partial derivatives. Wrong partials, wrong totals.
To check this, make sure you call check_partials on your components that are being complex-stepped, using a finite-difference check. You'll probably find some discrepancies.
The fixes available to you are:
Switch those components to use FD partials. Less accurate, but will probably work
Correct whatever problems in your compute are making your code non-cs-safe. Use OpenMDAO's custom functions if thats the problem, or possibly you need to be more careful about how you allocate and use numpy arrays in your compute (if you're allocating your own arrays, then you need to be careful to make sure they are complex too!).

How to properly connect a scalar to a vector entry?

We're searching a way to connect scalars (as an output) to vector entries (as an input).
In the "Nonlinear Circuit Analysis" example, there is a workaround in the class Node which loops over the number of scalars and adds each scalar as a new input. In the class Circuit, the added inputs are then accessed by their "indices" (e.g. 'I_in:0').
In our case, this loop must be integrated by a new Component, which solely loops the new inputs. This is why we'd like to avoid loops and directly use vector and matrix operations. In terms of the Circuit example, a way to achieve this would be to use some kind of target indices (see tgt_indices), which are not implemented (yet 😊).
In this case both classes would look like this:
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')
self.add_input('I_in', units='A', shape=self.options['n_in'])
self.add_input('I_out', units='A', shape=self.options['n_out'])
def apply_nonlinear(self, inputs, outputs, residuals):
residuals['V'] = 0.
residuals['V'] += inputs['I_in'].sum()
residuals['V'] -= inputs['I_out'].sum()
class Circuit(om.Group):
def setup(self):
self.add_subsystem('n1', Node(n_in=1, n_out=2), promotes_inputs=[('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', tgt_indices=[0])
self.connect('R2.I', 'n1.I_out', tgt_indices=[1])
self.connect('n2.V', ['R2.V_out', 'D1.V_in'])
self.connect('R2.I', 'n2.I_in', tgt_indices=[0])
self.connect('D1.I', 'n2.I_out', tgt_indices=[0])
...
So the main aspect is to connect output scalars to entries of an input vector similar to the src_indices option. Is there a way to do this or a reason against this?
Since we plan to use Dymos we`d like to use this functionality one dimension higher and connect output vectors to rows of input matrices.
You are correct that there is currently no tgt_indices like feature in OpenMDAO. Though it is technically feasible, it does present some API design and internal practical challenges. If you feel strongly about the need/value for this feature, you could consider submitting a POEM describing your proposed API for the dev-team to consider. You have a start on it with your provided example, but you'd need to think through details such as the following:
what happens if a user gives both src_indices and tgt_indices?
What do error msgs look like if there are overlapping tgt_indices
How does the api extend to the promotes function.
In the meantime you'll either need to use a MuxComponent, or write your own version of that component that would take in array inputs and push them into the combined matrix. Its slightly inefficient to add a component like this, but in the grand scheme of things it should not be too bad (as long as you take the time to define analytic derivatives for it. It would be expensive to CS/FD this component).

Creation of a 'partial objective' in OpenMDAO

I am creating a program that optimizes a set of coupled subcomponents to minimize for their total mass. Currently each component is a group that has a promoted output for it's mass and then another group group exists at the top level that takes each of these masses as inputs, computes the sum, and then this sum is used as the objective for the optimizer.
This program is designed to be operated by a user where the type and number of subcomponents is set at runtime. This proves problematic for my statically declared mass summing group that would need to change it's inputs depending on what components are added at runtime.
I was therefor wondering if is there a way to declare a 'partial objective' where each of these partial pieces would be summed together for the final objective processed by the ScipyOptimize Driver? The 'partial objectives', design variable and constraints could simply be added in each subsystem, and the subsystem is added to the model, they would ready to go to fit into the larger optimization.
Another way could be some sort of summer behavior in a group where the inputs to be summed were exclusively created via glob pattern. Something along the lines of
self.add_subsystem('sum', Summer(inputs='mass:*'))
Is there any way to achieve either of these types of functionality in OpenMDAO 3.1.1?
In OpenMDAO V3.1, there is a configure method that will let you accomplish what you want --- subject to a few caveats. The first caveat is that in V3.1 you can inspect the I/O of components from within a group configure but you can not inspect the I/O of child groups. This is something we are working to remedy, but as of V3.1 this restriction is present.
None the less, here is some code that accomplishes what I think you were seeking. Its not super clean, but it does achieve the kind of reactive setup that you were going for.
import openmdao.api as om
class Summer(om.ExplicitComponent):
def setup(self):
# note: will add inputs via the configure method of parent group
self.add_output('total_mass')
self.declare_partials('total_mass', wrt='*', val=1)
def compute(self, inputs, outputs):
outputs['total_mass'] = 0
for inp_name in inputs:
outputs['total_mass'] += inputs[inp_name]
class TotalMass(om.Group):
def setup(self):
# Only add the summing comp, others will be added by users
self.add_subsystem('sum', Summer())
def configure(self):
sum_comp = self.sum
# NOTE: need to access some private attributes of the group here,
# so this is a little fragile, but works as of OM V3.1
for subsys in self._subsystems_myproc:
s_name = subsys.name
if s_name == 'sum':
continue
i_name = f'{s_name}_mass'
sum_comp.add_input(i_name)
self.connect(f'{s_name}.mass', f'sum.{i_name}')
if __name__ == "__main__":
p = om.Problem()
tm = p.model.add_subsystem('tm', TotalMass())
tm.add_subsystem('part_1', om.ExecComp('mass=3+x'))
tm.add_subsystem('part_2', om.ExecComp('mass=5+x'))
p.setup()
p.run_model()
p.model.list_outputs()
We're planning changes that will make more model introspection at the time of setup/configure possible. Until those changes are implemented, then the typical way of achieving this is similar to what you've implemented. Without introspection, you need to give Summer the names of the inputs it should expect (not wildcard-based).
You can give your systems which compute mass some attribute, for instance 'mass_output_name'.
Then, you could iterate through all such systems:
mass_output_systems = [sys_a, sys_b, sys_c]
mass_names = [sys.mass_output_name for sys in mass_output_systems]
And then feed these to your summing subsystem:
self.add_subsystem('sum', Summer(inputs=mass_names))

How to use an external code component within the optimization framework

Hi I am trying to use the paraboloid external code component to get the same results as in the paraboloid optimization problem (openmdao v 2.2.0).
So in my mind the independent variables x,y should be updated and thus changing the input file of the external component to minimize the output f.
Not that I got this working but I basically add the external component's output to be the objective and the independent variables to be the design variables etc (see code below).
But more importantly I have a problem to conceptually understand how the optimizer would know the derivatives in such external codes.
I tried 'COBYLA' thinking that could be a way to go gradient-free approach but there seems to be a bug in the iprint statement, since I can not run the example paraboloid optimization either.
I think I have a similar problem with surrogates. For example I use Metamodelunstructured component to find my surrogate which performs well if I ask for a known value. But I do not see how to couple this component's output to be the objective of the optimizer. I think I am doing the right thing by giving the model objective. but not sure...
The answer might be that I am completely off from the optimization logic if so please refer me to the related papers for the algorithms behind.
Thanks in advance
from openmdao.api import Problem, Group, IndepVarComp
from openmdao.api import ScipyOptimizeDriver
from openmdao.components.tests.test_external_code import ParaboloidExternalCode
top = Problem()
top.model = model = Group()
# create and connect inputs
model.add_subsystem('p1', IndepVarComp('x', 3.0))
model.add_subsystem('p2', IndepVarComp('y', -4.0))
model.add_subsystem('p', ParaboloidExternalCode())
model.connect('p1.x', 'p.x')
model.connect('p2.y', 'p.y')
top.driver = ScipyOptimizeDriver()
top.driver.options['optimizer'] = 'SLSQP'
top.model.add_design_var('p1.x', lower=-50, upper=50)
top.model.add_design_var('p2.y', lower=-50, upper=50)
top.model.add_objective('p.f_xy')
top.driver.options['tol'] = 1e-9
top.driver.options['disp'] = True
top.setup()
top.run_driver()
# minimum value
# location of the minimum
print(top['p1.x'])
print(top['p2.y'])
So, I think the main thing you are asking is how to provide derivatives for external codes. I think there are really two options for this.
Finite difference across the external component.
The test example doesn't show how to do this, which is unfortunate, but you do this the same way that you would declare derivatives fd for a pure python component, namely by adding this line to the external component's setup method:
self.declare_partials(of='*', wrt='*', method='fd')
Provide another external method to calculate the derivatives, and wrap it in the "compute_partials" method.
We do this with CFD codes that provide an adjoint solution. You could possibly also use automatic differentiation on the external source code to produce a callable function in this way. However, I think method 1 is what you are asking for here.

Resources