easyscience 2.1.0__tar.gz → 2.2.0__tar.gz

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 (65) hide show
  1. {easyscience-2.1.0 → easyscience-2.2.0}/PKG-INFO +3 -2
  2. {easyscience-2.1.0 → easyscience-2.2.0}/README.md +2 -1
  3. easyscience-2.2.0/src/easyscience/__version__.py +1 -0
  4. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/base_classes/__init__.py +2 -7
  5. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/base_classes/collection_base.py +14 -9
  6. easyscience-2.2.0/src/easyscience/base_classes/easy_list.py +281 -0
  7. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/fitting/minimizers/minimizer_bumps.py +8 -1
  8. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/fitting/minimizers/minimizer_dfo.py +11 -3
  9. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/fitting/minimizers/minimizer_lmfit.py +11 -1
  10. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/global_object/map.py +79 -32
  11. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/variable/descriptor_number.py +1 -4
  12. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/variable/parameter.py +81 -8
  13. easyscience-2.1.0/src/easyscience/__version__.py +0 -1
  14. {easyscience-2.1.0 → easyscience-2.2.0}/.gitignore +0 -0
  15. {easyscience-2.1.0 → easyscience-2.2.0}/LICENSE +0 -0
  16. {easyscience-2.1.0 → easyscience-2.2.0}/pyproject.toml +0 -0
  17. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/__init__.py +0 -0
  18. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/base_classes/based_base.py +0 -0
  19. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/base_classes/model_base.py +0 -0
  20. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/base_classes/new_base.py +0 -0
  21. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/base_classes/obj_base.py +0 -0
  22. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/fitting/__init__.py +0 -0
  23. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/fitting/available_minimizers.py +0 -0
  24. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/fitting/calculators/__init__.py +0 -0
  25. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/fitting/calculators/interface_factory.py +0 -0
  26. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/fitting/fitter.py +0 -0
  27. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/fitting/minimizers/__init__.py +0 -0
  28. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/fitting/minimizers/factory.py +0 -0
  29. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/fitting/minimizers/minimizer_base.py +0 -0
  30. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/fitting/minimizers/utils.py +0 -0
  31. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/fitting/multi_fitter.py +0 -0
  32. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/global_object/__init__.py +0 -0
  33. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/global_object/global_object.py +0 -0
  34. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/global_object/hugger/__init__.py +0 -0
  35. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/global_object/hugger/hugger.py +0 -0
  36. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/global_object/hugger/property.py +0 -0
  37. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/global_object/logger.py +0 -0
  38. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/global_object/undo_redo.py +0 -0
  39. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/io/__init__.py +0 -0
  40. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/io/serializer_base.py +0 -0
  41. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/io/serializer_component.py +0 -0
  42. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/io/serializer_dict.py +0 -0
  43. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/job/__init__.py +0 -0
  44. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/job/analysis.py +0 -0
  45. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/job/experiment.py +0 -0
  46. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/job/job.py +0 -0
  47. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/job/theoreticalmodel.py +0 -0
  48. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/legacy/dict.py +0 -0
  49. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/legacy/json.py +0 -0
  50. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/legacy/legacy_core.py +0 -0
  51. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/legacy/xml.py +0 -0
  52. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/models/__init__.py +0 -0
  53. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/models/polynomial.py +0 -0
  54. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/utils/__init__.py +0 -0
  55. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/utils/classTools.py +0 -0
  56. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/utils/classUtils.py +0 -0
  57. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/utils/decorators.py +0 -0
  58. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/utils/string.py +0 -0
  59. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/variable/__init__.py +0 -0
  60. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/variable/descriptor_any_type.py +0 -0
  61. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/variable/descriptor_array.py +0 -0
  62. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/variable/descriptor_base.py +0 -0
  63. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/variable/descriptor_bool.py +0 -0
  64. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/variable/descriptor_str.py +0 -0
  65. {easyscience-2.1.0 → easyscience-2.2.0}/src/easyscience/variable/parameter_dependency_resolver.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: easyscience
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Generic logic for easyScience libraries
5
5
  Project-URL: homepage, https://github.com/EasyScience/EasyScience
6
6
  Project-URL: documentation, https://easyscience.github.io/EasyScience/
@@ -78,6 +78,7 @@ Description-Content-Type: text/markdown
78
78
  [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md)
79
79
  [![PyPI badge](http://img.shields.io/pypi/v/EasyScience.svg)](https://pypi.python.org/pypi/EasyScience)
80
80
  [![License: BSD 3-Clause](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](LICENSE)
81
+ [![codecov](https://codecov.io/github/EasyScience/corelib/graph/badge.svg?token=wc6Q0j0Q9t)](https://codecov.io/github/EasyScience/corelib)
81
82
 
82
83
  # Easyscience
83
84
 
@@ -110,7 +111,7 @@ After installation, launch the test suite:
110
111
 
111
112
  Documentation can be found at:
112
113
 
113
- [https://easyScience.github.io/EasyScience](https://easyScience.github.io/EasyScience)
114
+ [https://easyScience.github.io/corelib](https://easyScience.github.io/corelib)
114
115
 
115
116
  ## Contributing
116
117
  We absolutely welcome contributions. **EasyScience** is maintained by the ESS and on a volunteer basis and thus we need to foster a community that can support user questions and develop new features to make this software a useful tool for all users while encouraging every member of the community to share their ideas.
@@ -1,6 +1,7 @@
1
1
  [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md)
2
2
  [![PyPI badge](http://img.shields.io/pypi/v/EasyScience.svg)](https://pypi.python.org/pypi/EasyScience)
3
3
  [![License: BSD 3-Clause](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](LICENSE)
4
+ [![codecov](https://codecov.io/github/EasyScience/corelib/graph/badge.svg?token=wc6Q0j0Q9t)](https://codecov.io/github/EasyScience/corelib)
4
5
 
5
6
  # Easyscience
6
7
 
@@ -33,7 +34,7 @@ After installation, launch the test suite:
33
34
 
34
35
  Documentation can be found at:
35
36
 
36
- [https://easyScience.github.io/EasyScience](https://easyScience.github.io/EasyScience)
37
+ [https://easyScience.github.io/corelib](https://easyScience.github.io/corelib)
37
38
 
38
39
  ## Contributing
39
40
  We absolutely welcome contributions. **EasyScience** is maintained by the ESS and on a volunteer basis and thus we need to foster a community that can support user questions and develop new features to make this software a useful tool for all users while encouraging every member of the community to share their ideas.
@@ -0,0 +1 @@
1
+ __version__ = '2.2.0'
@@ -1,13 +1,8 @@
1
1
  from .based_base import BasedBase
2
2
  from .collection_base import CollectionBase
3
+ from .easy_list import EasyList
3
4
  from .model_base import ModelBase
4
5
  from .new_base import NewBase
5
6
  from .obj_base import ObjBase
6
7
 
7
- __all__ = [
8
- BasedBase,
9
- CollectionBase,
10
- ObjBase,
11
- ModelBase,
12
- NewBase,
13
- ]
8
+ __all__ = [BasedBase, CollectionBase, ObjBase, ModelBase, NewBase, EasyList]
@@ -14,6 +14,7 @@ from typing import Optional
14
14
  from typing import Tuple
15
15
  from typing import Union
16
16
 
17
+ from easyscience.base_classes.new_base import NewBase
17
18
  from easyscience.global_object.undo_redo import NotarizedDict
18
19
 
19
20
  from ..variable.descriptor_base import DescriptorBase
@@ -34,7 +35,7 @@ class CollectionBase(BasedBase, MutableSequence):
34
35
  def __init__(
35
36
  self,
36
37
  name: str,
37
- *args: Union[BasedBase, DescriptorBase],
38
+ *args: Union[BasedBase, DescriptorBase, NewBase],
38
39
  interface: Optional[InterfaceFactoryTemplate] = None,
39
40
  unique_name: Optional[str] = None,
40
41
  **kwargs,
@@ -64,7 +65,7 @@ class CollectionBase(BasedBase, MutableSequence):
64
65
  _kwargs[key] = item
65
66
  kwargs = _kwargs
66
67
  for item in list(kwargs.values()) + _args:
67
- if not issubclass(type(item), (DescriptorBase, BasedBase)):
68
+ if not issubclass(type(item), (DescriptorBase, BasedBase, NewBase)):
68
69
  raise AttributeError('A collection can only be formed from easyscience objects.')
69
70
  args = _args
70
71
  _kwargs = {}
@@ -90,19 +91,19 @@ class CollectionBase(BasedBase, MutableSequence):
90
91
  self.interface = interface
91
92
  self._kwargs._stack_enabled = True
92
93
 
93
- def insert(self, index: int, value: Union[DescriptorBase, BasedBase]) -> None:
94
+ def insert(self, index: int, value: Union[DescriptorBase, BasedBase, NewBase]) -> None:
94
95
  """
95
96
  Insert an object into the collection at an index.
96
97
 
97
98
  :param index: Index for EasyScience object to be inserted.
98
99
  :type index: int
99
100
  :param value: Object to be inserted.
100
- :type value: Union[BasedBase, DescriptorBase]
101
+ :type value: Union[BasedBase, DescriptorBase, NewBase]
101
102
  :return: None
102
103
  :rtype: None
103
104
  """
104
105
  t_ = type(value)
105
- if issubclass(t_, (BasedBase, DescriptorBase)):
106
+ if issubclass(t_, (BasedBase, DescriptorBase, NewBase)):
106
107
  update_key = list(self._kwargs.keys())
107
108
  values = list(self._kwargs.values())
108
109
  # Update the internal dict
@@ -117,7 +118,7 @@ class CollectionBase(BasedBase, MutableSequence):
117
118
  else:
118
119
  raise AttributeError('Only EasyScience objects can be put into an EasyScience group')
119
120
 
120
- def __getitem__(self, idx: Union[int, slice]) -> Union[DescriptorBase, BasedBase]:
121
+ def __getitem__(self, idx: Union[int, slice]) -> Union[DescriptorBase, BasedBase, NewBase]:
121
122
  """
122
123
  Get an item in the collection based on its index.
123
124
 
@@ -151,7 +152,7 @@ class CollectionBase(BasedBase, MutableSequence):
151
152
  keys = list(self._kwargs.keys())
152
153
  return self._kwargs[keys[idx]]
153
154
 
154
- def __setitem__(self, key: int, value: Union[BasedBase, DescriptorBase]) -> None:
155
+ def __setitem__(self, key: int, value: Union[BasedBase, DescriptorBase, NewBase]) -> None:
155
156
  """
156
157
  Set an item via it's index.
157
158
 
@@ -163,7 +164,7 @@ class CollectionBase(BasedBase, MutableSequence):
163
164
  if isinstance(value, Number): # noqa: S3827
164
165
  item = self.__getitem__(key)
165
166
  item.value = value
166
- elif issubclass(type(value), (BasedBase, DescriptorBase)):
167
+ elif issubclass(type(value), (BasedBase, DescriptorBase, NewBase)):
167
168
  update_key = list(self._kwargs.keys())
168
169
  values = list(self._kwargs.values())
169
170
  old_item = values[key]
@@ -233,7 +234,11 @@ class CollectionBase(BasedBase, MutableSequence):
233
234
  def __repr__(self) -> str:
234
235
  return f'{self.__class__.__name__} `{getattr(self, "name")}` of length {len(self)}'
235
236
 
236
- def sort(self, mapping: Callable[[Union[BasedBase, DescriptorBase]], Any], reverse: bool = False) -> None:
237
+ def sort(
238
+ self,
239
+ mapping: Callable[[Union[BasedBase, DescriptorBase, NewBase]], Any],
240
+ reverse: bool = False,
241
+ ) -> None:
237
242
  """
238
243
  Sort the collection according to the given mapping.
239
244
 
@@ -0,0 +1,281 @@
1
+ # SPDX-FileCopyrightText: 2025 EasyScience contributors <core@easyscience.software>
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+ # © 2021-2025 Contributors to the EasyScience project <https://github.com/easyScience/EasyScience
4
+
5
+ from __future__ import annotations
6
+
7
+ import copy
8
+ import warnings
9
+ from collections.abc import MutableSequence
10
+ from typing import Any
11
+ from typing import Callable
12
+ from typing import Dict
13
+ from typing import Iterable
14
+ from typing import List
15
+ from typing import Optional
16
+ from typing import Type
17
+ from typing import TypeVar
18
+ from typing import overload
19
+
20
+ from easyscience.io.serializer_base import SerializerBase
21
+
22
+ from .new_base import NewBase
23
+
24
+ ProtectedType_ = TypeVar('ProtectedType', bound=NewBase)
25
+
26
+
27
+ class EasyList(NewBase, MutableSequence[ProtectedType_]):
28
+ # If we were to inherit from List instead of MutableSequence,
29
+ # we would have to overwrite "extend", "remove", "__iadd__", "count", "append", "__iter__" and "clear"
30
+ def __init__(
31
+ self,
32
+ *args: ProtectedType_ | list[ProtectedType_],
33
+ protected_types: list[Type[NewBase]] | Type[NewBase] | None = None,
34
+ unique_name: Optional[str] = None,
35
+ display_name: Optional[str] = None,
36
+ **kwargs: Any,
37
+ ):
38
+ """
39
+ Initialize the EasyList.
40
+ :param args: Initial items to add to the list
41
+ :param protected_types: Types that are allowed in the list. Can be a single NewBase subclass or a list of them.
42
+ If None, defaults to [NewBase].
43
+ :param unique_name: Optional unique name for the list
44
+ :param display_name: Optional display name for the list
45
+ """
46
+ super().__init__(unique_name=unique_name, display_name=display_name)
47
+ if protected_types is None:
48
+ self._protected_types = [NewBase]
49
+ elif isinstance(protected_types, type) and issubclass(protected_types, NewBase):
50
+ self._protected_types = [protected_types]
51
+ elif isinstance(protected_types, Iterable) and all(issubclass(t, NewBase) for t in protected_types):
52
+ self._protected_types = list(protected_types)
53
+ else:
54
+ raise TypeError('protected_types must be a NewBase subclass or an iterable of NewBase subclasses')
55
+ self._data: List[ProtectedType_] = []
56
+
57
+ # Add initial items
58
+ for item in args:
59
+ if isinstance(item, list):
60
+ for sub_item in item:
61
+ self.append(sub_item)
62
+ else:
63
+ self.append(item)
64
+
65
+ # For deserialization, the dict can't contain an *args, so we check for 'data' in kwargs
66
+ if 'data' in kwargs:
67
+ data = kwargs.pop('data')
68
+ for item in data:
69
+ self.append(item)
70
+
71
+ # MutableSequence abstract methods
72
+
73
+ # Use @overload to provide precise type hints for different __getitem__ argument types
74
+ @overload
75
+ def __getitem__(self, idx: int) -> ProtectedType_: ...
76
+ @overload
77
+ def __getitem__(self, idx: slice) -> 'EasyList[ProtectedType_]': ...
78
+ @overload
79
+ def __getitem__(self, idx: str) -> ProtectedType_: ...
80
+ def __getitem__(self, idx: int | slice | str) -> ProtectedType_ | 'EasyList[ProtectedType_]':
81
+ """
82
+ Get an item by index, slice, or unique_name.
83
+
84
+ :param idx: Index, slice, or unique_name of the item
85
+ :return: The item or a new EasyList for slices
86
+ """
87
+ if isinstance(idx, int):
88
+ return self._data[idx]
89
+ elif isinstance(idx, slice):
90
+ return self.__class__(self._data[idx], protected_types=self._protected_types)
91
+ elif isinstance(idx, str):
92
+ element = next((r for r in self._data if self._get_key(r) == idx), None)
93
+ if element is not None:
94
+ return element
95
+ raise KeyError(f'No item with unique name "{idx}" found')
96
+ else:
97
+ raise TypeError('Index must be an int, slice, or str')
98
+
99
+ @overload
100
+ def __setitem__(self, idx: int, value: ProtectedType_) -> None: ...
101
+ @overload
102
+ def __setitem__(self, idx: slice, value: Iterable[ProtectedType_]) -> None: ...
103
+
104
+ def __setitem__(self, idx: int | slice, value: ProtectedType_ | Iterable[ProtectedType_]) -> None:
105
+ """
106
+ Set an item at an index.
107
+
108
+ :param idx: Index to set
109
+ :param value: New value
110
+ """
111
+ if isinstance(idx, int):
112
+ if not isinstance(value, tuple(self._protected_types)):
113
+ raise TypeError(f'Items must be one of {self._protected_types}, got {type(value)}')
114
+ if value is not self._data[idx] and value in self:
115
+ warnings.warn(f'Item with unique name "{self._get_key(value)}" already in EasyList, it will be ignored')
116
+ return
117
+ self._data[idx] = value
118
+ elif isinstance(idx, slice):
119
+ if not isinstance(value, Iterable):
120
+ raise TypeError('Value must be an iterable for slice assignment')
121
+ replaced = self._data[idx]
122
+ new_values = list(value)
123
+ if len(new_values) != len(replaced):
124
+ raise ValueError('Length of new values must match the length of the slice being replaced')
125
+ for i, v in enumerate(new_values):
126
+ if not isinstance(v, tuple(self._protected_types)):
127
+ raise TypeError(f'Items must be one of {self._protected_types}, got {type(v)}')
128
+ if v in self and replaced[i] is not v:
129
+ warnings.warn(f'Item with unique name "{v.unique_name}" already in EasyList, it will be ignored')
130
+ new_values[i] = replaced[i] # Keep the original value if the new one is a duplicate
131
+ self._data[idx] = new_values
132
+ else:
133
+ raise TypeError('Index must be an int or slice')
134
+
135
+ def __delitem__(self, idx: int | slice | str) -> None:
136
+ """
137
+ Delete an item by index, slice, or name.
138
+
139
+ :param idx: Index, slice, or name of item to delete
140
+ """
141
+ if isinstance(idx, (int, slice)):
142
+ del self._data[idx]
143
+ elif isinstance(idx, str):
144
+ for i, item in enumerate(self._data):
145
+ if self._get_key(item) == idx:
146
+ del self._data[i]
147
+ return
148
+ raise KeyError(f'No item with unique name "{idx}" found')
149
+ else:
150
+ raise TypeError('Index must be an int, slice, or str')
151
+
152
+ def __len__(self) -> int:
153
+ """Return the number of items in the collection."""
154
+ return len(self._data)
155
+
156
+ def insert(self, index: int, value: ProtectedType_) -> None:
157
+ """
158
+ Insert an item at an index.
159
+
160
+ :param index: Index to insert at
161
+ :param value: Item to insert
162
+ """
163
+ if not isinstance(index, int):
164
+ raise TypeError('Index must be an integer')
165
+ elif not isinstance(value, tuple(self._protected_types)):
166
+ raise TypeError(f'Items must be one of {self._protected_types}, got {type(value)}')
167
+ if value in self:
168
+ warnings.warn(f'Item with unique name "{self._get_key(value)}" already in EasyList, it will be ignored')
169
+ return
170
+ self._data.insert(index, value)
171
+
172
+ def _get_key(self, obj) -> str:
173
+ """
174
+ Get the unique name of an object.
175
+ Can be overridden to use a different attribute as the key.
176
+ :param object: Object to get the key for
177
+ :return: The key of the object
178
+ :rtype: str
179
+ """
180
+ return obj.unique_name
181
+
182
+ # Overwriting methods
183
+
184
+ def __repr__(self) -> str:
185
+ return f'{self.__class__.__name__} of length {len(self)} of type(s) {self._protected_types}'
186
+
187
+ def __contains__(self, item: ProtectedType_ | str) -> bool:
188
+ if isinstance(item, str):
189
+ return any(self._get_key(r) == item for r in self._data)
190
+ return item in self._data
191
+
192
+ def __reversed__(self):
193
+ return self._data.__reversed__()
194
+
195
+ def sort(self, key: Callable[[ProtectedType_], Any] = None, reverse: bool = False) -> None:
196
+ """
197
+ Sort the collection according to the given key function.
198
+
199
+ :param key: Mapping function to sort by
200
+ :param reverse: Whether to reverse the sort
201
+ """
202
+ self._data.sort(reverse=reverse, key=key)
203
+
204
+ def index(self, value: ProtectedType_ | str, start: int = 0, stop: int = None) -> int:
205
+ if stop is None:
206
+ stop = len(self._data)
207
+ if isinstance(value, str):
208
+ for i in range(start, min(stop, len(self._data))):
209
+ if self._get_key(self._data[i]) == value:
210
+ return i
211
+ raise ValueError(f'{value} is not in EasyList')
212
+ return self._data.index(value, start, stop)
213
+
214
+ def pop(self, index: int | str = -1) -> ProtectedType_:
215
+ """
216
+ Remove and return an item at the given index or unique_name.
217
+
218
+ :param index: Index or unique_name of the item to remove
219
+ :return: The removed item
220
+ """
221
+ if isinstance(index, int):
222
+ return self._data.pop(index)
223
+ elif isinstance(index, str):
224
+ for i, item in enumerate(self._data):
225
+ if self._get_key(item) == index:
226
+ return self._data.pop(i)
227
+ raise KeyError(f'No item with unique name "{index}" found')
228
+ else:
229
+ raise TypeError('Index must be an int or str')
230
+
231
+ # Serialization support
232
+
233
+ def to_dict(self) -> dict:
234
+ """
235
+ Convert the EasyList to a dictionary for serialization.
236
+
237
+ :return: Dictionary representation of the EasyList
238
+ """
239
+ dict_repr = super().to_dict()
240
+ if self._protected_types != [NewBase]:
241
+ dict_repr['protected_types'] = [
242
+ {'@module': cls_.__module__, '@class': cls_.__name__} for cls_ in self._protected_types
243
+ ] # noqa: E501
244
+ dict_repr['data'] = [item.to_dict() for item in self._data]
245
+ return dict_repr
246
+
247
+ @classmethod
248
+ def from_dict(cls, obj_dict: Dict[str, Any]) -> NewBase:
249
+ """
250
+ Re-create an EasyScience object from a full encoded dictionary.
251
+
252
+ :param obj_dict: dictionary containing the serialized contents (from `SerializerDict`) of an EasyScience object
253
+ :return: Reformed EasyScience object
254
+ """
255
+ if not SerializerBase._is_serialized_easyscience_object(obj_dict):
256
+ raise ValueError('Input must be a dictionary representing an EasyScience EasyList object.')
257
+ temp_dict = copy.deepcopy(obj_dict) # Make a copy to avoid mutating the input
258
+ if temp_dict['@class'] == cls.__name__:
259
+ if 'protected_types' in temp_dict:
260
+ protected_types = temp_dict.pop('protected_types')
261
+ for i, type_dict in enumerate(protected_types):
262
+ if '@module' in type_dict and '@class' in type_dict:
263
+ modname = type_dict['@module']
264
+ classname = type_dict['@class']
265
+ mod = __import__(modname, globals(), locals(), [classname], 0)
266
+ if hasattr(mod, classname):
267
+ cls_ = getattr(mod, classname)
268
+ protected_types[i] = cls_
269
+ else:
270
+ raise ImportError(f'Could not import class {classname} from module {modname}')
271
+ else:
272
+ raise ValueError(
273
+ 'Each protected type must be a serialized EasyScience class with @module and @class keys'
274
+ ) # noqa: E501
275
+ else:
276
+ protected_types = None
277
+ kwargs = SerializerBase.deserialize_dict(temp_dict)
278
+ data = kwargs.pop('data', [])
279
+ return cls(data, protected_types=protected_types, **kwargs)
280
+ else:
281
+ raise ValueError(f'Class name in dictionary does not match the expected class: {cls.__name__}.')
@@ -98,6 +98,11 @@ class Bumps(MinimizerBase):
98
98
  :type method: str
99
99
  :return: Fit results
100
100
  :rtype: ModelResult
101
+
102
+ For standard least squares, the weights should be 1/sigma, where
103
+ sigma is the standard deviation of the measurement. For
104
+ unweighted least squares, these should be 1.
105
+
101
106
  """
102
107
  method_dict = self._get_method_kwargs(method)
103
108
 
@@ -191,6 +196,8 @@ class Bumps(MinimizerBase):
191
196
  Generate a bumps model from the supplied `fit_function` and parameters in the base object.
192
197
  Note that this makes a callable as it needs to be initialized with *x*, *y*, *weights*
193
198
 
199
+ Weights are converted to dy (standard deviation of y).
200
+
194
201
  :return: Callable to make a bumps Curve model
195
202
  :rtype: Callable
196
203
  """
@@ -205,7 +212,7 @@ class Bumps(MinimizerBase):
205
212
  else:
206
213
  for par in parameters:
207
214
  bumps_pars[MINIMIZER_PARAMETER_PREFIX + par.unique_name] = obj.convert_to_par_object(par)
208
- return Curve(fit_func, x, y, dy=weights, **bumps_pars)
215
+ return Curve(fit_func, x, y, dy=1 / weights, **bumps_pars)
209
216
 
210
217
  return _make_func
211
218
 
@@ -74,7 +74,7 @@ class DFO(MinimizerBase):
74
74
  :type x: np.ndarray
75
75
  :param y: measured points
76
76
  :type y: np.ndarray
77
- :param weights: Weights for supplied measured points
77
+ :param weights: Weights for supplied measured points.
78
78
  :type weights: np.ndarray
79
79
  :param model: Optional Model which is being fitted to
80
80
  :type model: lmModel
@@ -85,6 +85,10 @@ class DFO(MinimizerBase):
85
85
  :type method: str
86
86
  :return: Fit results
87
87
  :rtype: ModelResult
88
+
89
+ For standard least squares, the weights should be 1/sigma, where
90
+ sigma is the standard deviation of the measurement. For
91
+ unweighted least squares, these should be 1.
88
92
  """
89
93
  x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights)
90
94
 
@@ -163,7 +167,7 @@ class DFO(MinimizerBase):
163
167
  def _residuals(pars_values: List[float]) -> np.ndarray:
164
168
  for idx, par_name in enumerate(dfo_pars.keys()):
165
169
  dfo_pars[par_name] = pars_values[idx]
166
- return (y - fit_func(x, **dfo_pars)) / weights
170
+ return (y - fit_func(x, **dfo_pars)) * weights
167
171
 
168
172
  return _residuals
169
173
 
@@ -267,7 +271,11 @@ class DFO(MinimizerBase):
267
271
  return results
268
272
 
269
273
  @staticmethod
270
- def _prepare_kwargs(tolerance: Optional[float] = None, max_evaluations: Optional[int] = None, **kwargs) -> dict[str:str]:
274
+ def _prepare_kwargs(
275
+ tolerance: Optional[float] = None,
276
+ max_evaluations: Optional[int] = None,
277
+ **kwargs,
278
+ ) -> dict[str:str]:
271
279
  if max_evaluations is not None:
272
280
  kwargs['maxfun'] = max_evaluations # max number of function evaluations
273
281
  if tolerance is not None:
@@ -111,6 +111,11 @@ class LMFit(MinimizerBase): # noqa: S101
111
111
  :param kwargs: Additional arguments for the fitting function.
112
112
  :return: Fit results
113
113
  :rtype: ModelResult
114
+
115
+ For standard least squares, the weights should be 1/sigma, where
116
+ sigma is the standard deviation of the measurement. For
117
+ unweighted least squares, these should be 1.
118
+
114
119
  """
115
120
  x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights)
116
121
 
@@ -230,7 +235,12 @@ class LMFit(MinimizerBase): # noqa: S101
230
235
  else:
231
236
  value = item.value
232
237
 
233
- model.set_param_hint(MINIMIZER_PARAMETER_PREFIX + str(name), value=value, min=item.min, max=item.max)
238
+ model.set_param_hint(
239
+ MINIMIZER_PARAMETER_PREFIX + str(name),
240
+ value=value,
241
+ min=item.min,
242
+ max=item.max,
243
+ )
234
244
 
235
245
  # Cache the model for later reference
236
246
  self._cached_model = model
@@ -74,9 +74,32 @@ class Map:
74
74
  # A dict with object names as keys and a list of their object types as values, with weak references
75
75
  self.__type_dict = {}
76
76
 
77
+ def _snapshot_items(self):
78
+ """Return a stable snapshot of __type_dict items.
79
+
80
+ Some callers iterate over __type_dict while other threads or
81
+ weakref finalizers may modify it. Creating a list snapshot (with
82
+ a retry loop) prevents RuntimeError: dictionary changed size during iteration.
83
+ """
84
+ while True:
85
+ try:
86
+ return list(self.__type_dict.items())
87
+ except RuntimeError:
88
+ # Dict changed during snapshot creation, retry
89
+ continue
90
+
77
91
  def vertices(self) -> List[str]:
78
- """returns the vertices of a map"""
79
- return list(self._store.keys())
92
+ """Returns the vertices of a map.
93
+
94
+ Uses a retry loop to handle RuntimeError that can occur when the
95
+ WeakValueDictionary is modified during iteration (e.g., by garbage collection).
96
+ """
97
+ while True:
98
+ try:
99
+ return list(self._store)
100
+ except RuntimeError:
101
+ # Dictionary changed size during iteration, retry
102
+ continue
80
103
 
81
104
  def edges(self):
82
105
  """returns the edges of a map"""
@@ -100,46 +123,64 @@ class Map:
100
123
 
101
124
  def _nested_get(self, obj_type: str) -> List[str]:
102
125
  """Access a nested object in root by key sequence."""
103
- return [key for key, item in self.__type_dict.items() if obj_type in item.type]
126
+ # Create a stable snapshot of the dict items to avoid RuntimeError
127
+ # when the dict is modified during iteration (e.g., by finalizers).
128
+ while True:
129
+ try:
130
+ items = self._snapshot_items()
131
+ return [key for key, item in items if obj_type in item.type]
132
+ except RuntimeError:
133
+ # In case the snapshot itself raises (very rare), retry
134
+ continue
104
135
 
105
136
  def get_item_by_key(self, item_id: str) -> object:
106
- if item_id in self._store.keys():
137
+ if item_id in self._store:
107
138
  return self._store[item_id]
108
139
  raise ValueError('Item not in map.')
109
140
 
110
141
  def is_known(self, vertex: object) -> bool:
111
- # All objects should have a 'unique_name' attribute
112
- return vertex.unique_name in self._store.keys()
142
+ """Check if a vertex is known in the map.
143
+
144
+ All objects should have a 'unique_name' attribute.
145
+ """
146
+ return vertex.unique_name in self._store
113
147
 
114
148
  def find_type(self, vertex: object) -> List[str]:
115
149
  if self.is_known(vertex):
116
150
  return self.__type_dict[vertex.unique_name].type
117
151
 
118
152
  def reset_type(self, obj, default_type: str):
119
- if obj.unique_name in self.__type_dict.keys():
153
+ if obj.unique_name in self.__type_dict:
120
154
  self.__type_dict[obj.unique_name].reset_type(default_type)
121
155
 
122
156
  def change_type(self, obj, new_type: str):
123
- if obj.unique_name in self.__type_dict.keys():
157
+ if obj.unique_name in self.__type_dict:
124
158
  self.__type_dict[obj.unique_name].type = new_type
125
159
 
126
160
  def add_vertex(self, obj: object, obj_type: str = None):
127
161
  name = obj.unique_name
128
- if name in self._store.keys():
162
+ if name in self._store:
129
163
  raise ValueError(f'Object name {name} already exists in the graph.')
164
+ # Clean up stale entry in __type_dict if the weak reference was collected
165
+ # but the finalizer hasn't run yet
166
+ if name in self.__type_dict:
167
+ del self.__type_dict[name]
168
+
130
169
  self._store[name] = obj
131
- self.__type_dict[name] = _EntryList() # Add objects type to the list of types
132
- self.__type_dict[name].finalizer = weakref.finalize(self._store[name], self.prune, name)
133
- self.__type_dict[name].type = obj_type
170
+
171
+ entry_list = _EntryList()
172
+ entry_list.finalizer = weakref.finalize(obj, self.prune_type_dict, name)
173
+ entry_list.type = obj_type
174
+ self.__type_dict[name] = entry_list # Add objects type to the list of types
134
175
 
135
176
  def add_edge(self, start_obj: object, end_obj: object):
136
- if start_obj.unique_name in self.__type_dict.keys():
177
+ if start_obj.unique_name in self.__type_dict:
137
178
  self.__type_dict[start_obj.unique_name].append(end_obj.unique_name)
138
179
  else:
139
180
  raise AttributeError('Start object not in map.')
140
181
 
141
182
  def get_edges(self, start_obj) -> List[str]:
142
- if start_obj.unique_name in self.__type_dict.keys():
183
+ if start_obj.unique_name in self.__type_dict:
143
184
  return list(self.__type_dict[start_obj.unique_name])
144
185
  else:
145
186
  raise AttributeError
@@ -151,8 +192,11 @@ class Map:
151
192
  vertices
152
193
  """
153
194
  edges = []
154
- for vertex in self.__type_dict:
155
- for neighbour in self.__type_dict[vertex]:
195
+ # Iterate over a snapshot of items and snapshot neighbour lists to
196
+ # avoid concurrent modification issues.
197
+ for vertex, neighbours in self._snapshot_items():
198
+ neighbours_snapshot = list(neighbours)
199
+ for neighbour in neighbours_snapshot:
156
200
  if {neighbour, vertex} not in edges:
157
201
  edges.append({vertex, neighbour})
158
202
  return edges
@@ -163,22 +207,25 @@ class Map:
163
207
  return
164
208
  vertex2 = child_obj.unique_name
165
209
 
166
- if vertex1 in self.__type_dict.keys() and vertex2 in self.__type_dict[vertex1]:
210
+ if vertex1 in self.__type_dict and vertex2 in self.__type_dict[vertex1]:
167
211
  del self.__type_dict[vertex1][self.__type_dict[vertex1].index(vertex2)]
168
212
 
213
+ def prune_type_dict(self, key: str):
214
+ if key in self.__type_dict:
215
+ del self.__type_dict[key]
216
+
169
217
  def prune(self, key: str):
170
- if key in self.__type_dict.keys():
218
+ if key in self.__type_dict:
171
219
  del self.__type_dict[key]
172
- del self._store[key]
220
+ if key in self._store:
221
+ del self._store[key]
173
222
 
174
223
  def find_isolated_vertices(self) -> list:
175
224
  """returns a list of isolated vertices."""
176
- graph = self.__type_dict
177
225
  isolated = []
178
- for vertex in graph:
179
- print(isolated, vertex)
180
- if not graph[vertex]:
181
- isolated += [vertex]
226
+ for vertex, neighbours in self._snapshot_items():
227
+ if not list(neighbours):
228
+ isolated.append(vertex)
182
229
  return isolated
183
230
 
184
231
  def find_path(self, start_vertex: str, end_vertex: str, path=[]) -> list:
@@ -230,9 +277,10 @@ class Map:
230
277
  path_length = sys.maxsize
231
278
  optimum_path = []
232
279
  if start_vertex is None:
233
- # We now have to find where to begin.....
234
- for possible_start, vertices in self.__type_dict.items():
235
- if end_vertex in vertices:
280
+ # We now have to find where to begin..... Iterate over a snapshot
281
+ for possible_start, vertices in self._snapshot_items():
282
+ vertices_snapshot = list(vertices)
283
+ if end_vertex in vertices_snapshot:
236
284
  temp_path = self.find_path(possible_start, end_vertex)
237
285
  if len(temp_path) < path_length:
238
286
  path_length = len(temp_path)
@@ -247,13 +295,13 @@ class Map:
247
295
  if vertices_encountered is None:
248
296
  vertices_encountered = set()
249
297
  graph = self.__type_dict
250
- vertices = list(graph.keys())
298
+ vertices = list(graph)
251
299
  if not start_vertex:
252
300
  # chose a vertex from graph as a starting point
253
301
  start_vertex = vertices[0]
254
302
  vertices_encountered.add(start_vertex)
255
303
  if len(vertices_encountered) != len(vertices):
256
- for vertex in graph[start_vertex]:
304
+ for vertex in list(graph[start_vertex]):
257
305
  if vertex not in vertices_encountered and self.is_connected(vertices_encountered, vertex):
258
306
  return True
259
307
  else:
@@ -262,10 +310,9 @@ class Map:
262
310
 
263
311
  def _clear(self):
264
312
  """Reset the map to an empty state. Only to be used for testing"""
265
- for vertex in self.vertices():
266
- self.prune(vertex)
313
+ self._store.clear()
314
+ self.__type_dict.clear()
267
315
  gc.collect()
268
- self.__type_dict = {}
269
316
 
270
317
  def __repr__(self) -> str:
271
318
  return f'Map object of {len(self._store)} vertices.'
@@ -423,16 +423,13 @@ class DescriptorNumber(DescriptorBase):
423
423
 
424
424
  def __truediv__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorNumber:
425
425
  if isinstance(other, numbers.Number):
426
- original_other = other
427
426
  if other == 0:
428
427
  raise ZeroDivisionError('Cannot divide by zero')
429
428
  new_value = self.full_value / other
430
429
  elif type(other) is DescriptorNumber:
431
- original_other = other.value
432
- if original_other == 0:
430
+ if other.value == 0:
433
431
  raise ZeroDivisionError('Cannot divide by zero')
434
432
  new_value = self.full_value / other.full_value
435
- other.value = original_other
436
433
  else:
437
434
  return NotImplemented
438
435
  descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value)
@@ -121,7 +121,12 @@ class Parameter(DescriptorNumber):
121
121
 
122
122
  @classmethod
123
123
  def from_dependency(
124
- cls, name: str, dependency_expression: str, dependency_map: Optional[dict] = None, **kwargs
124
+ cls,
125
+ name: str,
126
+ dependency_expression: str,
127
+ dependency_map: Optional[dict] = None,
128
+ desired_unit: str | sc.Unit | None = None,
129
+ **kwargs,
125
130
  ) -> Parameter: # noqa: E501
126
131
  """
127
132
  Create a dependent Parameter directly from a dependency expression.
@@ -129,15 +134,20 @@ class Parameter(DescriptorNumber):
129
134
  :param name: The name of the parameter
130
135
  :param dependency_expression: The dependency expression to evaluate. This should be a string which can be evaluated by the ASTEval interpreter.
131
136
  :param dependency_map: A dictionary of dependency expression symbol name and dependency object pairs. This is inserted into the asteval interpreter to resolve dependencies.
137
+ :param desired_unit: The desired unit of the dependent parameter.
132
138
  :param kwargs: Additional keyword arguments to pass to the Parameter constructor.
133
139
  :return: A new dependent Parameter object.
134
140
  """ # noqa: E501
135
141
  # Set default values for required parameters for the constructor, they get overwritten by the dependency anyways
136
- default_kwargs = {'value': 0.0, 'unit': '', 'variance': 0.0, 'min': -np.inf, 'max': np.inf}
142
+ default_kwargs = {'value': 0.0, 'variance': 0.0, 'min': -np.inf, 'max': np.inf}
137
143
  # Update with user-provided kwargs, to avoid errors.
138
144
  default_kwargs.update(kwargs)
139
145
  parameter = cls(name=name, **default_kwargs)
140
- parameter.make_dependent_on(dependency_expression=dependency_expression, dependency_map=dependency_map)
146
+ parameter.make_dependent_on(
147
+ dependency_expression=dependency_expression,
148
+ dependency_map=dependency_map,
149
+ desired_unit=desired_unit,
150
+ )
141
151
  return parameter
142
152
 
143
153
  def _update(self) -> None:
@@ -158,11 +168,20 @@ class Parameter(DescriptorNumber):
158
168
  ) # noqa: E501
159
169
  self._min.unit = temporary_parameter.unit
160
170
  self._max.unit = temporary_parameter.unit
171
+
172
+ if self._desired_unit is not None:
173
+ self._convert_unit(self._desired_unit)
174
+
161
175
  self._notify_observers()
162
176
  else:
163
177
  warnings.warn('This parameter is not dependent. It cannot be updated.')
164
178
 
165
- def make_dependent_on(self, dependency_expression: str, dependency_map: Optional[dict] = None) -> None:
179
+ def make_dependent_on(
180
+ self,
181
+ dependency_expression: str,
182
+ dependency_map: Optional[dict] = None,
183
+ desired_unit: str | sc.Unit | None = None,
184
+ ) -> None:
166
185
  """
167
186
  Make this parameter dependent on another parameter. This will overwrite the current value, unit, variance, min and max.
168
187
 
@@ -183,6 +202,9 @@ class Parameter(DescriptorNumber):
183
202
  A dictionary of dependency expression symbol name and dependency object pairs.
184
203
  This is inserted into the asteval interpreter to resolve dependencies.
185
204
 
205
+ :param desired_unit:
206
+ The desired unit of the dependent parameter. If None, the default unit of the dependency expression result is used.
207
+
186
208
  """ # noqa: E501
187
209
  if not isinstance(dependency_expression, str):
188
210
  raise TypeError('`dependency_expression` must be a string representing a valid dependency expression.')
@@ -212,6 +234,7 @@ class Parameter(DescriptorNumber):
212
234
  '_dependency_map': self._dependency_map,
213
235
  '_dependency_interpreter': self._dependency_interpreter,
214
236
  '_clean_dependency_string': self._clean_dependency_string,
237
+ '_desired_unit': self._desired_unit,
215
238
  }
216
239
  for dependency in self._dependency_map.values():
217
240
  dependency._detach_observer(self)
@@ -219,6 +242,9 @@ class Parameter(DescriptorNumber):
219
242
  self._independent = False
220
243
  self._dependency_string = dependency_expression
221
244
  self._dependency_map = dependency_map if dependency_map is not None else {}
245
+ if desired_unit is not None and not (isinstance(desired_unit, str) or isinstance(desired_unit, sc.Unit)):
246
+ raise TypeError('`desired_unit` must be a string representing a valid unit.')
247
+ self._desired_unit = desired_unit
222
248
  # List of allowed python constructs for the asteval interpreter
223
249
  asteval_config = {
224
250
  'import': False,
@@ -289,6 +315,17 @@ class Parameter(DescriptorNumber):
289
315
  raise error
290
316
  # Update the parameter with the dependency result
291
317
  self._fixed = False
318
+
319
+ if self._desired_unit is not None:
320
+ try:
321
+ dependency_result._convert_unit(self._desired_unit)
322
+ except Exception as e:
323
+ desired_unit_for_error_message = self._desired_unit
324
+ self._revert_dependency() # also deletes self._desired_unit
325
+ raise UnitError(
326
+ f'Failed to convert unit from {dependency_result.unit} to {desired_unit_for_error_message}: {e}'
327
+ )
328
+
292
329
  self._update()
293
330
 
294
331
  def make_independent(self) -> None:
@@ -306,6 +343,7 @@ class Parameter(DescriptorNumber):
306
343
  del self._dependency_interpreter
307
344
  del self._dependency_string
308
345
  del self._clean_dependency_string
346
+ del self._desired_unit
309
347
  else:
310
348
  raise AttributeError('This parameter is already independent.')
311
349
 
@@ -470,6 +508,28 @@ class Parameter(DescriptorNumber):
470
508
  """
471
509
  self._convert_unit(unit_str)
472
510
 
511
+ def set_desired_unit(self, unit_str: str | sc.Unit | None) -> None:
512
+ """
513
+ Set the desired unit for a dependent Parameter. This will convert the parameter to the desired unit.
514
+
515
+ :param unit_str: The desired unit as a string.
516
+ """
517
+
518
+ if self._independent:
519
+ raise AttributeError('This is an independent parameter, desired unit can only be set for dependent parameters.')
520
+ if not (isinstance(unit_str, str) or isinstance(unit_str, sc.Unit) or unit_str is None):
521
+ raise TypeError('`unit_str` must be a string representing a valid unit.')
522
+
523
+ if unit_str is not None:
524
+ try:
525
+ old_unit_for_message = self.unit
526
+ self._convert_unit(unit_str)
527
+ except Exception as e:
528
+ raise UnitError(f'Failed to convert unit from {old_unit_for_message} to {unit_str}: {e}')
529
+
530
+ self._desired_unit = unit_str
531
+ self._update()
532
+
473
533
  @property
474
534
  def min(self) -> numbers.Number:
475
535
  """
@@ -580,6 +640,9 @@ class Parameter(DescriptorNumber):
580
640
  # Save the dependency expression
581
641
  raw_dict['_dependency_string'] = self._clean_dependency_string
582
642
 
643
+ if self._desired_unit is not None:
644
+ raw_dict['_desired_unit'] = self._desired_unit
645
+
583
646
  # Mark that this parameter is dependent
584
647
  raw_dict['_independent'] = self._independent
585
648
 
@@ -648,6 +711,7 @@ class Parameter(DescriptorNumber):
648
711
  dependency_string = raw_dict.pop('_dependency_string', None)
649
712
  dependency_map_serializer_ids = raw_dict.pop('_dependency_map_serializer_ids', None)
650
713
  is_independent = raw_dict.pop('_independent', True)
714
+ desired_unit = raw_dict.pop('_desired_unit', None)
651
715
  # Note: Keep _serializer_id in the dict so it gets passed to __init__
652
716
 
653
717
  # Create the parameter using the base class method (serializer_id is now handled in __init__)
@@ -659,6 +723,7 @@ class Parameter(DescriptorNumber):
659
723
  param._pending_dependency_map_serializer_ids = dependency_map_serializer_ids
660
724
  # Keep parameter as independent initially - will be made dependent after all objects are loaded
661
725
  param._independent = True
726
+ param._pending_desired_unit = desired_unit
662
727
 
663
728
  return param
664
729
 
@@ -874,10 +939,14 @@ class Parameter(DescriptorNumber):
874
939
  elif self.max <= 0:
875
940
  combinations = [self.max / other.min, np.inf]
876
941
  else:
877
- combinations = [self.min / other.min, self.max / other.max, self.min / other.max, self.max / other.min]
942
+ combinations = [
943
+ self.min / other.min,
944
+ self.max / other.max,
945
+ self.min / other.max,
946
+ self.max / other.min,
947
+ ]
878
948
  else:
879
949
  combinations = [self.min / other.value, self.max / other.value]
880
- other.value = other_value
881
950
  else:
882
951
  return NotImplemented
883
952
  min_value = min(combinations)
@@ -926,7 +995,6 @@ class Parameter(DescriptorNumber):
926
995
  parameter = Parameter.from_scipp(name=self.name, full_value=new_full_value, min=min_value, max=max_value)
927
996
  parameter._convert_unit(parameter._base_unit())
928
997
  parameter.name = parameter.unique_name
929
- self.value = original_self
930
998
  return parameter
931
999
 
932
1000
  def __pow__(self, other: Union[DescriptorNumber, numbers.Number]) -> Parameter:
@@ -1019,13 +1087,18 @@ class Parameter(DescriptorNumber):
1019
1087
 
1020
1088
  # Establish the dependency relationship
1021
1089
  try:
1022
- self.make_dependent_on(dependency_expression=dependency_string, dependency_map=dependency_map)
1090
+ self.make_dependent_on(
1091
+ dependency_expression=dependency_string,
1092
+ dependency_map=dependency_map,
1093
+ desired_unit=self._pending_desired_unit,
1094
+ )
1023
1095
  except Exception as e:
1024
1096
  raise ValueError(f"Error establishing dependency '{dependency_string}': {e}")
1025
1097
 
1026
1098
  # Clean up temporary attributes
1027
1099
  delattr(self, '_pending_dependency_string')
1028
1100
  delattr(self, '_pending_dependency_map_serializer_ids')
1101
+ delattr(self, '_pending_desired_unit')
1029
1102
 
1030
1103
  def _find_parameter_by_serializer_id(self, serializer_id: str) -> Optional['DescriptorNumber']:
1031
1104
  """Find a parameter by its serializer_id from all parameters in the global map."""
@@ -1 +0,0 @@
1
- __version__ = '2.1.0'
File without changes
File without changes
File without changes