Tutorial: Simple wrapper development¶
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