Design

The Silvio reactive programming system that aims to simulate how different concepts inside a biological host interact with each other.

A module embodies a concept of a biological unit: for example the list of genes it contains, the associated metabolic model or a measurement of gene expressions.

The host contains multiple modules and allows modules to send events to each other. For example, when a gene is deleted from genome sequence, an event is sent to the metabolic model that accomodate for that delete gene.

The user of silvio can create its own host and include only those modules that are relevant for the simulation. Hosts can suffer changes (by altering the modules inside it) and the reactive events keep the entire system state in sync.

Then, processes can be run on the host. They use the current state of the host and generate data in the form of outcomes. The outcomes are packages of data (series, dataframes, values) that can be analyzed or stored.

The experiment is all-encompassing entity that contains all other concepts and which can dictate the random generation. Random number generation is stable in a sense that the same code will produce the same results.

Creating a Host

The best place to see examples of using silvio is to check the biolabsim workflows. The workflow usually defines a tailor-made host and experiment, which then get used in the notebooks.

This section will show how to create a host for direct use of module features. Workflows prefer to close off hosts in a way that only student-facing methods are available for use, and most methods simply redirect to the direct module features. Here we create a host that allows free usage of the modules.

To have a Host for personal use you have to create a new class that derives from the abstract Host. Then, you will have to define the following 3 methods:

  • make ( self ) -> None

The make method is responsible for building a new host out of nothing but arguments. Your implementation of make should add all necessary arguments, then create the modules while respecting their dependencies, and bind them with the host to allow event listening. The way a module is initialized follows a specific recipe which is very similar to creating hosts.

  • copy ( self, ref:Host ) -> None

The copy method will make a clone of a host. It uses the ref host to retrieve all necessary data for cloning. When all modules themselves implement copy, then this method consists usually of boilerplate code that copies all the modules and binds them to this host.

  • sync ( self ) -> None

The sync method starts up all the modules and the host itself. It may contain code that should run after a host is made, but it is not run if the host is copied. Usually we just call the sync of each module to allow each module to send some initializing events before we use the most. For example, the MetabolicModel module can import a CobraPy model and will use the sync step to share all of the contained genes with the GenomeList module.

What follows is an example of a minimally created module:

from silvio import Host, GenomeLibrary, sync_modules

class CustomHost (Host) :

    genome: GenomeLibrary  # Store the modules as properties.

    # Make takes additional arguments to be able to create a GenomeLibrary module.
    # Inside make, use the (new > make > bind) style to create each module.
    # The host also has extra method for setting the random number generator.
    def make ( self, bg_size:int, bg_gc_content:float ) -> None :
        self.genome = GenomeLibrary()
        self.genome.make( bg_size=bg_size, bg_gc_content=bg_gc_content, bg_rnd=self.make_generator() )
        self.genome.bind( host=self )

    # Copy has a single reference host argument that is used to copy all other modules.
    # Inside copy, use the (new > copy > bind) style to create each module.
    def copy ( self, ref:CustomHost ) -> None :
        self.genome = GenomeLibrary()
        self.genome.copy( ref=ref.genome )
        self.genome.bind( host=self )

    # Most sync methods will only run the sync on each module. We use a helper method for that.
    def sync ( self ) -> None :
        self.sync_modules([ self.genome ])

Now to use this host, we can create a new one and call its methods:

from silvio import CraftedGene
from Bio.Seq import Seq

# Similar modules, we use a (new > make > sync) style for creation
my_host = CustomHost( name="origin", seed=1885 )
my_host.make( bg_size=100, bg_gc_content=0.45 )
my_host.sync()

# Now we can freely use the module methods.
new_gene = CraftedGene( name="Custom1", orf=Seq("ATGCAAAGGTAA"), prom=Seq("TATAAATGTGTTC") )
my_host.genome.insert_gene( new_gene )
print( my_host.genome.sequence )

In the example above we initialize our host and add a handcrafted Gene to it. That addition will alter the sequence, which we then read on the last line. Adding the gene will also send a “gene has been added” event to all other modules. If another module for GenomeExpression would exist, then it would calculate the promoter strength of it and send an event of “a promoter strength for a gene has been altered”. Then, if a MetabolicFlux module would exist, it would listen to that event and alter the metabolic model in accordance to the new promoter strength. This is the chain of events where each module can listen to events that are important to it.

Creating an Experiment to hold Hosts

An experiment can hold all the hosts and be central entity set the possible actions and to dictate stable randomness.

An example follows:

class CustomExperiment (Experiment) :

    def __init__ ( self, seed:Optional[int] = None ) :
        super().__init__(seed=seed)

    def create_host ( self, name:str, bg_size:int, bg_gc_content:float ) -> CustomHost:
        seed = self.rnd_gen.pick_seed() # The experiment provides stable seed generation for hosts.
        new_host = CustomHost( name=name, seed=seed )
        new_host.make( bg_size=bg_size, bg_gc_content=bg_gc_content )
        new_host.sync()
        self.bind_host(new_host)  # Call this to access it from the experiment.
        return new_host

Defining an experiment is very useful if you want to handpick all possible methods the end user may or may not invoke. For internal usage it is not really necessary.

Creating a new Module

The creation of a new module follows a similar style to the host, but with an added step. You will have to extend the Module class and implement the following methods:

  • make ( self ) -> None

The make method implements for a module is created from arguments. You may add additional arguments to the method signature.

  • copy ( self, ref:Module ) -> None

The copy method will build the module to be an exact copy of the reference module.

  • bind ( self, host:Host ) -> None

The bind will register the event listeners on the holding host.

  • sync ( self, emit:EventEmitter, log:EventLogger ) -> None

And sync may run code after executing make.

Apart from implementing these 4 methods, the module should store all properties and establish all methods that it needs to perform its intent. When the module listens to events, it is usual to create listener methods that receive the event, an internal logger and an event emitter that chain more event calls. The event emission system will prevent infinite loops.

Here is a very simple example of a module that simply counts the number of genes it has. Every time an event is emitted for a gene addition, it will listen and update its internal state:

class PhenotypeSize (Module) :

    size: int  # Internal properties of the module.

    def make ( self, size:int = 0 ) -> None :
        self.size = 0

    def copy ( self, ref:PhenotypeSize ) -> None :
        self.size = ref.size

    def bind ( self, host:Host ) -> None :
        host.observe( InsertGeneEvent, self.listen_insert_gene )
        host.observe( RemoveGeneEvent, self.listen_remove_gene )

    def sync ( self, emit:EventEmitter, log:EventLogger ) -> None :
        pass # Nothing to sync.

    def listen_insert_gene ( self, event:InsertGeneEvent, emit:EventEmitter, log:EventLogger ) -> None :
        self.size += 1
        log("PhenotypeSize: incremented size by 1")

    def listen_remove_gene ( self, event:RemoveGeneEvent, emit:EventEmitter, log:EventLogger ) -> None :
        self.size -= 1
        log("PhenotypeSize: decremented size by 1")