Processes¶
You should interpret words and phrases that appear fully capitalized in this document as described in RFC 2119. Here is a brief summary of the RFC:
“MUST” indicates absolute requirements. Vivarium may not work correctly if you don’t follow these.
“SHOULD” indicates strong suggestions. You might have a valid reason for deviating from them, but be careful that you understand the ramifications.
“MAY” indicates truly optional features that you can include or exclude as you wish.
Models in Vivarium are built by combining processes, each of
which models a mechanism in the cell. These processes can be combined in
a compartment to build more complicated models. Process models are
defined in a class that inherit from
vivarium.core.process.Process
, and these
process classes can be instantiated to create individual
processes. During instantiation, the process class may accept
configuration options.
Note
Processes are the foundational building blocks of models in Vivarium, and they should be as simple to define and compose as possible.
Process Classes¶
Each process class MUST implement the API that we describe below.
Class Variables¶
Each process class SHOULD define default configurations in a
defaults
class variable. The constructor SHOULD read these defaults.
For example:
class MyProcess:
defaults = {
'growth_rate': 0.0006,
}
Constructor¶
The constructor of a process class MUST accept as its first positional argument an optional dictionary of configurations. If the process class is configurable, it SHOULD accept configuration options through this dictionary.
In the constructor, the process class MUST call its superclass constructor with its ports and a dictionary of parameters.
Defining Ports¶
Ports MUST be specified as a dictionary with port names as keys and lists of variable names as values. These port names may be chosen arbitrarily. Variable names are also at the discretion of the process class author, but note that if two processes are to be combined in a compartment and share variables through a shared store, the processes MUST use the same variable names for the shared variables.
Note
Variables always have the same name, no matter which process is interacting with them. This is unlike stores, which can take on different port names with each process.
Passing Parameters to Superclass Constructor¶
The dictionary of parameters SHOULD include any configuration options
not used by the process class. Any information needed by the process
class MAY also be included in these parameters. Once the object has
been instantiated, these parameters are available as
self.parameters
, where they have been stored by the
vivarium.core.process.Process
constructor.
Example Constructor¶
Let’s examine an example constructor from a growth process class.
def __init__(self, initial_parameters={}):
ports = {
'global': ['mass', 'volume']}
parameters = {'growth_rate': self.defaults['growth_rate']}
parameters.update(initial_parameters)
super(Growth, self).__init__(ports, parameters)
In this constructor, only one port, global
, is defined, from which
the process will only need the mass
and volume
variables. While
the default growth rate is 0.0006
, this can be overridden by
including a growth_rate
key in the configuration dictionary passed
to initial_parameters
.
Note
global
is a special port used by derivers. It
stores information about the total model state that, like mass
doesn’t fit into any store.
Ports Schema¶
The process class MUST implement a process_schema
method with no
required arguments. This method MUST return nested dictionaries of the
following form:
{
'port_name': {
'variable_name': {
'schema_key': 'schema_value',
...
},
...
},
...
}
schema_key
MUST be a schema key and have an appropriate
value. Any applicable and omitted schema keys will take on their default
values. Note that every variable SHOULD specify _default
. If the
cell will be dividing, every variable also MUST specify _divider
.
Variables in the ports schema SHOULD NOT specify _value
.
Example Ports Schema¶
def ports_schema(self):
return {
'global': {
'mass': {
'_emit': True,
'_default': 1339 * units.fg,
'_updater': 'set',
'_divider': 'split'},
'volume': {
'_updater': 'set',
'_divider': 'split'},
'divide': {
'_default': False,
'_updater': 'set'
}
}
}
Here we specify that only mass
should be emitted. We assign a
default value of 1339 fg to mass
, and we declare that the mass
and volume
variables should be split in half on division. Further,
we specify that all the three variables should have their updates set,
not accumulated.
Derivers¶
For each port, we can also specify a deriver. Each process class MUST implement a derivers method that returns a dictionary whose keys are the ports to which we want to apply derivers. For each port, the value in the dictionary must be a dictionary with the following keys:
Next Updates¶
Each process class MUST implement a next_update
method that accepts
two positional arguments: the timestep and the current state of
the model. The timestep describes, in units of seconds, the length of
time for which the update should be computed.
State Format¶
The next_update
method MUST accept the model state as a dictionary
of the same form as the default state dictionary, but with the dictionary of schema keys
replaced with the current (i.e. pre-update) value of the variable.
Note
In the code, you may see the model state referred to as
states
. This is left over from when stores were called states,
and so the model state was a collection of these states. As you may
already notice, this naming was confusing, which is why we now use
the name “stores.”
Because of masking, each port will contain only the variables specified in the constructor’s ports declaration, even if the linked store contains more variables.
Warning
The next_update
method MUST NOT modify the states it is
passed in any way. The state’s variables are not copied before they
are passed to next_update
, so changes to any objects in the
state will affect the model state before the update is applied.
Update Format¶
next_update
MUST return a single dictionary, the update that
describes how the modeled mechanism would change the model state over
the specified time. The update dictionary MUST be of the same form as the
default state dictionary, though with
the dictionaries of schema keys replaced with update values. Also,
variables that do not need to be updated can be excluded.
Example Next Update Method¶
Here is an example next_update
method for our growth process:
def next_update(self, timestep, states):
mass = states['global']['mass']
new_mass = mass * np.exp(self.parameters['growth_rate'] * timestep)
return {'global': {'mass': new_mass}}
Recall from our example schema that we use
the set
updater for the mass
variable. Thus, we compute the new
mass of the cell and include it in our update. Notice that we access the
growth rate specified in the constructor by using the
self.parameters
attribute.
Note
Notice that this function works regardless of what timestep we use. This is important because different compartments may need different timesteps based on what they are modeling.
Process Class Examples¶
Many of our process classes have examples in the form of test functions at the bottom. These are great resources if you are trying to figure out how to use a process.
If you are writing your own process, please include these examples!
Also, executing the process class Python file should execute one of
these examples and save the output as demonstrated in
vivarium.processes.convenience_kinetics
. Lastly, any top-level
functions you include that are prefixed with test_
will be executed
by pytest
. Please add these tests to help future developers make
sure they haven’t broken your process!
Using Process Objects¶
Your use of process objects will likely be limited to instantiating them and passing them to other functions in Vivarium that handle running the simulation. Still, you may find that in some instances, using process objects directly is helpful. For example, for simple processes, the clearest way to write a test may be to run your own simulation loop.
Simulating a process can be sketched by the following pseudocode:
# Create the process
configuration = {...}
process = ProcessClass(configuration)
# Get the initial state from the process's schema
# This means the stores and ports are the same
state = {}
schema = process.ports_schema()
for port, port_dict in schema.items():
for variable, variable_schema in port_dict.items():
state[port][variable] = variable_schema["_default"]
# Run the simulation in a loop for 10 seconds
time = 0
while time < 10:
# We are using a timestep of 1 second
update = process.next_update(1, state)
# This is a simplified way to apply the update that assumes all
# all variables are numbers and all updaters are "accumulate"
for port in update:
for variable_name, value in port.items():
state[port][variable_name] += value
# Now that the loop is finished, the predicted state after 10
# seconds is in "state"
The above pseudocode is simplified, and for all but the most simple processes you will be better off using Vivarium’s built-in simulation capabilities. We hope though that this helps you understand how processes are simulated and the purpose of the API we defined.