iqm-exa-common 25.34__py3-none-any.whl → 26.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. exa/common/api/proto_serialization/_parameter.py +4 -3
  2. exa/common/api/proto_serialization/nd_sweep.py +3 -8
  3. exa/common/api/proto_serialization/sequence.py +5 -5
  4. exa/common/control/sweep/exponential_sweep.py +15 -47
  5. exa/common/control/sweep/fixed_sweep.py +10 -14
  6. exa/common/control/sweep/linear_sweep.py +15 -40
  7. exa/common/control/sweep/option/__init__.py +1 -1
  8. exa/common/control/sweep/option/center_span_base_options.py +14 -7
  9. exa/common/control/sweep/option/center_span_options.py +13 -6
  10. exa/common/control/sweep/option/constants.py +2 -2
  11. exa/common/control/sweep/option/fixed_options.py +8 -2
  12. exa/common/control/sweep/option/option_converter.py +4 -8
  13. exa/common/control/sweep/option/start_stop_base_options.py +20 -6
  14. exa/common/control/sweep/option/start_stop_options.py +20 -5
  15. exa/common/control/sweep/option/sweep_options.py +9 -0
  16. exa/common/control/sweep/sweep.py +52 -16
  17. exa/common/control/sweep/sweep_values.py +58 -0
  18. exa/common/data/base_model.py +40 -0
  19. exa/common/data/parameter.py +123 -68
  20. exa/common/data/setting_node.py +481 -135
  21. exa/common/data/settingnode_v2.html.jinja2 +6 -6
  22. exa/common/data/value.py +49 -0
  23. exa/common/logger/logger.py +1 -1
  24. exa/common/qcm_data/file_adapter.py +2 -6
  25. exa/common/qcm_data/qcm_data_client.py +1 -37
  26. exa/common/sweep/database_serialization.py +30 -98
  27. exa/common/sweep/util.py +4 -5
  28. {iqm_exa_common-25.34.dist-info → iqm_exa_common-26.0.dist-info}/METADATA +2 -2
  29. iqm_exa_common-26.0.dist-info/RECORD +54 -0
  30. exa/common/api/model/__init__.py +0 -15
  31. exa/common/api/model/parameter_model.py +0 -111
  32. exa/common/api/model/setting_model.py +0 -63
  33. exa/common/api/model/setting_node_model.py +0 -72
  34. exa/common/api/model/sweep_model.py +0 -63
  35. exa/common/control/sweep/function_sweep.py +0 -35
  36. exa/common/control/sweep/option/function_options.py +0 -26
  37. exa/common/control/sweep/utils.py +0 -43
  38. iqm_exa_common-25.34.dist-info/RECORD +0 -59
  39. {iqm_exa_common-25.34.dist-info → iqm_exa_common-26.0.dist-info}/LICENSE.txt +0 -0
  40. {iqm_exa_common-25.34.dist-info → iqm_exa_common-26.0.dist-info}/WHEEL +0 -0
  41. {iqm_exa_common-25.34.dist-info → iqm_exa_common-26.0.dist-info}/top_level.txt +0 -0
@@ -14,31 +14,67 @@
14
14
 
15
15
  """Base immutable class for sweeps specifications."""
16
16
 
17
- from dataclasses import dataclass, field
18
- from typing import Any, List, Union
17
+ from typing import Any
18
+ import warnings
19
19
 
20
- from exa.common.control.sweep.option import SweepOptions
20
+ from exa.common.control.sweep.option import CenterSpanOptions, StartStopOptions, SweepOptions
21
+ from exa.common.control.sweep.sweep_values import SweepValues
22
+ from exa.common.data.base_model import BaseModel
21
23
  from exa.common.data.parameter import Parameter
24
+ from exa.common.errors.exa_error import InvalidSweepOptionsTypeError
22
25
 
23
26
 
24
- @dataclass(frozen=True)
25
- class Sweep:
27
+ class Sweep(BaseModel):
26
28
  """Base immutable class for sweeps."""
27
29
 
28
30
  parameter: Parameter
29
31
  """The Sweep represents changing the values of this Parameter."""
30
32
 
31
- options: SweepOptions
32
- """Range specification where the values are derived from."""
33
+ data: SweepValues
34
+ """List of values for :attr:`parameter`"""
33
35
 
34
- _data: List[Any] = field(init=False, repr=False)
36
+ def __init__(
37
+ self, parameter: Parameter, options: SweepOptions | None = None, *, data: SweepValues | None = None, **kwargs
38
+ ) -> None:
39
+ if options is None and data is None:
40
+ raise ValueError("Either 'options' or 'data' is required.")
41
+ if options is not None and data is not None:
42
+ raise ValueError(
43
+ "Can't use both 'options' and 'data' at the same time, give only either of the parameters."
44
+ )
45
+ if options is not None:
46
+ warnings.warn("'options' attribute is deprecated, use 'data' instead.", DeprecationWarning)
35
47
 
36
- @property
37
- def data(self) -> List[Any]:
38
- """List of values for :attr:`parameter`"""
39
- return self._data
48
+ if not isinstance(options, SweepOptions):
49
+ raise InvalidSweepOptionsTypeError(str(type(options)))
40
50
 
41
- def _validate_value(self, _value: Union[int, float, complex], value_label: str):
42
- if not self.parameter.validate(_value):
43
- error = ValueError(f"Invalid {value_label} value {_value} for parameter type {self.parameter.data_type}.")
44
- raise error
51
+ if isinstance(options, StartStopOptions):
52
+ data = self.__from_start_stop(parameter, options)
53
+ elif isinstance(options, CenterSpanOptions):
54
+ data = self.__from_center_span(parameter, options)
55
+ else:
56
+ data = options.data
57
+
58
+ super().__init__(parameter=parameter, data=data, **kwargs)
59
+
60
+ def model_post_init(self, __context: Any) -> None:
61
+ if not all(self.parameter.validate(value) for value in self.data):
62
+ raise ValueError(f"Invalid range data {self.data} for parameter type {self.parameter.data_type}.")
63
+
64
+ @classmethod
65
+ def __from_center_span(cls, parameter: Parameter, options: CenterSpanOptions) -> SweepValues:
66
+ cls._validate_value(parameter, options.center, "center")
67
+ cls._validate_value(parameter, options.span, "span")
68
+ return options.data
69
+
70
+ @classmethod
71
+ def __from_start_stop(cls, parameter: Parameter, options: StartStopOptions) -> SweepValues:
72
+ cls._validate_value(parameter, options.start, "start")
73
+ cls._validate_value(parameter, options.stop, "stop")
74
+ cls._validate_value(parameter, options.step, "step")
75
+ return options.data
76
+
77
+ @staticmethod
78
+ def _validate_value(parameter: Parameter, value: int | float | complex | str | bool, value_label: str):
79
+ if not parameter.validate(value):
80
+ raise ValueError(f"Invalid {value_label} value {value} for parameter type {parameter.data_type}.")
@@ -0,0 +1,58 @@
1
+ # Copyright 2024 IQM
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Pydantic compatible annotated class for sweep values."""
16
+
17
+ from typing import Annotated, Any
18
+
19
+ import numpy as np
20
+ from pydantic import PlainSerializer, PlainValidator, WithJsonSchema
21
+ from pydantic_core import core_schema
22
+
23
+
24
+ def validate_sweep_values(sweep_values: Any) -> Any:
25
+ """Validate (i.e. deserialize) JSON serializable sweep values to Python type, to support complex types."""
26
+ if sweep_values is None:
27
+ return None
28
+ if isinstance(sweep_values, np.ndarray):
29
+ sweep_values = sweep_values.tolist()
30
+ for index, value in enumerate(sweep_values):
31
+ if isinstance(value, dict):
32
+ if "__complex__" in value:
33
+ sweep_values[index] = complex(value["real"], value["imag"])
34
+ return sweep_values
35
+
36
+
37
+ def serialize_sweep_values(sweep_values: Any) -> Any:
38
+ """Serialize sweep values type to JSON serializable type, to support complex types."""
39
+ if sweep_values is None:
40
+ return None
41
+ if isinstance(sweep_values, list):
42
+ # This is kind of a hack to clean up Numpy values from a standard list,
43
+ # which can happen is the user converts ndarray to a list using list(array) instead of array.tolist().
44
+ sweep_values = np.asarray(sweep_values).tolist()
45
+ if isinstance(sweep_values, np.ndarray):
46
+ sweep_values = sweep_values.tolist()
47
+ for index, value in enumerate(sweep_values):
48
+ if isinstance(value, complex):
49
+ sweep_values[index] = {"__complex__": "true", "real": value.real, "imag": value.imag}
50
+ return sweep_values
51
+
52
+
53
+ SweepValues = Annotated[
54
+ list[Any] | np.ndarray[Any],
55
+ PlainValidator(validate_sweep_values),
56
+ PlainSerializer(serialize_sweep_values),
57
+ WithJsonSchema(core_schema.any_schema()),
58
+ ]
@@ -0,0 +1,40 @@
1
+ from typing import Any, Self
2
+
3
+ import pydantic
4
+ from pydantic import ConfigDict
5
+
6
+
7
+ class BaseModel(pydantic.BaseModel):
8
+ """Pydantic base model to change the behaviour of pydantic globally.
9
+ Note that setting model_config in child classes will merge the configs rather than override this one.
10
+ https://docs.pydantic.dev/latest/concepts/config/#change-behaviour-globally
11
+ """
12
+
13
+ model_config = ConfigDict(
14
+ # extra="forbid", # Forbid any extra attributes
15
+ # TODO (Marko): Use "ignore" at least temporarily
16
+ # Data sent by old server code might include extra attributes like "parent_name",
17
+ # which fails with a new client using extra="ignore".
18
+ # Before merging to master, consider if we want to be strict with extra="forbid",
19
+ # or would it be better to use extra="ignore" (shouldn't be needed if we update the server code anyway).
20
+ extra="ignore", # Ignore any extra attributes
21
+ validate_assignment=True, # Validate the data when the model is changed
22
+ validate_default=True, # Validate default values during validation
23
+ ser_json_inf_nan="constants", # Will serialize infinity and NaN values as Infinity and NaN
24
+ frozen=True, # This makes instances of the model potentially hashable if all the attributes are hashable
25
+ )
26
+
27
+ def model_copy(self, *, update: dict[str, Any] | None = None, deep: bool = True) -> Self:
28
+ """Returns a copy of the model.
29
+
30
+ Overrides the Pydantic default 'model_copy' to set 'deep=True' by default.
31
+ """
32
+ return super().model_copy(update=update, deep=deep)
33
+
34
+ def copy(self, **kwargs) -> Self:
35
+ """Returns a copy of the model.
36
+
37
+ DEPRECATED: Use model_copy(update: dict[str, Any], deep: bool) instead.
38
+ """
39
+ # Call deprecated copy() here deliberately to trigger deprecation warning from Pydantic.
40
+ return super().copy(update=kwargs, deep=True)
@@ -59,27 +59,33 @@ from __future__ import annotations
59
59
 
60
60
  import ast
61
61
  import copy
62
- from dataclasses import dataclass
63
62
  from enum import IntEnum
64
- import math
65
- from typing import Any, Dict, Hashable, List, Optional, Set, Tuple, Union
63
+ from typing import Any, Hashable, Self
64
+ import warnings
66
65
 
67
66
  import numpy as np
67
+ from pydantic import model_validator
68
68
  import xarray as xr
69
69
 
70
+ from exa.common.control.sweep.sweep_values import SweepValues
71
+ from exa.common.data.base_model import BaseModel
72
+ from exa.common.data.value import ObservationValue
70
73
  from exa.common.errors.exa_error import InvalidParameterValueError
71
74
 
72
- CastType = Union[Optional[str], List["CastType"]]
75
+ CastType = str | list["CastType"] | None
73
76
 
74
77
 
75
78
  class DataType(IntEnum):
76
79
  """Parameter data type."""
77
80
 
78
81
  ANYTHING = 0
79
- NUMBER = 1
82
+ FLOAT = 1
80
83
  COMPLEX = 2
81
84
  STRING = 3
82
85
  BOOLEAN = 4
86
+ INT = 5
87
+
88
+ NUMBER = 101 # Deprecated
83
89
 
84
90
  def cast(self, value: CastType) -> Any:
85
91
  if isinstance(value, list):
@@ -92,8 +98,14 @@ class DataType(IntEnum):
92
98
  return True
93
99
  elif isinstance(value, np.generic):
94
100
  return self.validate(value.item())
95
- elif self is DataType.NUMBER:
101
+ elif self in [DataType.FLOAT, DataType.NUMBER]:
102
+ # Accept int as well, i.e. 1 == 1.0
96
103
  return type(value) in (int, float)
104
+ elif self is DataType.INT:
105
+ # Accept float as well, i.e. 1.0 == 1
106
+ if type(value) in (int, float):
107
+ return value % 1 == 0
108
+ return False
97
109
  elif self is DataType.COMPLEX:
98
110
  return type(value) in (int, float, complex)
99
111
  elif self is DataType.STRING:
@@ -103,15 +115,16 @@ class DataType(IntEnum):
103
115
  else:
104
116
  return False
105
117
 
106
- def _cast(self, value: str) -> Any:
118
+ def _cast(self, value: str) -> Any: # noqa: PLR0911
107
119
  if value is None:
108
120
  return None
109
- elif self is DataType.NUMBER:
110
- if float(value) == math.floor(float(value)):
111
- return int(float(value))
112
- else:
113
- return float(value)
121
+ elif self in [DataType.FLOAT, DataType.NUMBER]:
122
+ return float(value)
123
+ elif self is DataType.INT:
124
+ return int(value)
114
125
  elif self is DataType.COMPLEX:
126
+ if isinstance(value, complex):
127
+ return value
115
128
  return complex("".join(value.split()))
116
129
  elif self is DataType.BOOLEAN:
117
130
  if value.lower() == "true" or value == "1":
@@ -150,8 +163,7 @@ class CollectionType(IntEnum):
150
163
  return value
151
164
 
152
165
 
153
- @dataclass(frozen=True)
154
- class Parameter:
166
+ class Parameter(BaseModel):
155
167
  """A basic data structure that represents a single variable.
156
168
 
157
169
  The variable can be a high-level or low-level control knob of an instrument such as the amplitude of a pulse
@@ -163,35 +175,54 @@ class Parameter:
163
175
 
164
176
  name: str
165
177
  """Parameter name used as identifier"""
166
- label: str = None
178
+ label: str = ""
167
179
  """Parameter label used as pretty identifier for display purposes. Default: `name`"""
168
- unit: str = None
180
+ unit: str = ""
169
181
  """SI unit of the quantity, if applicable."""
170
- data_type: Union[DataType, Tuple[DataType, ...]] = None
182
+ data_type: DataType | tuple[DataType, ...] = DataType.FLOAT
171
183
  """Data type or a tuple of datatypes that this parameter accepts and validates. One of :class:`.DataType`.
172
- Default: NUMBER."""
173
- collection_type: CollectionType = None
184
+ Default: FLOAT."""
185
+ collection_type: CollectionType = CollectionType.SCALAR
174
186
  """Data format that this parameter accepts and validates. One of :class:`.CollectionType`.
175
187
  Default: SCALAR."""
176
- element_indices: Optional[int | list[int]] = None
188
+ element_indices: int | list[int] | None = None
177
189
  """For parameters representing a single value in a collection-valued parent parameter, this field gives the indices
178
190
  of that value. If populated, the ``self.name`` and ``self.label`` will be updated in post init to include
179
191
  the indices (becoming ``"<parent name>__<index0>__<index1>__...__<indexN>"`` and ``"<parent label> <indices>"``
180
192
  , respectively). The parent name can then be retrieved with ``self.parent_name`` and the parent label with
181
193
  ``self.parent_label``."""
182
194
 
183
- _parent_name: Optional[str] = None
184
- _parent_label: Optional[str] = None
185
-
186
- def __post_init__(self) -> None:
187
- if self.data_type is None:
188
- object.__setattr__(self, "data_type", DataType.NUMBER)
189
- if self.unit is None:
190
- object.__setattr__(self, "unit", "")
191
- if self.label is None:
192
- object.__setattr__(self, "label", self.name)
193
- if self.collection_type is None:
194
- object.__setattr__(self, "collection_type", CollectionType.SCALAR)
195
+ _parent_name: str | None = None
196
+ _parent_label: str | None = None
197
+
198
+ def __init__(
199
+ self,
200
+ name: str,
201
+ label: str = "",
202
+ unit: str = "",
203
+ data_type: DataType | tuple[DataType, ...] = DataType.FLOAT,
204
+ collection_type: CollectionType = CollectionType.SCALAR,
205
+ element_indices: int | list[int] | None = None,
206
+ **kwargs,
207
+ ) -> None:
208
+ if not label:
209
+ label = name
210
+ if data_type == DataType.NUMBER:
211
+ warnings.warn(
212
+ "data_type 'DataType.NUMBER' is deprecated, using 'DataType.FLOAT' instead.",
213
+ DeprecationWarning,
214
+ )
215
+ # Consider DataType.NUMBER as a deprecated alias for DataType.FLOAT
216
+ data_type = DataType.FLOAT
217
+ super().__init__(
218
+ name=name,
219
+ label=label,
220
+ unit=unit,
221
+ data_type=data_type,
222
+ collection_type=collection_type,
223
+ element_indices=element_indices,
224
+ **kwargs,
225
+ )
195
226
  if self.element_indices is not None:
196
227
  if self.collection_type is not CollectionType.SCALAR:
197
228
  raise InvalidParameterValueError(
@@ -217,7 +248,7 @@ class Parameter:
217
248
  object.__setattr__(self, "name", name)
218
249
 
219
250
  @property
220
- def parent_name(self) -> Optional[str]:
251
+ def parent_name(self) -> str | None:
221
252
  """Returns the parent name.
222
253
 
223
254
  This `None` except in element-wise parameters where gives the name of the parent parameter.
@@ -225,7 +256,7 @@ class Parameter:
225
256
  return self._parent_name
226
257
 
227
258
  @property
228
- def parent_label(self) -> Optional[str]:
259
+ def parent_label(self) -> str | None:
229
260
  """Returns the parent label.
230
261
 
231
262
  This `None` except in element-wise parameters where gives the label of the parent parameter.
@@ -239,10 +270,10 @@ class Parameter:
239
270
 
240
271
  @staticmethod
241
272
  def build_data_set(
242
- variables: List[Tuple[Parameter, List[Any]]],
243
- data: Tuple[Parameter, List[Any]],
244
- attributes: Dict[str, Any] = None,
245
- extra_variables: Optional[List[Tuple[str, int]]] = None,
273
+ variables: list[tuple[Parameter, list[Any]]],
274
+ data: tuple[Parameter, SweepValues],
275
+ attributes: dict[str, Any] = None,
276
+ extra_variables: list[tuple[str, int]] | None = None,
246
277
  ):
247
278
  """Build an xarray Dataset, where the only DataArray is given by `results` and coordinates are given by
248
279
  `variables`. The data is reshaped to correspond to the sizes of the variables. For example,
@@ -279,7 +310,7 @@ class Parameter:
279
310
  """Validate that given `value` matches the :attr:`data_type` and :attr:`collection_type`."""
280
311
  return self._validate(value, self.data_type)
281
312
 
282
- def _validate(self, value: Any, data_type: Union[DataType, Tuple[DataType, ...]]) -> bool:
313
+ def _validate(self, value: Any, data_type: DataType | tuple[DataType, ...]) -> bool:
283
314
  # on the first iteration the `data_type` type is checked, in case it is a tuple,
284
315
  # current method is called once again with each `data_type` from tuple with further
285
316
  # checking of `collection_type`
@@ -292,22 +323,12 @@ class Parameter:
292
323
  else:
293
324
  return data_type.validate(value)
294
325
 
295
- def copy(self, **attributes):
296
- """Return a copy of the parameter, where attributes defined in `attributes` are replaced."""
297
- return Parameter(
298
- name=attributes.get("name", self.name),
299
- label=attributes.get("label", self.label),
300
- unit=attributes.get("unit", self.unit),
301
- data_type=attributes.get("data_type", self.data_type),
302
- collection_type=attributes.get("collection_type", self.collection_type),
303
- )
304
-
305
326
  def build_data_array(
306
327
  self,
307
328
  data: np.ndarray,
308
- dimensions: List[Hashable] = None,
309
- coords: Dict[Hashable, Any] = None,
310
- metadata: Dict[str, Any] = None,
329
+ dimensions: list[Hashable] = None,
330
+ coords: dict[Hashable, Any] = None,
331
+ metadata: dict[str, Any] = None,
311
332
  ) -> xr.DataArray:
312
333
  """Attach Parameter information to a numerical array.
313
334
 
@@ -375,24 +396,57 @@ class Parameter:
375
396
  )
376
397
 
377
398
 
378
- @dataclass(frozen=True)
379
- class Setting:
399
+ class Setting(BaseModel):
380
400
  """Physical quantity represented as a Parameter attached to a numerical value."""
381
401
 
382
402
  parameter: Parameter
383
403
  """The parameter this Setting represents."""
384
- value: Any
404
+ value: ObservationValue
385
405
  """Data value attached to the parameter."""
406
+ read_only: bool = False
407
+ """Indicates if the attribute is read-only."""
408
+ path: str = ""
409
+ """Path in the settings tree (starting from the root ``SettingNode``) for this setting."""
410
+
411
+ def __init__(
412
+ self,
413
+ parameter: Parameter | None = None,
414
+ value: ObservationValue | None = None,
415
+ read_only: bool = False,
416
+ path: str = "",
417
+ **kwargs,
418
+ ) -> None:
419
+ super().__init__(
420
+ parameter=parameter,
421
+ value=value,
422
+ read_only=read_only,
423
+ path=path,
424
+ **kwargs,
425
+ )
386
426
 
387
- def __post_init__(self) -> None:
427
+ @model_validator(mode="after")
428
+ def validate_parameter_value_after(self) -> Self:
388
429
  if self.parameter.collection_type in (CollectionType.LIST, CollectionType.NDARRAY):
430
+ # Use __setattr__ to set the value, since the instance is frozen.
431
+ # Ideally, this value would be set in "before" validation.
432
+ # However, "before" validation has to deal with raw data, which could be any arbitrary object.
433
+ # To avoid extra complexity, let Pydantic deal with the raw data and change the value in "after" validation.
389
434
  object.__setattr__(self, "value", self.parameter.collection_type.cast(self.value))
390
435
  if self.value is not None and not self.parameter.validate(self.value):
391
436
  raise InvalidParameterValueError("Invalid value {} for parameter {}.".format(self.value, self.parameter))
437
+ return self
392
438
 
393
- def update(self, value: Any) -> Setting:
439
+ def update(self, value: ObservationValue) -> Setting:
394
440
  """Create a new setting object with updated `value`."""
395
- return Setting(self.parameter, value)
441
+ if self.read_only:
442
+ raise ValueError(
443
+ f"Can't update the value of {self.parameter.name} to {value} since the setting is read-only."
444
+ )
445
+ if isinstance(value, list) and self.parameter.collection_type == CollectionType.NDARRAY:
446
+ value = np.array(value)
447
+ # Need to create a new Setting here instead of using Pydantic model_copy().
448
+ # model_copy() can't handle backdoor settings without errors, i.e. values with a list of 2 elements.
449
+ return Setting(self.parameter, value, self.read_only, self.path)
396
450
 
397
451
  @property
398
452
  def name(self):
@@ -420,19 +474,16 @@ class Setting:
420
474
  return self.parameter.unit
421
475
 
422
476
  @property
423
- def element_indices(self) -> Optional[tuple[int, ...]]:
477
+ def element_indices(self) -> tuple[int, ...] | None:
424
478
  """Element-wise indices of the parameter in ``self``."""
425
479
  return self.parameter.element_indices
426
480
 
427
- def copy(self):
428
- return Setting(self.parameter, self.value)
429
-
430
481
  @staticmethod
431
- def get_by_name(name: str, values: Set[Setting]) -> Optional[Setting]:
482
+ def get_by_name(name: str, values: set[Setting]) -> Setting | None:
432
483
  return next((setting for setting in values if setting.parameter.name == name), None)
433
484
 
434
485
  @staticmethod
435
- def remove_by_name(name: str, values: Set[Setting] = None) -> Set[Setting]:
486
+ def remove_by_name(name: str, values: set[Setting] = None) -> set[Setting]:
436
487
  if values is None:
437
488
  values = set()
438
489
  removed = copy.deepcopy(values)
@@ -440,7 +491,7 @@ class Setting:
440
491
  return removed
441
492
 
442
493
  @staticmethod
443
- def replace(settings: Union[Setting, List[Setting]], values: Set[Setting] = None) -> Set[Setting]:
494
+ def replace(settings: Setting | list[Setting], values: set[Setting] = None) -> set[Setting]:
444
495
  if values is None:
445
496
  values = set()
446
497
  if not isinstance(settings, list):
@@ -452,7 +503,7 @@ class Setting:
452
503
  return removed
453
504
 
454
505
  @staticmethod
455
- def diff_sets(first: Set[Setting], second: Set[Setting]) -> Set[Setting]:
506
+ def diff_sets(first: set[Setting], second: set[Setting]) -> set[Setting]:
456
507
  """Return a one-sided difference between two sets of Settings, prioritising values in `first`.
457
508
 
458
509
  Args:
@@ -472,7 +523,7 @@ class Setting:
472
523
  return diff
473
524
 
474
525
  @staticmethod
475
- def merge(settings1: Set[Setting], settings2: Set[Setting]) -> Set[Setting]:
526
+ def merge(settings1: set[Setting], settings2: set[Setting]) -> set[Setting]:
476
527
  if settings1 is None:
477
528
  settings1 = set()
478
529
  if settings2 is None:
@@ -520,4 +571,8 @@ class Setting:
520
571
  return self.parameter.__lt__(other.parameter)
521
572
 
522
573
  def __str__(self):
523
- return f"Setting({self.parameter.label} = {self.value} {self.parameter.unit})"
574
+ return f"Setting({self.parameter.label} = {self.value} {self.parameter.unit} {self.read_only=})"
575
+
576
+ def with_path_name(self) -> Setting:
577
+ """Copy of self with the parameter name replaced by the path name."""
578
+ return self.model_copy(update={"parameter": self.parameter.model_copy(update={"name": self.path})})