"""Base class for Instrument and InstrumentModule"""
from __future__ import annotations
import collections.abc
import logging
import warnings
from collections.abc import Callable, Mapping, Sequence
from typing import TYPE_CHECKING, Any, ClassVar, cast
import numpy as np
from typing_extensions import TypedDict, TypeVar, deprecated
from qcodes.logger import get_instrument_logger
from qcodes.metadatable import Metadatable, MetadatableWithName
from qcodes.parameters import Function, Parameter, ParameterBase
from qcodes.utils import DelegateAttributes, full_class
if TYPE_CHECKING:
from collections.abc import Callable, Mapping, Sequence
from typing_extensions import NotRequired
from qcodes.instrument.channel import ChannelTuple, InstrumentModule
from qcodes.logger.instrument_logger import InstrumentLoggerAdapter
from qcodes.utils import QCoDeSDeprecationWarning
log = logging.getLogger(__name__)
TParameter = TypeVar("TParameter", bound=ParameterBase, default=Parameter)
[docs]
class InstrumentBaseKWArgs(TypedDict):
"""
This TypedDict defines the type of the kwargs that can be passed to the InstrumentBase class.
A subclass of VisaInstrument should take ``**kwargs: Unpack[InstrumentBaseKWArgs]`` as input
and forward this to the super class to ensure that it can accept all the arguments defined here.
"""
metadata: NotRequired[Mapping[Any, Any] | None]
"""
Additional static metadata to add to this
instrument's JSON snapshot.
"""
label: NotRequired[str | None]
"""
Nicely formatted name of the instrument; if None,
the ``name`` is used.
"""
[docs]
class InstrumentBase(MetadatableWithName, DelegateAttributes):
"""
Base class for all QCodes instruments and instrument channels
Args:
name: an identifier for this instrument, particularly for
attaching it to a Station.
metadata: additional static metadata to add to this
instrument's JSON snapshot.
label: nicely formatted name of the instrument; if None, the
``name`` is used.
"""
def __init__(
self,
name: str,
metadata: Mapping[Any, Any] | None = None,
label: str | None = None,
) -> None:
name = self._replace_hyphen(name)
self._short_name = name
self._is_valid_identifier(self.full_name)
self.label = name if label is None else label
self._label: str
self.parameters: dict[str, ParameterBase] = {}
"""
All the parameters supported by this instrument.
Usually populated via :py:meth:`add_parameter`.
"""
self.functions: dict[str, Function] = {}
"""
All the functions supported by this
instrument. Usually populated via :py:meth:`add_function`.
"""
self.submodules: dict[str, InstrumentModule | ChannelTuple] = {}
"""
All the submodules of this instrument
such as channel lists or logical groupings of parameters.
Usually populated via :py:meth:`add_submodule`.
"""
self.instrument_modules: dict[str, InstrumentModule] = {}
"""
All the :class:`InstrumentModule` of this instrument
Usually populated via :py:meth:`add_submodule`.
"""
self._channel_lists: dict[str, ChannelTuple] = {}
"""
All the ChannelTuples of this instrument
Usually populated via :py:meth:`add_submodule`.
This is private until the correct name has been decided.
"""
super().__init__(metadata)
# This is needed for snapshot method to work
self._meta_attrs = ["name", "label"]
self.log: InstrumentLoggerAdapter = get_instrument_logger(self, __name__)
self.log.debug("Created instrument: %s", self.full_name)
@property
def label(self) -> str:
"""
Nicely formatted label of the instrument.
"""
return self._label
@label.setter
def label(self, label: str) -> None:
self._label = label
[docs]
def add_parameter(
self,
name: str,
parameter_class: type[TParameter] | None = None,
**kwargs: Any,
) -> TParameter:
"""
Bind one Parameter to this instrument.
Instrument subclasses can call this repeatedly in their ``__init__``
for every real parameter of the instrument.
In this sense, parameters are the state variables of the instrument,
anything the user can set and/or get.
Args:
name: How the parameter will be stored within
:attr:`.parameters` and also how you address it using the
shortcut methods: ``instrument.set(param_name, value)`` etc.
parameter_class: You can construct the parameter
out of any class. Default :class:`.parameters.Parameter`.
**kwargs: Constructor arguments for ``parameter_class``.
Raises:
KeyError: If this instrument already has a parameter with this
name and the parameter being replaced is not an abstract
parameter.
ValueError: If there is an existing abstract parameter and the
unit of the new parameter is inconsistent with the existing
one.
"""
if parameter_class is None:
parameter_class = cast(type[TParameter], Parameter)
if "bind_to_instrument" not in kwargs.keys():
kwargs["bind_to_instrument"] = True
try:
param = parameter_class(name=name, instrument=self, **kwargs)
except TypeError:
kwargs.pop("bind_to_instrument")
warnings.warn(
f"Parameter {name} on instrument {self.name} does "
f"not correctly pass kwargs to its baseclass. A "
f"Parameter class must take `**kwargs` and forward "
f"them to its baseclass.",
QCoDeSDeprecationWarning,
)
param = parameter_class(name=name, instrument=self, **kwargs)
existing_parameter = self.parameters.get(name, None)
if not existing_parameter:
warnings.warn(
f"Parameter {name} did not correctly register itself on instrument"
f" {self.name}. Please check that `instrument` argument is passed "
f"from {parameter_class!r} all the way to `ParameterBase`. "
"This will be an error in the future.",
QCoDeSDeprecationWarning,
)
self.parameters[name] = param
return param
[docs]
def remove_parameter(self, name: str) -> None:
"""
Remove a Parameter from this instrument.
Unlike modifying the parameters dict directly, this method will
make sure that the parameter is properly unbound from the instrument
if the parameter is added as a real attribute to the instrument.
If a property of the same name exists it will not be modified.
If name is an attribute but not a parameter, it will not be modified.
Args:
name: The name of the parameter to remove.
Raises:
KeyError: If the parameter does not exist on the instrument.
"""
self.parameters.pop(name)
is_property = isinstance(getattr(self.__class__, name, None), property)
if not is_property and hasattr(self, name):
try:
delattr(self, name)
except AttributeError:
self.log.warning(
"Could not remove attribute %s from %s", name, self.full_name
)
[docs]
def add_function(self, name: str, **kwargs: Any) -> None:
"""
Bind one ``Function`` to this instrument.
Instrument subclasses can call this repeatedly in their ``__init__``
for every real function of the instrument.
This functionality is meant for simple cases, principally things that
map to simple commands like ``*RST`` (reset) or those with just a few
arguments. It requires a fixed argument count, and positional args
only.
Note:
We do not recommend the usage of Function for any new driver.
Function does not add any significant features over a method
defined on the class.
Args:
name: How the Function will be stored within
``instrument.Functions`` and also how you address it using the
shortcut methods: ``instrument.call(func_name, *args)`` etc.
**kwargs: constructor kwargs for ``Function``
Raises:
KeyError: If this instrument already has a function with this
name.
"""
if name in self.functions:
raise KeyError(f"Duplicate function name {name}")
func = Function(name=name, instrument=self, **kwargs)
self.functions[name] = func
[docs]
def add_submodule(
self, name: str, submodule: InstrumentModule | ChannelTuple
) -> None:
"""
Bind one submodule to this instrument.
Instrument subclasses can call this repeatedly in their ``__init__``
method for every submodule of the instrument.
Submodules can effectively be considered as instruments within
the main instrument, and should at minimum be
snapshottable. For example, they can be used to either store
logical groupings of parameters, which may or may not be
repeated, or channel lists. They should either be an instance
of an ``InstrumentModule`` or a ``ChannelTuple``.
Args:
name: How the submodule will be stored within
``instrument.submodules`` and also how it can be
addressed.
submodule: The submodule to be stored.
Raises:
KeyError: If this instrument already contains a submodule with this
name.
TypeError: If the submodule that we are trying to add is
not an instance of an ``Metadatable`` object.
"""
if name in self.submodules:
raise KeyError(f"Duplicate submodule name {name}")
if not isinstance(submodule, Metadatable):
raise TypeError("Submodules must be metadatable.")
self.submodules[name] = submodule
if isinstance(submodule, collections.abc.Sequence):
# this is channel_list like:
# We cannot check against ChannelsList itself since that
# would introduce a circular dependency.
self._channel_lists[name] = submodule
else:
self.instrument_modules[name] = submodule
[docs]
def get_component(self, full_name: str) -> MetadatableWithName:
"""
Recursively get a component of the instrument by full_name.
Args:
full_name: The name of the component to get.
Returns:
The component with the given name.
Raises:
KeyError: If the component does not exist.
"""
name_parts = full_name.split("_")
name_parts.reverse()
component = self._get_component_by_name(name_parts.pop(), name_parts)
return component
def _get_component_by_name(
self, potential_top_level_name: str, remaining_name_parts: list[str]
) -> MetadatableWithName:
log.debug(
"trying to find component %s on %s, remaining %s",
potential_top_level_name,
self.full_name,
remaining_name_parts,
)
component: MetadatableWithName | None = None
sub_component_name_map = {
sub_component.short_name: sub_component
for sub_component in self.submodules.values()
}
channel_name_map: dict[str, InstrumentModule] = {}
for channel_list in self._channel_lists.values():
local_channels_name_map: dict[str, InstrumentModule] = {
channel.short_name: channel for channel in channel_list
}
channel_name_map.update(local_channels_name_map.items())
if potential_top_level_name in self.parameters:
component = self.parameters[potential_top_level_name]
elif potential_top_level_name in self.functions:
component = self.functions[potential_top_level_name]
elif potential_top_level_name in self.submodules:
# recursive call on found component
component = self.submodules[potential_top_level_name]
if len(remaining_name_parts) > 0:
remaining_name_parts.reverse()
remaining_name = "_".join(remaining_name_parts)
component = component.get_component(remaining_name)
remaining_name_parts = []
elif potential_top_level_name in sub_component_name_map:
component = sub_component_name_map[potential_top_level_name]
if len(remaining_name_parts) > 0:
remaining_name_parts.reverse()
remaining_name = "_".join(remaining_name_parts)
component = component.get_component(remaining_name)
remaining_name_parts = []
elif potential_top_level_name in channel_name_map:
component = channel_name_map[potential_top_level_name]
if len(remaining_name_parts) > 0:
remaining_name_parts.reverse()
remaining_name = "_".join(remaining_name_parts)
component = component.get_component(remaining_name)
remaining_name_parts = []
if component is not None:
if len(remaining_name_parts) == 0:
return component
if len(remaining_name_parts) == 0:
raise KeyError(
f"Found component {self.full_name} but could not "
f"match {potential_top_level_name} part."
)
new_potential_top_level_name = (
f"{potential_top_level_name}_{remaining_name_parts.pop()}"
)
component = self._get_component_by_name(
new_potential_top_level_name, remaining_name_parts
)
return component
[docs]
def snapshot_base(
self,
update: bool | None = False,
params_to_skip_update: Sequence[str] | None = None,
) -> dict[Any, Any]:
"""
State of the instrument as a JSON-compatible dict (everything that
the custom JSON encoder class
:class:`.NumpyJSONEncoder`
supports).
Args:
update: If ``True``, update the state by querying the
instrument. If None update the state if known to be invalid.
If ``False``, just use the latest values in memory and never
update state.
params_to_skip_update: List of parameter names that will be skipped
in update even if update is True. This is useful if you have
parameters that are slow to update but can be updated in a
different way (as in the qdac). If you want to skip the
update of certain parameters in all snapshots, use the
``snapshot_get`` attribute of those parameters instead.
Returns:
dict: base snapshot
"""
if params_to_skip_update is None:
params_to_skip_update = []
snap: dict[str, Any] = {
"functions": {
name: func.snapshot(update=update)
for name, func in self.functions.items()
},
"submodules": {
name: subm.snapshot(update=update)
for name, subm in self.submodules.items()
},
"parameters": {},
"__class__": full_class(self),
}
for name, param in self.parameters.items():
if param.snapshot_exclude:
continue
if params_to_skip_update and name in params_to_skip_update:
update_par: bool | None = False
else:
update_par = update
try:
snap["parameters"][name] = param.snapshot(update=update_par)
except Exception:
# really log this twice. Once verbose for the UI and once
# at lower level with more info for file based loggers
self.log.warning("Snapshot: Could not update parameter: %s", name)
self.log.info("Details for Snapshot:", exc_info=True)
snap["parameters"][name] = param.snapshot(update=False)
for attr in set(self._meta_attrs):
val = getattr(self, attr, None)
if val is not None:
if isinstance(val, Metadatable):
snap[attr] = val.snapshot(update=update)
else:
snap[attr] = val
return snap
[docs]
def print_readable_snapshot(
self, update: bool = False, max_chars: int = 80
) -> None:
"""
Prints a readable version of the snapshot.
The readable snapshot includes the name, value and unit of each
parameter.
A convenience function to quickly get an overview of the
status of an instrument.
Args:
update: If ``True``, update the state by querying the
instrument. If ``False``, just use the latest values in memory.
This argument gets passed to the snapshot function.
max_chars: the maximum number of characters per line. The
readable snapshot will be cropped if this value is exceeded.
Defaults to 80 to be consistent with default terminal width.
"""
floating_types = (float, np.integer, np.floating)
snapshot = self.snapshot(update=update)
par_lengths = [len(p) for p in snapshot["parameters"]]
# handle the case of no parameters
par_lengths = par_lengths or [0]
# Min of 50 is to prevent a super long parameter name to break this
# function
par_field_len = min(max(par_lengths) + 1, 50)
print(self.name + ":")
print("{0:<{1}}".format("\tparameter ", par_field_len) + "value")
print("-" * max_chars)
for par in sorted(snapshot["parameters"]):
name = snapshot["parameters"][par]["name"]
msg = "{0:<{1}}:".format(name, par_field_len)
# in case of e.g. ArrayParameters, that usually have
# snapshot_value == False, the parameter may not have
# a value in the snapshot
val = snapshot["parameters"][par].get("value", "Not available")
unit = snapshot["parameters"][par].get("unit", None)
if unit is None:
# this may be a multi parameter
unit = snapshot["parameters"][par].get("units", None)
if isinstance(val, floating_types):
msg += f"\t{val:.5g} "
# numpy float and int types format like builtins
else:
msg += f"\t{val} "
if unit != "": # corresponds to no unit
msg += f"({unit})"
# Truncate the message if it is longer than max length
if len(msg) > max_chars and not max_chars == -1:
msg = msg[0 : max_chars - 3] + "..."
print(msg)
for submodule in self.submodules.values():
submodule.print_readable_snapshot(update=update, max_chars=max_chars)
[docs]
def invalidate_cache(self) -> None:
"""
Invalidate the cache of all parameters on the instrument.
Calling this method will recursively mark the cache of all parameters
on the instrument and any parameter on instrument modules as invalid.
This is useful if you have performed manual operations
(e.g. using the frontpanel)
which changes the state of the instrument outside QCoDeS.
This in turn means that the next snapshot of the instrument will trigger
a (potentially slow) reread of all parameters of the instrument if you pass
`update=None` to snapshot.
"""
for parameter in self.parameters.values():
parameter.cache.invalidate()
for submodule in self.submodules.values():
submodule.invalidate_cache()
@property
def parent(self) -> InstrumentBase | None:
"""
The parent instrument. By default, this is ``None``.
Any SubInstrument should subclass this to return the parent instrument.
"""
return None
@property
def ancestors(self) -> tuple[InstrumentBase, ...]:
"""
Ancestors in the form of a list of :class:`InstrumentBase`
The list starts with the current module
then the parent and the parents parent
until the root instrument is reached.
"""
if self.parent is not None:
return (self, *self.parent.ancestors)
else:
return (self,)
@property
def root_instrument(self) -> InstrumentBase:
"""
The topmost parent of this module.
For the ``root_instrument`` this is ``self``.
"""
return self
@property
def name_parts(self) -> list[str]:
"""
A list of all the parts of the instrument name from :meth:`root_instrument`
to the current :class:`InstrumentModule`.
"""
return [self.short_name]
@property
def full_name(self) -> str:
"""
Full name of the instrument.
For an :class:`InstrumentModule` this includes
all parents separated by ``_``
"""
return "_".join(self.name_parts)
@property
def name(self) -> str:
"""
Full name of the instrument
This is equivalent to :meth:`full_name` for backwards compatibility.
"""
return self.full_name
@property
@deprecated(
"The private attribute `_name` is deprecated and will be removed. Use `full_name` instead.",
category=QCoDeSDeprecationWarning,
)
def _name(self) -> str:
"""
Private alias kept here for backwards compatibility
see https://github.com/zhinst/zhinst-qcodes/issues/27
"""
return self.full_name
@property
def short_name(self) -> str:
"""
Short name of the instrument.
For an :class:`InstrumentModule` this does
not include any parent names.
"""
return self._short_name
@staticmethod
def _is_valid_identifier(name: str) -> None:
"""Check whether given name is a valid instrument identifier."""
if not name.isidentifier():
raise ValueError(f"{name} invalid instrument identifier")
@staticmethod
def _replace_hyphen(name: str) -> str:
"""Replace - in name with _ and warn if any is found."""
new_name = str(name).replace("-", "_")
if name != new_name:
warnings.warn(f"Changed {name} to {new_name} for instrument identifier")
return new_name
def _is_abstract(self) -> bool:
"""
This method is run after the initialization of an instrument but
before the instrument is registered. It recursively checks that there are
no abstract parameters defined on the instrument or any instrument channels.
"""
is_abstract = False
abstract_parameters = [
parameter.name
for parameter in self.parameters.values()
if parameter.abstract
]
if any(abstract_parameters):
is_abstract = True
for submodule in self.instrument_modules.values():
if submodule._is_abstract():
is_abstract = True
for chanel_list in self._channel_lists.values():
for channel in chanel_list:
if channel._is_abstract():
is_abstract = True
return is_abstract
#
# shortcuts to parameters & setters & getters #
#
# instrument['someparam'] === instrument.parameters['someparam'] #
# instrument.someparam === instrument.parameters['someparam'] #
# instrument.get('someparam') === instrument['someparam'].get() #
# etc... #
#
delegate_attr_dicts: ClassVar[list[str]] = ["parameters", "functions", "submodules"]
[docs]
@deprecated(
"Use attributes directly on the instrument object instead.",
category=QCoDeSDeprecationWarning,
)
def __getitem__(self, key: str) -> Callable[..., Any] | Parameter:
"""
Delegate instrument['name'] to parameter or function 'name'.
Note:
This is deprecated. Use attributes directly or if dynamic attribute required look up via
.parameters or .functions dictionaries
"""
try:
return self.parameters[key]
except KeyError:
return self.functions[key]
[docs]
@deprecated(
"Call set directly on the parameter.",
category=QCoDeSDeprecationWarning,
)
def set(self, param_name: str, value: Any) -> None:
"""
Shortcut for setting a parameter from its name and new value.
Args:
param_name: The name of a parameter of this instrument.
value: The new value to set.
Note:
This is deprecated. Call set directly on the parameter.
"""
self.parameters[param_name].set(value)
[docs]
@deprecated(
"Call get directly on the parameter.",
category=QCoDeSDeprecationWarning,
)
def get(self, param_name: str) -> Any:
"""
Shortcut for getting a parameter from its name.
Args:
param_name: The name of a parameter of this instrument.
Returns:
The current value of the parameter.
Note:
This is deprecated. Call get directly on the parameter.
"""
return self.parameters[param_name].get()
[docs]
@deprecated(
"Call the function directly.",
category=QCoDeSDeprecationWarning,
)
def call(self, func_name: str, *args: Any) -> Any:
"""
Shortcut for calling a function from its name.
Args:
func_name: The name of a function of this instrument.
*args: any arguments to the function.
Returns:
The return value of the function.
Note:
This is deprecated. Call the function directly.
"""
return self.functions[func_name].call(*args)
[docs]
def __getstate__(self) -> None:
"""Prevent pickling instruments, and give a nice error message."""
raise RuntimeError(
f"Error when pickling instrument {self.name}. "
f"QCoDeS instruments can not be pickled."
)
[docs]
def validate_status(self, verbose: bool = False) -> None:
"""Validate the values of all gettable parameters
The validation is done for all parameters that have both a get and
set method.
Arguments:
verbose: If ``True``, then information about the
parameters that are being check is printed.
"""
for k, p in self.parameters.items():
if p.gettable and p.settable:
value = p.get()
if verbose:
print(f"validate_status: param {k}: {value}")
p.validate(value)