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
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
loaderfield.
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
interfacefield.
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
amplitudeparameter;has a
get_measurementmethod, 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
configurationis not supplied, the entire configuration of the instrument is returned. It should have the same structure as the argument ofconfigure().Otherwise, if
configurationis 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 withconfigure(). Note that, in this case,configurationmight be modified, and it is likely thatget_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; andany experiment instrument requiring the
oscilloscopeinterface
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.