SiLA Data Types
Typing is an essential part of any API design, and SiLA is no exception.
The SiLA 2 Standard Part A defines a set of basic and derived data types which can be used to define the parameters and return values of SiLA endpoints.
Data types are declared according to PEP 484. When using the UniteLabs CDK, our signature type hints are automatically converted to the respective SiLA data types by the SiLA decorators.
The data types, as specified by the SiLA 2 Standard, and their respective counterparts are defined in the data_types module of the CDK and in the SiLA Data Types section of the SiLA2 Specification.
Basic Data Types
The CDK wraps python native types and converts them into their respective SiLA data types when used as type hints in SiLA endpoints. The data types defined by the SiLA 2 Standard Part A and their respective python counterparts are as follows:
| SiLA Data Type | Python Type |
|---|---|
| String | str |
| Binary | bytes |
| Integer | int |
| Real | float |
| Boolean | bool |
| Time | datetime.time |
| Date | datetime.date |
| Timestamp | datetime.datetime |
Derived Data Types
Derived data types are those which combine multiple values or inner data types into a single type. The SiLA 2 Standard Part A defines two derived data types which we will focus on here: List and Structure.
| SiLA Data Type | Python Type |
|---|---|
| List | list |
| Structure | dataclasses.dataclass |
Derived data types can contain any of the basic data types and additionally utilize constraints to further specify the data.
Structures
Up to this point in the tutorials all of our function signatures have utilized basic data types. It is, however, important that we be able to define or derive our own complex types to keep our data well structured and human-readable.
Structures may be composed of any mixture of valid SiLA data types, including other Structures.
Let's take as an example the LabwareTransferFeature in the feature library.
In the LabwareTransferManipulatorControllerBase we see the following method:
@sila.UnobservableCommand()
async def prepare_for_input(
self,
handover_position: HandoverPosition,
internal_position: PositionIndex,
labware_type: str,
labware_unique_ID: str,
) -> str:
This method utilizes a Structure to specify how the complex data required by the method can be supplied by the user with HandoverPosition defined as follows:
@dataclasses.dataclass
class HandoverPosition:
"""
Specifies one of the possible positions of a device where labware items can be handed over.
Can contain a sub-position, e.g. for specifying a position in a rack.
Attributes:
Position: The position where the labware will be handed over.
SubPosition: A sub-specification of the position for more precise locations,
e.g. a position within a rack, where the rack's position is specified with `position`.
"""
position: str
sub_position: PositionIndex
Command vs Property Data Types
In addition to the basic data types, SiLA Commands may also return tuples of values. Per the SiLA 2 Standard, Commands may return multiple values, while Properties may only return a single value. This means that if you want to return multiple values from a Property, you will need to define a complex structure to wrap those values.
When defining a Command that returns multiple values, you can simply return a tuple of those values. Each value returned should be annotated in the docstring of the method with a name and description to make it clear to the user what each value represents. For example:
@sila.UnobservableCommand()
async def echo_string_and_integer(self, my_string: str, my_integer: int) -> tuple[str, int]:
"""
Echo a string and an integer value.
Args:
MyString: A string value.
MyInteger: An integer value.
Returns:
EchoString: The string value that was provided as `my_string`.
EchoInteger: The integer value that was provided as `my_integer`.
"""
return my_string, my_integer
Constraints
Each of the basic data types can be further constrained by the addition of Constraints to the type hint. Constraints are a way to specify limitations on the values that can be accepted for a given parameter or return value. For example, you may want to specify that an integer parameter must be within a certain range, or that a string parameter must match a certain regex pattern.
The basic data types and their respective constraints are as follows:
| SiLA Data Type | Python Native Type | Constraints |
|---|---|---|
| Binary | bytes | ContentType, Length, MinimalLength, MaximalLength, Schema |
| String | str | ContentType, Length, MinimalLength, MaximalLength, Schema, Pattern, Set |
| Integer | int | MinimalInclusive, MaximalInclusive, MinimalExclusive, MaximalExclusive, Set, Unit |
| Real | float | MinimalInclusive, MaximalInclusive, MinimalExclusive, MaximalExclusive, Set, Unit |
| Date | datetime.date | MinimalInclusive, MaximalInclusive, MinimalExclusive, MaximalExclusive, Set |
| Time | datetime.time | MinimalInclusive, MaximalInclusive, MinimalExclusive, MaximalExclusive, Set |
| Timestamp | datetime.datetime | MinimalInclusive, MaximalInclusive, MinimalExclusive, MaximalExclusive, Set |
| List | list | ElementCount, MinimalElementCount,MaximalElementCount |
We can also enforce limitations on standard data types through the addition of Constraint annotations to our type-hints.
Let's say that we have a function that takes a string input:
def my_func(input: string) -> None:
if input == "a":
do_a()
elif input == "b":
do_b()
Here we only have two valid input values, so we might want to limit the inputs that a user can provide to this function. With constraints this is simply a matter of wrapping the current parameter type-hint in typing.Annotated and adding as many constraints as desired.
def my_func(
input: typing.Annotated[str, sila.constraints.Set(["a", "b"])]
) -> None:
...
Now any value supplied which is not "a" or "b" will automatically raise a ValidationError.
Constraints can be used in SiLA endpoints directly as part of the parameter type hinting or can be built into Structuress, like the PositionIndex from the LabwareTransferManipulatorControllerBase:
@dataclasses.dataclass
class PositionIndex:
"""Specifies a position via an index number, starting at 1.
Attributes:
PositionIndex: A number indicating the position's index, starting at 1.
"""
position_index: typing.Annotated[int, sila.constraints.MinimalInclusive(value=1)]
Constraints come with pre-existing validations that are applied automatically before submission when data is provided by end-users.
Read up on available constraints in the installed Python SiLA library or in the SiLA2 Core Specification.
Runtime Constraints
Sometimes the valid constraints for a command parameter or return value depend on the specific device, and may vary across different configurations of the same device.
Consider for example a robotic arm that comes in different configurations which have different reach ranges.
Instead of writing different features or endpoints for each of these configurations, we can query an endpoint on the machine, and then dynamically set the constraints during the connector's startup.
This can be achieved by using two methods of the sila.Feature class: the on_before_start hook and the add_constraint method:
from ..io import get_device_range # a helper method in your io layer that retrieves the range from the device
class ArmMovementController(sila.Feature):
@typing.override
async def on_before_start(self) -> None:
min_range, max_range = await get_device_range()
self.add_constraint(
self.move_absolute,
"position",
[
sila.constraints.MinimalInclusive(min_range),
sila.constraints.MaximalInclusive(max_range),
]
)
@sila.UnobservableCommand()
async def move_absolute(self, position: int) -> None:
"""
Move the arm on the x-axis.
Args:
Position: Where to move the arm to
"""
...
After startup, the move_absolute command will be visible to a SiLA client with the constraints of min_range and max_range.
Enumerations
For convenience, the CDK also supports the use of python Enum classes as a way to define a set of valid values for a given parameter or return value. This is particularly useful for parameters that can only take on a limited set of values, such as a parameter that specifies the mode of operation for a device. To use an Enum as a type hint, simply define your Enum class and use it as the type hint for your parameter or return value. For example:
from enum import IntEnum
class OperationMode(IntEnum):
MODE_A = 0
MODE_B = 1
MODE_C = 2
@sila.UnobservableCommand()
async def set_operation_mode(self, mode: OperationMode) -> int:
"""
Set the operation mode for the device.
Args:
Mode: The operation mode to set for the device.
"""
return mode.value
Enumerations in the CDK are automatically converted to strings with a Set constraints. The names of the Enum members are lowercased and separated by the _ character to generate the user-facing string values. In the above example, the valid input values for the mode parameter would be "mode a", "mode b", and "mode c".