[1]:
from cosapp.base import System
class SimpleFunction(System):
def setup(self):
self.add_inward('a', 0.0)
self.add_inward('x', 1.0)
self.add_outward('y', 0.0)
def compute(self) -> None:
self.y = self.x**2 - self.a
[2]:
from cosapp.drivers import NonLinearSolver
f = SimpleFunction('f')
solver = f.add_driver(NonLinearSolver('solver'))
solver.add_unknown('x').add_equation('y == 0')
f.a = 2.0
f.run_drivers()
print(
f"{f.x = }",
f"{f.y = }",
sep="\n",
)
f.x = 1.4142135623730965
f.y = 3.9968028886505635e-15
Displaying attribute solver.problem
reveals the mathematical problem solved by the driver:
[3]:
solver.problem
[3]:
Unknowns [1]
x = 1.4142135623730965
Equations [1]
y == 0 := 3.9968028886505635e-15
Set target value dynamically¶
In the previous example, the target value for f.y
is hard-coded as the right-hand side of the design equation. Changing this value requires the setup of a new mathematical problem, with a new hard-coded equation.
Alternatively, method add_target
offers a convenient way is to set targets on variables:
[4]:
from cosapp.drivers import NonLinearSolver
f = SimpleFunction('f')
solver = f.add_driver(NonLinearSolver('solver'))
solver.add_unknown('x').add_target('y')
f.a = 2.0
f.y = 0.0 # set target value by setting output variable
f.run_drivers()
print(
f"{f.x = }",
f"{f.y = }",
sep="\n",
)
f.x = 1.4142135623730965
f.y = 3.9968028886505635e-15
Job done! Looking at attribute problem
shows that the actual mathematical problem has not changed:
[5]:
solver.problem
[5]:
Unknowns [1]
x = 1.4142135623730965
Equations [1]
y == 0.0 := 3.9968028886505635e-15
The difference, however, is that the right-hand side of the equation is now dynamically set to the current value of f.y
, before each solver execution. Therefore, we can now update the target value interactively, by simply assigning a new value to f.y
:
[6]:
f.y = -0.5 # update target value dynamically
f.run_drivers()
print(
f"{f.x = }",
f"{f.y = }",
sep="\n",
)
f.x = 1.2247448713915892
f.y = -0.4999999999999998
As can be seen after each computation, f.y
only reaches the targetted value within solver tolerance. Indeed, if add_target
offers a convenient way of defining target values, one must keep in mind that f.y
remains an output, whose value is strictly determined by the actual inputs of f
.
As a consequence, it is up to users to control the value of targetted variables before each solver execution.
Controlling more strictly target values, if necessary, can be achieved by defining initial values in RunSingleCase
sub-driver(s).
[7]:
from cosapp.drivers import NonLinearSolver, RunSingleCase
f = SimpleFunction('f')
solver = f.add_driver(NonLinearSolver('solver'))
solver.add_unknown('x').add_target('y')
case = solver.add_child(RunSingleCase('case'))
case.set_init({
'y': 0.5, # will be used as equation rhs
})
f.a = 2.0
f.x = 1.0
f.y = 0.0
f.run_drivers()
print(solver.problem)
Unknowns [1]
x = 1.5811388300841898
Equations [1]
y == 0.5 := 4.440892098500626e-16
Re-affecting f.y
interactively will not update the mathematical problem, as the target value for f.y
is now enforced by driver solver.case
:
[8]:
f.x = 1.0
f.y = 0.0
f.run_drivers()
print(solver.problem)
Unknowns [1]
x = 1.5811388300841898
Equations [1]
y == 0.5 := 4.440892098500626e-16
Changing the target value of f.y
can still be done through solver.case.set_init
:
[9]:
solver.case.set_init({
'x': 2.0,
'y': -0.5,
})
f.run_drivers()
print(solver.problem)
Unknowns [1]
x = 1.224744871391589
Equations [1]
y == -0.5 := -2.220446049250313e-16
Targets and design methods¶
Targets can be particularly interesting to define design methods with a controllable target value.
[10]:
from cosapp.base import System
class SimpleFunctionWithDesign(System):
def setup(self):
self.add_inward('a', 0.0)
self.add_inward('x', 1.0)
self.add_outward('y', 0.0)
design = self.add_design_method('y')
design.add_unknown('x').add_target('y')
def compute(self) -> None:
self.y = self.x**2 - self.a
[11]:
from cosapp.drivers import NonLinearSolver
f = SimpleFunctionWithDesign('f')
solver = f.add_driver(NonLinearSolver('solver'))
solver.extend(f.design_methods['y'])
f.a = 2.0
f.y = -0.5
f.run_drivers()
print(
f"{f.x = }",
f"{f.y = }",
sep="\n",
)
f.x = 1.2247448713915892
f.y = -0.4999999999999998
Without the use of add_target
, the only way to control the design value of y
would be to declare an additional inward y_target
, say, used as right-hand side value of design equation:
def setup(self):
self.add_inward('a', 0.0)
self.add_inward('x', 1.0)
self.add_outward('y', 0.0)
design = self.add_design_method('y')
self.add_inward('y_target', 0.0)
design.add_unknown('x').add_equation('y == y_target')
While this syntax works, it has the inconvenience of burdening the system with an inward only meaningful when design method 'y'
is activated.
Pulling a targetted variable¶
Consider a system with a targetted output pulled at parent level.
[12]:
class Composite(System):
def setup(self):
f = self.add_child(SimpleFunction('f'), pulling='x')
g = self.add_child(SimpleFunctionWithDesign('g'), pulling='y')
self.connect(f, g, {'y': 'a'}) # f.y -> g.a
# Promote design method of `g` at parent level
# Note:
# g.design('y') is the same as g.design_methods['y']
self.add_design_method('y').extend(g.design('y'))
[13]:
head = Composite('head')
solver = head.add_driver(NonLinearSolver('solver'))
solver.extend(head.design('y'))
head.x = 3.0
head.g.y = 5.0
head.f.a = 2.0
head.run_drivers()
print(
f"Solution:",
f"{head.g.x = }",
f"{head.g.y = }",
f"\n{solver.problem!r}",
sep="\n",
)
Solution:
head.g.x = 2.6457513110645907
head.g.y = 8.881784197001252e-16
Unknowns [1]
g.x = 2.6457513110645907
Equations [1]
y == 0.0 := 8.881784197001252e-16
What just happened?!
In this case, output head.g.y
is pulled up at parent level. Behind the scene, an upward connection is created from head.g.y
to head.y
, such that y
appears as a natural output of top system head
, computed by sub-system head.g
.
As a consequence, when activating head.design('y')
, it seems natural to set the targetted value in the context of system head
, that is setting head.y
instead of head.g.y
. Let’s try again:
[14]:
head = Composite('head')
solver = head.add_driver(NonLinearSolver('solver'))
solver.extend(head.design('y')) # activates a target on `head.y`
head.x = 3.0
head.y = 5.0 # set target value
head.f.a = 2.0
head.run_drivers()
print(
f"Solution:",
f"{head.g.x = }",
f"{head.g.y = }",
f"\n{solver.problem!r}",
sep="\n",
)
Solution:
head.g.x = 3.464101615137755
head.g.y = 5.000000000000002
Unknowns [1]
g.x = 3.464101615137755
Equations [1]
y == 5.0 := 1.7763568394002505e-15
Weak and strong targets¶
A target is said to be weak if it can be disregarded in certain situations. By default, targets are strong, meaning the target equation is always enforced.
A weak target is discarded if the targetted variable
is an output;
is connected to an input.
The second condition specifically excludes pulled outputs, which is the only admissible output-output connection.
Weak targets may be useful when a targetted variable is transmitted through a chain of systems, and one wants to specify the target on the last system only. This is typically the case in the next example, where we simulate three resistors in series, and wish to determine the current between end-point voltages.
[15]:
from cosapp.base import System, Port
class ElectricPort(Port):
def setup(self):
self.add_variable("I", 1.0, unit="A", desc="Current")
self.add_variable("V", 0.0, unit="V", desc="Voltage")
class Resistor(System):
def setup(self):
self.add_input(ElectricPort, 'elec_in')
self.add_output(ElectricPort, 'elec_out')
self.add_inward("R", 1e2, unit="ohm", desc="Resistance")
self.add_outward("deltaV", 0.0, unit="V")
# Off-design constraint: compute current to reach target voltage
self.add_unknown("elec_in.I").add_target("elec_out.V", weak=True)
def compute(self):
elec_in, elec_out = self.elec_in, self.elec_out
self.deltaV = self.R * elec_in.I
elec_out.I = elec_in.I
elec_out.V = elec_in.V - self.deltaV
class ThreeResistorSeries(System):
def setup(self):
R1 = self.add_child(Resistor("R1"), pulling="elec_in")
R2 = self.add_child(Resistor("R2"))
R3 = self.add_child(Resistor("R3"), pulling="elec_out")
self.add_property('resistances', (R1, R2, R3)) # for convenience
self.connect(R1.elec_out, R2.elec_in)
self.connect(R2.elec_out, R3.elec_in)
Each resistor defines an inner off-design problem, in which current is unknown, and a target is set on the output voltage. When several resistors are connected, local unknown currents are discarded every time they belong to a connected input port, which occurs at each node point connecting adjacent resistors. Likewise, local weak targets on voltages are discarded at each connecting node, where output voltage is transmitted to the next resistor.
Overall, one unknown (the incoming current into the first resistor, pulled as elec_in.I
) and one target (the output voltage of the last resistor, pulled as elec_out.V
) remain.
[16]:
circuit = ThreeResistorSeries('circuit')
circuit.R1.R = 100
circuit.R2.R = 50.
circuit.R3.R = 250
circuit.elec_in.I = 0.25 # initial guess
circuit.elec_in.V = 10
circuit.elec_out.V = -2
# Set bogus target values at connection points
# These values will be discarded, as targets are weak
circuit.R1.elec_out.V = 1.23e4
circuit.R2.elec_out.V = -8e17
circuit.add_driver(NonLinearSolver('solver'))
circuit.run_drivers()
# Show actual problem solved
circuit.drivers['solver'].problem
[16]:
Unknowns [1]
elec_in.I = 0.03
Equations [1]
elec_out.V == -2 := 0.0
[17]:
voltages = [circuit.elec_in.V]
voltages.extend(res.elec_out.V for res in circuit.resistances)
R_global = (circuit.elec_in.V - circuit.elec_out.V) / circuit.elec_in.I
print(
f"Solution:",
f"{circuit.elec_in.I = }",
f"{circuit.elec_in.V = }",
f"{circuit.elec_out.V = }",
f"{voltages = }",
sep="\n ",
)
print(
"",
f"Overall resistance: {R_global} Ohm",
f"Sum of resistances: {sum(res.R for res in circuit.resistances)} Ohm",
sep="\n",
)
Solution:
circuit.elec_in.I = 0.03
circuit.elec_in.V = 10
circuit.elec_out.V = -2.0
voltages = [10, 7.0, 5.5, -2.0]
Overall resistance: 400.0 Ohm
Sum of resistances: 400.0 Ohm
[18]:
import pandas as pd
import plotly.express as px
df = pd.DataFrame.from_dict(
{
"V": voltages,
"index": list(range(len(voltages))),
"node": list("ABCD"),
}
)
fig = px.scatter(df,
x="node", y="V",
title="Voltage profile",
)
fig.update_traces(
mode="lines+markers",
marker=dict(
size=10,
color='#636EFA',
),
)
fig.update_layout(
height=600,
hovermode='x',
)
fig.show()
Targetted expressions¶
A target can also be set on an evaluable expression, such as norm(v)
or y * (y + 1)
, as long as the expression only involves a single variable.
[19]:
class CubicFunction(System):
def setup(self):
self.add_inward('x', 1.0)
self.add_outward('y', 0.0)
def compute(self) -> None:
self.y = self.x**3
f = CubicFunction('f')
solver = f.add_driver(NonLinearSolver('solver'))
solver.add_unknown('x').add_target('abs(y)')
f.y = 8 # value used for targetted expression
f.x = 4 # positive initial value of unknown x
f.run_drivers()
print(
f"{f.x = }",
f"{f.y = }",
sep="\n",
)
f.x = 2.0
f.y = 8.0
Same computation, with a different initial guess on f.x
:
[20]:
f.y = 8
f.x = -5 # negative initial value
f.run_drivers()
print(
f"{f.x = }",
f"{f.y = }",
sep="\n",
)
f.x = -2.0
f.y = -8.0
[21]:
import logging
f = CubicFunction('f')
solver = f.add_driver(NonLinearSolver('solver'))
try:
solver.add_unknown('x').add_target('2 * y + cos(pi * x)')
except Exception as error:
logging.error(error)
ERROR:root:Targets are only supported for single variables; got frozenset({'x', 'y'})