Skip to content

Knowledge base schema features

Schema file

The database schema is defined in the schema.py file in your flow folder. It is evolves together with flow and designed to be easy to reuse and extend. The schema is defined as a set of Python classes that represent the different types of documents in the Qruise KB. Each class is derived from a base class DocumentTemplate and defines the properties of the document as class attributes. The properties can include primitive types (string, integer, float, datetime, boolean), references to other documents or subdocuments. The schema is stored in the database so database can ensure consistency of the data and schema can be discovered and explored by visualization tools. The schema is stored in the database, allowing the database to ensure consistency of the data, and it can be discovered and explored using visualisation tools.

Documents and subdocuments

The Qruise KB stores information as structured documents. Each document represents a physical entity, such as a qubit, resonator, or experiment, and contains a set of key-value pairs that describe its properties.

Documents

Documents in Qruise KB are defined as Python classes derived from a DocumentTemplate class. Here's a simple example of a Qubit document. (Real QruiseOS qubits are more complex, but this example is sufficient to illustrate the concept of a document.)

from qruise.kb import DocumentTemplate, LexicalKey

class Qubit(DocumentTemplate):
    """
    Qubit document
    """
    _key = LexicalKey("name")
    name: str
    """
    Qubit name i.e Q1
    """
    flux: float
    """
    Working point flux bias in V
    """
    frequency: float
    """
    Qubit frequency in Hz at the working point
    """
    t1: float
    """
    Qubit T1 time in s
    """

The DocumentTemplate class is the base class for all documents in the Qruise KB. The base class along with it's meta-class defined behavior to define, persist and load the objects from and into the database.The properties of the document are defined as class attributes with their types and descriptions. The inline documentation is also a part of schema and stored in the database.

Document key

A Document must have a unique identifier, which is used to reference it in the Qruise KB. The _key attribute defines the unique identifier for the document. In case of Qubit, the LexicalKey type is used to define the key as the name property. The effective database ID of the document is a combination of the name property and the object type, which is Qubit in this case, that would be "Qubit/Q1". Lexical key can combine multiple properties concatenating them with a "+" character. Use properties as type string or int for properties. Float to string conversion can suffer from rounding error. For named objects, i.e. qubit or a gate the lexical key is a preferred key type.

For a object which is not have individuality i.e. an Experiment one can use a random string key (RandomKey). Alternatively one could use a LexicalKey and initialized it with some pseudo-random value (like cuid, tuid or UUID).

Subdocuments

Subdocuments can be used to represent reusable complex data structures. Subdocument can't be inserted on it's own, it must be part of a document. Subdocument can't be referenced by multiple documents. It could be considered as value structured object, so if we want to use same values in another document it need to be copied (which is the default behavior of the Qruise KB Python objects).

An example of a subdocument is a Quantity class, which can be used to represent a physical quantity with its value, unit, default value, and scan range.

from qruise.kb import DocumentTemplate


class Quantity(DocumentTemplate):
    """
    Quantity subdocument
    """
    _subdocument = ()

    value: float | None
    """
    Value of the quantity
    """
    unit: str | None
    """
    Unit of the quantity
    """
    default: float | None
    """
    Default value of the quantity, i.e. as specified in the datasheet
    """
    window: float | None
    """
    Range of values to scan around old or default value
    """

The attribute _subdocument is used to mark the class as a subdocument. Subdocuments can't be inserted in the database on their own, they must be part of another document.

Now we can rewrite the Qubit document to use the Quantity subdocument for the values.

from qruise.kb import DocumentTemplate, LexicalKey

class Qubit(DocumentTemplate):
    """
    Qubit document
    """
    _key = LexicalKey("name")
    name: str
    """
    Qubit name i.e Q1
    """
    flux: Quantity | None
    """
    Working point Flux bias in V
    """
    frequency: Quantity | None
    """
    Qubit frequency in Hz at the working point
    """
    t1: Quantity | None
    """
    Qubit T1 time in s
    """
    t2echo: Quantity | None
    """
    Qubit T2 echo time in s
    """

Note that attributes of the Quantity as well as the Qubit class are optional. This is because the Qruise KB is designed to be flexible and allow missing or incomplete data. The None type is used to indicate that a value is not set. If a value is not set. Adding an optional field does not break schema as it not required to have a value, so it can be added at any time.

Properties

The properties can be a single value, a collection of values. The type of the single value or a collection item could be a primitive type (string, integer, float, datetime or boolean), reference to another document or a subdocument or set or a list of values.

Datetime

It's highly recommended to store datetime in UTC time Zone. Local time is not monotone, and can create issues when accessing the KB from multiple time Zone. UI and tools can convert the UTC time to local time. The Datetime is stored in the database as a string in ISO 8601 format, so it can be easily converted to and from a datetime object.

import datetime

class Experiment(DocumentTemplate):
    """
    Experiment document
    """
    _key = LexicalKey("id")
    id: str
    timestamp: datetime.datetime
    """
    Experiment record date and time (commit timestamp)
    """

# create an experiment
experiment = Experiment(id="exp1", timestamp=datetime.datetime.now(datetime.timezone.utc)

Optional

A property can be marked as optional using either the Optional or the | None type hints. This means that the property can be set to None or not set at all. This is useful for properties that are not always required, or for properties that may not be known at the time of creation. Internally Optional can be considered As a set of at most 1 element. Making a obligatory property optional does not break the schema, as it is not required to have a value. So it can be added at any time. If a value is not set, the property will be None. Adding an optional field does not break schema as it not required to have a value, so it can be added at any time.

    class Qubit(DocumentTemplate):
        flux: Quantity | None
        """
        Working point flux bias in V
        """

Set

A property can be marked as a set by using the Set type hint. This means that the property can contain multiple values, but the order of the values is not important. The values in a set are unique, so duplicate values will be removed. The set is unordered collection of unique elements. The set is represented as a Python set object. Adding a set property to a document does not break the schema, as it is will be considered as an empty set. One can also change the type of a property from a single value or an optional value to a set, so it will be interpreted as a set of 0 or 1 elements. Note that Subdocuments are compared by reference so a set of subdocuments can contain multiple identical subdocuments. Adding an sortable property (i.e. name or ordinal) to a subdocument allows to impose order on the set, so implement an ordered set. Set property can't be annotated as Optional. An empty set is equivalent representation of a value not set.

    class ResonanceFlux(DocumentTemplate):
        _subdocument = ()
        biased: Qubit
        flux: Quantity

    class Coupling(DocumentTemplate):
        resonance_fluxes: Set[ResonanceFlux]

List

A property can store a list of values. Its list is an ordered collection of values. The values in a list can be of any type, including primitive types (string, integer, float, boolean), references to other documents or subdocuments. The list is represented as a Python list object and stored in a database as a linked list, so requires more resources to store and load, so if order is not important and duplicate values are not to expect set is a preferred way to store multiple values. Adding a list property breaks the schema, since it must be initialized with empty list. The workaround is to add an optional subdocument typed property with a list inside.

class Component(Entity):
    _key = LexicalKey("name")
    name: str

class ConnectedComponent(Component)
    connected: List[Component]

Container types

To store experiment data, sampled shapes in the database, the Qruise KB supports bulk numeric types. Qruise KB supports the following bulk types: numpy.ndarray, xarray.Dataset, and xarray.DataArray. To use them just define a property of the type. Numeric property can be marked Optional. Do not use List and Set modifier in combination with numeric types. Use internal dimensions of the array to store multiple values. The numeric types are stored in the database as binary data, so they can be used to store large amounts of data without using a lot of space. xarray.Dataset store multiple values with annotated dimensions metadata about the data, such as the units, axis names. XArray uses Python NETCDF4 (h5netcdf) for serialization. Complex numbers is not part of official NetCDF4 specification, they are supported but is not fully compatible with other NetCDF4 tools. Use separate variables like I and Q for storing readout result. Then storing physical values, it's highly recommended to use standard SI units, like seconds, hertz, volts, etc and annotate data with units. This make display and analysis of data consistent.

import xarray as xr
import datetime

class Experiment(DocumentTemplate):
    """
    Experiment document
    """
    _key = LexicalKey("id")
    id: str
    """
    Experiment name i.e Q1
    """
    data: xr.Dataset | None
    """
    Experiment data
    """

In this example the data property is a reference to an xarray.Dataset object, which is used to store the experiment data. Smaller array (less than 2 kilobytes) are stored inline in the database, larger arrays are stored in Minio object store or in a local filesystem and referenced by a link. The property uses lazy-loading, so the data is not loaded from the object storage until the property it is accessed. This allow effective loading of many documents with subsequent filtering on metadata, without loading the bulk numeric data.

Arbitrary dict

The Qruise KB schema supports properties of type dict, allowing users to store arbitrary dictionaries containing structured key-value data. These dictionaries are serialised using hjson, a human-readable format that supports standard types as well as numpy arrays.

A typical use case is storing third-party objects that can be represented as dictionaries. Here's an example showing how Qiskit discriminators can be stored in the KB and included within a readout resonator:

class ReadoutDiscriminator(DocumentTemplate):
    """ 
    Qiskit discriminator object
    """
    _subdocument = ()
    number_of_states: int | None
    discriminator_type: str
    discriminator_config: dict | None
    assignment_matrix: List[float]

class ReadoutResonator(LineComponent):
    discriminator: ReadoutDiscriminator | None
    discriminator_3_state: ReadoutDiscriminator | None

It's possible to store any object in the database as a dict, but it's not recommended, since effectively it will be stored as a string in the database and it will be difficult to access the properties of the object without custom Python code. Database will not be able to check schema and consistency of the data. So it's better to use a subdocument or a document with properties to store the data the structure you are in control of.

Methods

Usually document template classes are pure data classes, that is they don't have any application specific methods except property getter/setters. Nevertheless, it is possible to define methods in the document template class. The methods can be used to add a behavior to a class. The methods can be defined as normal Python methods and can use the self parameter to access the document properties.

class Fit(DocumentTemplate):
    """Represents a fitting model"""
    _abstract=()
    _subdocument=()

    @abstract
    def __call__(self, x:float):
        """
        Fit the data
        """
        pass

class ParabolicFit(Fit):
    """
    Parabolic fit (i.e. frequency vs flux)
    """
    a: float
    x0: float
    y0: float

    def __call__(self, x: float) -> float:
        return self.a * (x - self.x0) ** 2 + self.y0

The Fit class is an abstract class that defines the interface for fitting models. The __call__ method is defined as an abstract method, which means that it must be implemented by any subclass of the Fit class. Class Fit is marked as abstract by the _abstract attribute. The ParabolicFit class is a concrete implementation of the Fit class that implements the __call__ method. The __call__ method can be used to fit the data and return the fitted value. The ParabolicFit class can be used as a property of another document, just like any other document. Here is an example of how to use the Fit class as a property of a document:

class Qubit(Component):
    freq_of_flux: Fit | None
    """
    Frequency vs flux fit
    """

The freq_of_flux property is a reference to a Fit object, which can be used to fit the data. The __call__ method of the Fit class can be used to fit the data and return the fitted value. The ParabolicFit is a specific implementation of the Fit class that implements the __call__ method. Storing / loading an object from or to the database takes care about instantiating the correct concrete class. This allows using different model implementations without changing the schema.

Document inheritance

As in Python the Qruise KB supports document type inheritance. This means that you can create a new document class that inherits from an existing document class and extends it by adds properties and/or methods. This is useful for creating specialized versions of documents or for reusing common properties across multiple documents. The QruiseKB allows fetching documents can be based on type, so having a specific base class can be useful to impose specific load behavior. In particular, standard Qruise KB schema has base class "Entity" which is used to mark documents keeping QPU characterization values and and effectively needed for system setup.

from qruise.kb import DocumentTemplate, LexicalKey, Entity

class Entity(DocumentTemplate):
    """
    Entity document
    """
    _abstract = ()

class Component(Entity):
    """
    Component document
    """
    _key = LexicalKey("name")
    name: str
    """
    Component name i.e Q1
    """


class Qubit(Component):
    """
    Qubit document
    """
    freq: Quantity | None
    """
    Qubit frequency in Hz at the working point
    """
    t1: Quantity | None
    """
    Qubit T1 time in s
    """
    t2echo: Quantity | None
    """
    Qubit T2 echo time in s
    """
    readout: Readout | None
    """
    Readout resonator
    """

class Readout(Component):
    """
    freq: Quantity | None
    """
    Readout resonator frequency in Hz at the working point
    amplitude: Quantity | None
    """
    readout amplitude
    """
    duration: Quantity | None
    """
    Readout duration in s
    """
    qubit: Qubit | None
    """

Deriving the Qubit class from the Component class allows us to reuse the name property and the _key attribute. The Readout class is also derived from the Component class, so it can be used as a property of the Qubit class. The readout property is a reference to another document, which is a Readout document. This allows us to connect qubit , amplitude and duration as part of the qubit document. The class Component is derived from the Entity class, which is a base class for all documents that represent physical entities. The Entity class can be used to mark documents that keep QPU characterization, so they can be loaded with a single database API call. The Entity class is marked as anstract with a class variable _abstract=() which prevents instantiation of the class.

Another examples of base classes are:

  • class Event which is a base for logged events like Experiments or manual correction.
  • class Calibration which is a base for storing calibrated control sequences like quantum gates or incoherent operations like unconditional reset.

Schema inheritance

To enable correct functioning of dashboard as also to follow best practices it's recommended that all Qruise-OS KB schemas have same set of basis classes. So Qruise-OS defines a core schema, and all Qruise-OS schemas should be derived from it. qruise experiment library defines types representing standard characterization and calibration experiments, so including it in the schema reduce a lot of boilerplate code and allows schema evolution with new releases of the Qruise-OS.

Here is an example of a minimal schema.py file using core and experiment catalog schemas.

from typing import List, Optional, Sequence, Set

from qruise.kb import DocumentTemplate, LexicalKey
from qruise.experiment.schema.core import (
    CoreSchema,
    Component,
    ConnectedComponent,
    Entity,
    Fit,
    Quantity,
    QruiseObject,
    QuantityList,
    Qubit,
)

from qruise.experiment.schema.experiments import (  # noqa: F401, F811
    Ramsey,
    Ramsey12,
    T2StarRamsey,
    T1,
    T2StarRamsey,
    ReadoutDiscriminator,
    ReadoutDiscriminator2,
)


TITLE = "Example"
DESCRIPTION = "Schema for QruiseOS flow examples"
AUTHORS = [
    "contact@qruise.com",
]

with CoreSchema(
    title=TITLE,
    description=DESCRIPTION,
    authors=AUTHORS,
) as ExampleSchema:

    class ParabolicFit(Fit):
        a: Quantity
        x0: Quantity
        y0: Quantity

        def __call__(self, x):
            return self.a * (x - self.x0) ** 2 + self.y0

    class FluxCrosstalk(Entity):
        name: str
        _key = LexicalKey("name")
        target: "Component"
        biased: "Component"
        crosstalk: Quantity

    class ReadoutLine(QruiseObject):
        _key = LexicalKey("name")
        index: int
        lo_freq: Optional[Quantity]  # Local oscillator frequency in Hertz

    class Resonator(Component):
        freq: Optional[Quantity]
        kappa: Optional[Quantity]

    class ReadoutResonator(ConnectedComponent):
        freq: Optional[Quantity]
        resonator: Optional[Resonator]
        amplitude: Optional[Quantity]
        amplitude_spectroscopy: Optional[Quantity]  # Amplitude of the readout scan
        relax_time: Optional[Quantity]
        duration: Optional[Quantity]
        delay: Optional[Quantity]
        line: Optional[ReadoutLine]
        inverse: Optional[bool]
        discriminator: Optional["ReadoutDiscriminator"]
        discriminator_3_state: Optional["ReadoutDiscriminator"]
        dispersive_shift: Optional[Quantity]

    class DragPulse(DocumentTemplate):
        _subdocument = ()
        amplitude: Optional[
            Quantity
        ]  # Amplitude of a default gaussian (6-sigma) PI-pulse in volts, specific to QC implementation
        duration: Optional[
            Quantity
        ]  # Duration of a default gaussian (6-sigma) PI-pulse in seconds, specific to QC implementation
        drag: Optional[Quantity]
        freq: Optional[Quantity]  # Qubit frequency in hertz, adjusted.
        drag_optimal_control: Optional[Quantity]  # delta, adjusted.

    class Qubit(Qubit):
        """
        Qubit component

        Attributes
        ----------

        flux : Optional[Quantity]
            Flux line signal level in volts
        freq_coarse : Optional[Quantity]
            Coarse spectroscopy measured qubit frequency in hertz from qubit spectroscopy.
        freq : Optional[Quantity]
            Ramsey measured accurate qubit frequency in hertz, measured with with a Ramsey experiment.
        anhar_coarse : Optional[Quantity]
            Coarse estimated anharmonicity, measured by pulsed spectroscopy of the second transition (Hz)
        anhar : Optional[Quantity]
            Anharmonicity, measured by Ramsey of the second transition (Hz)
        t1 : Optional[Quantity]
            :math:`T1` time in seconds, measured by measuring the decay of the excited state in Spin-echo experiment
        t2star : Optional[Quantity]
            :math:`T2\\star` time in seconds, measured by measuring the decay of the  state in Ramsey experiment (s)

        """

        readout: Optional["ReadoutResonator"]
        freq_specification: Optional[
            Quantity
        ]  # Qubit frequency in Hertz, from chip specification
        lo_freq: Optional[Quantity]  # Local oscillator frequency in Hertz
        freq_of_flux: Optional[Fit]
        flux_crosstalks: Set[FluxCrosstalk]

        flux: Optional[
            Quantity
        ]  # Working point (coupling off) of the flux line in volts
        freq_coarse: Optional[
            Quantity
        ]  # Qubit frequency, measured with a pulsed spectroscopy. (Hz)
        freq: Optional[
            Quantity
        ]  # Qubit frequency, precisely measured with a Ramsey experiment. (Hz)
        anhar_coarse: Optional[
            Quantity
        ]  # Anharmonicity, measured with a pulsed spectroscopy (Hz)
        anhar: Optional[
            Quantity
        ]  # Anharmonicity, precisely measured with a Ramsey experiment (Hz)
        t1: Optional[
            Quantity
        ]  # :math:`T1` time in seconds, measured by measuring the decay of the excited state in Spin-echo experiment
        t2star: Optional[
            Quantity
        ]  # :math:`T2\star` time in seconds, measured by measuring the decay of the state in Ramsey experiment (s)
        x180_amplitude_spectroscopy: Optional[
            Quantity
        ]  # CW pulse amplitude for the spectroscopy scan
        x180: Optional[DragPulse]
        x90: Optional[DragPulse]
        x180_ef: Optional[DragPulse]


    class Coupling(ConnectedComponent):
        """Coupling component"""

        strength: Optional[Quantity]
        resonance_fluxes: Set[ResonanceFlux]
        flux_off: Optional[Quantity]

        @classmethod
        def find(cls, connected: Sequence[Component]):
            return next(
                iter(
                    coupling
                    for coupling in Coupling.get_instances()
                    if connected == coupling.connected
                ),
                None,
            )

    # Experiments
    class Ramsey(Ramsey):
        pass

    class Ramsey12(Ramsey12):
        pass

    class T2StarRamsey(T2StarRamsey):
        pass

    class T1(T1):
        pass

    class ReadoutDiscriminator2(ReadoutDiscriminator2):
        pass

CoreSchema imported from qruise-experiment library contains boilerplate components for a consistent Qruise schema. Invoking it with CoreShema effectively creates a new scheme where all elements of parent schema are included. Open the context with with statement allows defining, extending and importing new document type in the context of the schema.

The documents defined in the base schema can be extended by sub-classing it with the same name like class Qubit(Qubit): All document type in a schema exist in one namespace, so deriving a class with a same name, effectively extends the bases one by adding a properties to it. If a base class (like in this case T1) does not exist in the current schema the type will be imported including all types it transitively relies on. So only types explicitly subclassed in schema context imported from qruise.experiment.schema.experiments will be included in the schema. This allows creating a schema containing only the types and properties only needed to store results of used experiments and can be easily extended by importing new types from library schemas.

Database schema merge

A schema evolves as the knowledge on QPU evolves. We add experiments, measuring or learning additional parameters. The schema is stored in the database but in the same time is defined a python So it can happen that schema stored in KB and the schema defined in schema.py are not identical. Nevertheless if changes are compatible issue can be resolved by mixing both schemas. The schema used in the script or notebook would be dynamic combination of both, thus allowing load/modify/store cycle in an experiment script or a notebook. Everything except methods is stored in in the KB, so one can start access and modify the KB without having a schema.py file.

Schema update

Schema update part of natural flow development. Every time we introduce a new experiment, new measured we have to add a property or document types. Extending schema document values and enable proper interaction with other components of Qruise-OS (dashboard, datas export and analysis tools). Schema can be considered as set of constrains on your stored data. In what aspect weakening that constrains introduce a non breaking change. Here some examples of safe schema modification operations.

  • Adding a new document class.
  • Modifying or removing a document class, if no instance of it exists in the KB.
  • Adding a Optional or Set property (cardinality 0) to an existing documents.
  • Deleting a Optional property where no instance of it exists in the KB.
  • Marking an obligatory property as optional.
  • Changing an optional property to a set property.
  • Changing inheritance hierarchy, if it does introduce new mandatory properties in any existing documents.
  • Modifying inline documentation of a property or document class.

So for some operation data modification and multiples steps are needed before a schema change can be applied. For example to delete a obligatory property, it must be first changed to optional, than set to None in all instances and then deleted.

Schema update with CLI tool

Assuming one have .QKB.json and schema.py file in the current directory, one can use the qruise CLI tool to update the schema. The tool will load the schema from the KB and merge it with the schema defined in the schema.py file. The merged schema will be in qruise KB.

qruise kb commit -m "Update schema"

Schema update Python API

The schema update notebook is usually the first step of the flow to ensure the KB schema will be able to store the data and KB objects have all expected properties. Qruise KB API provide methods to simplify migration.

from qruise.kb import Schema, connect
from pprint import pprint

client = connect()
schema = Schema()

# read old schema as list of dictionaries
old_schema = client.get_schema()

# Update schema meta and update from `schema.py` and then show all classes defined in it. replace=True enables deletion of unused document types removed from schema
migration = schema.prepare_migration(
    old_schema=old_schema,
    module="schema",
    replace=True,
)

# print summary of the migration
pprint(migration.get_diff())

# apply migration if needed
if migration.is_pending():
    migration.commit(client, commit_msg="Update schema")

Qruise KB Session

In a typical experiment notebook the schema object does not need to be instantiated explicitly.

from qruise.kb import create_session
from pprint import pprint

session = create_session()
session.load_schema() # load schema from the database
session.import_schema() # load schema from the schema.py file

pprint(session.schema.types)

Main components of a Session are:

  • client, API to access TerminusDB database
  • schema, schema object to access the schema
  • store, API to access blob object storage (Minio or local filesystem)
  • blob_store, helper to store and load numeric data objects

The create_session function use .QKB.json from the current directory to select the database to connect to. The load_schema method loads the schema from the database. session.import_schema loads schema.py file and merges it with the schema in the database. The import_schema method will not modify the database schema. To persist the changes, the session.save_schema method must be called. It's better to use schema update procedure described above since it checks if the schema is really changed and helps visualize the changes.