def test_extend(some_paramspecbases): ps1, ps2, ps3, _ = some_paramspecbases idps = InterDependencies_(standalones=(ps1, ps2)) idps_ext = idps.extend(dependencies={ps1: (ps3, )}) idps_expected = InterDependencies_(standalones=(ps2, ), dependencies={ps1: (ps3, )}) assert idps_ext == idps_expected # lazily check that we get brand new objects idps._id_to_paramspec[ps1.name].label = "Something new and awful" idps._id_to_paramspec[ps2.name].unit = "Ghastly unit" assert idps_ext._id_to_paramspec[ps1.name].label == 'blah' assert idps_ext._id_to_paramspec[ps2.name].unit == 'V' # reset the objects that are never supposed to be mutated idps._id_to_paramspec[ps1.name].label = "blah" idps._id_to_paramspec[ps2.name].unit = "V" idps = InterDependencies_(standalones=(ps2, )) idps_ext = idps.extend(dependencies={ps1: (ps2, )}) idps_expected = InterDependencies_(dependencies={ps1: (ps2, )}) assert idps_ext == idps_expected idps = InterDependencies_(dependencies={ps1: (ps2, )}) idps_ext = idps.extend(dependencies={ps1: (ps2, ps3)}) idps_expected = InterDependencies_(dependencies={ps1: (ps2, ps3)}) assert idps_ext == idps_expected idps = InterDependencies_() idps_ext = idps.extend(standalones=(ps1, ps2)) idps_expected = InterDependencies_(standalones=(ps2, ps1)) assert idps_ext == idps_expected ps_nu = deepcopy(ps1) ps_nu.unit += '/s' idps = InterDependencies_(standalones=(ps1, )) idps_ext = idps.extend(standalones=(ps_nu, )) idps_expected = InterDependencies_(standalones=(ps_nu, ps1)) assert idps_ext == idps_expected idps = InterDependencies_(dependencies={ps1: (ps2, )}) match = re.escape("Invalid dependencies/inferences") with pytest.raises(ValueError, match=match): idps_ext = idps.extend(inferences={ps2: (ps1, )})
class Measurement: """ Measurement procedure container Args: exp: Specify the experiment to use. If not given the default one is used. The default experiment is the latest one created. station: The QCoDeS station to snapshot. If not given, the default one is used. name: Name of the experiment. This will be passed down to the dataset produced by the measurement. If not given, a default value of 'results' is used for the dataset. """ def __init__(self, exp: Optional[Experiment] = None, station: Optional[qc.Station] = None, name: str = '') -> None: self.exitactions: List[Tuple[Callable, Sequence]] = [] self.enteractions: List[Tuple[Callable, Sequence]] = [] self.subscribers: List[Tuple[Callable, Union[MutableSequence, MutableMapping]]] = [] self.experiment = exp self.station = station self.name = name self._write_period: Optional[float] = None self._interdeps = InterDependencies_() self._parent_datasets: List[Dict] = [] @property def parameters(self) -> Dict[str, ParamSpecBase]: return deepcopy(self._interdeps._id_to_paramspec) @property def write_period(self) -> Optional[float]: return self._write_period @write_period.setter def write_period(self, wp: numeric_types) -> None: if not isinstance(wp, Number): raise ValueError('The write period must be a number (of seconds).') wp_float = float(wp) if wp_float < 1e-3: raise ValueError('The write period must be at least 1 ms.') self._write_period = wp_float def _paramspecbase_from_strings( self, name: str, setpoints: Sequence[str] = None, basis: Sequence[str] = None ) -> Tuple[Tuple[ParamSpecBase, ...], Tuple[ParamSpecBase, ...]]: """ Helper function to look up and get ParamSpecBases and to give a nice error message if the user tries to register a parameter with reference (setpoints, basis) to a parameter not registered with this measurement Called by _register_parameter only. Args: name: Name of the parameter to register setpoints: name(s) of the setpoint parameter(s) basis: name(s) of the parameter(s) that this parameter is inferred from """ idps = self._interdeps # now handle setpoints depends_on = [] if setpoints: for sp in setpoints: try: sp_psb = idps._id_to_paramspec[sp] depends_on.append(sp_psb) except KeyError: raise ValueError(f'Unknown setpoint: {sp}.' ' Please register that parameter first.') # now handle inferred parameters inf_from = [] if basis: for inff in basis: try: inff_psb = idps._id_to_paramspec[inff] inf_from.append(inff_psb) except KeyError: raise ValueError(f'Unknown basis parameter: {inff}.' ' Please register that parameter first.') return tuple(depends_on), tuple(inf_from) def register_parent(self: T, parent: DataSet, link_type: str, description: str = "") -> T: """ Register a parent for the outcome of this measurement Args: parent: The parent dataset link_type: A name for the type of parent-child link description: A free-text description of the relationship """ # we save the information in a way that is very compatible with the # Link object we will eventually make out of this information. We # cannot create a Link object just yet, because the DataSet of this # Measurement has not been given a GUID yet parent_dict = { 'tail': parent.guid, 'edge_type': link_type, 'description': description } self._parent_datasets.append(parent_dict) return self def register_parameter(self: T, parameter: _BaseParameter, setpoints: setpoints_type = None, basis: setpoints_type = None, paramtype: Optional[str] = None) -> T: """ Add QCoDeS Parameter to the dataset produced by running this measurement. Args: parameter: The parameter to add setpoints: The Parameter representing the setpoints for this parameter. If this parameter is a setpoint, it should be left blank basis: The parameters that this parameter is inferred from. If this parameter is not inferred from any other parameters, this should be left blank. paramtype: Type of the parameter, i.e. the SQL storage class, If None the paramtype will be inferred from the parameter type and the validator of the supplied parameter. """ if not isinstance(parameter, _BaseParameter): raise ValueError('Can not register object of type {}. Can only ' 'register a QCoDeS Parameter.' ''.format(type(parameter))) paramtype = self._infer_paramtype(parameter, paramtype) # default to numeric if paramtype is None: paramtype = 'numeric' # now the parameter type must be valid if paramtype not in ParamSpec.allowed_types: raise RuntimeError("Trying to register a parameter with type " f"{paramtype}. However, only " f"{ParamSpec.allowed_types} are supported.") # perhaps users will want a different name? But the name must be unique # on a per-run basis # we also use the name below, but perhaps is is better to have # a more robust Parameter2String function? name = str(parameter) if isinstance(parameter, ArrayParameter): self._register_arrayparameter(parameter, setpoints, basis, paramtype) elif isinstance(parameter, ParameterWithSetpoints): self._register_parameter_with_setpoints(parameter, setpoints, basis, paramtype) elif isinstance(parameter, MultiParameter): self._register_multiparameter( parameter, setpoints, basis, paramtype, ) elif isinstance(parameter, Parameter): self._register_parameter(name, parameter.label, parameter.unit, setpoints, basis, paramtype) else: raise RuntimeError("Does not know how to register a parameter" f"of type {type(parameter)}") return self @staticmethod def _infer_paramtype(parameter: _BaseParameter, paramtype: Optional[str]) -> Optional[str]: """ Infer the best parameter type to store the parameter supplied. Args: parameter: The parameter to to infer the type for paramtype: The initial supplied parameter type or None Returns: The inferred parameter type. If a not None parameter type is supplied this will be preferred over any inferred type. Returns None if a parameter type could not be inferred """ if paramtype is not None: return paramtype if isinstance(parameter.vals, vals.Arrays): paramtype = 'array' elif isinstance(parameter, ArrayParameter): paramtype = 'array' elif isinstance(parameter.vals, vals.Strings): paramtype = 'text' elif isinstance(parameter.vals, vals.ComplexNumbers): paramtype = 'complex' # TODO should we try to figure out if parts of a multiparameter are # arrays or something else? return paramtype def _register_parameter(self: T, name: str, label: Optional[str], unit: Optional[str], setpoints: Optional[setpoints_type], basis: Optional[setpoints_type], paramtype: str) -> T: """ Update the interdependencies object with a new group """ parameter: Optional[ParamSpecBase] try: parameter = self._interdeps[name] except KeyError: parameter = None paramspec = ParamSpecBase(name=name, paramtype=paramtype, label=label, unit=unit) # We want to allow the registration of the exact same parameter twice, # the reason being that e.g. two ArrayParameters could share the same # setpoint parameter, which would then be registered along with each # dependent (array)parameter if parameter is not None and parameter != paramspec: raise ValueError("Parameter already registered " "in this Measurement.") if setpoints is not None: sp_strings = [str(sp) for sp in setpoints] else: sp_strings = [] if basis is not None: bs_strings = [str(bs) for bs in basis] else: bs_strings = [] # get the ParamSpecBases depends_on, inf_from = self._paramspecbase_from_strings( name, sp_strings, bs_strings) if depends_on: self._interdeps = self._interdeps.extend( dependencies={paramspec: depends_on}) if inf_from: self._interdeps = self._interdeps.extend( inferences={paramspec: inf_from}) if not (depends_on or inf_from): self._interdeps = self._interdeps.extend(standalones=(paramspec, )) log.info(f'Registered {name} in the Measurement.') return self def _register_arrayparameter( self, parameter: ArrayParameter, setpoints: Optional[setpoints_type], basis: Optional[setpoints_type], paramtype: str, ) -> None: """ Register an ArrayParameter and the setpoints belonging to that ArrayParameter """ name = str(parameter) my_setpoints = list(setpoints) if setpoints else [] for i in range(len(parameter.shape)): if parameter.setpoint_full_names is not None and \ parameter.setpoint_full_names[i] is not None: spname = parameter.setpoint_full_names[i] else: spname = f'{name}_setpoint_{i}' if parameter.setpoint_labels: splabel = parameter.setpoint_labels[i] else: splabel = '' if parameter.setpoint_units: spunit = parameter.setpoint_units[i] else: spunit = '' self._register_parameter(name=spname, paramtype=paramtype, label=splabel, unit=spunit, setpoints=None, basis=None) my_setpoints += [spname] self._register_parameter(name, parameter.label, parameter.unit, my_setpoints, basis, paramtype) def _register_parameter_with_setpoints(self, parameter: ParameterWithSetpoints, setpoints: Optional[setpoints_type], basis: Optional[setpoints_type], paramtype: str) -> None: """ Register an ParameterWithSetpoints and the setpoints belonging to the Parameter """ name = str(parameter) my_setpoints = list(setpoints) if setpoints else [] for sp in parameter.setpoints: if not isinstance(sp, Parameter): raise RuntimeError("The setpoints of a " "ParameterWithSetpoints " "must be a Parameter") spname = sp.full_name splabel = sp.label spunit = sp.unit self._register_parameter(name=spname, paramtype=paramtype, label=splabel, unit=spunit, setpoints=None, basis=None) my_setpoints.append(spname) self._register_parameter(name, parameter.label, parameter.unit, my_setpoints, basis, paramtype) def _register_multiparameter(self, multiparameter: MultiParameter, setpoints: Optional[setpoints_type], basis: Optional[setpoints_type], paramtype: str) -> None: """ Find the individual multiparameter components and their setpoints and register those as individual parameters """ setpoints_lists = [] for i in range(len(multiparameter.shapes)): shape = multiparameter.shapes[i] name = multiparameter.full_names[i] if shape == (): my_setpoints = setpoints else: my_setpoints = list(setpoints) if setpoints else [] for j in range(len(shape)): if multiparameter.setpoint_full_names is not None and \ multiparameter.setpoint_full_names[i] is not None: spname = multiparameter.setpoint_full_names[i][j] else: spname = f'{name}_setpoint_{j}' if multiparameter.setpoint_labels is not None and \ multiparameter.setpoint_labels[i] is not None: splabel = multiparameter.setpoint_labels[i][j] else: splabel = '' if multiparameter.setpoint_units is not None and \ multiparameter.setpoint_units[i] is not None: spunit = multiparameter.setpoint_units[i][j] else: spunit = '' self._register_parameter(name=spname, paramtype=paramtype, label=splabel, unit=spunit, setpoints=None, basis=None) my_setpoints += [spname] setpoints_lists.append(my_setpoints) for i, setpoints in enumerate(setpoints_lists): self._register_parameter(multiparameter.names[i], multiparameter.labels[i], multiparameter.units[i], setpoints, basis, paramtype) def register_custom_parameter(self: T, name: str, label: str = None, unit: str = None, basis: setpoints_type = None, setpoints: setpoints_type = None, paramtype: str = 'numeric') -> T: """ Register a custom parameter with this measurement Args: name: The name that this parameter will have in the dataset. Must be unique (will overwrite an existing parameter with the same name!) label: The label unit: The unit basis: A list of either QCoDeS Parameters or the names of parameters already registered in the measurement that this parameter is inferred from setpoints: A list of either QCoDeS Parameters or the names of of parameters already registered in the measurement that are the setpoints of this parameter paramtype: Type of the parameter, i.e. the SQL storage class """ return self._register_parameter(name, label, unit, setpoints, basis, paramtype) def unregister_parameter(self, parameter: setpoints_type) -> None: """ Remove a custom/QCoDeS parameter from the dataset produced by running this measurement """ if isinstance(parameter, _BaseParameter): param = str(parameter) elif isinstance(parameter, str): param = parameter else: raise ValueError('Wrong input type. Must be a QCoDeS parameter or' ' the name (a string) of a parameter.') try: paramspec: ParamSpecBase = self._interdeps[param] except KeyError: return self._interdeps = self._interdeps.remove(paramspec) log.info(f'Removed {param} from Measurement.') def add_before_run(self: T, func: Callable, args: tuple) -> T: """ Add an action to be performed before the measurement. Args: func: Function to be performed args: The arguments to said function """ # some tentative cheap checking nargs = len(signature(func).parameters) if len(args) != nargs: raise ValueError('Mismatch between function call signature and ' 'the provided arguments.') self.enteractions.append((func, args)) return self def add_after_run(self: T, func: Callable, args: tuple) -> T: """ Add an action to be performed after the measurement. Args: func: Function to be performed args: The arguments to said function """ # some tentative cheap checking nargs = len(signature(func).parameters) if len(args) != nargs: raise ValueError('Mismatch between function call signature and ' 'the provided arguments.') self.exitactions.append((func, args)) return self def add_subscriber(self: T, func: Callable, state: Union[MutableSequence, MutableMapping]) -> T: """ Add a subscriber to the dataset of the measurement. Args: func: A function taking three positional arguments: a list of tuples of parameter values, an integer, a mutable variable (list or dict) to hold state/writes updates to. state: The variable to hold the state. """ self.subscribers.append((func, state)) return self def run(self) -> Runner: """ Returns the context manager for the experimental run """ return Runner(self.enteractions, self.exitactions, self.experiment, station=self.station, write_period=self._write_period, interdeps=self._interdeps, name=self.name, subscribers=self.subscribers, parent_datasets=self._parent_datasets)