1.2. Pulse Storage and Serialization¶
Serialization and deserialization mechanisms are implemented to enable persistent storage and thus reusability of pulse template definitions. Currently, the serialization format is a plain text document containing JSON formatted data. 1
Serialization is constructed in a way that allows that a given pulse template may refer to subtemplates which are used by several different parent templates (or more than once in one) such as, e.g., a measurement pulse.
These subtemplates are stored in a separate file and referenced by a unique identifier in all parent templates. On the other hand, there might be subtemplates which are only relevant to their parent and thus should be embedded in its serialization to avoid creating a multitude of files that have no value to the user. To allow the serialization process to make this distinction, each pulse template (or other serializable object) provides an optional identifier (which can be set by the user via the constructor for all pulse template variants). If an identifier is present in a pulse template, it is stored in a separate file. If not, it is embedded in its parent’s serialization.
The implementation of (de)serialization mainly relies on the PulseStorage
class and the Serializable
interface. Every class that implements the latter can be serialized and thus stored as a JSON file. Currently, this is the case for all PulseTemplate
variants as well as the ParameterConstraint
class.
The PulseStorage
offers a convenient dictionary-like interface for storing and retrieving pulse template objects (or other objects of type Serializable
) to the user. It is responsible for transparently invoking the actual JSON encoding (or decoding) of Serializable
objects including dealing with handling references to subtemplates with identifiers.
Finally, the StorageBackend
interface abstracts the actual storage backend. While currently there only exists a few implementations of this interface, most importantly the FilesystemStorageBackend
, this allows to support, e.g., database storage, in the future. PulseStorage
requires an instance of StorageBackend
which represents its persistent pulse storage during initialization.
For an example of how to use PulseStorage
to store and load pulse templates, see Storing Pulse Templates: PulseStorage and Serialization in the examples section.
1.2.1. Global Pulse Registry¶
qupulse features the concept of a pulse registry, i.e., a global dictionary-like object that keeps track of all named
pulse templates (and other Serializable
instances with identifiers) in the program to ensure that no identifier
is used twice accidentally. Every Serializable
instance automatically registers in the registry during object
construction and will raise an error if the given identifier is already taken.
To manage separate registries, every Serializable
(sub)class has an optional construction argument registry
to indicate the registry to use, although this should be used rarely as the presence of several distinct registries
undermines the intended purpose of preventing duplicated identifiers for serializable objects. If the registry
argument is not specified, the default global pulse registry is used.
PulseStorage
can (and should) be used as the pulse registry. Use the PulseStorage.set_to_default_registry()
method to set any PulseStorage
object as the central registry.
1.2.2. Implementing a Serializable
Class¶
To make any new class serializable, it must derive from the Serializable
and implement the methods Serializable.get_serialization_data()
, Serializable.deserialize()
and the Serializable.identifier
property.
If class objects should be stored in a separate file, the identifier must be a non-empty string. If, on the other hand, class objects should be embedded into their parent’s serialization (as is the case for, e.g., ParameterConstraint
), Serializable.identifier
must be None.
The Serializable
class takes care of handling the identifier. Deriving classes must forward the identifier
argument in the __init__
method to Serializable.__init__()
. Additionally, to comply with the pulse registry,
deriving classes must call Serializable._register
at the end of their own __init__
method, after the
object is completely assembled (and can potentially be serialized).
The method Serializable.get_serialization_data()
should return a dictionary of containing all relevant data. The objects contained
in the returned dictionary can be of any native Python type, sets, lists or dictionary as well as of type
Serializable
. Note that nested Serializable
objects, e.g., subtemplates of a pulse template,
should be contained as is in the dictionary returned, i.e., get_serialization_data
should
not make recursive calls to get_serialization_data
of nested objects.
The Serializable
class provides an implementation for Serializable.get_serialization_data()
which returns
a dictionary containing information about type and identifier. This should be called at the beginning of implementations
of Serializable.get_serialization_data()
in any derived class and all further information added to the dictionary
thus obtained.
The method Serializable.deserialize()
is invoked with all key-value pairs created by a call to Serializable.get_serialization_data()
as keyword arguments
as well as an additional identifier
keyword argument (which may be None
) and must return a valid corresponding
class instance. Serializable
provides a default implementation which forwards all incoming keyword
arguments to the classes __init__
method, which is sufficient in most cases. Derived classes only need to implement
deserialize
if they need to tweak the incoming keyword arguments before construction the corresponding class instance.
An example for this is SequencePulseTemplate
.
The following code snippet may serve as an example for a simple implementation of a serializable class:
from qupulse.serialization import Serializable, PulseRegistryType
from typing import Any, Dict, Optional
class Foo(Serializable):
def __init__(self,
template: Serializable,
mapping: Dict[str, int],
identifier: Optional[str]=None, registry:
PulseRegistryType=None) -> None:
super().__init__(identifier=identifier)
self.__template = template
self.__mapping = mapping
self._register(registry)
def get_serialization_data(self) -> Dict[str, Any]:
data = super().get_serialization_data()
data['template'] = self.__template
data['mapping'] = self.__mapping
return data
Footnotes
- 1
After some discussion of the format in which to store the data, JSON files were the favored solution. The main competitor were relational SQL databases, which could provide a central, globally accessible pulse database. However, since pulses are often changed between experiments, a more flexible solution that can be maintained by users without database experience and also allows changes only in a local environment was desired. Storing pulse templates in files was the obvious solution to this. This greatest-simplicity-requirement was also imposed on the data format, which thus resulted in JSON being chosen over XML or other similar formats. An additional favorable argument for JSON is the fact that Python already provides methods that convert dictionaries containing only native python types into valid JSON and back.