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.
- exa/common/api/proto_serialization/_parameter.py +4 -3
- exa/common/api/proto_serialization/nd_sweep.py +3 -8
- exa/common/api/proto_serialization/sequence.py +5 -5
- exa/common/control/sweep/exponential_sweep.py +15 -47
- exa/common/control/sweep/fixed_sweep.py +10 -14
- exa/common/control/sweep/linear_sweep.py +15 -40
- exa/common/control/sweep/option/__init__.py +1 -1
- exa/common/control/sweep/option/center_span_base_options.py +14 -7
- exa/common/control/sweep/option/center_span_options.py +13 -6
- exa/common/control/sweep/option/constants.py +2 -2
- exa/common/control/sweep/option/fixed_options.py +8 -2
- exa/common/control/sweep/option/option_converter.py +4 -8
- exa/common/control/sweep/option/start_stop_base_options.py +20 -6
- exa/common/control/sweep/option/start_stop_options.py +20 -5
- exa/common/control/sweep/option/sweep_options.py +9 -0
- exa/common/control/sweep/sweep.py +52 -16
- exa/common/control/sweep/sweep_values.py +58 -0
- exa/common/data/base_model.py +40 -0
- exa/common/data/parameter.py +123 -68
- exa/common/data/setting_node.py +481 -135
- exa/common/data/settingnode_v2.html.jinja2 +6 -6
- exa/common/data/value.py +49 -0
- exa/common/logger/logger.py +1 -1
- exa/common/qcm_data/file_adapter.py +2 -6
- exa/common/qcm_data/qcm_data_client.py +1 -37
- exa/common/sweep/database_serialization.py +30 -98
- exa/common/sweep/util.py +4 -5
- {iqm_exa_common-25.34.dist-info → iqm_exa_common-26.0.dist-info}/METADATA +2 -2
- iqm_exa_common-26.0.dist-info/RECORD +54 -0
- exa/common/api/model/__init__.py +0 -15
- exa/common/api/model/parameter_model.py +0 -111
- exa/common/api/model/setting_model.py +0 -63
- exa/common/api/model/setting_node_model.py +0 -72
- exa/common/api/model/sweep_model.py +0 -63
- exa/common/control/sweep/function_sweep.py +0 -35
- exa/common/control/sweep/option/function_options.py +0 -26
- exa/common/control/sweep/utils.py +0 -43
- iqm_exa_common-25.34.dist-info/RECORD +0 -59
- {iqm_exa_common-25.34.dist-info → iqm_exa_common-26.0.dist-info}/LICENSE.txt +0 -0
- {iqm_exa_common-25.34.dist-info → iqm_exa_common-26.0.dist-info}/WHEEL +0 -0
- {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
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
"""
|
|
33
|
+
data: SweepValues
|
|
34
|
+
"""List of values for :attr:`parameter`"""
|
|
33
35
|
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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)
|
exa/common/data/parameter.py
CHANGED
|
@@ -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
|
|
65
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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 =
|
|
178
|
+
label: str = ""
|
|
167
179
|
"""Parameter label used as pretty identifier for display purposes. Default: `name`"""
|
|
168
|
-
unit: str =
|
|
180
|
+
unit: str = ""
|
|
169
181
|
"""SI unit of the quantity, if applicable."""
|
|
170
|
-
data_type:
|
|
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:
|
|
173
|
-
collection_type: CollectionType =
|
|
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:
|
|
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:
|
|
184
|
-
_parent_label:
|
|
185
|
-
|
|
186
|
-
def
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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) ->
|
|
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) ->
|
|
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:
|
|
243
|
-
data:
|
|
244
|
-
attributes:
|
|
245
|
-
extra_variables:
|
|
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:
|
|
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:
|
|
309
|
-
coords:
|
|
310
|
-
metadata:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
439
|
+
def update(self, value: ObservationValue) -> Setting:
|
|
394
440
|
"""Create a new setting object with updated `value`."""
|
|
395
|
-
|
|
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) ->
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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})})
|