From 4ff6e20b9bd254cf09b19b23c3afef8b54947010 Mon Sep 17 00:00:00 2001 From: Tobias Hangleiter Date: Thu, 11 Dec 2025 08:39:32 +0100 Subject: [PATCH 1/7] Implement MultiParameter.unpack_self() --- src/qcodes/dataset/measurements.py | 4 -- src/qcodes/parameters/multi_parameter.py | 64 +++++++++++++++++++ .../measurement/test_self_unpacking.py | 50 ++++++++++++++- 3 files changed, 113 insertions(+), 5 deletions(-) diff --git a/src/qcodes/dataset/measurements.py b/src/qcodes/dataset/measurements.py index 6eb4c054df3d..9ee5c668a2b9 100644 --- a/src/qcodes/dataset/measurements.py +++ b/src/qcodes/dataset/measurements.py @@ -234,10 +234,6 @@ def add_result(self, *result_tuples: ResType) -> None: legacy_results_dict.update( self._unpack_arrayparameter(parameter_result) ) - elif isinstance(parameter_result[0], MultiParameter): - legacy_results_dict.update( - self._unpack_multiparameter(parameter_result) - ) else: self_unpacked_parameter_results.extend( parameter_result[0].unpack_self(parameter_result[1]) diff --git a/src/qcodes/parameters/multi_parameter.py b/src/qcodes/parameters/multi_parameter.py index 7c850edf19bf..6ce4abec309a 100644 --- a/src/qcodes/parameters/multi_parameter.py +++ b/src/qcodes/parameters/multi_parameter.py @@ -273,3 +273,67 @@ def setpoint_full_names(self) -> Sequence[Sequence[str]] | None: return tuple(full_sp_names) else: return self.setpoint_names + + def unpack_self(self, value: Sequence[Any]) -> list[tuple[ParameterBase, Any]]: + """ + Unpack the `subarrays` and `setpoints` from a :class:`MultiParameter` + into a list of tuples of (Parameter, values). + """ + result_list: list[tuple[ParameterBase, Any]] = [] + + if self.setpoints is None: + raise RuntimeError( + f"{self.full_name} is an " + f"{type(self)} " + f"without setpoints. Cannot handle this." + ) + + for i, shape in enumerate(self.shapes): + # value corresponds to the return of get(), which is a sequence + data = value[i] + + # Main component parameter + name = self.full_names[i] + # Create a proxy parameter with the correct name + param = ParameterBase(name=name, instrument=None) + result_list.append((param, data)) + + if shape != (): + # Handle setpoints + fallback_sp_name = f"{name}_setpoint" + + sp_names: Sequence[str] | None = None + if ( + self.setpoint_full_names is not None + and self.setpoint_full_names[i] is not None + ): + sp_names = self.setpoint_full_names[i] + + sp_values = self.setpoints[i] + + setpoint_axes = [] + setpoint_parameters_names = [] + + for j, sps in enumerate(sp_values): + if sp_names is not None: + spname = sp_names[j] + else: + spname = f"{fallback_sp_name}_{j}" + + sps_arr = np.array(sps) + while sps_arr.ndim > 1: + # The outermost setpoint axis or an nD param is nD + # but the innermost is 1D. In all cases we just need + # the axis along one dim, the innermost one. + sps_arr = sps_arr[0] + + setpoint_axes.append(sps_arr) + setpoint_parameters_names.append(spname) + + output_grids = np.meshgrid(*setpoint_axes, indexing="ij") + + for grid, sp_name in zip(output_grids, setpoint_parameters_names): + sp_param = ParameterBase(name=sp_name, instrument=None) + result_list.append((sp_param, grid)) + + return result_list diff --git a/tests/dataset/measurement/test_self_unpacking.py b/tests/dataset/measurement/test_self_unpacking.py index 7e0245ab17b1..440cfb7471a0 100644 --- a/tests/dataset/measurement/test_self_unpacking.py +++ b/tests/dataset/measurement/test_self_unpacking.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import numpy as np import pytest @@ -11,6 +11,7 @@ ) from qcodes.parameters import ( ManualParameter, + MultiParameter, Parameter, ParameterWithSetpoints, ParamRawDataType, @@ -93,6 +94,53 @@ def test_add_result_self_unpack(controlling_parameters, experiment): assert meas1_data["comp2"] == pytest.approx(np.linspace(10, 9, 11)) +class SimpleMultiParam(MultiParameter): + def __init__(self, name: str, **kwargs: Any) -> None: + super().__init__( + name=name, + names=("a", "b"), + shapes=((5,), (5,)), + labels=("A", "B"), + units=("A", "B"), + # Setpoints are 1D arrays + setpoints=((np.linspace(0, 4, 5),), (np.linspace(0, 4, 5),)), + setpoint_names=(("sp_a",), ("sp_b",)), + setpoint_labels=(("SP A",), ("SP B",)), + setpoint_units=(("V",), ("V",)), + **kwargs, + ) + + def get_raw(self) -> tuple[np.ndarray, np.ndarray]: + return np.arange(5), np.arange(5) + 10 + + +def test_add_result_multiparameter(experiment) -> None: + meas = Measurement(experiment) + multiparam = SimpleMultiParam("multi") + + meas.register_parameter(multiparam) + + with meas.run() as datasaver: + datasaver.add_result((multiparam, multiparam())) + ds = datasaver.dataset + + data = ds.get_parameter_data() + + # The MultiParameter is not attached to an instrument, so the keys for its + # components are their short names ('a' and 'b'). + + # Check component "a" + assert "a" in data + # Inner keys are also short names, 'a' for the component value and 'sp_a' for its setpoint + assert data["a"]["a"] == pytest.approx(np.arange(5)) + assert data["a"]["sp_a"] == pytest.approx(np.linspace(0, 4, 5)) + + # Check component "b" + assert "b" in data + assert data["b"]["b"] == pytest.approx(np.arange(5) + 10) + assert data["b"]["sp_b"] == pytest.approx(np.linspace(0, 4, 5)) + + def test_add_result_self_unpack_with_PWS(controlling_parameters, experiment): control1, comp1, comp2 = controlling_parameters pws_setpoints = Parameter( From e24a383d8afa83dde3c38f03845fba67955dbec7 Mon Sep 17 00:00:00 2001 From: Tobias Hangleiter Date: Fri, 12 Dec 2025 10:41:31 +0100 Subject: [PATCH 2/7] Update type hint --- src/qcodes/parameters/multi_parameter.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/qcodes/parameters/multi_parameter.py b/src/qcodes/parameters/multi_parameter.py index 6ce4abec309a..bbf48e852103 100644 --- a/src/qcodes/parameters/multi_parameter.py +++ b/src/qcodes/parameters/multi_parameter.py @@ -10,6 +10,7 @@ from .sequence_helpers import is_sequence_of if TYPE_CHECKING: + from qcodes.dataset.data_set_protocol import ValuesType from qcodes.instrument import InstrumentBase try: @@ -274,12 +275,12 @@ def setpoint_full_names(self) -> Sequence[Sequence[str]] | None: else: return self.setpoint_names - def unpack_self(self, value: Sequence[Any]) -> list[tuple[ParameterBase, Any]]: + def unpack_self(self, value: ValuesType) -> list[tuple[ParameterBase, ValuesType]]: """ Unpack the `subarrays` and `setpoints` from a :class:`MultiParameter` into a list of tuples of (Parameter, values). """ - result_list: list[tuple[ParameterBase, Any]] = [] + result_list: list[tuple[ParameterBase, ValuesType]] = [] if self.setpoints is None: raise RuntimeError( From 77dbdcf258da56d4daabf63c625dcbe7499c1dfb Mon Sep 17 00:00:00 2001 From: Tobias Hangleiter Date: Fri, 12 Dec 2025 10:41:57 +0100 Subject: [PATCH 3/7] Remove legacy _unpack_multiparameter() --- src/qcodes/dataset/measurements.py | 59 ------------------------------ 1 file changed, 59 deletions(-) diff --git a/src/qcodes/dataset/measurements.py b/src/qcodes/dataset/measurements.py index 9ee5c668a2b9..ca03a6e20470 100644 --- a/src/qcodes/dataset/measurements.py +++ b/src/qcodes/dataset/measurements.py @@ -312,65 +312,6 @@ def _unpack_arrayparameter( return res_dict - def _unpack_multiparameter( - self, partial_result: ResType - ) -> dict[ParamSpecBase, npt.NDArray]: - """ - Unpack the `subarrays` and `setpoints` from a :class:`MultiParameter` - and into a standard results dict form and return that dict - - """ - - parameter, data = partial_result - parameter = cast("MultiParameter", parameter) - - result_dict = {} - - if parameter.setpoints is None: - raise RuntimeError( - f"{parameter.full_name} is an " - f"{type(parameter)} " - f"without setpoints. Cannot handle this." - ) - for i in range(len(parameter.shapes)): - # if this loop runs, then 'data' is a Sequence - data = cast("Sequence[str | int | float | Any]", data) - - shape = parameter.shapes[i] - - try: - paramspec = self._interdeps._id_to_paramspec[parameter.full_names[i]] - except KeyError: - raise ValueError( - "Can not add result for parameter " - f"{parameter.names[i]}, " - "no such parameter registered " - "with this measurement." - ) - - result_dict.update({paramspec: np.array(data[i])}) - if shape != (): - # array parameter like part of the multiparameter - # need to find setpoints too - fallback_sp_name = f"{parameter.full_names[i]}_setpoint" - - sp_names: Sequence[str] | None - if ( - parameter.setpoint_full_names is not None - and parameter.setpoint_full_names[i] is not None - ): - sp_names = parameter.setpoint_full_names[i] - else: - sp_names = None - - result_dict.update( - self._unpack_setpoints_from_parameter( - parameter, parameter.setpoints[i], sp_names, fallback_sp_name - ) - ) - - return result_dict - def _unpack_setpoints_from_parameter( self, parameter: ParameterBase, From fc665ed4193c8882a67c85165dfca8f41ab2a09f Mon Sep 17 00:00:00 2001 From: Tobias Hangleiter Date: Fri, 19 Dec 2025 12:03:41 +0100 Subject: [PATCH 4/7] Always keep param_spec's hash up-to-date --- src/qcodes/parameters/parameter.py | 8 ++++---- src/qcodes/parameters/parameter_base.py | 6 +++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/qcodes/parameters/parameter.py b/src/qcodes/parameters/parameter.py index 1cfe55248471..6d7f531699c6 100644 --- a/src/qcodes/parameters/parameter.py +++ b/src/qcodes/parameters/parameter.py @@ -8,6 +8,7 @@ from types import MethodType from typing import TYPE_CHECKING, Any, Literal +from . import ParamSpecBase from .command import Command from .parameter_base import ParamDataType, ParameterBase, ParamRawDataType from .sweep_values import SweepFixedValues @@ -17,7 +18,6 @@ from qcodes.instrument import InstrumentBase from qcodes.logger.instrument_logger import InstrumentLoggerAdapter - from qcodes.parameters import ParamSpecBase from qcodes.validators import Validator @@ -441,9 +441,9 @@ def sweep( @property def param_spec(self) -> ParamSpecBase: paramspecbase = super().param_spec # Sets the name and paramtype - paramspecbase.label = self.label - paramspecbase.unit = self.unit - return paramspecbase + return ParamSpecBase( + paramspecbase.name, paramspecbase.type, self.label, self.unit + ) class ManualParameter(Parameter): diff --git a/src/qcodes/parameters/parameter_base.py b/src/qcodes/parameters/parameter_base.py index baba76c77fd2..0b4272e96c2b 100644 --- a/src/qcodes/parameters/parameter_base.py +++ b/src/qcodes/parameters/parameter_base.py @@ -1214,7 +1214,11 @@ def _set_paramtype(self, paramtype: str) -> None: logging.warning( f"Tried to set a new paramtype {paramtype}, but this parameter already has paramtype {self.paramtype} which does not match" ) - self.param_spec.type = paramtype + # ParamSpecBase is secretly immutable (its hash is computed only at instantiation time), + # so to change the paramtype we need to create a new one + self._param_spec = ParamSpecBase( + self.param_spec.name, paramtype, self.param_spec.label, self.param_spec.unit + ) @property def depends_on(self) -> ParameterSet: From 7290fba8b62324caac385edbc0512f58206a10a4 Mon Sep 17 00:00:00 2001 From: Tobias Hangleiter Date: Fri, 19 Dec 2025 12:05:59 +0100 Subject: [PATCH 5/7] Allow ParameterWithSetpoints to depend on other parameters --- .../parameters/parameter_with_setpoints.py | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/src/qcodes/parameters/parameter_with_setpoints.py b/src/qcodes/parameters/parameter_with_setpoints.py index d9ef9badf4dd..25800be9939b 100644 --- a/src/qcodes/parameters/parameter_with_setpoints.py +++ b/src/qcodes/parameters/parameter_with_setpoints.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, TypeVar import numpy as np @@ -13,7 +13,7 @@ from collections.abc import Callable, Sequence from qcodes.dataset.data_set_protocol import ValuesType - from qcodes.parameters.parameter_base import ParamDataType, ParameterBase + from qcodes.parameters.parameter_base import ParamDataType LOG = logging.getLogger(__name__) @@ -66,6 +66,7 @@ def __init__( self.setpoints = setpoints self._validate_on_get = True + self._depends_on = ParameterSetWithSetpoints(self.setpoints) @property def setpoints(self) -> Sequence[ParameterBase]: @@ -155,10 +156,6 @@ def validate(self, value: ParamDataType) -> None: self.validate_consistent_shape() super().validate(value) - @property - def depends_on(self) -> ParameterSet: - return ParameterSet(self.setpoints) - def unpack_self(self, value: ValuesType) -> list[tuple[ParameterBase, ValuesType]]: unpacked_results: list[tuple[ParameterBase, ValuesType]] = [] setpoint_params = [] @@ -204,3 +201,31 @@ def expand_setpoints_helper( return parameter.unpack_self(results) else: return parameter.unpack_self(parameter.get()) + + +P = TypeVar("P", bound=ParameterBase) + + +class ParameterSetWithSetpoints(ParameterSet[P]): + """An ordered :class:`ParameterSet` that always keeps *setpoints* + flushed to the very right.""" + + def __init__( + self, setpoints: Sequence[P], parameters: Sequence[P] | None = None + ) -> None: + super().__init__(parameters) + self._setpoints = setpoints + self.update(self._setpoints) + + @property + def setpoints(self) -> Sequence[P]: + return self._setpoints + + def add(self, value: P) -> None: + # Not the most efficient but unlikely to be a bottleneck + params = [param for param in self._dict if param not in self._setpoints] + params.append(value) + params.extend(self.setpoints) + self._dict.clear() + for param in params: + self._dict[param] = None From b782b7a0d18ccd446a2e8c15b0d5bf82dd223341 Mon Sep 17 00:00:00 2001 From: Tobias Hangleiter Date: Fri, 19 Dec 2025 12:09:58 +0100 Subject: [PATCH 6/7] Implement _self_register_parameter for MultiParamerter --- src/qcodes/dataset/measurements.py | 177 ++++++----------------- src/qcodes/parameters/multi_parameter.py | 90 +++++++++++- 2 files changed, 131 insertions(+), 136 deletions(-) diff --git a/src/qcodes/dataset/measurements.py b/src/qcodes/dataset/measurements.py index ca03a6e20470..003cefc82b12 100644 --- a/src/qcodes/dataset/measurements.py +++ b/src/qcodes/dataset/measurements.py @@ -47,8 +47,8 @@ GroupedParameter, ManualParameter, MultiParameter, - Parameter, ParameterBase, + ParameterSet, ParameterWithSetpoints, ParamSpecBase, ) @@ -850,8 +850,15 @@ def _paramspecs_and_parameters_from_setpoints( if setpoints is not None: for setpoint in setpoints: if isinstance(setpoint, ParameterBase): - paramspecs.append(setpoint.param_spec) - parameters.append(setpoint) + if isinstance(setpoint, MultiParameter): + specs, params = self._paramspecs_and_parameters_from_setpoints( + setpoint.full_names + ) + paramspecs.extend(specs) + parameters.extend(params) + else: + paramspecs.append(setpoint.param_spec) + parameters.append(setpoint) elif ( isinstance(setpoint, str) and ( @@ -868,12 +875,26 @@ def _paramspecs_and_parameters_from_setpoints( ) return paramspecs, parameters + @staticmethod + def _extend_paramspecs(paramspecs: list[ParamSpecBase], parameters: ParameterSet): + for param in parameters: + if not isinstance(param, MultiParameter): + paramspecs.append(param.param_spec) + else: + paramspecs.extend(p.param_spec for p in param.register_parameters) + return paramspecs + def _self_register_parameter( self: Self, parameter: ParameterBase, setpoints: SetpointsType | None = None, basis: SetpointsType | None = None, ) -> Self: + if isinstance(parameter, MultiParameter): + for mp_param in parameter.register_parameters: + self._self_register_parameter(mp_param, setpoints, basis) + return self + # It is important to preserve the order of the setpoints (and basis) arguments # when building the dependency trees, as this order is implicitly used to assign # the axis-order for multidimensional data variables where shape alone is @@ -888,11 +909,11 @@ def _self_register_parameter( ) # Append internal dependencies/inferences - dependency_paramspecs.extend( - [param.param_spec for param in parameter.depends_on] + dependency_paramspecs = self._extend_paramspecs( + dependency_paramspecs, parameter.depends_on ) - inference_paramspecs.extend( - [param.param_spec for param in parameter.is_controlled_by] + inference_paramspecs = self._extend_paramspecs( + inference_paramspecs, parameter.is_controlled_by ) # Make ParamSpecTrees and extend interdeps @@ -917,15 +938,11 @@ def _self_register_parameter( log.info(f"Registered {parameter.register_name} in the Measurement.") # Recursively register all other interdependent parameters related to this parameter - interdependent_parameters = list( - chain.from_iterable( - [ - dependency_parameters, - inference_parameters, - parameter.depends_on, - parameter.is_controlled_by, - ] - ) + interdependent_parameters = chain( + dependency_parameters, + inference_parameters, + parameter.depends_on, + parameter.is_controlled_by, ) for interdependent_parameter in interdependent_parameters: if interdependent_parameter not in self._registered_parameters: @@ -973,14 +990,7 @@ def register_parameter( case ArrayParameter(): paramtype = self._infer_paramtype(parameter, paramtype) self._register_arrayparameter(parameter, setpoints, basis, paramtype) - case MultiParameter(): - paramtype = self._infer_paramtype(parameter, paramtype) - self._register_multiparameter( - parameter, - setpoints, - basis, - paramtype, - ) + self._registered_parameters.add(parameter) case GroupedParameter(): paramtype = self._infer_paramtype(parameter, paramtype) self._register_parameter( @@ -991,16 +1001,15 @@ def register_parameter( basis, paramtype, ) - case ParameterBase() | ParameterWithSetpoints(): - if paramtype is not None: - parameter.paramtype = paramtype + self._registered_parameters.add(parameter) + case ParameterBase() | ParameterWithSetpoints() | MultiParameter(): + parameter.paramtype = self._infer_paramtype(parameter, paramtype) self._self_register_parameter(parameter, setpoints, basis) case _: raise ValueError( f"Can not register object of type {type(parameter)}. Can only " "register a QCoDeS Parameter." ) - self._registered_parameters.add(parameter) return self @@ -1033,6 +1042,12 @@ def _infer_paramtype(parameter: ParameterBase, paramtype: str | None) -> str: return_paramtype: str if paramtype is not None: # override with argument return_paramtype = paramtype + elif isinstance(parameter, MultiParameter): + # No vals by default. + if any(shp for shp in parameter.shapes): + return_paramtype = "array" + else: + return_paramtype = "numeric" elif isinstance(parameter.vals, vals.Arrays): return_paramtype = "array" elif isinstance(parameter, ArrayParameter): @@ -1162,112 +1177,6 @@ def _register_arrayparameter( paramtype, ) - def _register_parameter_with_setpoints( - self, - parameter: ParameterWithSetpoints, - setpoints: SetpointsType | None, - basis: SetpointsType | None, - paramtype: str, - ) -> None: - """ - Register an ParameterWithSetpoints and the setpoints belonging to the - 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.register_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( - parameter.register_name, - parameter.label, - parameter.unit, - my_setpoints, - basis, - paramtype, - ) - - def _register_multiparameter( - self, - multiparameter: MultiParameter, - setpoints: SetpointsType | None, - basis: SetpointsType | None, - 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, expanded_setpoints in enumerate(setpoints_lists): - self._register_parameter( - multiparameter.full_names[i], - multiparameter.labels[i], - multiparameter.units[i], - expanded_setpoints, - basis, - paramtype, - ) - def register_custom_parameter( self: Self, name: str, diff --git a/src/qcodes/parameters/multi_parameter.py b/src/qcodes/parameters/multi_parameter.py index bbf48e852103..d31dde7dbe96 100644 --- a/src/qcodes/parameters/multi_parameter.py +++ b/src/qcodes/parameters/multi_parameter.py @@ -2,11 +2,15 @@ import os from collections.abc import Iterator, Mapping, Sequence +from functools import cached_property from typing import TYPE_CHECKING, Any import numpy as np -from .parameter_base import ParameterBase +from .. import validators +from .parameter import ManualParameter +from .parameter_base import ParameterBase, ParameterSet +from .parameter_with_setpoints import ParameterWithSetpoints from .sequence_helpers import is_sequence_of if TYPE_CHECKING: @@ -166,6 +170,8 @@ def __init__( **kwargs, ) + # This is potentially wrong, but the design does not allow heterogeneous types currently + self._paramtype: str = "array" if any(shp for shp in shapes) else "numeric" self._meta_attrs.extend( [ "setpoint_names", @@ -275,7 +281,87 @@ def setpoint_full_names(self) -> Sequence[Sequence[str]] | None: else: return self.setpoint_names - def unpack_self(self, value: ValuesType) -> list[tuple[ParameterBase, ValuesType]]: + @property + def paramtype(self) -> str: + # Override because parent method asks param_spec, which MultiParameter does not have. + return self._paramtype + + @paramtype.setter + def paramtype(self, paramtype: str) -> None: + self._paramtype = paramtype + + @cached_property + def register_parameters( + self, + ) -> ParameterSet[ManualParameter | ParameterWithSetpoints]: + """Persistent set of dummy parameters used for registering the + MultiParameter in a measurement.""" + mp_parameters = [] + for i in range(len(self.shapes)): + shape = self.shapes[i] + name = self.full_names[i] + mp_parameter: ManualParameter | ParameterWithSetpoints + sp_parameters = [] + if shape != (): + for j in range(len(shape)): + if ( + self.setpoint_full_names is not None + and self.setpoint_full_names[i] is not None + ): + spname = self.setpoint_full_names[i][j] + else: + spname = f"{name}_setpoint_{j}" + if ( + self.setpoint_labels is not None + and self.setpoint_labels[i] is not None + ): + splabel = self.setpoint_labels[i][j] + else: + splabel = "" + if ( + self.setpoint_units is not None + and self.setpoint_units[i] is not None + ): + spunit = self.setpoint_units[i][j] + else: + spunit = "" + + sp_parameter = ManualParameter( + name=spname, + label=splabel, + unit=spunit, + vals=validators.Arrays(shape=(shape[j],)), + ) + sp_parameters.append(sp_parameter) + + mp_parameter = ParameterWithSetpoints( + name=self.full_names[i], + label=self.labels[i], + unit=self.units[i], + setpoints=sp_parameters, + vals=validators.Arrays(shape=shape), + ) + # PWS.paramtype is array always + else: + mp_parameter = ManualParameter( + name=self.full_names[i], + label=self.labels[i], + unit=self.units[i], + ) + if self.paramtype is not None: + mp_parameter.paramtype = self.paramtype + + mp_parameter.depends_on.update(self.depends_on) + mp_parameter.has_control_of.update(self.has_control_of) + mp_parameter.is_controlled_by.update(self.is_controlled_by) + mp_parameters.append(mp_parameter) + + return ParameterSet(mp_parameters) + + def unpack_self( + self, + value: Sequence[ValuesType], # type: ignore[override] + ) -> list[tuple[ParameterBase, ValuesType]]: """ Unpack the `subarrays` and `setpoints` from a :class:`MultiParameter` into a list of tuples of (Parameter, values). From 2bd28325073e931fa0f7bab4270c176cc93f88cd Mon Sep 17 00:00:00 2001 From: Tobias Hangleiter Date: Fri, 19 Dec 2025 12:11:55 +0100 Subject: [PATCH 7/7] Add tests --- tests/dataset/conftest.py | 42 +++++- .../test_self_registering_parameters.py | 129 ++++++++++++++++++ .../measurement/test_self_unpacking.py | 105 +++++++------- 3 files changed, 217 insertions(+), 59 deletions(-) diff --git a/tests/dataset/conftest.py b/tests/dataset/conftest.py index 000f7974d5ab..b601e12682f2 100644 --- a/tests/dataset/conftest.py +++ b/tests/dataset/conftest.py @@ -7,13 +7,14 @@ from collections.abc import Generator from contextlib import contextmanager from enum import StrEnum -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import numpy as np import pytest from pytest import FixtureRequest import qcodes as qc +from qcodes import MultiParameter from qcodes.dataset.data_set import DataSet from qcodes.dataset.descriptions.dependencies import InterDependencies_ from qcodes.dataset.descriptions.param_spec import ParamSpec @@ -796,3 +797,42 @@ def get_raw(self): shape = self.vals.shape return np.random.rand(*shape) + + +class SimpleMultiParam(MultiParameter): + def __init__(self, name: str, npts: int = 5, **kwargs: Any) -> None: + self.npts = npts + if self.npts == 0: + sp_kwargs = dict( + shapes=((),) * 2, + setpoints=((),) * 2, + setpoint_names=((),) * 2, + setpoint_labels=((),) * 2, + setpoint_units=((),) * 2, + ) + else: + sp_kwargs = dict( + # Setpoints are 1D arrays + shapes=((self.npts,), (self.npts,)), + setpoints=( + (np.linspace(0, 4, self.npts),), + (np.linspace(1, 2, self.npts),), + ), + setpoint_names=(("sp_a",), ("sp_b",)), + setpoint_labels=(("SP A",), ("SP B",)), + setpoint_units=(("SPAU",), ("SPBU",)), + ) + super().__init__( + name=name, + names=("a", "b"), + labels=("A", "B"), + units=("AU", "BU"), + **sp_kwargs, + **kwargs, + ) + + def get_raw(self) -> tuple[np.ndarray | float, np.ndarray | float]: + if self.npts == 0: + return 0.0, 10.0 + else: + return np.arange(self.npts), np.arange(self.npts) + 10 diff --git a/tests/dataset/measurement/test_self_registering_parameters.py b/tests/dataset/measurement/test_self_registering_parameters.py index 38e01738cf93..0b9bebce7359 100644 --- a/tests/dataset/measurement/test_self_registering_parameters.py +++ b/tests/dataset/measurement/test_self_registering_parameters.py @@ -1,9 +1,11 @@ +import random from typing import TYPE_CHECKING import pytest from qcodes.dataset import Measurement from qcodes.parameters import ManualParameter +from tests.dataset.conftest import SimpleMultiParam if TYPE_CHECKING: from collections.abc import Generator @@ -113,3 +115,130 @@ def test_registering_dependent_param_with_setpoints(dependent_parameters) -> Non assert dependency_tree[dep1.param_spec][1] == setpoints2.param_spec assert dependency_tree[dep1.param_spec][2] == indep1.param_spec assert dependency_tree[dep1.param_spec][3] == indep2.param_spec + + +def test_registering_multi_param() -> None: + multi = SimpleMultiParam("multi", npts=random.randint(1, 11)) + + meas = Measurement() + meas.register_parameter(multi) + dependency_tree = meas._interdeps.dependencies + + assert len(meas._registered_parameters) == 4 + assert len(dependency_tree) == 2 + + for param in multi.register_parameters: + assert param in meas._registered_parameters + for setpoints in param.setpoints: + assert setpoints in meas._registered_parameters + assert dependency_tree[param.param_spec][0] == setpoints.param_spec + + # no setpoints + multi = SimpleMultiParam("multi", npts=0) + + meas = Measurement() + meas.register_parameter(multi) + dependency_tree = meas._interdeps.dependencies + + assert len(meas._registered_parameters) == 2 + assert len(dependency_tree) == 0 + + for param in multi.register_parameters: + assert param in meas._registered_parameters + + +def test_registering_controlled_multi_param(control_parameters) -> None: + control1, comp1, comp2 = control_parameters + multi = SimpleMultiParam("multi") + multi.has_control_of.add(comp1) + multi.has_control_of.add(comp2) + control1.has_control_of.add(multi) + + meas = Measurement() + meas.register_parameter(multi) + inferences_tree = meas._interdeps.inferences + + assert len(meas._registered_parameters) == 7 + assert len(inferences_tree) == 4 + + for param in multi.register_parameters: + assert param in meas._registered_parameters + for setpoints in param.setpoints: + assert setpoints in meas._registered_parameters + assert inferences_tree[param.param_spec][0] == control1.param_spec + assert param.param_spec in inferences_tree[comp1.param_spec] + assert param.param_spec in inferences_tree[comp2.param_spec] + + assert comp1 in meas._registered_parameters + assert comp2 in meas._registered_parameters + assert control1 in meas._registered_parameters + + assert control1.param_spec in inferences_tree[comp1.param_spec] + assert control1.param_spec in inferences_tree[comp2.param_spec] + + +def test_registering_dependent_multi_param(dependent_parameters) -> None: + _, indep1, indep2 = dependent_parameters + multi = SimpleMultiParam("multi") + multi.depends_on.add(indep1) + multi.depends_on.add(indep2) + + meas = Measurement() + meas.register_parameter(multi) + dependency_tree = meas._interdeps.dependencies + + assert len(meas._registered_parameters) == 6 + assert len(dependency_tree) == 2 + + for param in multi.register_parameters: + assert param in meas._registered_parameters + for setpoints in param.setpoints: + assert setpoints in meas._registered_parameters + assert dependency_tree[param.param_spec] == ( + indep1.param_spec, + indep2.param_spec, + setpoints.param_spec, + ) + + assert indep1 in meas._registered_parameters + assert indep2 in meas._registered_parameters + + +def test_registering_param_dependent_on_multi_param(dependent_parameters) -> None: + dep1, *_ = dependent_parameters + multi = SimpleMultiParam("multi", npts=random.randint(1, 11)) + # Chained dependency + dep1.depends_on.add(multi) + + meas = Measurement() + with pytest.raises( + ValueError, + match=r"Paramspec a both depends on \['sp_a'\] and is depended upon by \['dep1'\]", + ): + meas.register_parameter(dep1) + + +def test_registering_param_dependent_on_multi_param_no_sp(dependent_parameters) -> None: + dep1, indep1, indep2 = dependent_parameters + multi = SimpleMultiParam("multi", npts=0) + dep1.depends_on.add(multi) + + meas = Measurement() + meas.register_parameter(dep1) + dependency_tree = meas._interdeps.dependencies + + assert len(meas._registered_parameters) == 5 + assert len(dependency_tree) == 1 + + for param in multi.register_parameters: + assert param in meas._registered_parameters + + assert dep1 in meas._registered_parameters + assert indep1 in meas._registered_parameters + assert indep2 in meas._registered_parameters + + assert dependency_tree[dep1.param_spec] == ( + indep1.param_spec, + indep2.param_spec, + *(param.param_spec for param in multi.register_parameters), + ) diff --git a/tests/dataset/measurement/test_self_unpacking.py b/tests/dataset/measurement/test_self_unpacking.py index 440cfb7471a0..476de8fedcc4 100644 --- a/tests/dataset/measurement/test_self_unpacking.py +++ b/tests/dataset/measurement/test_self_unpacking.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING import numpy as np import pytest @@ -11,12 +11,12 @@ ) from qcodes.parameters import ( ManualParameter, - MultiParameter, Parameter, ParameterWithSetpoints, ParamRawDataType, ) from qcodes.validators import Arrays +from tests.dataset.conftest import SimpleMultiParam if TYPE_CHECKING: from collections.abc import Generator @@ -94,27 +94,7 @@ def test_add_result_self_unpack(controlling_parameters, experiment): assert meas1_data["comp2"] == pytest.approx(np.linspace(10, 9, 11)) -class SimpleMultiParam(MultiParameter): - def __init__(self, name: str, **kwargs: Any) -> None: - super().__init__( - name=name, - names=("a", "b"), - shapes=((5,), (5,)), - labels=("A", "B"), - units=("A", "B"), - # Setpoints are 1D arrays - setpoints=((np.linspace(0, 4, 5),), (np.linspace(0, 4, 5),)), - setpoint_names=(("sp_a",), ("sp_b",)), - setpoint_labels=(("SP A",), ("SP B",)), - setpoint_units=(("V",), ("V",)), - **kwargs, - ) - - def get_raw(self) -> tuple[np.ndarray, np.ndarray]: - return np.arange(5), np.arange(5) + 10 - - -def test_add_result_multiparameter(experiment) -> None: +def test_add_result_self_unpack_with_multiparameter(experiment) -> None: meas = Measurement(experiment) multiparam = SimpleMultiParam("multi") @@ -132,13 +112,13 @@ def test_add_result_multiparameter(experiment) -> None: # Check component "a" assert "a" in data # Inner keys are also short names, 'a' for the component value and 'sp_a' for its setpoint - assert data["a"]["a"] == pytest.approx(np.arange(5)) - assert data["a"]["sp_a"] == pytest.approx(np.linspace(0, 4, 5)) + assert data["a"]["a"] == pytest.approx(np.linspace(0, 4, 5)[None, :]) + assert data["a"]["sp_a"] == pytest.approx(np.arange(5)[None, :]) # Check component "b" assert "b" in data - assert data["b"]["b"] == pytest.approx(np.arange(5) + 10) - assert data["b"]["sp_b"] == pytest.approx(np.linspace(0, 4, 5)) + assert data["b"]["b"] == pytest.approx(np.arange(5)[None, :] + 10) + assert data["b"]["sp_b"] == pytest.approx(np.linspace(1, 2, 5)[None, :]) def test_add_result_self_unpack_with_PWS(controlling_parameters, experiment): @@ -155,40 +135,49 @@ def test_add_result_self_unpack_with_PWS(controlling_parameters, experiment): get_cmd=lambda: np.linspace(-2, 2, 11) + comp1(), ) - meas = Measurement(experiment) - meas.register_parameter(pws, setpoints=[control1]) + def make_assertions(meas): + assert all( + param in meas._registered_parameters + for param in (comp1, comp2, control1, pws, pws_setpoints) + ) - assert all( - param in meas._registered_parameters - for param in (comp1, comp2, control1, pws, pws_setpoints) - ) + with meas.run() as datasaver: + for val in np.linspace(0, 1, 11): + control1(val) + datasaver.add_result((pws, pws()), (control1, val)) + ds = datasaver.dataset + + dataset_data = ds.get_parameter_data() + pws_data = dataset_data.get("pws", None) + assert (pws_data) is not None + assert all( + param_name in pws_data.keys() + for param_name in ("pws", "comp1", "comp2", "control1", "pws_setpoints") + ) + expected_setpoints, expected_control = np.meshgrid( + np.linspace(-1, 1, 11), np.linspace(0, 1, 11) + ) + assert pws_data["control1"] == pytest.approx(expected_control) + assert pws_data["comp1"] == pytest.approx(expected_control) + assert pws_data["comp2"] == pytest.approx(10 - expected_control) + assert pws_data["pws_setpoints"] == pytest.approx(expected_setpoints) - with meas.run() as datasaver: - for val in np.linspace(0, 1, 11): - control1(val) - datasaver.add_result((pws, pws()), (control1, val)) - ds = datasaver.dataset + assert pws_data["control1"].shape == (11, 11) + assert pws_data["comp1"].shape == (11, 11) + assert pws_data["comp2"].shape == (11, 11) + assert pws_data["pws"].shape == (11, 11) + assert pws_data["pws_setpoints"].shape == (11, 11) - dataset_data = ds.get_parameter_data() - pws_data = dataset_data.get("pws", None) - assert (pws_data) is not None - assert all( - param_name in pws_data.keys() - for param_name in ("pws", "comp1", "comp2", "control1", "pws_setpoints") - ) - expected_setpoints, expected_control = np.meshgrid( - np.linspace(-1, 1, 11), np.linspace(0, 1, 11) - ) - assert pws_data["control1"] == pytest.approx(expected_control) - assert pws_data["comp1"] == pytest.approx(expected_control) - assert pws_data["comp2"] == pytest.approx(10 - expected_control) - assert pws_data["pws_setpoints"] == pytest.approx(expected_setpoints) - - assert pws_data["control1"].shape == (11, 11) - assert pws_data["comp1"].shape == (11, 11) - assert pws_data["comp2"].shape == (11, 11) - assert pws_data["pws"].shape == (11, 11) - assert pws_data["pws_setpoints"].shape == (11, 11) + meas1 = Measurement(experiment) + meas1.register_parameter(pws, setpoints=[control1]) + + make_assertions(meas1) + + pws.depends_on.add(control1) + meas2 = Measurement(experiment) + meas2.register_parameter(pws) + + make_assertions(meas2) # Testing equality methods for deduplication