CoSApp examples
Tips & Tricks¶
Purge all drivers from a system¶
Often necessary to start off a new, clean simulation workflow.
Drivers are stored in a plain dictionary drivers. Thus, cleaning all drivers from system s is as simple as
s.drivers.clear()
Loop through all elements in a composite system tree¶
Browsing through composite trees is usually tackled with recursive functions. However, writing such functions can sometimes be tricky.
As of version 0.11.7, a recursive iterator makes it very easy to loop through system or driver trees.
system = SomeComplexSystem('system')
for elem in system.tree():
# do whatever
This functionality might come handy if one wishes to collect data from all sub-systems, say.
[1]:
from cosapp.base import System
a = System('a')
# First layer
a.add_child(System('aa'))
a.add_child(System('ab'))
# Second layer
a.ab.add_child(System('aba'))
a.ab.add_child(System('abb'))
a.ab.add_child(System('abc'))
print([elem.name for elem in a.tree()])
['aa', 'aba', 'abb', 'abc', 'ab', 'a']
System.tree() accepts optional argument downwards, which determines whether the iterator should yield elements from top to bottom (downwards=True), or from bottom to top (downwards=False - default behaviour).
[2]:
print([elem.name for elem in a.tree(downwards=True)])
['a', 'aa', 'ab', 'aba', 'abb', 'abc']
Note that the iterator follows the execution order of all elements in the tree:
[3]:
print([elem.name for elem in a.tree()])
a.exec_order = ['ab', 'aa']
a.ab.exec_order = ['abc', 'aba', 'abb']
print([elem.name for elem in a.tree()])
['aa', 'aba', 'abb', 'abc', 'ab', 'a']
['abc', 'aba', 'abb', 'ab', 'aa', 'a']
Synchronize sub-system inputs¶
Inputs/inwards of a system specify the necessary information for local execution of the system. Very often, though, inputs of two or more sub-systems must take the same value, in the context of their parent assembly.
For instance, consider a system PlaneWing with inward length. When gathered in a higher-level system Airplane as left_wing and right_wing, it may be desirable to force both wings to have the same length, by construction.
Such input synchronization is natively handled by CoSApp through the pulling mechanism:
class Airplane(System):
def setup(self):
self.add_child(PlaneWing('left_wing', pulling={'length': 'wing_length'}))
self.add_child(PlaneWing('right_wing', pulling={'length': 'wing_length'}))
Explaination:¶
Pulling an input/inward creates a parent-to-child downward connector. Thus, the first add_child statement will create new inward wing_length on the fly, as well as a connector wing_length -> left_wing.length. When the right wing sub-system is created, a second connector wing_length -> right_wing.length is generated, using existing inward wing_length.
plane = Airplane('plane')
plane.wing_length = 12.3
plane.run_once() # will propagate `wing_length` to both wings
Notes¶
Connectors only transfer information when
run_once()orrun_drivers()is invoked.Any value directly assigned to
plane.left_wing.length, say, will be eventually superseded byplane.wing_lengthat each model execution.Renaming pulled inputs/inwards at parent level is not mandatory. Passing a list of attribute names rather than a dictionary as
pullingargument will trigger the same mechanisms, with no name changes. In the example above, it is just more explicit to expose inwardwing_lengthrather than justlengthin the context of the airplane.
Create collections of sub-systems and/or ports in systems¶
Consider an arbitrary number of resistors in series, modeled as an assembly of subsystems R1, R2, etc.
In this case, the number of resistors n is provided at setup, and the n resistors are created with a loop of the kind:
def setup(self, n=2):
for i in range(n)
self.add_child(Resistor(f"R{i}"))
While this is creation loop is unavoidable, it can be interesting to get an attribute storing all resistors as an iterable collection.
One can pull this trick using method add_property, as illustrated below:
[4]:
from cosapp.base import Port, System
class ElecPort(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(ElecPort, 'elec_in')
self.add_output(ElecPort, 'elec_out')
self.add_inward("R", 1e2, unit="ohm", desc="Resistance")
def compute(self):
self.elec_out.I = I = self.elec_in.I
self.elec_out.V = self.elec_in.V - self.R * I
class ResistorSeries(System):
def setup(self, n=2):
self.add_property('n', max(int(n), 2))
# Create a tuple of children, and store it as a property
self.add_property('resistors', tuple(
self.add_child(Resistor(f"R{i}"))
for i in range(self.n)
))
R = self.resistors
for previous, current in zip(R, R[1:]):
self.connect(current.elec_in, previous.elec_out)
# Pull first `elec_in` and last `elec_out`
self.add_input(ElecPort, 'elec_in')
self.add_output(ElecPort, 'elec_out')
self.connect(self.elec_in, R[0].elec_in)
self.connect(self.elec_out, R[-1].elec_out)
The same trick can be used to create collections of ports, if needed.
Use of property resistors makes the code clearer, and allows one to loop through the child/port collection without resorting to syntaxes of the kind s[f"R{i}"] for i in range(s.n).
In the next cell, for example, we show two identical ways of initializing the resistances:
[5]:
s = ResistorSeries('s', n=5)
for res in s.resistors:
res.R = 1.25e3
# instead of:
for i in range(s.n):
s[f"R{i}"].R = 1.25e3
Note in particular that the first expression, apart from being much simpler, is also more robust to name changes in ResistorSeries, as we do not need to know the naming convention of sub-systems (R1, R2, etc.), and of their total number n.
System properties are immutable by nature, so resistors cannot be redefined. Furthermore, use of tuple garanties that each individual element of the collection is also immutable, which is exactly what we wish to achieve here.
[6]:
import logging
# Check that resistors cannot be individually reassigned:
try:
s.resistors[0] = None
except Exception as error:
logging.exception(error)
ERROR:root:'tuple' object does not support item assignment
Traceback (most recent call last):
File "<ipython-input-1-940a2c6173b3>", line 5, in <module>
s.resistors[0] = None
TypeError: 'tuple' object does not support item assignment
Write alternative ways to create a system¶
Consider a system class containing a 2D numpy array, which we would like to define from local data or from a file. In the next cell, we show a first, awkward implementation, where all parameters are provided to the setup method, and different courses of action are decided, depending on data type.
[7]:
from cosapp.base import System
import numpy
class MySystem(System):
def setup(self, n: int, **options):
self.add_property('n', int(n))
self.add_inward('x', numpy.zeros((self.n, 3)))
# Get data from array
try:
x = options['x']
except KeyError:
pass
else:
if numpy.shape(x) == self.x.shape:
self.x = numpy.copy(x)
# Get data from file
try:
filename = options['filename']
except KeyError:
pass
else:
x = numpy.load(filename)
if x.shape == self.x.shape:
self.x = x
# And so on...
[8]:
s = MySystem('s', n=2, x=[[0, 0.1, 0.2], [-0.5, 0.9, 0.4]])
print(s.x)
[[ 0. 0.1 0.2]
[-0.5 0.9 0.4]]
The complexity of parsing through options at setup can be simplified using ad-hoc functions referred to as factories, declared as class methods by decorator @classmethod. Note that the first argument of class methods is always cls (as opposed to self for traditional object methods), denoting the class itself. Factories are special class method whose job is to create and return a new class instance (an object) from a specific set of arguments.
Class methods are invoked with syntax ClassName.method_name(...).
In the next cell, we implement factory methods from_data and from_file, each with its own signature:
[9]:
from cosapp.base import System
import numpy
class MySystem(System):
def setup(self, n: int):
self.add_property('n', int(n))
self.add_inward('x', numpy.zeros((self.n, 3)))
@classmethod
def from_data(cls, name: str, data) -> "MySystem":
n = cls.check_shape(data)
s = cls(name, n=n) # newly created system
s.x = numpy.array(data, dtype=float)
return s
@classmethod
def from_file(cls, name: str, filename) -> "MySystem":
data = numpy.load(filename)
return cls.from_data(name, data)
@staticmethod
def check_shape(data) -> int:
"""Checks that `data` is a (N x 3) array-like object.
If so, returns integer N. If not, raises `ValueError`.
"""
shape = numpy.shape(data)
ok = len(shape) == 2 and shape[1] == 3
if not ok:
raise ValueError("data must be a (N x 3) array-like object")
return shape[0]
[10]:
s1 = MySystem('s1', n=4) # usual way
print(f"s1.x =\n{s1.x}")
# Create a system from existing data with `from_data`
s2 = MySystem.from_data('s2', [[0, 0.1, 0.2], [-0.5, 0.9, 0.4]])
print(f"\ns2.x =\n{s2.x}")
s1.x =
[[0. 0. 0.]
[0. 0. 0.]
[0. 0. 0.]
[0. 0. 0.]]
s2.x =
[[ 0. 0.1 0.2]
[-0.5 0.9 0.4]]
[11]:
import logging
try:
s3 = MySystem.from_file('s3', 'data.pickle')
except Exception as error:
logging.error(
f" {type(error).__name__}: {error}"
f"\nDoes not work for s3 since the file does not exist, but you get the idea!"
)
else:
print(f"\ns3.x =\n{s3.x}")
ERROR:root: FileNotFoundError: [Errno 2] No such file or directory: 'data.pickle'
Does not work for s3 since the file does not exist, but you get the idea!