Design Methods¶
Design methods allow component designers to identify, from expert knowledge, the different ways users can design a component from functional requirements. In practice, design methods are mathematical problems that can be activated on demand.
A system can contain several design methods, each reflecting an engineering practice.
Declaring a design method in a system¶
Design methods are declared at System setup, using System.add_design_method. This class method takes the name of the design method as single argument; it will create a new entry in an internal dictionary of MathematicalProblem objects, mapped to their names.
Such objects bear unknowns and equations, declared with methods add_unkown and add_equation:
class MySystem(System):
def setup(self):
self.add_inward("x", 1.0)
self.add_outward("y", 0.0)
design = self.add_design_method("find_root") # create problem `design`, and store it with key "find_root"
design.add_unknown("x").add_equation("y == 0") # define problem by declaring unknowns and equations
# Add a second design method "my_way":
self.add_design_method("my_way").add_unknown("x").add_equation("y == 2 * x")
def compute(self):
self.y = self.x**2 - 3
Example¶
[1]:
from cosapp.base import System, Port
class XPort(Port):
def setup(self):
self.add_variable("x", 1.0)
class MultiplyWithDesignMethod(System):
def setup(self):
self.add_input(XPort, "p_in", {"x": 1.0})
self.add_output(XPort, "p_out", {"x": 1.0})
self.add_inward("K1", 5.0)
# Intrinsic constraints
self.add_inward("expected_output", 1.0)
self.add_unknown("p_in.x").add_equation("p_out.x == expected_output")
# Design methods
self.add_design_method("dx").add_unknown("K1").add_equation("p_out.x - p_in.x == 5")
def compute(self):
self.p_out.x = self.p_in.x * self.K1
Class MultiplyWithDesignMethod defines two types of mathematical problems, through add_unknown and add_equation:
Unknowns and equations declared directly in the system definition (that is
self.add_unknownandself.add_equationin systemsetup) are always enforced, for all instances of the class. They are referred to as the intrinsic problem of the class. Composite systems automatically collect the intrinsic problems of their sub-systems.Unknowns and equations declared within a design method define a design problem, which may or may not be activated.
Solving the intrinsic problem¶
[2]:
from cosapp.drivers import NonLinearSolver
# Create system
m = MultiplyWithDesignMethod("m")
# Add solver
solver = m.add_driver(NonLinearSolver("solver"))
# Initialize & solve system
m.K1 = 5.0
m.expected_output = 7.5
m.run_drivers()
print(
f"Intrinsic problem for K1 = {m.K1}:",
solver.problem,
"",
"Result:",
f"{ m.K1 = !s}",
f"{ m.p_in.x = !s}",
f"{ m.p_out.x = !s}",
sep="\n",
)
Intrinsic problem for K1 = 5.0:
Unknowns [1]
p_in.x = 1.5
Equations [1]
p_out.x == expected_output := 0.0
Result:
m.K1 = 5.0
m.p_in.x = 1.5
m.p_out.x = 7.5
Activating a design method¶
Design methods are activated by merging the predefined design method into the solver problem, using method NonLinearSolver.add_problem.
[3]:
from cosapp.drivers import NonLinearSolver
m = MultiplyWithDesignMethod("m")
solver = m.add_driver(NonLinearSolver("solver"))
# Activate design method "dx" of system `m`
solver.add_problem(m.design_methods["dx"])
# Initialize & solve system
m.K1 = 5.0
m.expected_output = 7.5
m.run_drivers()
print(
"Design problem:",
solver.problem,
"",
"Result:",
f" {m.K1 = !s}",
f" {m.p_in.x = !s}",
f" {m.p_out.x = !s}",
sep="\n",
)
Design problem:
Unknowns [2]
K1 = 3.0
p_in.x = 2.5
Equations [2]
p_out.x - p_in.x == 5 := 0.0
p_out.x == expected_output := 0.0
Result:
m.K1 = 3.0
m.p_in.x = 2.5
m.p_out.x = 7.5
Reusing sub-system design methods at parent level¶
Composite systems can take advantage of design methods defined for their sub-systems, and thus construct composite design methods.
Aggregation of design problems by extension¶
In the example below, design method “design” merges design constraints from sub-systems a and b:
class CompositeSystem(System):
def setup(self):
a = self.add_child(ComponentA("a"))
b = self.add_child(ComponentB("b"))
design = self.add_design_method("design")
design.extend(a.design_methods["design_this"])
design.extend(b.design_methods["design_that"])
Promotion of sub-system design methods¶
One can also use method System.pull_design_method to promote sub-system design methods at parent level. Arguments are (1) the sub-system (or list thereof) from which design methods are promoted, and (2) the list of design method names, or a name mapping, if required (rules for variable pulling and connections apply - see tutorial on Systems).
In the example below, design method “design_x”, merging design problems from sub-systems a and b, is created at parent level. Additionaly, design method “design_y” of sub-system a is exposed at parent level as “design_foo”.
class CompositeSystem(System):
def setup(self):
a = self.add_child(ComponentA("a"))
b = self.add_child(ComponentB("b"))
self.pull_design_method([a, b], "design_x")
self.pull_design_method(a, {"design_y": "design_foo"})
Write dynamic design problems with custom arguments¶
As mentioned earlier, a design method is a predefined mathematical problem, stored in dictionary design_methods, to be used in design problems. The main limitation is that design methods defined at setup are static, that is defined once and for all.
For advanced uses, one may want to create mathematical problems dynamically, with optional or ad-hoc parameters, say. This can typically be achieved by writing dedicated object-bound methods creating said problem on-the-fly, using custom arguments. A convenient function, System.new_problem, may be used to this end:
[4]:
import math
from cosapp.base import System
from cosapp.core import MathematicalProblem
class SystemWithDynamicDesignMethod(System):
def setup(self):
self.add_inward("x", 1.0)
self.add_outward("y", 0.0)
def compute(self):
self.y = math.sin(self.x**2 - 2)
def find_root(self, **options) -> MathematicalProblem:
"""Compute `x` such that `y == 0`.
Additional options apply to unknown `x`.
"""
problem = self.new_problem()
problem.add_unknown("x", **options)
problem.add_equation("y == 0")
return problem
First simulation, with no constraint on the unknown¶
[5]:
from cosapp.drivers import NonLinearSolver
from cosapp.recorders import DataFrameRecorder
s = SystemWithDynamicDesignMethod("s")
solver = s.add_driver(NonLinearSolver("solver"))
# Activate design method `find_root` of system `s`
solver.add_problem(s.find_root())
# Add a recorder to store the convergence path
solver.add_recorder(DataFrameRecorder(), history=True)
s.x = 0.7 # initial guess
s.run_drivers()
# Retrieve data from the recorder
data = solver.recorder.export_data()
# import pandas as pd
# pd.set_option('display.precision', 10)
print(
f"{s.x = !s}",
f"{s.y = !s}",
f"\nConvergence path:\n",
data[["x", "y"]],
sep="\n",
)
s.x = 12.612677458790806
s.y = 2.9403902792268246e-14
Convergence path:
x y
0 0.700000 -9.981525e-01
1 12.432821 9.784907e-01
2 12.621465 2.199241e-01
3 12.612529 -3.749895e-03
4 12.612677 -8.336797e-09
5 12.612677 2.940390e-14
In this example, the initial value of 0.7 chosen for variable s.x corresponds to a derivative dy/dx of about 0.085. Consequently, with no constraint enforced on the Newton-Raphson algorithm used by NonLinearSolver, variable s.x undergoes a large jump from 0.7 to about 12.43 in one single iteration. From there, the algorithm quickly converges to a local root of function \(y = f(x) = \sin(x^2 - 2)\), namely \(\sqrt(2 + 50\pi) \approx 12.612677\).
Second try, imposing a maximum step on the unknown¶
In this section, we impose a maximum absolute step on x at each solver iteration.
[6]:
s = SystemWithDynamicDesignMethod("s")
solver = s.add_driver(NonLinearSolver("solver"))
# Activate design method `find_root` of system `s`
# with a maximum absolute step size of 0.1 for unknown `x`
solver.add_problem(s.find_root(max_abs_step=0.1))
# Add a recorder to store the convergence path
solver.add_recorder(DataFrameRecorder(), history=True)
s.x = 0.7 # initial value
s.run_drivers()
# Retrieve data from the recorder
data = solver.recorder.export_data()
print(
f"{s.x = !s}",
f"{s.y = !s}",
f"\nConvergence path:\n",
data[["x", "y"]],
sep="\n",
)
s.x = 1.4142135623730951
s.y = 4.440892098500626e-16
Convergence path:
x y
0 0.700000 -9.981525e-01
1 0.800000 -9.778646e-01
2 0.900000 -9.283690e-01
3 1.000000 -8.414710e-01
4 1.100000 -7.103533e-01
5 1.200000 -5.311862e-01
6 1.300000 -3.050586e-01
7 1.400000 -3.998933e-02
8 1.414293 2.252897e-04
9 1.414213 -1.197841e-06
10 1.414214 -3.372103e-11
11 1.414214 4.440892e-16
The convergence path reveals that unknown x undergoes constrained steps at the beginning of the iterative process. Eventually, the steps computed by the root-finding algorithm become smaller than the imposed maximum step, and the algorithm rapidly converges towards the first root, \(\sqrt{2}\).
Show information on a design method¶
Another advantage of implementing design methods as class methods is that it provides a way to document them with docstrings, readily accessible through Python function help:
[7]:
help(s.find_root)
Help on method find_root in module __main__:
find_root(**options) -> cosapp.core.numerics.basics.MathematicalProblem method of __main__.SystemWithDynamicDesignMethod instance
Compute `x` such that `y == 0`.
Additional options apply to unknown `x`.
Congrats! You are now ready to update your System into a design model with CoSApp!