Creating hardware modules satisfying multiple interfaces
This article will describe the qudi feature to overload interface attributes within hardware modules in case of namespace collisions between multiple inherited interface classes.
Why do we even need this feature?
Imagine you have an interface class DataReaderInterface
which interfaces a simple data reader device.
You also have another interface class DataOutputInterface
which interfaces a simple data output device.
Now let's say you have a piece of hardware that can handle both tasks simultaneously and you want
to write a hardware module for that.
First of all you would need to inherit both interfaces in the class definition of your hardware module:
from qudi.interface.data_reader import DataReaderInterface
from qudi.interface.data_output import DataOutputInterface
class MyHardwareModule(DataReaderInterface, DataOutputInterface):
""" This will become my new fancy hardware module to combine DataReaderInterface and
DataOutputInterface functionality
"""
pass
So far so good...
Now let's have a closer look at the class members of DataReaderInterface
and
DataOutputInterface
:
from abc import abstractmethod
from qudi.core.module import Base
class DataReaderInterface(Base):
""" This is a fictional data reader interface.
"""
@property
@abstractmethod
def constraints(self):
""" A read-only data structure containing all hardware parameter limitations.
"""
raise NotImplementedError
@abstractmethod
def get_data(self):
""" Get the read data array.
@return iterable: Data array
"""
raise NotImplementedError
@abstractmethod
def start(self):
""" Start the data acquisition.
"""
raise NotImplementedError
class DataOutputInterface(Base):
""" This is a fictional data output interface.
"""
@property
@abstractmethod
def constraints(self):
""" A read-only data structure containing all hardware parameter limitations.
"""
raise NotImplementedError
@abstractmethod
def set_data(self, data):
""" Set the data array to output.
@param iterable data: The data array to output
"""
raise NotImplementedError
@abstractmethod
def start(self):
""" Start the data output.
"""
raise NotImplementedError
As you can see it will be no problem to just implement the get_data
and set_data
methods in our
new hardware module since they have unique names that are just present in a single interface,
respectively.
The method start
and the property constraints
however are an entirely different story. In a
traditional way you can only define one attribute with that name in your hardware module, thus
preventing to start each task separately and getting the constraints selectively.
And this is where the ominous meta object qudi.util.overload.OverloadedAttribute
comes into play.
How to overload an interface attribute
The meta object OverloadedAttribute
enables you to flag any attribute
(descriptor object, variable, method/function etc.) as an overloaded attribute giving the
possibility to register multiple implementations for the attribute under unique str
keys.
Let's look at an example on how this can be used in a hardware module based on the example classes presented in the previous section:
from qudi.interface.data_reader import DataReaderInterface
from qudi.interface.data_output import DataOutputInterface
from qudi.util.overload import OverloadedAttribute
class MyHardwareModule(DataReaderInterface, DataOutputInterface):
""" This will become my new fancy hardware module to combine DataReaderInterface and
DataOutputInterface functionality.
"""
# Define qudi module activation/deactivation
...
# Do other stuff
...
def get_data(self):
# Do something
return tuple(range(42))
def set_data(self, data):
# Do something
pass
# Flag "start" attribute as overloaded attribute
start = OverloadedAttribute()
# Register multiple implementations for "start" via convenient decorator
# The key words under which the implementations are registered must be the corresponding
# interface class names.
# Make sure to use "start" as attribute name for all implementations.
@start.overload('DataReaderInterface')
def start(self):
# Start the data reader
print('Data reader started through "DataReaderInterface" interface method')
@start.overload('DataOutputInterface')
def start(self):
# Start the data output
print('Data output started through "DataOutputInterface" interface method')
# You can do the same for properties. Just make sure to apply the @property decorator first.
constraints = OverloadedAttribute()
@constraints.overload('DataReaderInterface')
@property
def constraints(self):
# Return data reader constraints
print('Data reader constraints requested through "DataReaderInterface" interface.')
return dict()
@constraints.overload('DataOutputInterface')
@property
def constraints(self):
# Return data output constraints
print('Data output constraints requested through "DataOutputInterface" interface.')
return dict()
As already mentioned, set_data
and get_data
do not need special treatment.
Through the OverloadedAttribute
object and decorators used here, the two implementations for
start
will both be registered to the attribute start
; each one associated to a different
interface class name ('DataReaderInterface' or 'DataOutputInterface').
Same goes for the property constraints
.
The string given in the overload
decorator is used as a keyword to address which implementation
to use:
# Call different implementations for "start"
<MyHardwareModule>.start['DataReaderInterface']()
<MyHardwareModule>.start['DataOutputInterface']()
# Get different implementations for "constraints" property
<MyHardwareModule>.constraints['DataReaderInterface']
<MyHardwareModule>.constraints['DataOutputInterface']
So when accessing overloaded attributes of a hardware class directly, you can select which implementation to address by adding the respective overload keyword in square brackets after the method name just as you would for any mapping in Python.
Using keywords that have not been registered will result in KeyError
.
You can however always overwrite the interface attribute in your hardware class as usual by just defining it regularly (without the decorator and the meta object) if you do not need to overload it.
Interface methods and logic module Connector
Now you might think this new way of addressing overloaded attributes will not work seamlessly with logic modules due to the changed attribute access syntax.
In order to work around this issue the qudi.core.connector.Connector
object is your best friend.
During instantiation of a Connector
object the logic module passes the interface type or class
name as parameter. As such the Connector
instance can provide a hardware module proxy object when
called to hide the overload mechanics of interface methods from the calling logic module.
This is enabled by qudi.util.overload.OverloadProxy
.
To illustrate this further, let's assume you have a logic module MyLogicModule
which is
interfacing DataReaderInterface
and DataOutputInterface
through our new hardware module
MyHardwareModule
.
A call to each different start
implementation would look like:
from qudi.core.connector import Connector
from qudi.core.module import LogicBase
class MyLogicModule(LogicBase):
""" Fictional logic module illustrating the use of the Connector object with overloaded
interface methods.
"""
# Instantiate connectors
_data_reader = Connector(name='data_reader', interface='DataReaderInterface')
_data_output = Connector(name='data_output', interface='DataOutputInterface')
# Declare other class-level stuff
...
# define qudi module on_activate/on_deactivate
...
# example method with calls to hardware module(s)
def do_stuff(self):
self._data_reader().start() # Will call "start" implementation for "DataReaderInterface"
self._data_output().start() # Will call "start" implementation for "DataOutputInterface"
As you can see through the use of the Connector
object, the logic does not need to know if two
separate devices are connected or a single device with overloaded interface methods.
Generalization
The qudi meta object OverloadedAttribute
as well as OverloadProxy
can in fact be used in a very
general way and not only with qudi hardware interfaces.
It can overload any attribute type (descriptor objects, callables, staticmethods, classmethods,
etc.) in the class body with any non-empty str
keywords. You do not need qudi module base/meta
classes for the overload mechanism to work, which allows you to use it with any Python3 class.
The same is true for hiding the overloading semantics using OverloadProxy
.