Tutorial: Simple wrapper development

Binder

In this tutorial we will implement a very simple simulation wrapper. It can be used to understand which methods need to be implemented, and how.

The source files can be found here.

Background

Wrappers are the way to extend SimPhoNy to support other back-ends. For an in-depth explanation, you can go to the wrapper development section of the documentation. Here we will explain with more detail what has to be implemented.

Requirements

In order to run this code, you need to have the simple_ontology available here.

Remember that once you have OSP-core installed and the ontology file locally, you can simply run pico install <path/to/ontology_file.yml>

[ ]:
# You can download and install the ontology by running this cell
!curl -s https://raw.githubusercontent.com/simphony/wrapper-development/master/osp/wrappers/simple_simulation/simple_ontology.ontology.yml -o simple_ontology.ontology.yml
!pico install simple_ontology.ontology.yml

Let’s get hands on

Syntactic layer

As you know, SimPhoNy consists of 3 layers, with the wrappers being relevant in the last 2 (interoperability and syntactic layers). The syntactic layer talks directly to the back-end in a way that it can be understood.

Since this wrapper aims to be as minimalistic as possible (while still being meaningfull), we have created a dummy syntactic layer that emulates talking to a simulation tool.

Note: In order to reduce the amount of code, the docstrings hav been erased. You can refer to the source file for the complete information.

[1]:
# This is the representation of an atom in the "engine"
class Atom():

    def __init__(self, position, velocity):
        self.position = position
        self.velocity = velocity

The engine only works with atoms, setting and getting their position and velocities

[2]:
class SimulationEngine:
    def __init__(self):
        self.atoms = list()
        print("Engine instantiated!")

    def __str__(self):
        return "Some Engine Connection"

    def run(self, timesteps=1):
        print("Now the engine is running")
        for atom in self.atoms:
            atom.position += atom.velocity * timesteps

    def add_atom(self, position, velocity):
        print("Add atom %s with position %s and velocity %s"
              % (len(self.atoms), position, velocity))
        self.atoms.append(Atom(position, velocity))

    def update_position(self, idx, position):
        print("Update atom %s. Setting position to %s"
              % (idx, position))
        self.atoms[idx].position = position

    def update_velocity(self, idx, velocity):
        print("Update atom %s. Setting velocity to %s"
              % (idx, velocity))
        self.atoms[idx].velocity = velocity

    def get_velocity(self, idx):
        return self.atoms[idx].velocity

    def get_position(self, idx):
        return self.atoms[idx].position

Interoperability layer

Since a lot of 3rd-party tools come with a syntactic layer, the bulk of the work when developping a wrapper for SimPhoNy is here.

We will explain step by step all the code required.

First, we import the parent Simulation Wrapper Session and the namespace (ontology). The engine is not necessary since it is in the previous codebock.

[ ]:
from osp.core.session import SimWrapperSession
# from osp.wrappers.simple_simulation import SimulationEngine
from osp.core.namespaces import simple_ontology

Next, we will go through each of the methods.

Note: to be able to break the class into multiple blocks, we will use inheritance, to add a method each time. In truth, all the definitions should go under one same class definition.

The first method is the __init__. This method is called when a new object is instantiated. Here we will call the __init__ method of the parent class and initialise the necessary elements.

Most simulation engines will have an internal way to keep track of, for example, particles. To make sure that the entities in the semantic layer are properly synched, we usually use a mapper. This could be anything from a list or dictionary to a more complex and sofisticated data structure.

[4]:
class SimpleSimulationSession(SimWrapperSession):

    def __init__(self, engine=None, **kwargs):
        super().__init__(engine or SimulationEngine(), **kwargs)
        self.mapper = dict()  # maps uuid to index in the backend

Next comes the output to the str() method. It will be a string returned in __str__(self).

[5]:
class SimpleSimulationSession(SimpleSimulationSession):

    def __str__(self):
        return "Simple sample Wrapper Session"

When the run() or commit() method is called on the session, all the objects that have been added since the last run have to be sent to the back end. This is done through _apply_added. The method should iterate through all the entities in the buffer and trigger different actions depending on which type of entity it is.

Remember that we can check the type using the is_a method, or querying for the oclass attribute of an entity.

In this example, we will only contact the back end if an atom has been added. However, normal wrappers will have a lot more comparisons (if and elif) to determine which entity it is and act accordingly

[6]:
class SimpleSimulationSession(SimpleSimulationSession):

    # OVERRIDE
    def _apply_added(self, root_obj, buffer):
        """Adds the added cuds to the engine."""
        for obj in buffer.values():
            if obj.is_a(simple_ontology.Atom):
                # Add the atom to the mapper
                self.mapper[obj.uid] = len(self.mapper)
                pos = obj.get(oclass=simple_ontology.Position)[0].value
                vel = obj.get(oclass=simple_ontology.Velocity)[0].value
                self._engine.add_atom(pos, vel)

Just like _apply_added is used to modify the engine with the new objects, _apply_updated changes the existing ones.

[7]:
class SimpleSimulationSession(SimpleSimulationSession):

    # OVERRIDE
    def _apply_updated(self, root_obj, buffer):
        """Updates the updated cuds in the engine."""
        for obj in buffer.values():

            # case 1: we change the velocity
            if obj.is_a(simple_ontology.Velocity):
                atom = obj.get(rel=simple_ontology.isPartOf)[0]
                idx = self.mapper[atom.uid]
                self._engine.update_velocity(idx, obj.value)

            # case 2: we change the position
            elif obj.is_a(simple_ontology.Position):
                atom = obj.get(rel=simple_ontology.isPartOf)[0]
                idx = self.mapper[atom.uid]
                self._engine.update_position(idx, obj.value)

Similarly to the previous methods, _apply_deleted should remove entities from the engine. In this specific case we left it empty to simplify the code (both in the session and the engine classes).

[8]:
class SimpleSimulationSession(SimpleSimulationSession):
    # OVERRIDE
    def _apply_deleted(self, root_obj, buffer):
        """Deletes the deleted cuds from the engine."""

The previous methods synchronise the engine with the cuds, i.e. the communication is from the semantic layer towards the syntactic. The way to update the cuds with the latest information from the engine is _load_from_backend.

It is most often called when the user calls the get on a cuds object that has potentially been changed by the engine.

When _load_from_backend is called for a given cuds object (through its uid), the method should: - Check if any of the attributes of the object has changed (like the value for a position). - Check if any new children cuds objects have been created (like a static atom that gets a new velocity when another bumps into it).

However, it does not have to be recursive and check for more than itself. This is because if the user queries any of the contained elements, that will trigger another call to _load_from_backend.

[9]:
class SimpleSimulationSession(SimpleSimulationSession):

    # OVERRIDE
    def _load_from_backend(self, uids, expired=None):
        """Loads the cuds object from the simulation engine"""
        for uid in uids:
            if uid in self._registry:
                obj = self._registry.get(uid)

                # check whether user wants to load a position
                if obj.is_a(simple_ontology.Position):
                    atom = obj.get(rel=simple_ontology.isPartOf)[0]
                    idx = self.mapper[atom.uid]
                    pos = self._engine.get_position(idx)
                    obj.value = pos

                # check whether user wants to load a velocity
                elif obj.is_a(simple_ontology.Velocity):
                    atom = obj.get(rel=simple_ontology.isPartOf)[0]
                    idx = self.mapper[atom.uid]
                    vel = self._engine.get_velocity(idx)
                    obj.value = vel

                yield obj

The last method that needs to be overridden is _run. It simply has to call the run method of the engine. This could also need to send some information, like the number of steps. For that reason, the root_cuds_object is available for query.

[10]:
class SimpleSimulationSession(SimpleSimulationSession):

    # OVERRIDE
    def _run(self, root_cuds_object):
        """Call the run command of the engine."""
        self._engine.run()

Now we can run an example:

[11]:
from osp.core.utils import pretty_print
import numpy as np

m = simple_ontology.Material()
for i in range(3):
    a = m.add(simple_ontology.Atom())
    a.add(
        simple_ontology.Position(value=[i, i, i], unit="m"),
        simple_ontology.Velocity(value=np.random.random(3), unit="m/s")
    )

# Run a simulation
with SimpleSimulationSession() as session:
    w = simple_ontology.Wrapper(session=session)
    m = w.add(m)
    w.session.run()

    pretty_print(m)

    for atom in m.get(rel=simple_ontology.hasPart):
        atom.get(oclass=simple_ontology.Velocity)[0].value = [0, 0, 0]
    w.session.run()

    pretty_print(m)
Engine instantiated!
Add atom 0 with position [0. 0. 0.] and velocity [0.63000616 0.38951439 0.12717548]
Add atom 1 with position [1. 1. 1.] and velocity [0.80816851 0.04562681 0.44983098]
Add atom 2 with position [2. 2. 2.] and velocity [0.3849223  0.50767213 0.82963311]
Now the engine is running
- Cuds object:
  uuid: a0a97dbe-584d-4764-b085-7b597e323d20
  type: simple_ontology.Material
  superclasses: cuba.Entity, simple_ontology.Material
  description:
    To Be Determined

   |_Relationship simple_ontology.hasPart:
     -  simple_ontology.Atom cuds object:
     .  uuid: 221a1793-c54a-4e42-bdeb-08921617fbac
     .   |_Relationship simple_ontology.hasPart:
     .     -  simple_ontology.Position cuds object:
     .     .  uuid: db17082e-d9d3-4a48-bce1-9402d4315200
     .     .  unit: m
     .     .  value: [0.63000616 0.38951439 0.12717548]
     .     -  simple_ontology.Velocity cuds object:
     .        uuid: fc7d778d-b18a-4b60-a6ab-ba855a2c2874
     .        value: [0.63000616 0.38951439 0.12717548]
     .        unit: m/s
     -  simple_ontology.Atom cuds object:
     .  uuid: 222df5d0-c0fe-435b-b5e2-0d5f7ebd32a9
     .   |_Relationship simple_ontology.hasPart:
     .     -  simple_ontology.Position cuds object:
     .     .  uuid: f0eb08c4-88a3-40de-893f-89473cd194e8
     .     .  value: [1.80816851 1.04562681 1.44983098]
     .     .  unit: m
     .     -  simple_ontology.Velocity cuds object:
     .        uuid: d3e7b5ce-3409-4a4e-bbdb-13a2addaee1c
     .        value: [0.80816851 0.04562681 0.44983098]
     .        unit: m/s
     -  simple_ontology.Atom cuds object:
        uuid: 13bfe4ee-32e8-4fbe-bad5-f98f46aa297a
         |_Relationship simple_ontology.hasPart:
           -  simple_ontology.Position cuds object:
           .  uuid: da5c35f0-afe5-4b56-b5fa-631b72ee32ad
           .  value: [2.3849223  2.50767213 2.82963311]
           .  unit: m
           -  simple_ontology.Velocity cuds object:
              uuid: 9b6c2c1c-3e63-4144-b7c9-d6223c0b79f7
              value: [0.3849223  0.50767213 0.82963311]
              unit: m/s
Update atom 0. Setting velocity to [0. 0. 0.]
Update atom 1. Setting velocity to [0. 0. 0.]
Update atom 2. Setting velocity to [0. 0. 0.]
Now the engine is running
- Cuds object:
  uuid: a0a97dbe-584d-4764-b085-7b597e323d20
  type: simple_ontology.Material
  superclasses: cuba.Entity, simple_ontology.Material
  description:
    To Be Determined

   |_Relationship simple_ontology.hasPart:
     -  simple_ontology.Atom cuds object:
     .  uuid: 221a1793-c54a-4e42-bdeb-08921617fbac
     .   |_Relationship simple_ontology.hasPart:
     .     -  simple_ontology.Position cuds object:
     .     .  uuid: db17082e-d9d3-4a48-bce1-9402d4315200
     .     .  unit: m
     .     .  value: [0.63000616 0.38951439 0.12717548]
     .     -  simple_ontology.Velocity cuds object:
     .        uuid: fc7d778d-b18a-4b60-a6ab-ba855a2c2874
     .        value: [0. 0. 0.]
     .        unit: m/s
     -  simple_ontology.Atom cuds object:
     .  uuid: 222df5d0-c0fe-435b-b5e2-0d5f7ebd32a9
     .   |_Relationship simple_ontology.hasPart:
     .     -  simple_ontology.Position cuds object:
     .     .  uuid: f0eb08c4-88a3-40de-893f-89473cd194e8
     .     .  value: [1.80816851 1.04562681 1.44983098]
     .     .  unit: m
     .     -  simple_ontology.Velocity cuds object:
     .        uuid: d3e7b5ce-3409-4a4e-bbdb-13a2addaee1c
     .        unit: m/s
     .        value: [0. 0. 0.]
     -  simple_ontology.Atom cuds object:
        uuid: 13bfe4ee-32e8-4fbe-bad5-f98f46aa297a
         |_Relationship simple_ontology.hasPart:
           -  simple_ontology.Position cuds object:
           .  uuid: da5c35f0-afe5-4b56-b5fa-631b72ee32ad
           .  value: [2.3849223  2.50767213 2.82963311]
           .  unit: m
           -  simple_ontology.Velocity cuds object:
              uuid: 9b6c2c1c-3e63-4144-b7c9-d6223c0b79f7
              value: [0. 0. 0.]
              unit: m/s