Defining composite models
A composite model consists of collections of functions and differential equations composed together by functional substitution and variable aggregation. Composite models in psymple
are captured by composite ported objects.
The functions and differential equations to be composed come from ported objects added as children. The ports of these child objects are then connected to the ports of the parent ported object by directed wires to capture functional substitution, and variable wires to capture variable aggregation.
Functions and differential equations
First read how to define functions and define ODEs as ported objects in psymple
.
Example
A very simple problem which can be modelled in a composite ported object is the following.
Example: projectile with air resistance
Consider an object dropped from a height \(h\) at time \(t=0\). The object falls vertically downwards under the force of gravity with speed \(v(t)\), and is acted on by air resistance with magnitude
directed vertically upwards, where \(C_D\) is the drag coefficient of the object, \(\rho\) is the air density, \(A\) is the effective surface area of the object, and \(m\) is its mass.
In psymple
, the forces acting on the falling object can be modelled individually, and then aggregated together. Let the positive direction be downwards. For the gravitational force \(F_G = mg\), applying Newton's second law gives \(\frac{dv}{dt} = g\), while for the resistance force, \(\frac{dv}{dt} = - \mu v^2\), where \(\mu = \frac{C_D \rho A}{2m}\).
The following three ported objects capture the two dynamic components, and the multiplier \(\mu\). See defining functions and defining ODEs for more detail.
from psymple.build import FunctionalPortedObject, VariablePortedObject
v_gravity = VariablePortedObject(
name="v_gravity",
input_ports=[("g", 9.81)], # (1)!
assignments=[("v", "g")],
)
v_drag = VariablePortedObject(
name="v_drag",
assignments=[("v", "-mu * v**2")], # (2)!
)
f_drag = FunctionalPortedObject(
name="f_drag",
assignments=[("mu", "C * rho * A / (2 * m)")], # (3)!
)
-
The default \(g=9.81\) is specified here so that we don't need to worry about it later.
-
This assignment automatically creates an input port
"mu"
and variable port"v"
of"v_drag"
to connect to. -
This assignment automatically creates input ports
["C", "rho", "A", "m"]
and an output port "mu
" of"f_drag"
to connect to.
To create the falling object model from these components, psymple
needs to know:
- That the variable at port
"v"
of"v_gravity"
and the variable at port"v"
of"v_drag"
need to be aggregated, - That the input
"mu"
of"v_drag"
needs to read the output value"mu"
of"f_drag"
, - How to obtain the values of the inputs
["C", "rho", "A", "m"]
of"f_drag"
.
Referencing a child port
The port of a child object is referenced by the string "name.port_name"
where name = child.name
is the attribute specified when instantiating that child object. This does not need to be the same as the python
identifier.
These are all accomplished inside this composite ported object:
from psymple.build import CompositePortedObject
model = CompositePortedObject(
name="model",
children=[v_gravity, v_drag, f_drag], # (1)!
input_ports=["C", "rho", "A", "m"], # (2)!
variable_ports=["v"], # (3)!
directed_wires=[
("C", "f_drag.C"), # (4)!
("rho", "f_drag.rho"),
("A", "f_drag.A"),
("m", "f_drag.m"),
("f_drag.mu", "v_drag.mu"), # (5)!
],
variable_wires=[
(["v_gravity.v", "v_drag.v"], "v") # (6)!
],
)
-
This imports the three ported objects from before into
"model"
so that their ports and assignments can be accessed. -
This creates a list of inputs, or model dependencies, on the outside of
"model"
. Adjusting these will change the behaviour of the model. -
This creates a variable port
"v"
for"model"
which will access the velocity of the object. -
This tuple tells
psymple
to identify the value at input"C"
with the value of"C"
inside the assignment of"f_drag"
. Similarly for the next three tuples. -
This tuple tells
psymple
to identify input"mu"
of"v_drag"
with the output value"mu"
of"f_drag"
. -
This tuple tells
psymple
to aggregate the variable at port"v"
of"v_gravity"
and the variable at port"v"
of"v_drag"
together, and expose the aggregated variable at the variable port"v"
of"model"
.
There are two syntaxes for specifying directed wires. The following are equivalent in this example:
Similarly, there are two syntaxes for specifying variable wires. The following are equivalent:
When psymple
builds the model
composite ported object, it:
- Creates the input ports and variable ports specified.
- Builds a
DirectedWire
instance for each item in the argument listdirected_wires
. In doing so, it checks that all the ports exist and are of the correct type (source ports must be input ports ofmodel
, or output ports/variable ports of its children, and destination ports must be input ports of children, or output ports ofmodel
). - Builds a
VariableAggregationWiring
instance for each item in the argument listvariable_wires
. In doing so, it checks all the ports exist and are variable ports.
Reading variable values as inputs
It is common to need to perform calculations on system variables. To facilitate this in psymple
, instances of VariablePortedObject
can also be given output ports for each exposed variable. A directed wire can connect to these output ports in the same way as for output ports of functional or composite ported objects.
Example: separated growth rate
Consider a population \(x\) according to the differential equation \(\frac{dx}{dt} = r_c x\), where \(r_c = 0.1\) is the per-capita growth rate of the population.
While this equation can be captured in a single variable ported object in psymple
, it can also be captured as a composite ported object in which the differential equation and rate calculation are separated, as follows.
from psymple.build import (
VariablePortedObject,
FunctionalPortedObject,
CompositePortedObject,
)
pop_ode = VariablePortedObject(
name="pop_ode",
assignments=[("x", "r")] # (1)!
)
growth_rate = FunctionalPortedObject(
name="growth_rate",
assignments=[("r", "r_c * x")], # (2)!
)
pop = CompositePortedObject(
name="pop",
children=[pop_ode, growth_rate],
input_ports=[("r_c", 0.1)],
variable_ports=["x"],
directed_wires=[
("r_c", "growth_rate.r_c"),
("pop_ode.x", "growth_rate.x"), # (3)!
("growth_rate.r", "pop_ode.r"),
],
variable_wires=[(["pop_ode.x"], "x")] # (4)!
)
-
This specifies a generic differential equation \(\frac{dx}{dt} = r\).
-
This calculates the total rate \(r\) as the product of the per-capita rate \(r_c\) and the population variable \(x\).
-
The variable
x
ofpop_ode
is read using a directed wire, and fed into the inputx
ofgrowth_rate
. -
The variable
x
ofpop_ode
can still be exposed or aggregated using variable wires.
Upon compilation, the composite object pop
will substitute r
for r_c * x
, and expose the resulting assignment at the variable port x
.
Next steps
Once models are defined using composite ported objects, they can be used to define a simulable system
Notes on best practice
Arbitrary nesting
Composite ported objects can have other composite ported objects as children. This allows for arbitrarily complex nested structures to be built, which can reflect system hierarchies.
Automatic port creation
Currently, ports are not automatically created in CompositePortedObject
instances. A common source of errors is either a child not being added to an object, or not manually creating a port.
A future update may support automatic port creation when wires with external ports are specified, along with easing the process of forwarding inputs into children.