[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

  1. is an output;

  2. 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'})