Implementing loaders#

Phileas relies on loaders to match required instruments with suitable drivers, instantiate them based on the Bench configuration file parameters, and use them to configure the experiment according to the Experiment configuration file. They are made to be reusable: once a loader has been developed, it can be used to handle the same instrument without polluting the experiment scripts.

This pages contains a step-by-step guide to implement a loader for an experiment instrument. It is based on the simulated oscilloscope available in phileas.mock_instruments.Oscilloscope.

Inside most loaders, a driver#

Before implementing a loader, you usually[1] have to find a Python driver for the instrument. Otherwise, you have to create it.

class phileas.mock_instruments.Oscilloscope(probe: Probe, id: str = 'mock-oscilloscope-driver:1', width: int = 8)[source]#

Simulated 8-bit oscilloscope driver. It uses a {py:class}`~phileas.mock_instruments.Probe` and quantifies its output.

probe: Probe#

Probe which is connected to the oscilloscope

id: str = 'mock-oscilloscope-driver:1'#

Unique identifier of the oscilloscope.

width: int = 8#

Bit width of the ADC.

fw_version: ClassVar[str] = '12.3'#

Version of the oscilloscope firmware.

get_measurement() ndarray[source]#

Sampled and quantified value of the probe.

In our example, the Oscilloscope driver is quite simple, as it is simulated. It exposes a phileas.mock_instruments.Oscilloscope.get_measurement() method which returns the acquisition of the probe that is connected to it. The amplitude and bit width can be controlled by directly modifying the corresponding attributes.

1probe = mock_instruments.RandomProbe(shape=(10, ))
2oscilloscope = mock_instruments.Oscilloscope(probe, amplitude=1)
3
4print(f"Measurements: {oscilloscope.get_measurement()}")
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[2], line 2
      1 probe = mock_instruments.RandomProbe(shape=(10, ))
----> 2 oscilloscope = mock_instruments.Oscilloscope(probe, amplitude=1)
      4 print(f"Measurements: {oscilloscope.get_measurement()}")

TypeError: Oscilloscope.__init__() got an unexpected keyword argument 'amplitude'

Here, we use the RandomProbe probe, which returns random values with a given shape.

Developing a loader boils down to creating a class that Phileas can interact with, in order to automatically using the driver. This is done by sub-classing Loader.

Defining the interface of the loader#

The first step is to define two attributes that are used to match the loader to the bench and experiment configuration entries.

Loader.name: ClassVar[str]#

Name of the loader, which is usually the name of the loaded instrument. It is to be matched with the bench configuration loader field.

In our case, the loader is called phileas-mock_oscilloscope-phileas. It is a good habit to use consistent naming in your projects. For example, we recommend brand-model_number-driver_library.

Loader.interfaces: ClassVar[set[str]]#

Interfaces the loaded instrument supports. Interfaces are arbitrary names referred to in the experiment configuration file, allowing to choose which bench instrument is used for which experiment instrument, depending on its interface field.

Interfaces are mostly used to provide interoperability between loaders. You should be able to replace a loader with another, as long as they expose the same interfaces. In our example, we chose to define oscilloscope as the interface of an instrument which

  • can be configured with an amplitude parameter;

  • has a get_measurement method, which returns the last acquired values.

Instantiating the driver#

The first method that we cover is initiate_connection(), which is responsible for instantiating the driver.

abstract Loader.initiate_connection(configuration: dict) Any[source]#

Initialize a connection to a given instrument, given its bench configuration, and return it.

It takes a single argument, which is the content of instrument entry of the bench configuration file. It is a dictionary with DataTree values, and usually string keys. It must return an instantiated driver which is ready to be used. In our case, we define it like this[2]:

def initiate_connection(self, configuration: dict) -> Oscilloscope:
    """
    Parameters:
     - probe (required): Name of the simulated probe to use.

    Supported probes, and parameters:
     - random-probe:
        shape: tuple with the required probe output shape
    """
    probe = configuration["probe"]
    if probe == "random-probe":
        return Oscilloscope(probe=RandomProbe(shape=configuration["shape"]))
    else:
        raise ValueError(f"Unsupported probe type: {probe}")

The bench configuration is expected to have a first parameter indicating which probe is used. If a random probe is requested, its shape is additionally retrieved from configuration. Finally, the built oscilloscope is returned.

Note

Notice the docstring, enclosed in """, which documents the parameters required by the method. It is important to define it clearly, so that others can easily reuse the loader.

With this implementation, a valid bench configuration is:

simulated-oscilloscope:
    loader: phileas-mock_oscilloscope-phileas
    probe: random-probe

Configuring the instrument#

Now, in order to use the instrument, loaders are expected to implement the configure() method.

abstract Loader.configure(instrument: Any, configuration: dict)[source]#

Configure an instrument - whose connection has already been initiated - using its experiment configuration. The instrument should be modified, and is not returned.

It uses a driver, supplied as the instrument argument, to configure the instrument according to configuration, which is derived from an experiment configuration. We define it to set the amplitude of the oscilloscope:

def configure(self, instrument: Oscilloscope, configuration: dict):
    """
    Parameters:
     - amplitude (optional): Amplitude of the oscilloscope.
    """
    if "amplitude" in configuration:
        amplitude = configuration["amplitude"]
        instrument.amplitude = amplitude
        self.logger.info(f"Amplitude set to {amplitude}.")

This construct is often found: the existence of a key in configuration is checked, and then it is used. This is useful to add optional parameters.

Note that this method does not return anything.

A valid experiment configuration is for this loader is

oscilloscope:
    interface: oscilloscope
    amplitude: !range
        start: 0.1
        end: 10
        steps: 4

Getting the state of an instrument#

Actually, the Oscilloscope does not support all amplitude values. In can only use powers of 10, and automatically rounds to the logarithmic closer supported value:

1oscilloscope.amplitude = 8
2oscilloscope.amplitude

In that case, you can optionally the get_effective_configuration() methods, which is the converse of configure(), and enables the user to retrieve the actual configuration of the oscilloscope.

Loader.get_effective_configuration(instrument: Any, configuration: None | dict[str, phileas.iteration.base.DataTree] = None) dict[str, phileas.iteration.base.DataTree][source]#

Returns the effective configuration of an instrument. The purpose of this method is to be called after configure(), in order to verify that the required parameters have been properly applied. It might be useful in different situations, for example:

  • the instrument might have a parameter with which the driver uses a best effort approach. If the user requests an unsupported value, it automatically chooses a supported value. For example, the only integer values might be supported, and the driver rounds the user-supplied parameter.

  • Setting a parameter might not be deterministic. For example, the effective position of a piezoelectric actuator differs from its target.

  • The user manually configures an instrument, and wants to get its configuration back. In this case, dump_state() might also be interesting.

If configuration is not supplied, the entire configuration of the instrument is returned. It should have the same structure as the argument of configure().

Otherwise, if configuration is supplied, its values are replaced with the effective parameter values. This is typically used to verify the value of a configuration that has just been applied with configure(). Note that, in this case, configuration might be modified, and it is likely that get_effective_configuration (instrument, configuration) is configuration.

It is not necessary to implement this method.

We could thus declare it as:

def get_effective_configuration(
        self, instrument: Oscilloscope, configuration: None | dict = None
    ) -> dict:
        dump_all = configuration is None
        eff_conf = {}

        if dump_all or "amplitude" in configuration:
            eff_conf["amplitude"] = instrument.amplitude

        return eff_conf

See also

You can retrieve the configuration of an instrument, or a whole bench, with the method get_effective_instrument_configuration() and get_effective_experiment_configuration() of ExperimentFactory.

The configuration of an instrument does not entirely define its state. Indeed, if someone were to swap two similar instruments on an experiment bench, their configuration would not change, although one might be not behave similarly as the other. For this reason, you can implement the get_id() method:

Loader.get_id(instrument: Any) str[source]#

Returns a unique identifier of an instrument. Two physically different instruments are expected to have different identifiers.

It enables an experiment factory to know the identity of each instrument on the bench. In our case, the oscilloscope driver has an id field, which is supposed to uniquely represent the driven oscilloscope. We can thus simply use the implementation:

def get_id(self, instrument: Oscilloscope) -> str:
    return instrument.id

Note

In the case of SCPI-driven instruments, you should return the output of the *IDN? command.

Finally, the actual state of the same instrument might change in time. For example, the fw_version field might change, after updating the firmware of the oscilloscope. In this case, the oscilloscope might still expose the same configuration API, but could behave differently. Implementing dump_state() and restore_state() - both of which are optional - enables the experiment factory to find bench variations, or to replicate exactly the state of an experiment bench.

Loader.dump_state(instrument: Any) phileas.iteration.base.DataTree[source]#

Dump the state of an instrument. It should, as much as possible, represent the exact state of the instrument. It should contain the value of parameters that can be configured with configure(), but also other ones. These might include, among others, calibration values, the exact mode which is used, firmware and driver versions, etc.

The purpose of this state is to enable comparing similar instruments, to find out if they can be swapped or how to make them interchangeable.

It is not necessary to implement this method. However, when it is implemented, together with restore_state(), it allows to guarantee that the instrument always starts in the same state. It can also be used to capture a manual configuration, in order to replicate it later.

Loader.restore_state(instrument: Any, state: phileas.iteration.base.DataTree)[source]#

Put the instrument in a previously recorded state. See dump_state().

In our case, the full state of the oscilloscope contains its configurable amplitude, but also its bit width, firmware version, and the probe it uses:

def dump_state(self, instrument: Oscilloscope) -> DataTree:
    return {
        "probe": f"{type(instrument.probe).__name__}",
        "amplitude": instrument.amplitude,
        "bit_width": instrument.width,
        "fw_version": instrument.fw_version,
    }

We restoring the state of the instrument, we are being careful not to damage it, and first check that the state is actually applicable. It is not possible to change the probe the oscilloscope is plugged to, and we don’t consider this as required. Thus, we simply warn the user if he tries to do so:

def restore_state(self, instrument: Oscilloscope, state: dict[str, Any]):
    if instrument.fw_version != state["fw_version"]:
        raise ValueError(
            f"Dumped FW version {state['fw_version']} is not compatible with"
            f" instrument FW version {instrument.fw_version}."
        )

    if instrument.width != state["bit_width"]:
        raise ValueError(
            f"Dumped bit width {state['bit_width']} is not compatible with "
            f"instrument bit width {instrument.width}."
        )
    instrument.amplitude = state["amplitude"]

    if type(instrument.probe).__name__ != state["probe"]:
        self.logger.warning("Cannot change the oscilloscope probe after instrument initialization.")

See also

Getting the state of an experiment bench covers more extensively how you can indirectly use these methods to effectively compare the state of different benches.

Putting it all together#

Finally, the resulting loader can be defined as:

class OscilloscopeLoader(Loader):
    """
    Loader of a simulated oscilloscope.
    """

    name = "phileas-mock_oscilloscope-phileas"
    interfaces = {"oscilloscope"}

    def initiate_connection(self, configuration: dict) -> Oscilloscope:
        """
        Parameters:
         - probe (required): Name of the simulated probe to use.

        Supported probes, and parameters:
         - random-probe:
            shape: tuple with the required probe output shape
        """
        probe = configuration["probe"]
        if probe == "random-probe":
            return Oscilloscope(probe=RandomProbe(shape=configuration["shape"]))
        else:
            raise ValueError(f"Unsupported probe type: {probe}")

    def configure(self, instrument: Oscilloscope, configuration: dict):
        """
        Parameters:
         - amplitude (optional): Amplitude of the oscilloscope.
        """
        if "amplitude" in configuration:
            amplitude = configuration["amplitude"]
            instrument.amplitude = amplitude
            self.logger.info(f"Amplitude set to {amplitude}.")

    def get_effective_configuration(
            self, instrument: Oscilloscope, configuration: None | dict = None
        ) -> dict:
            dump_all = configuration is None
            eff_conf = {}

            if dump_all or "amplitude" in configuration:
                eff_conf["amplitude"] = instrument.amplitude

            return eff_conf

    def get_id(self, instrument: Oscilloscope) -> str:
        return instrument.id

    def dump_state(self, instrument: Oscilloscope) -> DataTree:
        return {
            "probe": f"{type(instrument.probe).__name__}",
            "amplitude": instrument.amplitude,
            "bit_width": instrument.width,
            "fw_version": instrument.fw_version,
        }

    def restore_state(self, instrument: Oscilloscope, state: dict[str, Any]):
        if instrument.fw_version != state["fw_version"]:
            raise ValueError(
                f"Dumped FW version {state['fw_version']} is not compatible with"
                f" instrument FW version {instrument.fw_version}."
            )

        if instrument.width != state["bit_width"]:
            raise ValueError(
                f"Dumped bit width {state['bit_width']} is not compatible with "
                f"instrument bit width {instrument.width}."
            )
        instrument.amplitude = state["amplitude"]

        if type(instrument.probe).__name__ != state["probe"]:
            self.logger.warning("Cannot change the oscilloscope probe after instrument initialization.")

However, for Phileas to know how to use it when requested by a bench configuration, it must be registered. This is done by a the register_default_loader() class decorator. Using it is simple: in our case, we can modify the definition with:

@register_default_loader
class OscilloscopeLoader(Loader):
    ...

Alternatively, if you want to register a loader which is defined elsewhere, you can use it as a usual function:

register_default_loader(OscilloscopeLoader)

Now that it is done, Phileas is able to match

  • any bench configuration entry with loader: phileas-mock_oscilloscope-phileas; and

  • any experiment instrument requiring the oscilloscope interface

to the OscilloscopeLoader loader.

Logging#

Loaders have a logger logging handler that can be used by the user. This has the advantage of providing situational logs, which are registered under phileas.loader-name.

Documentation#

Loaders wrap drivers in order to let Phileas automatically use them. However, they should be understood by users in order to write bench and experiment configurations. For this reason, it is strongly recommended to add docstrings to the initiate_connection() and configure() methods when implementing them.

The documentation of all the loaders currently registered by Phileas in a script can be obtained from python -m phileas list-loaders script.py.

Warning

python -m phileas list-loaders script.py imports the script. Make sure to enclose all the side-effect operations in if __name__ == "__main__":.

Bench-only instruments and references#

Bench-only instruments only require the initiate_connection() of their loader. They are introduced here.

These instruments are often referred to when initiating the connection to other instruments. In this case, you can use get_bench_instrument() to retrieve the bench-only instrument:

# ...in the instrument which uses the bench-only instrument
def initiate_connection(self, configuration: dict) -> Any:
    bench_only_name = configuration["bench-only"]
    bench_only = self.instruments_factory.get_bench_instrument(bench_only_name)
    # You can now use the bench-only instrument driver

Note that bench instruments initialization is lazy. Thus, only required instruments are initialized, and it is done only once. In other words, calling get_bench_instrument() with the same argument either returns always the same value, or fails.

Warning

Do not introduce initiate_connection() loops, as it creates an infinite loop. Indeed, if instrumentA calls get_bench_instrument("instrumentB") and instrumentB calls get_bench_instrument("instrumentA"), none of these instruments can be instantiated, and the program loops endlessly.

Loaders without drivers#

Sometimes, it might be convenient not to use a dedicated driver. In this case, the loader acts as a driver. This is not recommended, however it is allowed. Since Loader is a Python class, you can add other methods that specifically drive the instrument.

However, note that __init__ is not called by you, but by Phileas itself. Thus, you should not modify its signature, and ensure that __init__() is called. We recommend to consider initiate_connection as the constructor, and to return self.

Note

You can implement __init__, but in this case it won’t have any user-supplied argument.