Composites¶
Once you start building models with Vivarium, you will probably discover groups of processes that you want to reuse. For example, if you have a group of processes that model a cell, you might want to create many instances of those processes to a collection of cells. In Vivarium, we support grouping processes using composites.
Note
The terminology here can be tricky because “process” can refer both to the process object and to the process class. However, for composites, the analogous ideas have different names: “composer” and “composite.” You can use a composer object to generate different composite objects depending on what configuration you provide. Therefore, composers are like process classes while composites are like process objects.
Processes and Stores¶
A model in Vivarium consists of state variables, which are grouped into collections called stores, and processes which mutate those variables at each timestep. A topology defines which processes operate on which stores.
For example, consider a variable tracking ATP concentrations. We might assign a “cytoplasm” store to two processes: one for metabolism and one for sodium pumps. Now when we run a simulation the metabolism and sodium pump processes will be changing the same variable, the ATP concentration in the cytoplasm store. This means that if the rate of metabolism decreases, the cytoplasmic ATP concentration variable will drop, so the sodium pump will export less sodium. Thus, shared stores in composites let us simulate interacting concurrent processes.
Process and Store Implementation¶
Processes¶
We write processes as classes that inherit from
vivarium.core.process.Process
. To create a composite, we
create instances of these classes to make the processes we want to
compose. If a process is configurable, we might provide a dictionary of
configuration options. For information on configuring a process, see the
process’s documentation, e.g.
vivarium.processes.tree_mass.TreeMass
.
We uniquely name each process in the composite. This lets us include instances of the same process class.
Stores¶
We represent stores with the vivarium.core.store.Store
class; see its documentation for further details. Note that when
constructing a composite, you don’t need to create the stores. Instead,
the Vivarium engine automatically creates the stores based on the
topology you specify.
Tip
To see the data held by a store, you can use the
vivarium.core.store.Store.get_config()
function. This
returns a dictionary representation of the store’s data. To show this
dictionary more readably, use
vivarium.library.pretty.format_dict()
.
Topologies¶
Each process has a list of named ports, one for each store it expects. The process can perform all its computations in terms of these ports, and the process also provides its update using port names. This means that a composite can apply each process to any collection of stores, making processes modular.
This modularity is analogous to the modularity of Python functions. Think of each process as a function like this:
def sodium_pump(cytoplasm, extracellularSpace):
...
return "Update: Decrease ATP concentration in cytoplasm by x mM"
A function’s modularity comes from the fact that we can pass in different
objects for the cytoplasm
parameter, even objects the function
authors hadn’t thought of. cytoplasm
is like the port, to which we
can provide any store we like.
How do we specify which store goes with which port? To continue the function analogy from above, we need something analogous to this:
cell = Cell()
bloodVessel = BloodVessel()
# We need something like the line below
update = sodium_pump(cytoplasm=cell, extracellularSpace=bloodVessel)
When we call sodium_pump
, we specify which objects go with which
parameters. Analogously, we specify the mapping between ports and stores
using a topology.
Defining Topologies¶
We define topologies as dictionaries with process names as keys and dictionaries (termed “sub-dictionaries”) as values. These sub-dictionaries have port names as keys and paths to stores as values. For example, the topology for the ATP example we have been considering might look like this:
{
'sodium_pump': {
'cytoplasm': ('cell',),
'extracellularSpace': ('bloodVessel',),
},
'metabolism': {
'cytoplasm': ('cell',),
},
}
Advanced Topologies¶
The syntax used for declaring paths is a Unix-style tuple, with every
element in the tuple going further down the path from the root compartment,
and ..
moving up a level to an outer compartment.
topology = {
'process': {
'port1': ('path','to','store'), # connect port1 to inner compartment
'port2': ('..','outer_store') # connect port2 to outer compartment
}
}
You can splitting a port into multiple stores. Variables read through the same
port can come from different stores. To do this, the port is mapped to a
dictionary with a _path
key that specifies the path to the default store.
Variables that need to be read from different stores each get their own path in
that same dictionary. This same approach can be used to remap variable names, so
different processes can use the same variable but see it with different names.
topology = {
# split a port into multiple stores
'process1': {
'port': {
'_path': ('path_to','default_store'),
'rewired_variable': ('path_to','alternate_store')
}
}
# mapping variable names in process to different name in store
'process2': {
'port': {
'_path': ('path to','default_store'),
'variable_name': 'new_variable_name'
}
}
}
Flows for Ordered Step Operations¶
When constructing a composite of many steps, you may find that some steps depend on other steps. For example, you might have one step that calculates the cell’s mass and another step that calculates the cell’s volume based on that mass. Vivarium supports these dependencies, which you can specify in a flow. Flows have the same structure as topologies, but instead of their leaf values being paths, they are lists of paths where each path specifies a dependency step. For example, this flow would represent our mass-volume dependency:
{
'mass_calculator': [],
'volume_calculator': [('mass_calculator',)],
}
The simulation engine will automatically figure out what order to run the steps in such that the dependencies in the flow are respected. Note that if two orderings both respect the flow, you should not assume that the engine will pick one of the two orderings.
Note
Step updates are applied immediately after the step executes, which is unlike process updates.
Composers¶
Most of the time, you won’t need to create composites directly. Instead,
you’ll create composers that know how to generate composites. To create
a composer, you need to define a composer class that inherits from
vivarium.core.composer.Composer
and implements the
vivarium.core.composer.Composer.generate_processes()
and
vivarium.core.composer.Composer.generate_topology()
methods.
generate_processes
should return a mapping from process names to
instantiated process objects, while generate_topology
should return
a topology.
Example Composer¶
To put all this information together, let’s take a look at an example composer that combines the glucose phosphorylation process from the process-writing tutorial with an injector, which lets us “inject” molecules into a store.
class InjectedGlcPhosphorylation(Composer):
defaults = {
'glucose_phosphorylation': {
'k_cat': 1e-2,
},
'injector': {
'substrate_rate_map': {
'GLC': 1e-4,
'ATP': 1e-3,
},
},
}
def generate_processes(self, config):
injector = Injector(self.config['injector'])
glucose_phosphorylation = GlucosePhosphorylation(
self.config['glucose_phosphorylation'])
return {
'injector': injector,
'glucose_phosphorylation': glucose_phosphorylation,
}
def generate_topology(self, config):
return {
'injector': {
'internal': ('internal', ),
},
'glucose_phosphorylation': {
'cytoplasm': ('cell', ),
'nucleoside_phosphates': ('cell', ),
'global': ('global', ),
},
}
Notice how we use the generate_processes
function to create a
dictionary that maps process names to instantiated and configured
process objects. Similarly, we use generate_topology
to create a
dictionary that maps port names to stores. To create steps and flows,
use the generate_steps
and generate_flow
methods.
You may wonder why we identify stores with tuples. In more complex compartments, these tuples could contain many elements that specify a kind of file path. We represent the total model state as a tree, and we can create a store at any node to represent the sub-tree rooted at that node. This tree is analogous to directory trees on a filesystem, and we use tuples of store names to specify a path through this tree. We call this tree the hierarchy, and we discuss it in more detail in the hierarchy guide.