Quick start with Dessia SDK

Quick start with Dessia SDK

This is a technical document that explains the use of the dessia_common library for engineers.

Dessia provides a framework named dessia_common. Its main goal is to provide engineers and developers with generic features to design their engineering systems as "Object-Oriented Engineering". It means :

  • Propose concepts to define complex systems as "Object-Oriented" (called models),
  • Provide base classes and concepts to support these descriptions,
  • Show models described as such on the platform.

Here is a short list of the generic capabilities of the base class :

  • Serialization : transform a model into a readable and storable element for the web app,
  • Comparison : describe hash and equality based on the data, rather than the identity,
  • Interactivity : how to import, export and display models on and from the platform,
  • Typing scope : how to describe inputs and attribute of models.

1. Introduction

There are two main base classes:

  • Model: A modern approach to data model. It is a DessiaObject with added constraints. This restrictions enable deeper platform features, as well as a simpler learning curve for the developer,
  • DessiaObject: A class with all features with fewer restrictions that Model.

The philosophy of Dessia is to break down any system into elementary objects.

Developers write custom classes inheriting from one of these two base classes. This ensures their objects, the instances of custom classes, are platform-compatible. It also enables to add custom import, display and export behavior, generically implemented by the platform.

For example, to write a class describing a cylinder, it will be necessary to:

  1. List all its attributes (inner diameter, outer diameter, thickness, etc.).
  2. List all the methods specific to cylinders.

By respecting this formalism, an object instance of Cylinder will offer a display that includes its 2D diagram and 3D representation.

2. Basics

2.1. Implementing Model

This feature is only available from dessia_common >1.0.0, compatible with platform >3.1.0

from dessia_common.models.core import Model
from dessia_common.models.decorators import modelclass
 
 
@modelclass
class Cylinder(Model):
    """ This class describe a Cylinder and its features. """
    internal_diameter: float
    external_diameter: float
    height: float

Let's break this bit of code down.

Model and modelclass, imported from the the models subpackage are necessary to transform the Cylinder class to a platform-compatible one.

  • Model : Implements all the base feature for a class to be handled by the platform,
  • modelclass : Allow for a simplified, dataclass syntax. It transforms the class into a python dataclass (opens in a new tab), with the following constraints :
    • eq = False : uses generic Model comparison behavior,
    • kwonly = True : forces user to use keywords arguments when instantiating an object of the decorated class,
    • slots = True : restrict class attributes to its arguments (here, internal_diameter, external_diameter, height)

internal_diameter: float is a definition of an attribute of the cylinder class. It is composed of its name and its type (see Typing Scope section). It can also specify a default value.

attribute_name: Type = default_value

You can instantiate a Cylinder object :

cylinder = Cylinder(
    internal_diameter=0.6,
    external_diameter=0.8,
    height=0.2,
    name="My Cylinder"
)

Note the name attribute that is given as an argument but has not been defined in the Cylinder signature. This is because Cylinder inherits from Model that has a name argument. Globally, every instance of a class inheriting from a base one should either overwrite or implement all its attributes.

2.2. Implementing DessiaObject

from dessia_common.core import DessiaObject
 
 
class Cylinder(DessiaObject):
    """ This class describe a Cylinder and its features. """
 
    def __init__(self, internal_radius: float, width: float, height: float, name: str = ""):
        self.internal_diameter = internal_radius * 2
        self.external_diameter = (internal_radius + width) * 2
        self.height = height
 
        super().__init__(name=name)

Code breakdown : DessiaObject ca be imported from dessia_common.core module. Cylinder classically inherits from DessiaObject, which also implements all the generic features to transform Cylinder to platform-compatible class. Arguments, their types and default values are described in the __init__ signature. Attributes are set in the self instance in the __init__ body. Note that the attributes can be a composition of the class arguments. This is one of the main difference between Model and DessiaObject. In that regard, DessiaObject can be seen as an unrestricted version of Model.

2.3. Differences between DessiaObject and Model

Arguments and attributes

As you can tell, Model has a simpler syntax than DessiaObject. It also has more restrictions, but enables for more advanced platform features. First of all, Model restricts a class slots (its attributes) to be exactly its arguments. DessiaObject, on the contrary, allows for attributes to be composed of arguments, and does not force arguments to be set as attributes. However Model is designed not to be too stiff : you can add properties, computed from the class attributes, still exposed to the platform. Consider the following case :

from dessia_common.models.core import Model
from dessia_common.models.decorators import modelclass, model_property
 
 
@modelclass
class Sphere(Model):
    """ This class describe a Sphere and its features. """
    radius: float
 
    @model_property
    def diameter(self) -> float:
        return self.radius * 2
 
    @model_property
    def volume(self) -> float:
        return (4 * math.pi * self.radius**3) / 3
 
@modelclass
class Ball(Sphere):
    """ This class describe a Ball, a Sphere with a material density. """
    density: float
 
    @model_property
    def mass(self) -> float:
        return self.volume * self.density
 
my_special_ball = Ball(radius=0.001, density=1, name="A ball made of water")
print(f"Mass : {my_special_ball.mass} kg")

Decorator model_property allows you to define additionnal attributes that are not part of class arguments, but are still exposed to the platform.

You can also implement it using DessiaObject :

class Sphere(DessiaObject):
    """ This class describe a Sphere and its features. """
 
    def __init__(self, radius: float, name: str = ""):
        self.radius = radius
        self.diameter = radius * 2
        self.volume = (4 * math.pi * self.radius**3) / 3
 
        super().__init__(name=name)
 
 
 
class Ball(Sphere):
    """ This class describe a Ball, a Sphere with a material density. """
 
    def __init__(self, radius: float, density: float, name: str = ""):
        self.density = density
 
        super().__init__(radius=radius, name=name)
 
        self.mass = self.volume * self.density

Note that you have two ways to define the mass property, both with its own caveats.

  • As property : classic properties are not picked by the platform, thus are only usable at runtime in user code, and not visible from the web app.
  • As attribute : mass is a composition of arguments and/or other attributes. In order to use it on the platform, we would have to know its type, which can be obtained either :
    • By automatic discovery, which may not be trivial, and potentially faulty,
    • By user declaration (self.mass: float = self.volume * self.density), which is cumbersome and unappealing, as well as being potentially wrong or deprecated (depending on how the code has changed over time).

The consequence, as platform usage, is that a DessiaObject inheriting class cannot benefit from advanved introspection. Only arguments can be used on the platform, which can lead to undesired behaviors, especially when the class attributes are different from its arguments.

For classes inheriting from DessiaObject, we advise to always keep the set of attributes equal to the set of arguments. Store your arguments as attributes (self.my_argument = argument) and avoid storing attributes that are composition of arguments self.attribute = argument1 + argument2.

Model and model_property approach tries to make up both caveats. Altough a model_property decorated method return value still needs to be typed, and thus is still declarative, this is way more natural. This is also good coding practices. Model properties enable the platform to naturally discover and split :

  • what is needed in order to create an object (the arguments),
  • the composition of an object (the attributes, ie. arguments + properties). This is a core feature of the web app, enabling better class introspection and more advanced platform features, like filters based on attributes, or annotated CADs.

There is a way to turn a DessiaObject into a Model-like class, by declaring properties.

class Ball(Sphere):
    """ This class describe a Ball, a Sphere with a material density. """
 
    __declared_properties__ = {"mass": float}
 
    def __init__(self, radius: float, density: float, name: str = ""):
        self.density = density
 
        super().__init__(radius=radius, name=name)
 
        self.mass = self.volume * self.density

In this example, mass is not set as an argument within Ball's __init__ method, but as we declare it with its type in the __declared_properties__ class attribute, it will still be discoverable by the platform. This is less of a good implementation than what a Model-based class would look like, especially because this is prone to errors (typos, static check...), or subject to refactor maintenance : if you were to rename you mass attribute, you would also need to change its declaration.

Additionnal pros of using Model

  • As Model restricts the possible attributes of a class to its arguments, by setting its __slots__ attribute, it also prevents its __dict__ attribute from being generated by python. Therefore, this should result in a better memory usage, globally speaking.
  • By preventing from implementing a custom __init__, it should allow for faster object instantiation, and thus provide better platform performances.

3. Typing Scope

Since Python is a dynamically-typed language and the Dessia platform generates forms for end users to input object attributes, it is necessary to introduce object typing to enable the web app to automatically generate an appropriate form.

In that extent, as exposed in the previous section, it is necessary to define types for:

  • All arguments of classes (signatures of classes inheriting from either Model or DessiaObject)
  • All arguments passed tp other methods of the class, as well as any possible return values, espacially For method flagged with model_property decorator.

Data types implemented by the platform are defined by the dessia_common library :

  • Float, integer, boolean or string (builtin)
  • List or tuple of :
    • builtin values,
    • instances of classes inheriting from DessiaObject or Model
  • Dictionaries with a string key and a builtin value
  • Union of objects inheriting from DessiaObject or Model
  • Generic files.

3.1. Builtins

Available types

NamePython typeExample of valueImport from
Floatfloat1.2-
Integerint3-
BooleanboolTrue-
Stringstr"foo”-
def my_function(my_float: float = 1.2, my_integer: int = 3,
                my_boolean: bool = True, my_string: str = "foo")

3.2. Sequences

Available types

NamePython typeConstraintImport from
Homogeneous Sequencelist[T]- Cannot have several different types
- No length constraint
-
Heterogeneous Sequencetuple[T, U, V]- Can have several different types
- Length is defined
- Order is defined
-
Homogeneous Arraylist[list[T]]- T must be “simple”, i.e a python builtin (int, float, str, bool)-
Dynamic Dictionarydict[str, T]- Keys are always of type str
- Values type (T) must be simple, i.e a python builtin (int, float, str, bool)
-
@modelclass
class Bearing(Model):
    balls: list[Ball]
    position: tuple[float, float, float]
    check: bool = True
    diameter: float = 0.01
    name: str = ""

tuple has no constraint over member types. i-th value type is not necessarily a builtin as shown in the example.

3.3 Custom Classes

Available types

General case

In order to type a variable as an instance of one of your custom classes, you can simply reference the class as a type.

def my_function(ball: Ball)
    """ball argument is an instance of the Ball class."""

Recursive typing and classes that are not yet fully defined

You might encounter one of the following cases.

# Case 1 : Recursive typing. You need to type a variable as the class itself.
# Let's take the exemple of a "generate" classmethod that returns an instance of the class.
 
class MyClass:
    @classmethod
    def generate_one(cls) -> MyClass:
        """This return typing fails in current Python versions because MyClass if not yet fully defined.
        Your IDE should warn you about it."""
        return cls()
 
 
# Case 2 : Typing with a class that is not defined yet.
 
class ClassA:
    def __init__(self, my_object: ClassB)
        """my_object's typing fails because ClassB is defined further down the module."""
        self.my_object = my_object
 
class ClassB:
    pass

In these cases simply add the following statement at the start of the package.

from __future__ import annotations

This will ensure that the classes can be referenced before they are fully defined.

Example :

from __future__ import annotations
 
class ClassA:
    def __init__(self, my_object: ClassB):
        self.my_object = my_object
 
class ClassB:
    @classmethod
    def generate_one(cls) -> ClassB:
        return cls()
 
    @classmethod
    def generate_many(cls) -> list[ClassB]:
        return [cls()]

... is a perfectly valid code in Python 3.12. In a further release of Python, the import statement won't be needed anymore, but it still remains unplanned.

You could use the ForwardRef proxy to achieve the same result, but while it should be working with dessia_common its use is discouraged by Python devs themselves.

3.4. Unions

Available types

NamePython typeMeaningConstraintImport from
UnionUnion[T, U, V]Value can be of type T, U or V- T, U & V should be related for this typing to make sense.typing
InstanceOfInstanceOf[T]Value is of type “any subclass from T”, including T itself.- T must derive from DessiaObject Or Modeldessia_common.typings

We advise not to use Union as platform-exposed inputs and use InstanceOf[T], instead.

InstanceOf[T] types an argument where the candidate value is an instance of any subclass of T. In order for it to be handled by the platform, T must be a subclass of DessiaObject or Model itself. Using InstanceOf[T] ensures U and V share a common denominator that is T. In other word U and V at least behave like T, so their instances are valid candidates for the current scope.

On the contrary, Union[T, U, V] does not guarantee T, U & V being related by any mean. This make generic form generation tricky : should we show a for input for T, U or V ? In that regard, form spreadsheets naturally implement InstanceOf[T] because T is a subclass of DessiaObect or Model. We can show a table where columns are a union of candidates' arguments. However, we do not want to totally forbid duck-typing code, so Union is still allowed and supported across the platform. Consider the following case :

class Dog:
    def make_noise(self, loud: bool = False) -> str:
        return "BARK!" if loud else "bwarf"
 
    def sleep(self) -> str:
        return "The dog is sleeping..."
 
class Bus:
    def make_noise(self, loud: bool = True) -> str:
        return "HONK!" if loud else "bing bing"
 
def danger_approaching(road_user: Union[Dog, Bus]):
    """ This is perfectly ok, because both Dog and Bus can make noise the same way."""
    sound = road_user.make_noise(loud=True)
    print(f"{road_user.__class__.__name__} is defending itself : {sound}")
 
def danger_went_away(road_user: Union[Dog, Bus]):
    """ This is not ok, because only Dog can sleep."""
    road_user.make_hugs()

In order to turn it into an inheritance scheme, we advise you to implement it using InstanceOf :

lass PotentiallyNoisyRoadUser:
    def make_noise(self, loud: bool = False) -> str:
        return ""
 
class Dog(PotentiallyNoisyRoadUser):
    def make_noise(self, loud: bool = False) -> str:
        return "BARK!" if loud else "bwarf"
 
    def sleep(self) -> str:
        return "The dog is sleeping..."
 
class Bus(PotentiallyNoisyRoadUser):
    def make_noise(self, loud: bool = True) -> str:
        return "HONK!" if loud else "bing bing"
 
class Cyclist(PotentiallyNoisyRoadUser):
    pass
 
def danger_approaching(road_user: InstanceOf[PotentiallyNoisyRoadUser]):
    sound = road_user.make_noise(loud=True)
    print(f"{road_user.__class__.__name__} is defending itself : {sound}")

As much as any object-oriented languages will naturally consider any subclass of T being usable as T (covariance), without any specific mention in code typing (InstanceOf), we decided, in the scope of our generic forms, to implement it this way :

  • value: T : value is strictly of type T, and is not an instance of any subclass of T,
  • value: InstanceOf[T]: value is an instance of any subclass of T, or an instance of T.

We took this decision for simplicity and consistency, as we think that most of our users want T to be a specific typing, rather than the candidate value being an instance of a subclass. This is the case most of the time, and users wanting to type a value as "an instance of any subtype of T" should specify it in its typing.

Disallow base class from valid candidates

You can chose to only allow children classes as valid candidate subtype :

Annotated[InstanceOf[BaseClass], True]

The True boolean value here, means that base class is considered "abstract". Therefore, it should not be considered a valid type for a given value, as it should not be instantiated.

  • InstanceOf[BaseClass] stands for Union[BaseClass, ChildClass0, …, ChildClassN]
  • Annotated[InstanceOf[BaseClass], True] stands for Union[ChildClass0, …, ChildClassN]

3.5. Files

Available types

Several options are available to type a file input:

  • BinaryFile for binary-like file objects,
  • StringFile if it is a text file with UTF-8 encoding and no specific extension. If we want to force the extension to be txt, we can use TextFile.
  • XLSXFile if it is an Excel file with the extension xlsx
  • XLSFile if it is an Excel file with the extension xls
  • XLSMFile if it is an Excel file with the extension xlsm
  • CSVFile if it is a csv file
  • MarkdownFile if it is a markdown file
  • JsonFile if it is a text file with the extension json

These can be found in the files module of dessia_common.

For example, if we want to read an Excel file from a class method, we can use the following syntax (it is necessary to import the openpyxl package to read Excel files):

from dessia_common.files import XLSXFile
from openpyxl import load_workbook
 
class Catalog(DessiaObject):
    _standalone_in_db = True
    def __init__(self, name: str = ''):
        DessiaObject.__init__(self, name=name)
 
    @classmethod
    def read_exel(cls, excel: XLSXFile):
        excel_catalog.seek(0)
        wb2 = load_workbook(excel_catalog, data_only=True)
        sheet_workbook = wb2['catalog']

File-like inputs can only be inputs for methods and cannot be stored in class instances.

3.6. Enumerations

Available types

Enumeration types will give you the ability to pick a string value from a restricted amount of possible choices, defined by the code. In the form, a dropdown will be shown with allowed values :

Color

NamePython typeMeaningImport fromConstraint
LiteralLiteral[”a”, “b”, “c”]Value is a string that is one of “a”, “b”, or “c”typing
KeyOfKeyOf[DICT]Value is one of DICT’s keysdessia_common.typings- DICT is a dictionary object of type dict[str, T]
- DICT is a constant

Example of typical usage, that will give the previously shown dropdown input in both case :

class EnumWithLiteral(DessiaObject)
    """ A dummy class to show proper usage of Literal Type. """
 
    def __init__(self, color: Literal["red", "green", "blue"] = "red", name: str = ""):
    self.color = color
    super().__init__(name=name)
 
COLORS = {"red": (255, 0, 0), "green": (0, 255, 0), "blue": (0, 0, 255)}
 
class EnumWithKeyOf(DessiaObject):
    """ A dummy class to show proper usage of KeyOf Type. """
 
    def __init__(self, color_name: KeyOf[COLORS], name: str = ""):
        self.color_name = color_name
        super().__init__(name=name)
 
    @property
    def color(self):
        return COLORS[self.color_name]

Note that in this example, COLORS is a dictionary that doesn’t comply with the constraint given in section 3.2. Its type is dict[string, tuple[float, float, float])]. Its values are “complex” (tuple[float, float, float]) and not of simple builtin type. This is because we never actually need to input its value in a platform form. It is only used as a internal variable in the code and, as a result, does not need to comply with the previous constraints.

These constraints are only relevant if your a typing a class attribute or a function argument, which will likely be shown as a form input.

Nevertheless, we still advise you build your code with these constraints applied to a maximum of your objects, even internal ones, because they usually refer to good coding and structuring practices.

4. Interactivity

4.1. displays

You can add displays to your classes using decorators. These could be 3D, 2D or plain text (markdown). As an example, this class will have 3 view on the platform (3D, 2D and markdown) :

from dessia_common.decorators import cad_view, plot_data_view
from dessia_common.models.core import Model
from dessia_common.models.decorators import modelclass
from volmdlr import Point2D, OXYZ
from volmdlr.primitives2d import ClosedRoundedLineSegments2D
from volmdlr.primitives3d import ExtrudedProfile
from volmdlr.model import VolumeModel
from plot_data import PrimitiveGroup
 
@modelclass
class Square(Model):
    """ This class describe a Sphere and its features. """
    length: float
 
    def contour(self) -> ClosedRoundedLineSegments2D:
        """ Squared contour. """
        points = [
            Point2D(0, 0),
            Point2D(0, self.length),
            Point2D(self.length, self.length),
            Point2D(self.length, 0)
        ]
        return ClosedRoundedLineSegments2D(points=points, radius={})
 
    @cad_view("CAD", load_by_default=True)
    def cad_display(self):
        """ Volmdlr primitives of 'cubes'. """
        contour = self.contour()
        cube = ExtrudedProfile(frame=OXYZ, outer_contour2d=contour, inner_contours2d=[], extrusion_length=self.length)
        return VolumeModel(primitives=[cube], name=self.name).babylon_data()
 
    @plot_data_view("2D View")
    def primitives(self) -> PrimitiveGroup:
        """ Test plot data decorator for primitives. """
        contour = self.contour().plot_data()
        return PrimitiveGroup(primitives=[contour], name=f"{self.name} contour")
 
    @markdown_view("Report")
    def markdown(self) -> str:
        return f"The square named *{self.name}* has an edge length of {self.length}m"

Learn more about displays in the displays section.

4.2 Exports