From 5d62a99630ae86ead2e9b586e6b20e8c6bdf2344 Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Wed, 5 Mar 2025 13:25:22 +0100 Subject: [PATCH 01/18] first changes --- src/easyscience/Objects/ObjectClasses.py | 2 +- .../Objects/new_variable/parameter.py | 28 +++---- src/easyscience/Objects/virtual.py | 6 +- src/easyscience/fitting/Constraints.py | 18 ++-- tests/unit_tests/Fitting/test_constraints.py | 16 ++-- .../Objects/new_variable/test_parameter.py | 36 ++++---- tests/unit_tests/Objects/test_BaseObj.py | 83 ++++++++++--------- .../global_object/test_undo_redo.py | 40 ++++----- 8 files changed, 118 insertions(+), 111 deletions(-) diff --git a/src/easyscience/Objects/ObjectClasses.py b/src/easyscience/Objects/ObjectClasses.py index 037f0a4..8318d2a 100644 --- a/src/easyscience/Objects/ObjectClasses.py +++ b/src/easyscience/Objects/ObjectClasses.py @@ -202,7 +202,7 @@ def get_fit_parameters(self) -> Union[List[Parameter], List[NewParameter]]: if hasattr(item, 'get_fit_parameters'): fit_list = [*fit_list, *item.get_fit_parameters()] elif isinstance(item, Parameter) or isinstance(item, NewParameter): - if item.enabled and not item.fixed: + if item.independent and not item.fixed: fit_list.append(item) return fit_list diff --git a/src/easyscience/Objects/new_variable/parameter.py b/src/easyscience/Objects/new_variable/parameter.py index 66626cd..10ebc8e 100644 --- a/src/easyscience/Objects/new_variable/parameter.py +++ b/src/easyscience/Objects/new_variable/parameter.py @@ -54,7 +54,7 @@ def __init__( url: Optional[str] = None, display_name: Optional[str] = None, callback: property = property(), - enabled: Optional[bool] = True, + independent: Optional[bool] = True, parent: Optional[Any] = None, ): """ @@ -72,7 +72,7 @@ def __init__( :param description: A brief summary of what this object is :param url: Lookup url for documentation/information :param display_name: The name of the object as it should be displayed - :param enabled: Can the objects value be set + :param independent: Can the objects value be set :param parent: The object which is the parent to this one .. note:: @@ -113,7 +113,7 @@ def __init__( # Create additional fitting elements self._fixed = fixed - self._enabled = enabled + self._independent = independent self._initial_scalar = copy.deepcopy(self._scalar) builtin_constraint = { # Last argument in constructor is the name of the property holding the value of the constraint @@ -171,9 +171,9 @@ def value(self, value: numbers.Number) -> None: :param value: New value of self """ - if not self.enabled: + if not self.independent: if global_object.debug: - raise CoreSetException(f'{str(self)} is not enabled.') + raise CoreSetException(f'{str(self)} is not independent.') return if not isinstance(value, numbers.Number) or isinstance(value, bool): @@ -289,12 +289,12 @@ def fixed(self, fixed: bool) -> None: :param fixed: True = fixed, False = can vary """ - if not self.enabled: + if not self.independent: if self._global_object.stack.enabled: # Remove the recorded change from the stack self._global_object.stack.pop() if global_object.debug: - raise CoreSetException(f'{str(self)} is not enabled.') + raise CoreSetException(f'{str(self)} is not independent.') return if not isinstance(fixed, bool): raise ValueError(f'{fixed=} must be a boolean. Got {type(fixed)}') @@ -329,8 +329,8 @@ def bounds(self, new_bound: Tuple[numbers.Number, numbers.Number]) -> None: raise ValueError(f'Current paramter value: {self._scalar.value} must be within {new_bound=}') # Enable the parameter if needed - if not self.enabled: - self.enabled = True + if not self.independent: + self.independent = True # Free parameter if needed if self.fixed: self.fixed = False @@ -376,23 +376,23 @@ def _constraint_runner( return value @property - def enabled(self) -> bool: + def independent(self) -> bool: """ Logical property to see if the objects value can be directly set. :return: Can the objects value be set """ - return self._enabled + return self._independent - @enabled.setter + @independent.setter @property_stack_deco - def enabled(self, value: bool) -> None: + def independent(self, value: bool) -> None: """ Enable and disable the direct setting of an objects value field. :param value: True - objects value can be set, False - the opposite """ - self._enabled = value + self._independent = value def __copy__(self) -> Parameter: new_obj = super().__copy__() diff --git a/src/easyscience/Objects/virtual.py b/src/easyscience/Objects/virtual.py index 12562f6..3fcf8e6 100644 --- a/src/easyscience/Objects/virtual.py +++ b/src/easyscience/Objects/virtual.py @@ -79,8 +79,8 @@ def component_realizer(obj: BV, component: str, recursive: bool = True): if not isinstance(obj, Iterable) or not issubclass(obj.__class__, MutableSequence): old_component = obj._kwargs[component] new_components = realizer(obj._kwargs[component]) - if hasattr(new_components, 'enabled'): - new_components.enabled = True + if hasattr(new_components, 'independent'): + new_components.independent = True else: old_component = obj[component] new_components = realizer(obj[component]) @@ -169,7 +169,7 @@ def virtualizer(obj: BV) -> BV: d['fixed'] = True d['unique_name'] = None v_p = cls(**d) - v_p._enabled = False + v_p.independent = False constraint = ObjConstraint(v_p, '', obj) constraint.external = True obj._constraints['virtual'][v_p.unique_name] = constraint diff --git a/src/easyscience/fitting/Constraints.py b/src/easyscience/fitting/Constraints.py index b123d12..f998063 100644 --- a/src/easyscience/fitting/Constraints.py +++ b/src/easyscience/fitting/Constraints.py @@ -60,11 +60,11 @@ def __init__( # Test if dependent is a parameter or a descriptor. # We can not import `Parameter`, so...... if dependent_obj.__class__.__name__ == 'Parameter': - if not dependent_obj.enabled: + if not dependent_obj.independent: raise AssertionError('A dependent object needs to be initially enabled.') if global_object.debug: print(f'Dependent variable {dependent_obj}. It should be a `Descriptor`.' f'Setting to fixed') - dependent_obj.enabled = False + dependent_obj.independent = False self._finalizer = weakref.finalize(self, cleanup_constraint, self.dependent_obj_ids, True) self.operator = operator @@ -94,10 +94,10 @@ def enabled(self, enabled_value: bool): if self._enabled == enabled_value: return elif enabled_value: - self.get_obj(self.dependent_obj_ids).enabled = False + self.get_obj(self.dependent_obj_ids).independent = False self() else: - self.get_obj(self.dependent_obj_ids).enabled = True + self.get_obj(self.dependent_obj_ids).independent = True self._enabled = enabled_value def __call__(self, *args, no_set: bool = False, **kwargs): @@ -126,12 +126,12 @@ def __call__(self, *args, no_set: bool = False, **kwargs): if not no_set: toggle = False - if not dependent_obj.enabled: - dependent_obj.enabled = True + if not dependent_obj.independent: + dependent_obj.independent = True toggle = True dependent_obj.value = value if toggle: - dependent_obj.enabled = False + dependent_obj.independent = False return value @abstractmethod @@ -514,10 +514,10 @@ def __repr__(self) -> str: return f'{self.__class__.__name__}' -def cleanup_constraint(obj_id: str, enabled: bool): +def cleanup_constraint(obj_id: str, independent: bool): try: obj = global_object.map.get_item_by_key(obj_id) - obj.enabled = enabled + obj.independent = independent except ValueError: if global_object.debug: print(f'Object with ID {obj_id} has already been deleted') diff --git a/tests/unit_tests/Fitting/test_constraints.py b/tests/unit_tests/Fitting/test_constraints.py index b20247e..17d35f2 100644 --- a/tests/unit_tests/Fitting/test_constraints.py +++ b/tests/unit_tests/Fitting/test_constraints.py @@ -113,22 +113,22 @@ def test_ObjConstraint_Multiple(threePars): def test_ConstraintEnable_Disable(twoPars): - assert twoPars[0][0].enabled - assert twoPars[0][1].enabled + assert twoPars[0][0].independent + assert twoPars[0][1].independent c = ObjConstraint(twoPars[0][0], "", twoPars[0][1]) twoPars[0][0].user_constraints["num_1"] = c assert c.enabled - assert twoPars[0][1].enabled - assert not twoPars[0][0].enabled + assert twoPars[0][1].independent + assert not twoPars[0][0].independent c.enabled = False assert not c.enabled - assert twoPars[0][1].enabled - assert twoPars[0][0].enabled + assert twoPars[0][1].independent + assert twoPars[0][0].independent c.enabled = True assert c.enabled - assert twoPars[0][1].enabled - assert not twoPars[0][0].enabled + assert twoPars[0][1].independent + assert not twoPars[0][0].independent diff --git a/tests/unit_tests/Objects/new_variable/test_parameter.py b/tests/unit_tests/Objects/new_variable/test_parameter.py index 5269d81..72fcc7d 100644 --- a/tests/unit_tests/Objects/new_variable/test_parameter.py +++ b/tests/unit_tests/Objects/new_variable/test_parameter.py @@ -24,7 +24,7 @@ def parameter(self) -> Parameter: url="url", display_name="display_name", callback=self.mock_callback, - enabled="enabled", + independent="independent", parent=None, ) return parameter @@ -40,7 +40,7 @@ def test_init(self, parameter: Parameter): assert parameter._max.value == 10 assert parameter._max.unit == "m" assert parameter._callback == self.mock_callback - assert parameter._enabled == "enabled" + assert parameter._independent == "independent" # From super assert parameter._scalar.value == 1 @@ -69,7 +69,7 @@ def test_init_value_min_exception(self): url="url", display_name="display_name", callback=mock_callback, - enabled="enabled", + independent="independent", parent=None, ) @@ -91,7 +91,7 @@ def test_init_value_max_exception(self): url="url", display_name="display_name", callback=mock_callback, - enabled="enabled", + independent="independent", parent=None, ) @@ -189,7 +189,7 @@ def test_bounds(self, parameter: Parameter): def test_set_bounds(self, parameter: Parameter): # When - parameter._enabled = False + parameter._independent = False parameter._fixed = True # Then @@ -198,12 +198,12 @@ def test_set_bounds(self, parameter: Parameter): # Expect assert parameter.min == -10 assert parameter.max == 5 - assert parameter._enabled == True + assert parameter._independent == True assert parameter._fixed == False def test_set_bounds_exception_min(self, parameter: Parameter): # When - parameter._enabled = False + parameter._independent = False parameter._fixed = True # Then @@ -213,12 +213,12 @@ def test_set_bounds_exception_min(self, parameter: Parameter): # Expect assert parameter.min == 0 assert parameter.max == 10 - assert parameter._enabled == False + assert parameter._independent == False assert parameter._fixed == True def test_set_bounds_exception_max(self, parameter: Parameter): # When - parameter._enabled = False + parameter._independent = False parameter._fixed = True # Then @@ -228,22 +228,22 @@ def test_set_bounds_exception_max(self, parameter: Parameter): # Expect assert parameter.min == 0 assert parameter.max == 10 - assert parameter._enabled == False + assert parameter._independent == False assert parameter._fixed == True - def test_enabled(self, parameter: Parameter): + def test_independent(self, parameter: Parameter): # When - parameter._enabled = True + parameter._independent = True # Then Expect - assert parameter.enabled is True + assert parameter.independent is True - def test_set_enabled(self, parameter: Parameter): + def test_set_independent(self, parameter: Parameter): # When - parameter.enabled = False + parameter.independent = False # Then Expect - assert parameter._enabled is False + assert parameter._independent is False def test_value_match_callback(self, parameter: Parameter): # When @@ -313,7 +313,7 @@ def test_copy(self, parameter: Parameter): assert parameter_copy._description == parameter._description assert parameter_copy._url == parameter._url assert parameter_copy._display_name == parameter._display_name - assert parameter_copy._enabled == parameter._enabled + assert parameter_copy._independent == parameter._independent def test_as_data_dict(self, clear, parameter: Parameter): # When Then @@ -331,7 +331,7 @@ def test_as_data_dict(self, clear, parameter: Parameter): "description": "description", "url": "url", "display_name": "display_name", - "enabled": "enabled", + "independent": "independent", "unique_name": "Parameter_0", } diff --git a/tests/unit_tests/Objects/test_BaseObj.py b/tests/unit_tests/Objects/test_BaseObj.py index 5fdb087..742c3b5 100644 --- a/tests/unit_tests/Objects/test_BaseObj.py +++ b/tests/unit_tests/Objects/test_BaseObj.py @@ -17,8 +17,8 @@ import easyscience from easyscience.Objects.ObjectClasses import BaseObj -from easyscience.Objects.ObjectClasses import Descriptor -from easyscience.Objects.ObjectClasses import Parameter +from easyscience.Objects.new_variable import DescriptorNumber +from easyscience.Objects.new_variable import Parameter from easyscience.Utils.io.dict import DictSerializer from easyscience import global_object @@ -31,9 +31,9 @@ def setup_pars(): d = { "name": "test", "par1": Parameter("p1", 0.1, fixed=True), - "des1": Descriptor("d1", 0.1), + "des1": DescriptorNumber("d1", 0.1), "par2": Parameter("p2", 0.1), - "des2": Descriptor("d2", 0.1), + "des2": DescriptorNumber("d2", 0.1), "par3": Parameter("p3", 0.1), } return d @@ -103,8 +103,8 @@ def test_baseobj_set(setup_pars: dict): obj = BaseObj(name, **kwargs) new_value = 5.0 with not_raises([AttributeError, ValueError]): - obj.p1 = new_value - assert obj.p1.raw_value == new_value + obj.p1.value = new_value + assert obj.p1.value == new_value def test_baseobj_get_parameters(setup_pars: dict): @@ -134,25 +134,29 @@ def test_baseobj_as_dict(setup_pars: dict): "@class": "BaseObj", "@version": easyscience.__version__, "name": "test", + "unique_name": "BaseObj_0", "par1": { "@module": Parameter.__module__, "@class": Parameter.__name__, "@version": easyscience.__version__, "name": "p1", "value": 0.1, - "error": 0.0, + "unit": "dimensionless", + "variance": 0.0, "min": -np.inf, "max": np.inf, "fixed": True, - "units": "dimensionless", + "unique_name": "Parameter_0" }, "des1": { - "@module": Descriptor.__module__, - "@class": Descriptor.__name__, + "@module": DescriptorNumber.__module__, + "@class": DescriptorNumber.__name__, "@version": easyscience.__version__, "name": "d1", "value": 0.1, - "units": "dimensionless", + "unit": "dimensionless", + "variable": "d1", + "unique_name": "DescriptorNumber_0", "description": "", "url": "", "display_name": "d1", @@ -163,19 +167,22 @@ def test_baseobj_as_dict(setup_pars: dict): "@version": easyscience.__version__, "name": "p2", "value": 0.1, - "error": 0.0, + "unit": "dimensionless", + "variance": 0.0, "min": -np.inf, "max": np.inf, "fixed": False, - "units": "dimensionless", + "unique_name": "Parameter_1" }, "des2": { - "@module": Descriptor.__module__, - "@class": Descriptor.__name__, + "@module": DescriptorNumber.__module__, + "@class": DescriptorNumber.__name__, "@version": easyscience.__version__, "name": "d2", "value": 0.1, - "units": "dimensionless", + "unit": "dimensionless", + "variance": None, + "unique_name": "DescriptorNumber_1", "description": "", "url": "", "display_name": "d2", @@ -186,11 +193,11 @@ def test_baseobj_as_dict(setup_pars: dict): "@version": easyscience.__version__, "name": "p3", "value": 0.1, - "error": 0.0, + "unit": "dimensionless", + "variance": 0.0, "min": -np.inf, "max": np.inf, "fixed": False, - "units": "dimensionless", }, } @@ -333,23 +340,23 @@ def from_pars(cls, m, c, diff): return cls(m, c, diff) def __call__(self, *args, **kwargs): - return super(L2, self).__call__(*args, **kwargs) + self.diff.raw_value + return super(L2, self).__call__(*args, **kwargs) + self.diff.value l2 = L2.from_pars(1, 2, 3) - assert l2.m.raw_value == 1 - assert l2.c.raw_value == 2 - assert l2.diff.raw_value == 3 + assert l2.m.value == 1 + assert l2.c.value == 2 + assert l2.diff.value == 3 l2.diff = 4 assert isinstance(l2.diff, Parameter) - assert l2.diff.raw_value == 4 + assert l2.diff.value == 4 l2.foo = "foo" assert l2.foo == "foo" x = np.linspace(0, 10, 100) - y = l2.m.raw_value * x + l2.c.raw_value + l2.diff.raw_value + y = l2.m.value * x + l2.c.value + l2.diff.value assert np.allclose(l2(x), y) @@ -368,11 +375,11 @@ def from_pars(cls, a: float): a = A.from_pars(a_start) graph = a._global_object.map - assert a.a.raw_value == a_start + assert a.a.value == a_start assert len(graph.get_edges(a)) == 1 setattr(a, "a", a_end) - assert a.a.raw_value == a_end + assert a.a.value == a_end assert len(graph.get_edges(a)) == 1 @@ -409,11 +416,11 @@ def from_pars(cls, a: float): a = A.from_pars(a_start) graph = a._global_object.map - assert a.a.raw_value == a_start + assert a.a.value == a_start assert len(graph.get_edges(a)) == 1 setattr(a, "a", a_end) - assert a.a.raw_value == a_end + assert a.a.value == a_end assert len(graph.get_edges(a)) == 1 @@ -434,14 +441,14 @@ def from_pars(cls, a: float): a = A.from_pars(a_start) graph = a._global_object.map - assert a.a.raw_value == a_start + assert a.a.value == a_start assert len(graph.get_edges(a)) == 1 a_ = Parameter("a", a_end) assert a.a.unique_name in graph.get_edges(a) a__ = a.a setattr(a, "a", a_) - assert a.a.raw_value == a_end + assert a.a.value == a_end assert len(graph.get_edges(a)) == 1 assert a_.unique_name in graph.get_edges(a) assert a__.unique_name not in graph.get_edges(a) @@ -455,13 +462,13 @@ def __init__(self, a: Optional[Union[Parameter, float]] = None): self.a = a a = A() - assert a.a.raw_value == 1.0 + assert a.a.value == 1.0 a = A(2.0) - assert a.a.raw_value == 2.0 + assert a.a.value == 2.0 a = A(Parameter("a", 3.0)) - assert a.a.raw_value == 3.0 + assert a.a.value == 3.0 a.a = 4.0 - assert a.a.raw_value == 4.0 + assert a.a.value == 4.0 class B(BaseObj): def __init__(self, b: Optional[Union[A, Parameter, float]] = None): @@ -472,13 +479,13 @@ def __init__(self, b: Optional[Union[A, Parameter, float]] = None): self.b = b b = B() - assert b.b.a.raw_value == 1.0 + assert b.b.a.value == 1.0 b = B(2.0) - assert b.b.a.raw_value == 2.0 + assert b.b.a.value == 2.0 b = B(A(3.0)) - assert b.b.a.raw_value == 3.0 + assert b.b.a.value == 3.0 b.b.a = 4.0 - assert b.b.a.raw_value == 4.0 + assert b.b.a.value == 4.0 def test_unique_name_generator(clear): # When Then diff --git a/tests/unit_tests/global_object/test_undo_redo.py b/tests/unit_tests/global_object/test_undo_redo.py index fb067d1..d1b02c6 100644 --- a/tests/unit_tests/global_object/test_undo_redo.py +++ b/tests/unit_tests/global_object/test_undo_redo.py @@ -12,8 +12,8 @@ from easyscience.Objects.Groups import BaseCollection from easyscience.Objects.ObjectClasses import BaseObj -from easyscience.Objects.Variable import Descriptor -from easyscience.Objects.Variable import Parameter +from easyscience.Objects.new_variable import DescriptorNumber +from easyscience.Objects.new_variable import Parameter from easyscience.fitting import Fitter @@ -24,7 +24,7 @@ def createSingleObjs(idx): if idx % 2: return Parameter(name, idx) else: - return Descriptor(name, idx) + return DescriptorNumber(name, idx) def createParam(option): @@ -67,7 +67,7 @@ def getter(_obj, _attr): for option in [ ("value", 500), ("error", 5), - ("enabled", False), + ("independent", False), ("unit", "meter / second"), ("display_name", "boom"), ("fixed", False), @@ -77,7 +77,7 @@ def getter(_obj, _attr): ], ) @pytest.mark.parametrize( - "idx", [pytest.param(0, id="Descriptor"), pytest.param(1, id="Parameter")] + "idx", [pytest.param(0, id="DescriptorNumber"), pytest.param(1, id="Parameter")] ) def test_SinglesUndoRedo(idx, test): obj = createSingleObjs(idx) @@ -96,7 +96,7 @@ def test_Parameter_Bounds_UndoRedo(value): from easyscience import global_object global_object.stack.enabled = True - p = Parameter("test", 1, enabled=value) + p = Parameter("test", 1, independent=value) assert p.min == -np.inf assert p.max == np.inf assert p.bounds == (-np.inf, np.inf) @@ -105,13 +105,13 @@ def test_Parameter_Bounds_UndoRedo(value): assert p.min == 0 assert p.max == 2 assert p.bounds == (0, 2) - assert p.enabled is True + assert p.independent is True global_object.stack.undo() assert p.min == -np.inf assert p.max == np.inf assert p.bounds == (-np.inf, np.inf) - assert p.enabled is value + assert p.independent is value def test_BaseObjUndoRedo(): @@ -125,7 +125,7 @@ def test_BaseObjUndoRedo(): # Test setting value for b_obj in objs.values(): - e = doUndoRedo(obj, b_obj.name, b_obj.raw_value + 1, "raw_value") + e = doUndoRedo(obj, b_obj.name, b_obj.value + 1, "value") if e: raise e @@ -209,25 +209,25 @@ def test_UndoRedoMacros(): global_object.stack.enabled = True global_object.stack.beginMacro(undo_text) - values = [item.raw_value for item in items] + values = [item.value for item in items] for item, value in zip(items, values): item.value = value + offset global_object.stack.endMacro() for item, old_value in zip(items, values): - assert item.raw_value == old_value + offset + assert item.value == old_value + offset assert global_object.stack.undoText() == undo_text global_object.stack.undo() for item, old_value in zip(items, values): - assert item.raw_value == old_value + assert item.value == old_value assert global_object.stack.redoText() == undo_text global_object.stack.redo() for item, old_value in zip(items, values): - assert item.raw_value == old_value + offset + assert item.value == old_value + offset @pytest.mark.parametrize("fit_engine", ["LMFit", "Bumps", "DFO"]) @@ -254,7 +254,7 @@ def from_pars(cls, m_value: float, c_value: float): return cls(m=m, c=c) def __call__(self, x: np.ndarray) -> np.ndarray: - return self.m.raw_value * x + self.c.raw_value + return self.m.value * x + self.c.value l1 = Line.default() m_sp = 4 @@ -277,18 +277,18 @@ def __call__(self, x: np.ndarray) -> np.ndarray: global_object.stack.enabled = True res = f.fit(x, y) - # assert l1.c.raw_value == pytest.approx(l2.c.raw_value, rel=l2.c.error * 3) - # assert l1.m.raw_value == pytest.approx(l2.m.raw_value, rel=l2.m.error * 3) + # assert l1.c.value == pytest.approx(l2.c.value, rel=l2.c.error * 3) + # assert l1.m.value == pytest.approx(l2.m.value, rel=l2.m.error * 3) assert global_object.stack.undoText() == "Fitting routine" global_object.stack.undo() - assert l2.m.raw_value == m_sp - assert l2.c.raw_value == c_sp + assert l2.m.value == m_sp + assert l2.c.value == c_sp assert global_object.stack.redoText() == "Fitting routine" global_object.stack.redo() - assert l2.m.raw_value == res.p[f"p{l2.m.unique_name}"] - assert l2.c.raw_value == res.p[f"p{l2.c.unique_name}"] + assert l2.m.value == res.p[f"p{l2.m.unique_name}"] + assert l2.c.value == res.p[f"p{l2.c.unique_name}"] # @pytest.mark.parametrize('math_funcs', [pytest.param([Parameter.__iadd__, float.__add__], id='Addition'), From e27d5955d3b0d7a23769884b1284f31e997472f4 Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Tue, 1 Apr 2025 15:15:43 +0200 Subject: [PATCH 02/18] Add Observer pattern to DescriptorNumber --- .../Objects/variable/descriptor_number.py | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/easyscience/Objects/variable/descriptor_number.py b/src/easyscience/Objects/variable/descriptor_number.py index cfba4a4..b162a22 100644 --- a/src/easyscience/Objects/variable/descriptor_number.py +++ b/src/easyscience/Objects/variable/descriptor_number.py @@ -74,6 +74,8 @@ def __init__( if self.unit is not None: self.convert_unit(self._base_unit()) + self._observers: List[DescriptorNumber] = [] + @classmethod def from_scipp(cls, name: str, full_value: Variable, **kwargs) -> DescriptorNumber: """ @@ -90,6 +92,19 @@ def from_scipp(cls, name: str, full_value: Variable, **kwargs) -> DescriptorNumb raise TypeError(f'{full_value=} must be a scipp scalar') return cls(name=name, value=full_value.value, unit=full_value.unit, variance=full_value.variance, **kwargs) + def attach_observer(self, observer: DescriptorNumber) -> None: + """Attach an observer to the descriptor.""" + self._observers.append(observer) + + def detach_observer(self, observer: DescriptorNumber) -> None: + """Detach an observer from the descriptor.""" + self._observers.remove(observer) + + def notify_observers(self) -> None: + """Notify all observers of a change.""" + for observer in self._observers: + observer.update(self) + @property def full_value(self) -> Variable: """ @@ -125,6 +140,8 @@ def value(self, value: numbers.Number) -> None: if not isinstance(value, numbers.Number) or isinstance(value, bool): raise TypeError(f'{value=} must be a number') self._scalar.value = float(value) + # Notify observers of the change + self.notify_observers() @property def unit(self) -> str: @@ -168,6 +185,8 @@ def variance(self, variance_float: float) -> None: raise ValueError(f'{variance_float=} must be positive') variance_float = float(variance_float) self._scalar.variance = variance_float + # Notify observers of the change + self.notify_observers() @property def error(self) -> float: @@ -197,6 +216,8 @@ def error(self, value: float) -> None: self._scalar.variance = value**2 else: self._scalar.variance = None + # Notify observers of the change + self.notify_observers() def convert_unit(self, unit_str: str) -> None: """ @@ -228,7 +249,8 @@ def set_scalar(obj, scalar): # Update the scalar self._scalar = new_scalar - + # Notify observers of the change + self.notify_observers() # Just to get return type right def __copy__(self) -> DescriptorNumber: From df1d138f410df125eaf6809bbe5d2aa5d43d723b Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Wed, 2 Apr 2025 14:30:43 +0200 Subject: [PATCH 03/18] rename and move observer methods --- .../Objects/variable/descriptor_number.py | 19 ++++++++++--------- src/easyscience/Objects/variable/parameter.py | 18 ++++++++++++++++-- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/easyscience/Objects/variable/descriptor_number.py b/src/easyscience/Objects/variable/descriptor_number.py index b162a22..088b6d0 100644 --- a/src/easyscience/Objects/variable/descriptor_number.py +++ b/src/easyscience/Objects/variable/descriptor_number.py @@ -47,6 +47,8 @@ def __init__( param parent: Parent of the descriptor .. note:: Undo/Redo functionality is implemented for the attributes `variance`, `error`, `unit` and `value`. """ + self._observers: List[DescriptorNumber] = [] + if not isinstance(value, numbers.Number) or isinstance(value, bool): raise TypeError(f'{value=} must be a number') if variance is not None: @@ -74,7 +76,6 @@ def __init__( if self.unit is not None: self.convert_unit(self._base_unit()) - self._observers: List[DescriptorNumber] = [] @classmethod def from_scipp(cls, name: str, full_value: Variable, **kwargs) -> DescriptorNumber: @@ -92,18 +93,18 @@ def from_scipp(cls, name: str, full_value: Variable, **kwargs) -> DescriptorNumb raise TypeError(f'{full_value=} must be a scipp scalar') return cls(name=name, value=full_value.value, unit=full_value.unit, variance=full_value.variance, **kwargs) - def attach_observer(self, observer: DescriptorNumber) -> None: + def _attach_observer(self, observer: DescriptorNumber) -> None: """Attach an observer to the descriptor.""" self._observers.append(observer) - def detach_observer(self, observer: DescriptorNumber) -> None: + def _detach_observer(self, observer: DescriptorNumber) -> None: """Detach an observer from the descriptor.""" self._observers.remove(observer) - def notify_observers(self) -> None: + def _notify_observers(self) -> None: """Notify all observers of a change.""" for observer in self._observers: - observer.update(self) + observer._update(self) @property def full_value(self) -> Variable: @@ -141,7 +142,7 @@ def value(self, value: numbers.Number) -> None: raise TypeError(f'{value=} must be a number') self._scalar.value = float(value) # Notify observers of the change - self.notify_observers() + self._notify_observers() @property def unit(self) -> str: @@ -186,7 +187,7 @@ def variance(self, variance_float: float) -> None: variance_float = float(variance_float) self._scalar.variance = variance_float # Notify observers of the change - self.notify_observers() + self._notify_observers() @property def error(self) -> float: @@ -217,7 +218,7 @@ def error(self, value: float) -> None: else: self._scalar.variance = None # Notify observers of the change - self.notify_observers() + self._notify_observers() def convert_unit(self, unit_str: str) -> None: """ @@ -250,7 +251,7 @@ def set_scalar(obj, scalar): # Update the scalar self._scalar = new_scalar # Notify observers of the change - self.notify_observers() + self._notify_observers() # Just to get return type right def __copy__(self) -> DescriptorNumber: diff --git a/src/easyscience/Objects/variable/parameter.py b/src/easyscience/Objects/variable/parameter.py index 4a4edc5..d031b95 100644 --- a/src/easyscience/Objects/variable/parameter.py +++ b/src/easyscience/Objects/variable/parameter.py @@ -11,6 +11,7 @@ from types import MappingProxyType from typing import Any from typing import Dict +from typing import List from typing import Optional from typing import Tuple from typing import Union @@ -78,12 +79,12 @@ def __init__( .. note:: Undo/Redo functionality is implemented for the attributes `value`, `variance`, `error`, `min`, `max`, `bounds`, `fixed`, `unit` """ # noqa: E501 + if not isinstance(value, numbers.Number): + raise TypeError('`value` must be a number') if not isinstance(min, numbers.Number): raise TypeError('`min` must be a number') if not isinstance(max, numbers.Number): raise TypeError('`max` must be a number') - if not isinstance(value, numbers.Number): - raise TypeError('`value` must be a number') if value < min: raise ValueError(f'{value=} can not be less than {min=}') if value > max: @@ -125,6 +126,13 @@ def __init__( } self._constraints = Constraints(builtin=builtin_constraint, user={}, virtual={}) + self._observers: List[DescriptorNumber] = [] + + def _update(self) -> None: + """ + Update the parameter. This is called by the interface when the parameter is changed. + """ + @property def value_no_call_back(self) -> numbers.Number: """ @@ -207,6 +215,9 @@ def value(self, value: numbers.Number) -> None: if self._callback.fset is not None: self._callback.fset(self._scalar.value) + # Notify observers of the change + self._notify_observers() + def convert_unit(self, unit_str: str) -> None: """ Perform unit conversion. The value, max and min can change on unit change. @@ -218,6 +229,7 @@ def convert_unit(self, unit_str: str) -> None: new_unit = sc.Unit(unit_str) # unit_str is tested in super method self._min = self._min.to(unit=new_unit) self._max = self._max.to(unit=new_unit) + self._notify_observers() @property def min(self) -> numbers.Number: @@ -246,6 +258,7 @@ def min(self, min_value: numbers.Number) -> None: self._min.value = min_value else: raise ValueError(f'The current value ({self.value}) is smaller than the desired min value ({min_value}).') + self._notify_observers() @property def max(self) -> numbers.Number: @@ -274,6 +287,7 @@ def max(self, max_value: numbers.Number) -> None: self._max.value = max_value else: raise ValueError(f'The current value ({self.value}) is greater than the desired max value ({max_value}).') + self._notify_observers() @property def fixed(self) -> bool: From 7b61c5cbc4ede8582432d9632e1f61b3643659ed Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Wed, 2 Apr 2025 14:43:35 +0200 Subject: [PATCH 04/18] Add the dependency_interpreter to the global_object --- src/easyscience/global_object/global_object.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/easyscience/global_object/global_object.py b/src/easyscience/global_object/global_object.py index c78db4e..fde2595 100644 --- a/src/easyscience/global_object/global_object.py +++ b/src/easyscience/global_object/global_object.py @@ -5,6 +5,8 @@ __author__ = 'github.com/wardsimon' __version__ = '0.1.0' +from asteval import Interpreter + from easyscience.Utils.classUtils import singleton from .hugger.hugger import ScriptManager @@ -19,6 +21,8 @@ class GlobalObject: into the collective. """ + __dependency_interpreter = Interpreter(minimal=True) + __dependency_interpreter.config['if'] = True __log = Logger() __map = Map() __stack = None @@ -35,6 +39,8 @@ def __init__(self): self.script: ScriptManager = ScriptManager() # Map. This is the conduit database between all global object species self.map: Map = self.__map + # Dependency interpreter. This is used to evaluate dependencies in dependent Parameters + self.dependency_interpreter: Interpreter = self.__dependency_interpreter def instantiate_stack(self): """ From 6fd5d7a546f551d7f0d4b9e57811aefa7586f2d8 Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Wed, 2 Apr 2025 16:06:07 +0200 Subject: [PATCH 05/18] implement the _update method --- src/easyscience/Objects/variable/parameter.py | 11 +++++++++-- src/easyscience/global_object/global_object.py | 1 + 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/easyscience/Objects/variable/parameter.py b/src/easyscience/Objects/variable/parameter.py index d031b95..aa47ee7 100644 --- a/src/easyscience/Objects/variable/parameter.py +++ b/src/easyscience/Objects/variable/parameter.py @@ -130,8 +130,15 @@ def __init__( def _update(self) -> None: """ - Update the parameter. This is called by the interface when the parameter is changed. - """ + Update the parameter. This is called by the DescriptorNumbers/Parameters who have this Parameter as a dependency. + """ + temporary_parameter = self._global_object.dependency_interpreter(self._dependency_string) + self._scalar.value = temporary_parameter.value + self._scalar.unit = temporary_parameter.unit + self._scalar.variance = temporary_parameter.variance + self._min.value = temporary_parameter.min + self._max.value = temporary_parameter.max + self._notify_observers() @property def value_no_call_back(self) -> numbers.Number: diff --git a/src/easyscience/global_object/global_object.py b/src/easyscience/global_object/global_object.py index fde2595..738c4bf 100644 --- a/src/easyscience/global_object/global_object.py +++ b/src/easyscience/global_object/global_object.py @@ -23,6 +23,7 @@ class GlobalObject: __dependency_interpreter = Interpreter(minimal=True) __dependency_interpreter.config['if'] = True + __dependency_interpreter.readonly_symbols = [] __log = Logger() __map = Map() __stack = None From 2d41091362ca38631887389426a85313a93aaa55 Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Thu, 3 Apr 2025 14:27:34 +0200 Subject: [PATCH 06/18] Update constructor to accept strings as values --- src/easyscience/Objects/variable/parameter.py | 79 ++++++++++++------- 1 file changed, 50 insertions(+), 29 deletions(-) diff --git a/src/easyscience/Objects/variable/parameter.py b/src/easyscience/Objects/variable/parameter.py index aa47ee7..e3b5557 100644 --- a/src/easyscience/Objects/variable/parameter.py +++ b/src/easyscience/Objects/variable/parameter.py @@ -6,6 +6,7 @@ import copy import numbers +import warnings import weakref from collections import namedtuple from types import MappingProxyType @@ -55,7 +56,6 @@ def __init__( url: Optional[str] = None, display_name: Optional[str] = None, callback: property = property(), - independent: Optional[bool] = True, parent: Optional[Any] = None, ): """ @@ -69,32 +69,53 @@ def __init__( :param variance: The variance of the value :param min: The minimum value for fitting :param max: The maximum value for fitting - :param fixed: Can the parameter vary while fitting? + :param fixed: Can the parameter vary? :param description: A brief summary of what this object is :param url: Lookup url for documentation/information :param display_name: The name of the object as it should be displayed - :param independent: Can the objects value be set + :param independent: Is the object dependent on another object? :param parent: The object which is the parent to this one .. note:: Undo/Redo functionality is implemented for the attributes `value`, `variance`, `error`, `min`, `max`, `bounds`, `fixed`, `unit` """ # noqa: E501 - if not isinstance(value, numbers.Number): - raise TypeError('`value` must be a number') - if not isinstance(min, numbers.Number): - raise TypeError('`min` must be a number') - if not isinstance(max, numbers.Number): - raise TypeError('`max` must be a number') - if value < min: - raise ValueError(f'{value=} can not be less than {min=}') - if value > max: - raise ValueError(f'{value=} can not be greater than {max=}') - - if np.isclose(min, max, rtol=1e-9, atol=0.0): - raise ValueError('The min and max bounds cannot be identical. Please use fixed=True instead to fix the value.') - if not isinstance(fixed, bool): - raise TypeError('`fixed` must be either True or False') - + self._observers: List[DescriptorNumber] = [] + # If value is a string, the Parameter is a dependent parameter. + # The string is then run through the dependency interpreter to overwrite the value, unit, variance, min and max. # noqa: E501 + if isinstance(value, str): + if unit !='': + warnings.warn('Dependent parameters infer their unit from their dependency. The set unit will be ignored.') + if variance != 0.0: + warnings.warn('Dependent parameters compute their variance from their dependency. The set variance will be ignored.') # noqa: E501 + if min != -np.inf: + warnings.warn('Dependent parameters compute their minimum value from their dependency. The set min will be ignored.') # noqa: E501 + if max != np.inf: + warnings.warn('Dependent parameters compute their maximum value from their dependency. The set max will be ignored.') # noqa: E501 + self._dependency_string = value + self._independent = False + try: + dependency_result = self._global_object.dependency_interpreter(self._dependency_string) + except Exception as message: + raise ValueError(f'Invalid dependency expression: {self._dependency_string}') from message + value = dependency_result.value + unit = dependency_result.unit + variance = dependency_result.variance + min = dependency_result.min if isinstance(dependency_result, Parameter) else -np.inf + max = dependency_result.max if isinstance(dependency_result, Parameter) else np.inf + elif isinstance(value, numbers.Number): + if not isinstance(max, numbers.Number): + raise TypeError('`max` must be a number') + if value < min: + raise ValueError(f'{value=} can not be less than {min=}') + if value > max: + raise ValueError(f'{value=} can not be greater than {max=}') + if np.isclose(min, max, rtol=1e-9, atol=0.0): + raise ValueError('The min and max bounds cannot be identical. Please use fixed=True instead to fix the value.') + if not isinstance(fixed, bool): + raise TypeError('`fixed` must be either True or False') + self._independent = True + else: + raise TypeError('`value` must be a number or a string representing a valid dependency expression.') self._fixed = fixed # For fitting, but must be initialized before super().__init__ self._min = sc.scalar(float(min), unit=unit) self._max = sc.scalar(float(max), unit=unit) @@ -116,8 +137,6 @@ def __init__( weakref.finalize(self, self._callback.fdel) # Create additional fitting elements - self._fixed = fixed - self._independent = independent self._initial_scalar = copy.deepcopy(self._scalar) builtin_constraint = { # Last argument in constructor is the name of the property holding the value of the constraint @@ -126,19 +145,21 @@ def __init__( } self._constraints = Constraints(builtin=builtin_constraint, user={}, virtual={}) - self._observers: List[DescriptorNumber] = [] def _update(self) -> None: """ Update the parameter. This is called by the DescriptorNumbers/Parameters who have this Parameter as a dependency. """ - temporary_parameter = self._global_object.dependency_interpreter(self._dependency_string) - self._scalar.value = temporary_parameter.value - self._scalar.unit = temporary_parameter.unit - self._scalar.variance = temporary_parameter.variance - self._min.value = temporary_parameter.min - self._max.value = temporary_parameter.max - self._notify_observers() + if not self._independent: + temporary_parameter = self._global_object.dependency_interpreter(self._dependency_string) + self._scalar.value = temporary_parameter.value + self._scalar.unit = temporary_parameter.unit + self._scalar.variance = temporary_parameter.variance + self._min.value = temporary_parameter.min + self._max.value = temporary_parameter.max + self._notify_observers() + else: + warnings.warn('This parameter is not dependent. It cannot be updated.') @property def value_no_call_back(self) -> numbers.Number: From 78532cd223f07a2a2e3b361ef3bff5a2f707b7ba Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Mon, 7 Apr 2025 12:52:17 +0200 Subject: [PATCH 07/18] Disable setters when parameter is dependent --- src/easyscience/Objects/variable/parameter.py | 114 +++++++++++------- 1 file changed, 71 insertions(+), 43 deletions(-) diff --git a/src/easyscience/Objects/variable/parameter.py b/src/easyscience/Objects/variable/parameter.py index e3b5557..67f82da 100644 --- a/src/easyscience/Objects/variable/parameter.py +++ b/src/easyscience/Objects/variable/parameter.py @@ -211,40 +211,62 @@ def value(self, value: numbers.Number) -> None: :param value: New value of self """ - if not self.independent: - if global_object.debug: - raise CoreSetException(f'{str(self)} is not independent.') - return + if self._independent: + if not isinstance(value, numbers.Number) or isinstance(value, bool): + raise TypeError(f'{value=} must be a number') + + # Need to set the value for constraints to be functional + self._scalar.value = float(value) + # if self._callback.fset is not None: + # self._callback.fset(self._scalar.value) + + # Deals with min/max + value = self._constraint_runner(self.builtin_constraints, self._scalar.value) + + # Deals with user constraints + # Changes should not be registrered in the undo/redo stack + stack_state = global_object.stack.enabled + if stack_state: + global_object.stack.force_state(False) + try: + value = self._constraint_runner(self.user_constraints, value) + finally: + global_object.stack.force_state(stack_state) - if not isinstance(value, numbers.Number) or isinstance(value, bool): - raise TypeError(f'{value=} must be a number') + value = self._constraint_runner(self._constraints.virtual, value) - # Need to set the value for constraints to be functional - self._scalar.value = float(value) - # if self._callback.fset is not None: - # self._callback.fset(self._scalar.value) + self._scalar.value = float(value) + if self._callback.fset is not None: + self._callback.fset(self._scalar.value) - # Deals with min/max - value = self._constraint_runner(self.builtin_constraints, self._scalar.value) + # Notify observers of the change + self._notify_observers() + else: + raise AttributeError("This parameter is not independent, its value cannot be set directly. Please make it independent first.") # noqa: E501 - # Deals with user constraints - # Changes should not be registrered in the undo/redo stack - stack_state = global_object.stack.enabled - if stack_state: - global_object.stack.force_state(False) - try: - value = self._constraint_runner(self.user_constraints, value) - finally: - global_object.stack.force_state(stack_state) + @DescriptorNumber.variance.setter + def variance(self, variance_float: float) -> None: + """ + Set the variance. - value = self._constraint_runner(self._constraints.virtual, value) + :param variance_float: Variance as a float + """ + if self._independent: + DescriptorNumber.variance.fset(self, variance_float) + else: + raise AttributeError("This parameter is not independent, its variance cannot be set directly. Please make it independent first.") # noqa: E501 - self._scalar.value = float(value) - if self._callback.fset is not None: - self._callback.fset(self._scalar.value) + @DescriptorNumber.error.setter + def error(self, value: float) -> None: + """ + Set the standard deviation for the parameter. - # Notify observers of the change - self._notify_observers() + :param value: New error value + """ + if self._independent: + DescriptorNumber.error.fset(self, value) + else: + raise AttributeError("This parameter is not independent, its error cannot be set directly. Please make it independent first.") # noqa: E501 def convert_unit(self, unit_str: str) -> None: """ @@ -278,15 +300,18 @@ def min(self, min_value: numbers.Number) -> None: :param min_value: new minimum value :return: None """ - if not isinstance(min_value, numbers.Number): - raise TypeError('`min` must be a number') - if np.isclose(min_value, self._max.value, rtol=1e-9, atol=0.0): - raise ValueError('The min and max bounds cannot be identical. Please use fixed=True instead to fix the value.') - if min_value <= self.value: - self._min.value = min_value + if self._independent: + if not isinstance(min_value, numbers.Number): + raise TypeError('`min` must be a number') + if np.isclose(min_value, self._max.value, rtol=1e-9, atol=0.0): + raise ValueError('The min and max bounds cannot be identical. Please use fixed=True instead to fix the value.') + if min_value <= self.value: + self._min.value = min_value + else: + raise ValueError(f'The current value ({self.value}) is smaller than the desired min value ({min_value}).') + self._notify_observers() else: - raise ValueError(f'The current value ({self.value}) is smaller than the desired min value ({min_value}).') - self._notify_observers() + raise AttributeError("This parameter is not independent, its min cannot be set directly. Please make it independent first.") # noqa: E501 @property def max(self) -> numbers.Number: @@ -307,15 +332,18 @@ def max(self, max_value: numbers.Number) -> None: :param max_value: new maximum value :return: None """ - if not isinstance(max_value, numbers.Number): - raise TypeError('`max` must be a number') - if np.isclose(max_value, self._min.value, rtol=1e-9, atol=0.0): - raise ValueError('The min and max bounds cannot be identical. Please use fixed=True instead to fix the value.') - if max_value >= self.value: - self._max.value = max_value + if self._independent: + if not isinstance(max_value, numbers.Number): + raise TypeError('`max` must be a number') + if np.isclose(max_value, self._min.value, rtol=1e-9, atol=0.0): + raise ValueError('The min and max bounds cannot be identical. Please use fixed=True instead to fix the value.') + if max_value >= self.value: + self._max.value = max_value + else: + raise ValueError(f'The current value ({self.value}) is greater than the desired max value ({max_value}).') + self._notify_observers() else: - raise ValueError(f'The current value ({self.value}) is greater than the desired max value ({max_value}).') - self._notify_observers() + raise AttributeError("This parameter is not independent, its max cannot be set directly. Please make it independent first.") # noqa: E501 @property def fixed(self) -> bool: From 5ec44a58599d1ab4bfa90172f8f7abd84534b92a Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Tue, 8 Apr 2025 15:04:09 +0200 Subject: [PATCH 08/18] Allow unique_names in dependency expression --- .../Objects/variable/descriptor_number.py | 2 +- src/easyscience/Objects/variable/parameter.py | 58 ++++++++++++++----- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/src/easyscience/Objects/variable/descriptor_number.py b/src/easyscience/Objects/variable/descriptor_number.py index 088b6d0..f5aec19 100644 --- a/src/easyscience/Objects/variable/descriptor_number.py +++ b/src/easyscience/Objects/variable/descriptor_number.py @@ -104,7 +104,7 @@ def _detach_observer(self, observer: DescriptorNumber) -> None: def _notify_observers(self) -> None: """Notify all observers of a change.""" for observer in self._observers: - observer._update(self) + observer._update() @property def full_value(self) -> Variable: diff --git a/src/easyscience/Objects/variable/parameter.py b/src/easyscience/Objects/variable/parameter.py index 67f82da..14b65cf 100644 --- a/src/easyscience/Objects/variable/parameter.py +++ b/src/easyscience/Objects/variable/parameter.py @@ -6,6 +6,7 @@ import copy import numbers +import re import warnings import weakref from collections import namedtuple @@ -93,8 +94,9 @@ def __init__( warnings.warn('Dependent parameters compute their maximum value from their dependency. The set max will be ignored.') # noqa: E501 self._dependency_string = value self._independent = False + self._process_dependency_unique_names(self._dependency_string) try: - dependency_result = self._global_object.dependency_interpreter(self._dependency_string) + dependency_result = self._global_object.dependency_interpreter(self._clean_dependency_string) except Exception as message: raise ValueError(f'Invalid dependency expression: {self._dependency_string}') from message value = dependency_result.value @@ -151,7 +153,7 @@ def _update(self) -> None: Update the parameter. This is called by the DescriptorNumbers/Parameters who have this Parameter as a dependency. """ if not self._independent: - temporary_parameter = self._global_object.dependency_interpreter(self._dependency_string) + temporary_parameter = self._global_object.dependency_interpreter(self._clean_dependency_string) self._scalar.value = temporary_parameter.value self._scalar.unit = temporary_parameter.unit self._scalar.variance = temporary_parameter.variance @@ -390,6 +392,7 @@ def bounds(self) -> Tuple[numbers.Number, numbers.Number]: :return: Tuple of the parameters minimum and maximum values """ return self.min, self.max + @bounds.setter def bounds(self, new_bound: Tuple[numbers.Number, numbers.Number]) -> None: """ @@ -439,6 +442,25 @@ def builtin_constraints(self) -> Dict[str, SelfConstraint]: """ return MappingProxyType(self._constraints.builtin) + @property + def independent(self) -> bool: + """ + Logical property to see if the objects value can be directly set. + + :return: Can the objects value be set + """ + return self._independent + + @independent.setter + @property_stack_deco + def independent(self, value: bool) -> None: + """ + Enable and disable the direct setting of an objects value field. + + :param value: True - objects value can be set, False - the opposite + """ + self._independent = value + @property def user_constraints(self) -> Dict[str, ConstraintBase]: """ @@ -470,24 +492,28 @@ def _constraint_runner( value = constained_value return value - @property - def independent(self) -> bool: - """ - Logical property to see if the objects value can be directly set. - - :return: Can the objects value be set + def _process_dependency_unique_names(self, dependency_expression: str): """ - return self._independent + Add the unique names of the parameters to the ASTEval interpreter. This is used to evaluate the dependency expression. - @independent.setter - @property_stack_deco - def independent(self, value: bool) -> None: + :param dependency_expression: The dependency expression to be evaluated """ - Enable and disable the direct setting of an objects value field. + # Get the unique_names from the expression string regardless of the quotes used + inputted_unique_names = re.findall("(\'.+?\')", dependency_expression) + inputted_unique_names += re.findall('(\".+?\")', dependency_expression) - :param value: True - objects value can be set, False - the opposite - """ - self._independent = value + clean_dependency_string = dependency_expression + existing_unique_names = self._global_object.map.vertices() + # Add the unique names of the parameters to the ASTEVAL interpreter + for name in inputted_unique_names: + stripped_name = name.strip("'\"") + if stripped_name not in existing_unique_names: + raise ValueError(f'A Parameter with unique_name {stripped_name} does not exist. Please check your dependency expression.') # noqa: E501 + dependent_parameter = self._global_object.map.get_item_by_key(stripped_name) + self._global_object.dependency_interpreter.symtable['__'+stripped_name+'__'] = dependent_parameter + dependent_parameter._attach_observer(self) + clean_dependency_string = clean_dependency_string.replace(name, '__'+stripped_name+'__') + self._clean_dependency_string = clean_dependency_string def __copy__(self) -> Parameter: new_obj = super().__copy__() From cb4d2c3a5313125c954b68cb0aac1a12bb14c9fc Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Thu, 10 Apr 2025 10:29:16 +0200 Subject: [PATCH 09/18] Allow symbols from the global scope in the dependency expression --- src/easyscience/Objects/variable/parameter.py | 55 ++++++++++++++++++- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/src/easyscience/Objects/variable/parameter.py b/src/easyscience/Objects/variable/parameter.py index 14b65cf..cddbccb 100644 --- a/src/easyscience/Objects/variable/parameter.py +++ b/src/easyscience/Objects/variable/parameter.py @@ -4,6 +4,7 @@ from __future__ import annotations +import ast import copy import numbers import re @@ -94,6 +95,7 @@ def __init__( warnings.warn('Dependent parameters compute their maximum value from their dependency. The set max will be ignored.') # noqa: E501 self._dependency_string = value self._independent = False + self._process_dependency_symbol_names(self._dependency_string) self._process_dependency_unique_names(self._dependency_string) try: dependency_result = self._global_object.dependency_interpreter(self._clean_dependency_string) @@ -510,11 +512,58 @@ def _process_dependency_unique_names(self, dependency_expression: str): if stripped_name not in existing_unique_names: raise ValueError(f'A Parameter with unique_name {stripped_name} does not exist. Please check your dependency expression.') # noqa: E501 dependent_parameter = self._global_object.map.get_item_by_key(stripped_name) - self._global_object.dependency_interpreter.symtable['__'+stripped_name+'__'] = dependent_parameter - dependent_parameter._attach_observer(self) - clean_dependency_string = clean_dependency_string.replace(name, '__'+stripped_name+'__') + if isinstance(dependent_parameter, DescriptorNumber): + self._global_object.dependency_interpreter.symtable['__'+stripped_name+'__'] = dependent_parameter + dependent_parameter._attach_observer(self) + clean_dependency_string = clean_dependency_string.replace(name, '__'+stripped_name+'__') + else: + raise ValueError(f'The object with unique_name {stripped_name} is not a Parameter/DescriptorNumber. Please check your dependency expression.') # noqa: E501 self._clean_dependency_string = clean_dependency_string + def _process_dependency_symbol_names(self, dependency_expression: str): + """ + Add the symbol names of the parameters to the ASTEval interpreter. This is used to evaluate the dependency expression. + + :param dependency_expression: The dependency expression to be evaluated + """ + # Get the symbol names in the dependency expression by walking the abstract syntax tree with ast. + + abstract_syntax_tree = ast.parse(dependency_expression) + abstract_syntax_tree_nodes = ast.walk(abstract_syntax_tree) + for node in abstract_syntax_tree_nodes: + # If the node is a Name, check if it is in globals and add it to the interpreter + if isinstance(node, ast.Name): + name = node.id + if name in globals(): + object = globals()[name] + if isinstance(object, DescriptorNumber): + self._global_object.dependency_interpreter.symtable[name] = object + object._attach_observer(self) + else: + raise ValueError(f'Object {name} not found in globals. Please check your dependency expression.') + # If the node is an attribute, get the attribute tree and check if the last element ie. the Name node is in globals + elif isinstance(node, ast.Attribute): + attribute_list = self._get_attribute_tree(node) + object_name = attribute_list[-1] + if object_name in globals(): + object = globals()[object_name] + else: + raise ValueError(f'Object {object_name} not found in globals. Please check your dependency expression.') # noqa: E501 + attribute_object = eval('.'.join(attribute_list.reverse())) # noqa: S307 + if isinstance(attribute_object, DescriptorNumber): + self._global_object.dependency_interpreter.symtable[object_name] = object + attribute_object._attach_observer(self) + + def _get_attribute_tree(self, node: ast.Attribute, attribute_list: list = []) -> list: + if isinstance(node, ast.Attribute): + attribute_list.append(node.attr) + return self._get_attribute_tree(node.value, attribute_list) + elif isinstance(node, ast.Name): + attribute_list.append(node.id) + return attribute_list + else: + raise ValueError(f'Invalid node type: {type(node)}') + def __copy__(self) -> Parameter: new_obj = super().__copy__() new_obj._callback = property() From addac0c94190f4cd93282a314a6bab0b6ddfa50f Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Tue, 15 Apr 2025 11:48:30 +0200 Subject: [PATCH 10/18] Change symbol names to come from a dependency_map dict input rather than from the global scope --- .../Objects/variable/descriptor_number.py | 54 ++++++--- src/easyscience/Objects/variable/parameter.py | 113 +++++++----------- .../global_object/global_object.py | 7 -- 3 files changed, 81 insertions(+), 93 deletions(-) diff --git a/src/easyscience/Objects/variable/descriptor_number.py b/src/easyscience/Objects/variable/descriptor_number.py index f5aec19..beddcf2 100644 --- a/src/easyscience/Objects/variable/descriptor_number.py +++ b/src/easyscience/Objects/variable/descriptor_number.py @@ -18,6 +18,22 @@ from .descriptor_base import DescriptorBase +# Why is this a decorator? Because otherwise we would need a flag on the convert_unit method to avoid +# infinite recursion. This is a bit cleaner as it avoids the need for a internal only flag on a user method. +def notify_observers(func): + """ + Decorator to notify observers of a change in the descriptor. + + :param func: Function to be decorated + :return: Decorated function + """ + def wrapper(self, *args, **kwargs): + result = func(self, *args, **kwargs) + self._notify_observers() + return result + + return wrapper + class DescriptorNumber(DescriptorBase): """ A `Descriptor` for Number values with units. The internal representation is a scipp scalar. @@ -74,7 +90,7 @@ def __init__( # Call convert_unit during initialization to ensure that the unit has no numbers in it, and to ensure unit consistency. if self.unit is not None: - self.convert_unit(self._base_unit()) + self._convert_unit(self._base_unit()) @classmethod @@ -131,6 +147,7 @@ def value(self) -> numbers.Number: return self._scalar.value @value.setter + @notify_observers @property_stack_deco def value(self, value: numbers.Number) -> None: """ @@ -141,8 +158,6 @@ def value(self, value: numbers.Number) -> None: if not isinstance(value, numbers.Number) or isinstance(value, bool): raise TypeError(f'{value=} must be a number') self._scalar.value = float(value) - # Notify observers of the change - self._notify_observers() @property def unit(self) -> str: @@ -172,6 +187,7 @@ def variance(self) -> float: return self._scalar.variance @variance.setter + @notify_observers @property_stack_deco def variance(self, variance_float: float) -> None: """ @@ -186,8 +202,6 @@ def variance(self, variance_float: float) -> None: raise ValueError(f'{variance_float=} must be positive') variance_float = float(variance_float) self._scalar.variance = variance_float - # Notify observers of the change - self._notify_observers() @property def error(self) -> float: @@ -201,6 +215,7 @@ def error(self) -> float: return float(np.sqrt(self._scalar.variance)) @error.setter + @notify_observers @property_stack_deco def error(self, value: float) -> None: """ @@ -217,10 +232,8 @@ def error(self, value: float) -> None: self._scalar.variance = value**2 else: self._scalar.variance = None - # Notify observers of the change - self._notify_observers() - def convert_unit(self, unit_str: str) -> None: + def _convert_unit(self, unit_str: str) -> None: """ Convert the value from one unit system to another. @@ -250,8 +263,15 @@ def set_scalar(obj, scalar): # Update the scalar self._scalar = new_scalar - # Notify observers of the change - self._notify_observers() + + @notify_observers + def convert_unit(self, unit_str: str) -> None: + """ + Convert the value from one unit system to another. + + :param unit_str: New unit in string form + """ + self._convert_unit(unit_str) # Just to get return type right def __copy__(self) -> DescriptorNumber: @@ -290,11 +310,11 @@ def __add__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorN elif type(other) is DescriptorNumber: original_unit = other.unit try: - other.convert_unit(self.unit) + other._convert_unit(self.unit) except UnitError: raise UnitError(f'Values with units {self.unit} and {other.unit} cannot be added') from None new_value = self.full_value + other.full_value - other.convert_unit(original_unit) + other._convert_unit(original_unit) else: return NotImplemented descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value) @@ -320,11 +340,11 @@ def __sub__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorN elif type(other) is DescriptorNumber: original_unit = other.unit try: - other.convert_unit(self.unit) + other._convert_unit(self.unit) except UnitError: raise UnitError(f'Values with units {self.unit} and {other.unit} cannot be subtracted') from None new_value = self.full_value - other.full_value - other.convert_unit(original_unit) + other._convert_unit(original_unit) else: return NotImplemented descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value) @@ -350,7 +370,7 @@ def __mul__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorN else: return NotImplemented descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value) - descriptor_number.convert_unit(descriptor_number._base_unit()) + descriptor_number._convert_unit(descriptor_number._base_unit()) descriptor_number.name = descriptor_number.unique_name return descriptor_number @@ -378,7 +398,7 @@ def __truediv__(self, other: Union[DescriptorNumber, numbers.Number]) -> Descrip else: return NotImplemented descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value) - descriptor_number.convert_unit(descriptor_number._base_unit()) + descriptor_number._convert_unit(descriptor_number._base_unit()) descriptor_number.name = descriptor_number.unique_name return descriptor_number @@ -445,4 +465,4 @@ def _base_unit(self) -> str: return string[i:] elif letter not in ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '+', '-']: return string[i:] - return '' + return '' \ No newline at end of file diff --git a/src/easyscience/Objects/variable/parameter.py b/src/easyscience/Objects/variable/parameter.py index cddbccb..ba9b50f 100644 --- a/src/easyscience/Objects/variable/parameter.py +++ b/src/easyscience/Objects/variable/parameter.py @@ -4,7 +4,6 @@ from __future__ import annotations -import ast import copy import numbers import re @@ -21,6 +20,7 @@ import numpy as np import scipp as sc +from asteval import Interpreter from scipp import UnitError from scipp import Variable @@ -31,6 +31,7 @@ from easyscience.Utils.Exceptions import CoreSetException from .descriptor_number import DescriptorNumber +from .descriptor_number import notify_observers Constraints = namedtuple('Constraints', ['user', 'builtin', 'virtual']) @@ -59,6 +60,7 @@ def __init__( display_name: Optional[str] = None, callback: property = property(), parent: Optional[Any] = None, + dependency_map: Optional[dict] = {}, ): """ This class is an extension of a `DescriptorNumber`. Where the descriptor was for static @@ -75,8 +77,8 @@ def __init__( :param description: A brief summary of what this object is :param url: Lookup url for documentation/information :param display_name: The name of the object as it should be displayed - :param independent: Is the object dependent on another object? :param parent: The object which is the parent to this one + :param dependency_map: A dictionary of dependencies. This is inserted into the asteval interpreter to resolve dependencies. .. note:: Undo/Redo functionality is implemented for the attributes `value`, `variance`, `error`, `min`, `max`, `bounds`, `fixed`, `unit` @@ -93,14 +95,24 @@ def __init__( warnings.warn('Dependent parameters compute their minimum value from their dependency. The set min will be ignored.') # noqa: E501 if max != np.inf: warnings.warn('Dependent parameters compute their maximum value from their dependency. The set max will be ignored.') # noqa: E501 + self._dependency_interpreter = Interpreter(minimal=True) + self._dependency_interpreter.config['if'] = True self._dependency_string = value + self._dependency_map = dependency_map self._independent = False - self._process_dependency_symbol_names(self._dependency_string) self._process_dependency_unique_names(self._dependency_string) + for key, value in self._dependency_map.items(): + if isinstance(value, DescriptorNumber): + self._dependency_interpreter.symtable[key] = value + value._attach_observer(self) + else: + raise TypeError(f'{key=} must be a DescriptorNumber or Parameter. Got {type(value)}') try: - dependency_result = self._global_object.dependency_interpreter(self._clean_dependency_string) - except Exception as message: - raise ValueError(f'Invalid dependency expression: {self._dependency_string}') from message + dependency_result = self._dependency_interpreter.eval(self._clean_dependency_string, raise_errors=True) + except NameError as message: + raise NameError('\nUnknown name encountered in dependecy expression:'+ + '\n'+'\n'.join(str(message).split("\n")[1:])+ + '\nPlease check your expression or add the name to the dependency_map') from None value = dependency_result.value unit = dependency_result.unit variance = dependency_result.variance @@ -149,13 +161,12 @@ def __init__( } self._constraints = Constraints(builtin=builtin_constraint, user={}, virtual={}) - def _update(self) -> None: """ Update the parameter. This is called by the DescriptorNumbers/Parameters who have this Parameter as a dependency. """ if not self._independent: - temporary_parameter = self._global_object.dependency_interpreter(self._clean_dependency_string) + temporary_parameter = self._dependency_interpreter(self._clean_dependency_string) self._scalar.value = temporary_parameter.value self._scalar.unit = temporary_parameter.unit self._scalar.variance = temporary_parameter.variance @@ -272,18 +283,27 @@ def error(self, value: float) -> None: else: raise AttributeError("This parameter is not independent, its error cannot be set directly. Please make it independent first.") # noqa: E501 - def convert_unit(self, unit_str: str) -> None: + def _convert_unit(self, unit_str: str) -> None: """ Perform unit conversion. The value, max and min can change on unit change. :param new_unit: new unit :return: None """ - super().convert_unit(unit_str) + super()._convert_unit(unit_str=unit_str) new_unit = sc.Unit(unit_str) # unit_str is tested in super method self._min = self._min.to(unit=new_unit) self._max = self._max.to(unit=new_unit) - self._notify_observers() + + @notify_observers + def convert_unit(self, unit_str: str) -> None: + """ + Perform unit conversion. The value, max and min can change on unit change. + + :param new_unit: new unit + :return: None + """ + self._convert_unit(unit_str) @property def min(self) -> numbers.Number: @@ -513,57 +533,12 @@ def _process_dependency_unique_names(self, dependency_expression: str): raise ValueError(f'A Parameter with unique_name {stripped_name} does not exist. Please check your dependency expression.') # noqa: E501 dependent_parameter = self._global_object.map.get_item_by_key(stripped_name) if isinstance(dependent_parameter, DescriptorNumber): - self._global_object.dependency_interpreter.symtable['__'+stripped_name+'__'] = dependent_parameter - dependent_parameter._attach_observer(self) + self._dependency_map['__'+stripped_name+'__'] = dependent_parameter clean_dependency_string = clean_dependency_string.replace(name, '__'+stripped_name+'__') else: raise ValueError(f'The object with unique_name {stripped_name} is not a Parameter/DescriptorNumber. Please check your dependency expression.') # noqa: E501 self._clean_dependency_string = clean_dependency_string - def _process_dependency_symbol_names(self, dependency_expression: str): - """ - Add the symbol names of the parameters to the ASTEval interpreter. This is used to evaluate the dependency expression. - - :param dependency_expression: The dependency expression to be evaluated - """ - # Get the symbol names in the dependency expression by walking the abstract syntax tree with ast. - - abstract_syntax_tree = ast.parse(dependency_expression) - abstract_syntax_tree_nodes = ast.walk(abstract_syntax_tree) - for node in abstract_syntax_tree_nodes: - # If the node is a Name, check if it is in globals and add it to the interpreter - if isinstance(node, ast.Name): - name = node.id - if name in globals(): - object = globals()[name] - if isinstance(object, DescriptorNumber): - self._global_object.dependency_interpreter.symtable[name] = object - object._attach_observer(self) - else: - raise ValueError(f'Object {name} not found in globals. Please check your dependency expression.') - # If the node is an attribute, get the attribute tree and check if the last element ie. the Name node is in globals - elif isinstance(node, ast.Attribute): - attribute_list = self._get_attribute_tree(node) - object_name = attribute_list[-1] - if object_name in globals(): - object = globals()[object_name] - else: - raise ValueError(f'Object {object_name} not found in globals. Please check your dependency expression.') # noqa: E501 - attribute_object = eval('.'.join(attribute_list.reverse())) # noqa: S307 - if isinstance(attribute_object, DescriptorNumber): - self._global_object.dependency_interpreter.symtable[object_name] = object - attribute_object._attach_observer(self) - - def _get_attribute_tree(self, node: ast.Attribute, attribute_list: list = []) -> list: - if isinstance(node, ast.Attribute): - attribute_list.append(node.attr) - return self._get_attribute_tree(node.value, attribute_list) - elif isinstance(node, ast.Name): - attribute_list.append(node.id) - return attribute_list - else: - raise ValueError(f'Invalid node type: {type(node)}') - def __copy__(self) -> Parameter: new_obj = super().__copy__() new_obj._callback = property() @@ -596,13 +571,13 @@ def __add__(self, other: Union[DescriptorNumber, Parameter, numbers.Number]) -> elif isinstance(other, DescriptorNumber): # Parameter inherits from DescriptorNumber and is also handled here other_unit = other.unit try: - other.convert_unit(self.unit) + other._convert_unit(self.unit) except UnitError: raise UnitError(f'Values with units {self.unit} and {other.unit} cannot be added') from None new_full_value = self.full_value + other.full_value min_value = self.min + other.min if isinstance(other, Parameter) else self.min + other.value max_value = self.max + other.max if isinstance(other, Parameter) else self.max + other.value - other.convert_unit(other_unit) + other._convert_unit(other_unit) else: return NotImplemented parameter = Parameter.from_scipp(name=self.name, full_value=new_full_value, min=min_value, max=max_value) @@ -619,13 +594,13 @@ def __radd__(self, other: Union[DescriptorNumber, numbers.Number]) -> Parameter: elif isinstance(other, DescriptorNumber): # Parameter inherits from DescriptorNumber and is also handled here original_unit = self.unit try: - self.convert_unit(other.unit) + self._convert_unit(other.unit) except UnitError: raise UnitError(f'Values with units {other.unit} and {self.unit} cannot be added') from None new_full_value = self.full_value + other.full_value min_value = self.min + other.value max_value = self.max + other.value - self.convert_unit(original_unit) + self._convert_unit(original_unit) else: return NotImplemented parameter = Parameter.from_scipp(name=self.name, full_value=new_full_value, min=min_value, max=max_value) @@ -642,7 +617,7 @@ def __sub__(self, other: Union[DescriptorNumber, Parameter, numbers.Number]) -> elif isinstance(other, DescriptorNumber): # Parameter inherits from DescriptorNumber and is also handled here other_unit = other.unit try: - other.convert_unit(self.unit) + other._convert_unit(self.unit) except UnitError: raise UnitError(f'Values with units {self.unit} and {other.unit} cannot be subtracted') from None new_full_value = self.full_value - other.full_value @@ -652,7 +627,7 @@ def __sub__(self, other: Union[DescriptorNumber, Parameter, numbers.Number]) -> else: min_value = self.min - other.value max_value = self.max - other.value - other.convert_unit(other_unit) + other._convert_unit(other_unit) else: return NotImplemented parameter = Parameter.from_scipp(name=self.name, full_value=new_full_value, min=min_value, max=max_value) @@ -669,13 +644,13 @@ def __rsub__(self, other: Union[DescriptorNumber, numbers.Number]) -> Parameter: elif isinstance(other, DescriptorNumber): # Parameter inherits from DescriptorNumber and is also handled here original_unit = self.unit try: - self.convert_unit(other.unit) + self._convert_unit(other.unit) except UnitError: raise UnitError(f'Values with units {other.unit} and {self.unit} cannot be subtracted') from None new_full_value = other.full_value - self.full_value min_value = other.value - self.max max_value = other.value - self.min - self.convert_unit(original_unit) + self._convert_unit(original_unit) else: return NotImplemented parameter = Parameter.from_scipp(name=self.name, full_value=new_full_value, min=min_value, max=max_value) @@ -719,7 +694,7 @@ def __mul__(self, other: Union[DescriptorNumber, Parameter, numbers.Number]) -> min_value = min(combinations) max_value = max(combinations) parameter = Parameter.from_scipp(name=self.name, full_value=new_full_value, min=min_value, max=max_value) - parameter.convert_unit(parameter._base_unit()) + parameter._convert_unit(parameter._base_unit()) parameter.name = parameter.unique_name return parameter @@ -743,7 +718,7 @@ def __rmul__(self, other: Union[DescriptorNumber, numbers.Number]) -> Parameter: min_value = min(combinations) max_value = max(combinations) parameter = Parameter.from_scipp(name=self.name, full_value=new_full_value, min=min_value, max=max_value) - parameter.convert_unit(parameter._base_unit()) + parameter._convert_unit(parameter._base_unit()) parameter.name = parameter.unique_name return parameter @@ -785,7 +760,7 @@ def __truediv__(self, other: Union[DescriptorNumber, Parameter, numbers.Number]) min_value = min(combinations) max_value = max(combinations) parameter = Parameter.from_scipp(name=self.name, full_value=new_full_value, min=min_value, max=max_value) - parameter.convert_unit(parameter._base_unit()) + parameter._convert_unit(parameter._base_unit()) parameter.name = parameter.unique_name return parameter @@ -826,7 +801,7 @@ def __rtruediv__(self, other: Union[DescriptorNumber, numbers.Number]) -> Parame min_value = min(combinations) max_value = max(combinations) parameter = Parameter.from_scipp(name=self.name, full_value=new_full_value, min=min_value, max=max_value) - parameter.convert_unit(parameter._base_unit()) + parameter._convert_unit(parameter._base_unit()) parameter.name = parameter.unique_name self.value = original_self return parameter diff --git a/src/easyscience/global_object/global_object.py b/src/easyscience/global_object/global_object.py index 738c4bf..c78db4e 100644 --- a/src/easyscience/global_object/global_object.py +++ b/src/easyscience/global_object/global_object.py @@ -5,8 +5,6 @@ __author__ = 'github.com/wardsimon' __version__ = '0.1.0' -from asteval import Interpreter - from easyscience.Utils.classUtils import singleton from .hugger.hugger import ScriptManager @@ -21,9 +19,6 @@ class GlobalObject: into the collective. """ - __dependency_interpreter = Interpreter(minimal=True) - __dependency_interpreter.config['if'] = True - __dependency_interpreter.readonly_symbols = [] __log = Logger() __map = Map() __stack = None @@ -40,8 +35,6 @@ def __init__(self): self.script: ScriptManager = ScriptManager() # Map. This is the conduit database between all global object species self.map: Map = self.__map - # Dependency interpreter. This is used to evaluate dependencies in dependent Parameters - self.dependency_interpreter: Interpreter = self.__dependency_interpreter def instantiate_stack(self): """ From 047568c379c10e49ac6809c3c33f3bc59c5266f7 Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Wed, 16 Apr 2025 15:30:20 +0200 Subject: [PATCH 11/18] Allow dependencies of dependent parameters. Check for cyclic dependencies --- .../Objects/variable/descriptor_number.py | 13 +++++++-- src/easyscience/Objects/variable/parameter.py | 29 ++++++++++++++----- .../global_object/global_object.py | 2 ++ 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/easyscience/Objects/variable/descriptor_number.py b/src/easyscience/Objects/variable/descriptor_number.py index beddcf2..f98e9c0 100644 --- a/src/easyscience/Objects/variable/descriptor_number.py +++ b/src/easyscience/Objects/variable/descriptor_number.py @@ -117,10 +117,17 @@ def _detach_observer(self, observer: DescriptorNumber) -> None: """Detach an observer from the descriptor.""" self._observers.remove(observer) - def _notify_observers(self) -> None: - """Notify all observers of a change.""" + def _notify_observers(self, update_id=None) -> None: + """Notify all observers of a change. + + :param update_id: Optional update ID to pass to observers. Used to avoid cyclic depenencies. + + """ + if update_id is None: + self._global_object.update_id_iterator += 1 + update_id = self._global_object.update_id_iterator for observer in self._observers: - observer._update() + observer._update(update_id=update_id, updating_object=self.unique_name) @property def full_value(self) -> Variable: diff --git a/src/easyscience/Objects/variable/parameter.py b/src/easyscience/Objects/variable/parameter.py index ba9b50f..14faa18 100644 --- a/src/easyscience/Objects/variable/parameter.py +++ b/src/easyscience/Objects/variable/parameter.py @@ -99,11 +99,13 @@ def __init__( self._dependency_interpreter.config['if'] = True self._dependency_string = value self._dependency_map = dependency_map + self._dependency_updates = {} # Used to track update ids to avoid cyclic dependencies self._independent = False self._process_dependency_unique_names(self._dependency_string) for key, value in self._dependency_map.items(): if isinstance(value, DescriptorNumber): self._dependency_interpreter.symtable[key] = value + self._dependency_updates[value.unique_name] = 0 value._attach_observer(self) else: raise TypeError(f'{key=} must be a DescriptorNumber or Parameter. Got {type(value)}') @@ -161,18 +163,29 @@ def __init__( } self._constraints = Constraints(builtin=builtin_constraint, user={}, virtual={}) - def _update(self) -> None: + def _update(self, update_id: int, updating_object: str) -> None: """ Update the parameter. This is called by the DescriptorNumbers/Parameters who have this Parameter as a dependency. + + :param update_id: The id of the update. This is used to avoid cyclic dependencies. + :param updating_object: The unique_name of the object which is updating this parameter. + """ if not self._independent: - temporary_parameter = self._dependency_interpreter(self._clean_dependency_string) - self._scalar.value = temporary_parameter.value - self._scalar.unit = temporary_parameter.unit - self._scalar.variance = temporary_parameter.variance - self._min.value = temporary_parameter.min - self._max.value = temporary_parameter.max - self._notify_observers() + # Check if this parameter has already been updated by the updating object with this update id + if self._dependency_updates[updating_object] == update_id: + warnings.warn('Warning: Cyclic dependency detected!\n' + + f'This parameter, {self.unique_name}, has already been updated by {updating_object} during this update.\n' + # noqa: E501 + 'This update will be ignored. Please check your dependencies.') + else: + # Update the value of the parameter using the dependency interpreter + temporary_parameter = self._dependency_interpreter(self._clean_dependency_string) + self._scalar.value = temporary_parameter.value + self._scalar.unit = temporary_parameter.unit + self._scalar.variance = temporary_parameter.variance + self._min.value = temporary_parameter.min + self._max.value = temporary_parameter.max + self._notify_observers(update_id=update_id) else: warnings.warn('This parameter is not dependent. It cannot be updated.') diff --git a/src/easyscience/global_object/global_object.py b/src/easyscience/global_object/global_object.py index c78db4e..dd188db 100644 --- a/src/easyscience/global_object/global_object.py +++ b/src/easyscience/global_object/global_object.py @@ -36,6 +36,8 @@ def __init__(self): # Map. This is the conduit database between all global object species self.map: Map = self.__map + self.update_id_iterator = 0 + def instantiate_stack(self): """ The undo/redo stack references the collective. Hence it has to be imported From 878293ca60940f04754d344f7871f4b5012021a9 Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Wed, 23 Apr 2025 13:46:03 +0200 Subject: [PATCH 12/18] Make method for making a parameter dependent after initialization. Move dependent parameter constructor to class method --- src/easyscience/Objects/variable/parameter.py | 139 +++++++++++------- 1 file changed, 86 insertions(+), 53 deletions(-) diff --git a/src/easyscience/Objects/variable/parameter.py b/src/easyscience/Objects/variable/parameter.py index 14faa18..2f6226d 100644 --- a/src/easyscience/Objects/variable/parameter.py +++ b/src/easyscience/Objects/variable/parameter.py @@ -60,7 +60,6 @@ def __init__( display_name: Optional[str] = None, callback: property = property(), parent: Optional[Any] = None, - dependency_map: Optional[dict] = {}, ): """ This class is an extension of a `DescriptorNumber`. Where the descriptor was for static @@ -78,62 +77,26 @@ def __init__( :param url: Lookup url for documentation/information :param display_name: The name of the object as it should be displayed :param parent: The object which is the parent to this one - :param dependency_map: A dictionary of dependencies. This is inserted into the asteval interpreter to resolve dependencies. .. note:: Undo/Redo functionality is implemented for the attributes `value`, `variance`, `error`, `min`, `max`, `bounds`, `fixed`, `unit` """ # noqa: E501 + if not isinstance(min, numbers.Number): + raise TypeError('`min` must be a number') + if not isinstance(max, numbers.Number): + raise TypeError('`max` must be a number') + if not isinstance(value, numbers.Number): + raise TypeError('`value` must be a number') + if value < min: + raise ValueError(f'{value=} can not be less than {min=}') + if value > max: + raise ValueError(f'{value=} can not be greater than {max=}') + if np.isclose(min, max, rtol=1e-9, atol=0.0): + raise ValueError('The min and max bounds cannot be identical. Please use fixed=True instead to fix the value.') + if not isinstance(fixed, bool): + raise TypeError('`fixed` must be either True or False') + self._independent = True self._observers: List[DescriptorNumber] = [] - # If value is a string, the Parameter is a dependent parameter. - # The string is then run through the dependency interpreter to overwrite the value, unit, variance, min and max. # noqa: E501 - if isinstance(value, str): - if unit !='': - warnings.warn('Dependent parameters infer their unit from their dependency. The set unit will be ignored.') - if variance != 0.0: - warnings.warn('Dependent parameters compute their variance from their dependency. The set variance will be ignored.') # noqa: E501 - if min != -np.inf: - warnings.warn('Dependent parameters compute their minimum value from their dependency. The set min will be ignored.') # noqa: E501 - if max != np.inf: - warnings.warn('Dependent parameters compute their maximum value from their dependency. The set max will be ignored.') # noqa: E501 - self._dependency_interpreter = Interpreter(minimal=True) - self._dependency_interpreter.config['if'] = True - self._dependency_string = value - self._dependency_map = dependency_map - self._dependency_updates = {} # Used to track update ids to avoid cyclic dependencies - self._independent = False - self._process_dependency_unique_names(self._dependency_string) - for key, value in self._dependency_map.items(): - if isinstance(value, DescriptorNumber): - self._dependency_interpreter.symtable[key] = value - self._dependency_updates[value.unique_name] = 0 - value._attach_observer(self) - else: - raise TypeError(f'{key=} must be a DescriptorNumber or Parameter. Got {type(value)}') - try: - dependency_result = self._dependency_interpreter.eval(self._clean_dependency_string, raise_errors=True) - except NameError as message: - raise NameError('\nUnknown name encountered in dependecy expression:'+ - '\n'+'\n'.join(str(message).split("\n")[1:])+ - '\nPlease check your expression or add the name to the dependency_map') from None - value = dependency_result.value - unit = dependency_result.unit - variance = dependency_result.variance - min = dependency_result.min if isinstance(dependency_result, Parameter) else -np.inf - max = dependency_result.max if isinstance(dependency_result, Parameter) else np.inf - elif isinstance(value, numbers.Number): - if not isinstance(max, numbers.Number): - raise TypeError('`max` must be a number') - if value < min: - raise ValueError(f'{value=} can not be less than {min=}') - if value > max: - raise ValueError(f'{value=} can not be greater than {max=}') - if np.isclose(min, max, rtol=1e-9, atol=0.0): - raise ValueError('The min and max bounds cannot be identical. Please use fixed=True instead to fix the value.') - if not isinstance(fixed, bool): - raise TypeError('`fixed` must be either True or False') - self._independent = True - else: - raise TypeError('`value` must be a number or a string representing a valid dependency expression.') self._fixed = fixed # For fitting, but must be initialized before super().__init__ self._min = sc.scalar(float(min), unit=unit) self._max = sc.scalar(float(max), unit=unit) @@ -163,6 +126,22 @@ def __init__( } self._constraints = Constraints(builtin=builtin_constraint, user={}, virtual={}) + @classmethod + def from_dependency(cls, name: str, dependency_expression: str, dependency_map: Optional[dict] = None, **kwargs) -> Parameter: # noqa: E501 + """ + Create a dependent Parameter directly from a dependency expression. + + :param name: The name of the parameter + :param dependency_expression: The dependency expression to evaluate. This should be a string which can be evaluated by the ASTEval interpreter. + :param dependency_map: A dictionary of dependency expression symbol name and dependency object pairs. This is inserted into the asteval interpreter to resolve dependencies. + :param kwargs: Additional keyword arguments to pass to the Parameter constructor. + :return: A new dependent Parameter object. + """ # noqa: E501 + parameter = cls(name=name, value=0.0, unit='', variance=0.0, min=-np.inf, max=np.inf, **kwargs) + parameter.make_dependent(dependency_expression=dependency_expression, dependency_map=dependency_map) + return parameter + + def _update(self, update_id: int, updating_object: str) -> None: """ Update the parameter. This is called by the DescriptorNumbers/Parameters who have this Parameter as a dependency. @@ -189,6 +168,60 @@ def _update(self, update_id: int, updating_object: str) -> None: else: warnings.warn('This parameter is not dependent. It cannot be updated.') + def make_dependent(self, dependency_expression: str, dependency_map: Optional[dict] = None) -> None: + """ + Make this parameter dependent on another parameter. This will overwrite the current value, unit, variance, min and max. + + :param dependency_expression: The dependency expression to evaluate. This should be a string which can be evaluated by the ASTEval interpreter. + :param dependency_map: A dictionary of dependency expression symbol name and dependency object pairs. This is inserted into the asteval interpreter to resolve dependencies. + """ # noqa: E501 + if not isinstance(dependency_expression, str): + raise TypeError('`dependency_expression` must be a string representing a valid dependency expression.') + if not (isinstance(dependency_map, dict) or dependency_map is None): + raise TypeError('`dependency_map` must be a dictionary of dependencies and their corresponding names in the dependecy expression.') # noqa: E501 + for key, value in self._dependency_map.items(): + if not isinstance(key, str): + raise TypeError('`dependency_map` keys must be strings representing the names of the dependencies in the dependency expression.') # noqa: E501 + if not isinstance(value, DescriptorNumber): + raise TypeError(f'`dependency_map` values must be DescriptorNumbers or Parameters. Got {type(value)} for {key}.') # noqa: E501 + + # If we're overwriting the dependency + if not self._independent: + for old_dependency in self._dependency_map.values(): + old_dependency._detach_observer(self) + + self._dependency_string = dependency_expression + self._dependency_map = dependency_map if dependency_map is not None else {} + self._dependency_interpreter = Interpreter(minimal=True) + self._dependency_interpreter.config['if'] = True + self._dependency_updates = {} # Used to track update ids to avoid cyclic dependencies + + self._process_dependency_unique_names(self._dependency_string) + for key, value in self._dependency_map.items(): + self._dependency_interpreter.symtable[key] = value + self._dependency_interpreter.readonly_symbols.add(key) + value._attach_observer(self) + try: + dependency_result = self._dependency_interpreter.eval(self._clean_dependency_string, raise_errors=True) + except NameError as message: + raise NameError('\nUnknown name encountered in dependecy expression:'+ + '\n'+'\n'.join(str(message).split("\n")[1:])+ + '\nPlease check your expression or add the name to the `dependency_map`') from None + except Exception as message: + raise Exception('\nError encountered in dependecy expression:'+ + '\n'+'\n'.join(str(message).split("\n")[1:])+ + '\nPlease check your expression') from None + if not isinstance(dependency_result, DescriptorNumber): + raise TypeError(f'The dependency expression: "{self._clean_dependency_string}" returned a {type(dependency_result)}, it should return a Parameter or DescriptorNumber.') # noqa: E501 + self._scalar.value = dependency_result.value + self._scalar.unit = dependency_result.unit + self._scalar.variance = dependency_result.variance + self._min = dependency_result.min if isinstance(dependency_result, Parameter) else dependency_result.value + self._max = dependency_result.max if isinstance(dependency_result, Parameter) else dependency_result.value + self._independent = False + self._fixed = False + self._notify_observers() + @property def value_no_call_back(self) -> numbers.Number: """ @@ -549,7 +582,7 @@ def _process_dependency_unique_names(self, dependency_expression: str): self._dependency_map['__'+stripped_name+'__'] = dependent_parameter clean_dependency_string = clean_dependency_string.replace(name, '__'+stripped_name+'__') else: - raise ValueError(f'The object with unique_name {stripped_name} is not a Parameter/DescriptorNumber. Please check your dependency expression.') # noqa: E501 + raise ValueError(f'The object with unique_name {stripped_name} is not a Parameter or DescriptorNumber. Please check your dependency expression.') # noqa: E501 self._clean_dependency_string = clean_dependency_string def __copy__(self) -> Parameter: From 51bd9c48e2b3a025d4221f50591cbc87c76cfa2e Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Wed, 23 Apr 2025 14:47:32 +0200 Subject: [PATCH 13/18] Add make_dependent method --- src/easyscience/Objects/variable/parameter.py | 70 +++++++++++-------- 1 file changed, 39 insertions(+), 31 deletions(-) diff --git a/src/easyscience/Objects/variable/parameter.py b/src/easyscience/Objects/variable/parameter.py index 2f6226d..ccc99a4 100644 --- a/src/easyscience/Objects/variable/parameter.py +++ b/src/easyscience/Objects/variable/parameter.py @@ -222,6 +222,25 @@ def make_dependent(self, dependency_expression: str, dependency_map: Optional[di self._fixed = False self._notify_observers() + def make_independent(self) -> None: + """ + Make this parameter independent. + This will remove the dependency expression, the dependency map and the dependency interpreter. + + :return: None + """ + if not self._independent: + for dependency in self._dependency_map.values(): + dependency._detach_observer(self) + self._independent = True + del self._dependency_map + del self._dependency_updates + del self._dependency_interpreter + del self._dependency_string + del self._clean_dependency_string + else: + raise AttributeError('This parameter is already independent.') + @property def value_no_call_back(self) -> numbers.Number: """ @@ -303,7 +322,7 @@ def value(self, value: numbers.Number) -> None: # Notify observers of the change self._notify_observers() else: - raise AttributeError("This parameter is not independent, its value cannot be set directly. Please make it independent first.") # noqa: E501 + raise AttributeError("This is a dependent parameter, its value cannot be set directly.") @DescriptorNumber.variance.setter def variance(self, variance_float: float) -> None: @@ -315,7 +334,7 @@ def variance(self, variance_float: float) -> None: if self._independent: DescriptorNumber.variance.fset(self, variance_float) else: - raise AttributeError("This parameter is not independent, its variance cannot be set directly. Please make it independent first.") # noqa: E501 + raise AttributeError("This is a dependent parameter, its variance cannot be set directly.") @DescriptorNumber.error.setter def error(self, value: float) -> None: @@ -327,7 +346,7 @@ def error(self, value: float) -> None: if self._independent: DescriptorNumber.error.fset(self, value) else: - raise AttributeError("This parameter is not independent, its error cannot be set directly. Please make it independent first.") # noqa: E501 + raise AttributeError("This is a dependent parameter, its error cannot be set directly.") def _convert_unit(self, unit_str: str) -> None: """ @@ -381,7 +400,7 @@ def min(self, min_value: numbers.Number) -> None: raise ValueError(f'The current value ({self.value}) is smaller than the desired min value ({min_value}).') self._notify_observers() else: - raise AttributeError("This parameter is not independent, its min cannot be set directly. Please make it independent first.") # noqa: E501 + raise AttributeError("This is a dependent parameter, its minimum value cannot be set directly.") @property def max(self) -> numbers.Number: @@ -413,7 +432,7 @@ def max(self, max_value: numbers.Number) -> None: raise ValueError(f'The current value ({self.value}) is greater than the desired max value ({max_value}).') self._notify_observers() else: - raise AttributeError("This parameter is not independent, its max cannot be set directly. Please make it independent first.") # noqa: E501 + raise AttributeError("This is a dependent parameter, its maximum value cannot be set directly.") @property def fixed(self) -> bool: @@ -433,17 +452,17 @@ def fixed(self, fixed: bool) -> None: :param fixed: True = fixed, False = can vary """ - if not self.independent: + if not isinstance(fixed, bool): + raise ValueError(f'{fixed=} must be a boolean. Got {type(fixed)}') + if self._independent: + self._fixed = fixed + else: if self._global_object.stack.enabled: # Remove the recorded change from the stack global_object.stack.pop() - if global_object.debug: - raise CoreSetException(f'{str(self)} is not independent.') - return - if not isinstance(fixed, bool): - raise ValueError(f'{fixed=} must be a boolean. Got {type(fixed)}') - self._fixed = fixed + raise AttributeError("This is a dependent parameter, dependent parameters cannot be fixed.") + # Is this alias really needed? @property def free(self) -> bool: return not self.fixed @@ -452,6 +471,14 @@ def free(self) -> bool: def free(self, value: bool) -> None: self.fixed = not value + def independent(self) -> bool: + """ + Is the parameter independent? + + :return: True = independent, False = dependent + """ + return self._independent + @property def bounds(self) -> Tuple[numbers.Number, numbers.Number]: """ @@ -510,25 +537,6 @@ def builtin_constraints(self) -> Dict[str, SelfConstraint]: """ return MappingProxyType(self._constraints.builtin) - @property - def independent(self) -> bool: - """ - Logical property to see if the objects value can be directly set. - - :return: Can the objects value be set - """ - return self._independent - - @independent.setter - @property_stack_deco - def independent(self, value: bool) -> None: - """ - Enable and disable the direct setting of an objects value field. - - :param value: True - objects value can be set, False - the opposite - """ - self._independent = value - @property def user_constraints(self) -> Dict[str, ConstraintBase]: """ From b285ef18012e9e5fa72b7b1e9484bd12ea0134ef Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Wed, 23 Apr 2025 15:02:39 +0200 Subject: [PATCH 14/18] Add dependency property getters --- src/easyscience/Objects/variable/parameter.py | 53 ++++++++++++++++--- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/src/easyscience/Objects/variable/parameter.py b/src/easyscience/Objects/variable/parameter.py index ccc99a4..1cdf4f4 100644 --- a/src/easyscience/Objects/variable/parameter.py +++ b/src/easyscience/Objects/variable/parameter.py @@ -241,6 +241,51 @@ def make_independent(self) -> None: else: raise AttributeError('This parameter is already independent.') + @property + def independent(self) -> bool: + """ + Is the parameter independent? + + :return: True = independent, False = dependent + """ + return self._independent + + @independent.setter + def independent(self, value: bool) -> None: + raise AttributeError('This property is read-only. Use `make_independent` and `make_dependent` to change the state of the parameter.') # noqa: E501 + + @property + def depedency_expression(self) -> str: + """ + Get the dependency expression of this parameter. + + :return: The dependency expression of this parameter. + """ + if not self._independent: + return self._dependency_string + else: + raise AttributeError('This parameter is independent. It has no dependency expression.') + + @depedency_expression.setter + def depedency_expression(self, new_expression: str) -> None: + raise AttributeError('Dependency expression is read-only. Use `make_dependent` to change the dependency expression.') + + @property + def dependency_map(self) -> Dict[str, DescriptorNumber]: + """ + Get the dependency map of this parameter. + + :return: The dependency map of this parameter. + """ + if not self._independent: + return self._dependency_map + else: + raise AttributeError('This parameter is independent. It has no dependency map.') + + @dependency_map.setter + def dependency_map(self, new_map: Dict[str, DescriptorNumber]) -> None: + raise AttributeError('Dependency map is read-only. Use `make_dependent` to change the dependency map.') + @property def value_no_call_back(self) -> numbers.Number: """ @@ -471,14 +516,6 @@ def free(self) -> bool: def free(self, value: bool) -> None: self.fixed = not value - def independent(self) -> bool: - """ - Is the parameter independent? - - :return: True = independent, False = dependent - """ - return self._independent - @property def bounds(self) -> Tuple[numbers.Number, numbers.Number]: """ From e3fad227555322382cdf17b85363940af8c3d61a Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Fri, 25 Apr 2025 11:56:09 +0200 Subject: [PATCH 15/18] Remove old constraints and fix failing unit tests. Also fix small bugs in the dependent parameters. --- Examples/fitting/README.rst | 6 - Examples/fitting/plot_constraints.py | 22 - docs/src/fitting/constraints.rst | 75 --- docs/src/index.rst | 1 - src/easyscience/Constraints.py | 498 ------------------ src/easyscience/Objects/ObjectClasses.py | 16 - .../Objects/variable/descriptor_number.py | 3 + src/easyscience/Objects/variable/parameter.py | 140 +---- src/easyscience/fitting/fitter.py | 18 +- .../fitting/minimizers/minimizer_base.py | 21 - .../Fitting/minimizers/test_minimizer_base.py | 5 - .../Fitting/minimizers/test_minimizer_dfo.py | 4 - tests/unit_tests/Fitting/test_fitter.py | 40 -- tests/unit_tests/Objects/test_BaseObj.py | 3 +- tests/unit_tests/Objects/test_Groups.py | 16 - .../Objects/variable/test_parameter.py | 63 +-- .../variable/test_parameter_from_legacy.py | 424 --------------- .../global_object/test_undo_redo.py | 28 +- tests/unit_tests/utils/io_tests/test_core.py | 61 --- tests/unit_tests/utils/io_tests/test_dict.py | 235 +-------- tests/unit_tests/utils/io_tests/test_json.py | 73 --- tests/unit_tests/utils/io_tests/test_xml.py | 34 -- 22 files changed, 31 insertions(+), 1755 deletions(-) delete mode 100644 Examples/fitting/README.rst delete mode 100644 Examples/fitting/plot_constraints.py delete mode 100644 docs/src/fitting/constraints.rst delete mode 100644 src/easyscience/Constraints.py delete mode 100644 tests/unit_tests/Objects/variable/test_parameter_from_legacy.py diff --git a/Examples/fitting/README.rst b/Examples/fitting/README.rst deleted file mode 100644 index e0c24c4..0000000 --- a/Examples/fitting/README.rst +++ /dev/null @@ -1,6 +0,0 @@ -.. _fitting_examples: - -Fitting Examples ------------------------- - -This section gathers examples which correspond to fitting data. diff --git a/Examples/fitting/plot_constraints.py b/Examples/fitting/plot_constraints.py deleted file mode 100644 index b150bc8..0000000 --- a/Examples/fitting/plot_constraints.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -Constraints example -=================== -This example shows the usages of the different constraints. -""" - -from easyscience import Constraints -from easyscience.Objects.ObjectClasses import Parameter - -p1 = Parameter('p1', 1) -constraint = Constraints.NumericConstraint(p1, '<', 5) -p1.user_constraints['c1'] = constraint - -for value in range(4, 7): - p1.value = value - print(f'Set Value: {value}, Parameter Value: {p1}') - -# %% -# To include embedded rST, use a line of >= 20 ``#``'s or ``#%%`` between your -# rST and your code. This separates your example -# into distinct text and code blocks. You can continue writing code below the -# embedded rST text block: diff --git a/docs/src/fitting/constraints.rst b/docs/src/fitting/constraints.rst deleted file mode 100644 index d92c87c..0000000 --- a/docs/src/fitting/constraints.rst +++ /dev/null @@ -1,75 +0,0 @@ -====================== -Constraints -====================== - -Constraints are a fundamental component in non-trivial fitting operations. They can also be used to affirm the minimum/maximum of a parameter or tie parameters together in a model. - -Anatomy of a constraint ------------------------ - -A constraint is a rule which is applied to a **dependent** variable. This rule can consist of a logical operation, relation to one or more **independent** variables or an arbitrary function. - - -Constraints on Parameters -^^^^^^^^^^^^^^^^^^^^^^^^^ - -:class:`easyscience.Objects.Base.Parameter` has the properties `builtin_constraints` and `user_constraints`. These are dictionaries which correspond to constraints which are intrinsic and extrinsic to the Parameter. This means that on the value change of the Parameter firstly the `builtin_constraints` are evaluated, followed by the `user_constraints`. - - -Constraints on Fitting -^^^^^^^^^^^^^^^^^^^^^^ - -:class:`easyscience.fitting.Fitter` has the ability to evaluate user supplied constraints which effect the value of both fixed and non-fixed parameters. A good example of one such use case would be the ratio between two parameters, where you would create a :class:`easyscience.fitting.Constraints.ObjConstraint`. - -Using constraints ------------------ - -A constraint can be used in one of three ways; Assignment to a parameter, assignment to fitting or on demand. The first two are covered and on demand is shown below. - -.. code-block:: python - - from easyscience.fitting.Constraints import NumericConstraint - from easyscience.Objects.Base import Parameter - # Create an `a < 1` constraint - a = Parameter('a', 0.5) - constraint = NumericConstraint(a, '<=', 1) - # Evaluate the constraint on demand - a.value = 5.0 - constraint() - # A will now equal 1 - -Constraint Reference --------------------- - -.. minigallery:: easyscience.fitting.Constraints.NumericConstraint - :add-heading: Examples using `Constraints` - -Built-in constraints -^^^^^^^^^^^^^^^^^^^^ - -These are the built in constraints which you can use - -.. autoclass:: easyscience.fitting.Constraints.SelfConstraint - :members: +enabled - -.. autoclass:: easyscience.fitting.Constraints.NumericConstraint - :members: +enabled - -.. autoclass:: easyscience.fitting.Constraints.ObjConstraint - :members: +enabled - -.. autoclass:: easyscience.fitting.Constraints.FunctionalConstraint - :members: +enabled - -.. autoclass:: easyscience.fitting.Constraints.MultiObjConstraint - :members: +enabled - -User created constraints -^^^^^^^^^^^^^^^^^^^^^^^^ - -You can also make your own constraints by subclassing the :class:`easyscience.fitting.Constraints.ConstraintBase` class. For this at a minimum the abstract methods ``_parse_operator`` and ``__repr__`` need to be written. - -.. autoclass:: easyscience.fitting.Constraints.ConstraintBase - :members: - :private-members: - :special-members: __repr__ \ No newline at end of file diff --git a/docs/src/index.rst b/docs/src/index.rst index 3683a18..ca99dda 100644 --- a/docs/src/index.rst +++ b/docs/src/index.rst @@ -56,7 +56,6 @@ Documentation :maxdepth: 3 fitting/introduction - fitting/constraints .. toctree:: :maxdepth: 2 diff --git a/src/easyscience/Constraints.py b/src/easyscience/Constraints.py deleted file mode 100644 index c60c501..0000000 --- a/src/easyscience/Constraints.py +++ /dev/null @@ -1,498 +0,0 @@ -# SPDX-FileCopyrightText: 2023 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -# © 2021-2023 Contributors to the EasyScience project bool: - """ - Is the current constraint enabled. - - :return: Logical answer to if the constraint is enabled. - """ - return self._enabled - - @enabled.setter - def enabled(self, enabled_value: bool): - """ - Set the enabled state of the constraint. If the new value is the same as the current value only the state is - changed. - - ... note:: If the new value is ``True`` the constraint is also applied after enabling. - - :param enabled_value: New state of the constraint. - :return: None - """ - - if self._enabled == enabled_value: - return - elif enabled_value: - self.get_obj(self.dependent_obj_ids).independent = False - self() - else: - self.get_obj(self.dependent_obj_ids).independent = True - self._enabled = enabled_value - - def __call__(self, *args, no_set: bool = False, **kwargs): - """ - Method which applies the constraint - - :return: None if `no_set` is False, float otherwise. - """ - if not self.enabled: - if no_set: - return None - return - independent_objs = None - if isinstance(self.dependent_obj_ids, str): - dependent_obj = self.get_obj(self.dependent_obj_ids) - else: - raise AttributeError - if isinstance(self.independent_obj_ids, str): - independent_objs = self.get_obj(self.independent_obj_ids) - elif isinstance(self.independent_obj_ids, list): - independent_objs = [self.get_obj(obj_id) for obj_id in self.independent_obj_ids] - if independent_objs is not None: - value = self._parse_operator(independent_objs, *args, **kwargs) - else: - value = self._parse_operator(dependent_obj, *args, **kwargs) - - if not no_set: - toggle = False - if not dependent_obj.independent: - dependent_obj.independent = True - toggle = True - dependent_obj.value = value - if toggle: - dependent_obj.independent = False - return value - - @abstractmethod - def _parse_operator(self, obj: V, *args, **kwargs) -> Number: - """ - Abstract method which contains the constraint logic - - :param obj: The object/objects which the constraint will use - :return: A numeric result of the constraint logic - """ - - @abstractmethod - def __repr__(self): - pass - - def get_obj(self, key: int) -> V: - """ - Get an EasyScience object from its unique key - - :param key: an EasyScience objects unique key - :return: EasyScience object - """ - return self._global_object.map.get_item_by_key(key) - - -C = TypeVar('C', bound=ConstraintBase) - - -class NumericConstraint(ConstraintBase): - """ - A `NumericConstraint` is a constraint whereby a dependent parameters value is something of an independent parameters - value. I.e. a < 1, a > 5 - """ - - def __init__(self, dependent_obj: V, operator: str, value: Number): - """ - A `NumericConstraint` is a constraint whereby a dependent parameters value is something of an independent - parameters value. I.e. a < 1, a > 5 - - :param dependent_obj: Dependent Parameter - :param operator: Relation to between the parameter and the values. e.g. ``=``, ``<``, ``>`` - :param value: What the parameters value should be compared against. - - :example: - - .. code-block:: python - - from easyscience.fitting.Constraints import NumericConstraint - from easyscience.Objects.Base import Parameter - # Create an `a < 1` constraint - a = Parameter('a', 0.2) - constraint = NumericConstraint(a, '<=', 1) - a.user_constraints['LEQ_1'] = constraint - # This works - a.value = 0.85 - # This triggers the constraint - a.value = 2.0 - # `a` is set to the maximum of the constraint (`a = 1`) - """ - super(NumericConstraint, self).__init__(dependent_obj, operator=operator, value=value) - - def _parse_operator(self, obj: V, *args, **kwargs) -> Number: - ## TODO Probably needs to be updated when DescriptorArray is implemented - - value = obj.value_no_call_back - - if isinstance(value, list): - value = np.array(value) - self.aeval.symtable['value1'] = value - self.aeval.symtable['value2'] = self.value - try: - self.aeval.eval(f'value3 = value1 {self.operator} value2') - logic = self.aeval.symtable['value3'] - if isinstance(logic, np.ndarray): - value[not logic] = self.aeval.symtable['value2'] - else: - if not logic: - value = self.aeval.symtable['value2'] - except Exception as e: - raise e - finally: - self.aeval = Interpreter() - return value - - def __repr__(self) -> str: - return f'{self.__class__.__name__} with `value` {self.operator} {self.value}' - - -class SelfConstraint(ConstraintBase): - """ - A `SelfConstraint` is a constraint which tests a logical constraint on a property of itself, similar to a - `NumericConstraint`. i.e. a > a.min. These constraints are usually used in the internal EasyScience logic. - """ - - def __init__(self, dependent_obj: V, operator: str, value: str): - """ - A `SelfConstraint` is a constraint which tests a logical constraint on a property of itself, similar to - a `NumericConstraint`. i.e. a > a.min. - - :param dependent_obj: Dependent Parameter - :param operator: Relation to between the parameter and the values. e.g. ``=``, ``<``, ``>`` - :param value: Name of attribute to be compared against - - :example: - - .. code-block:: python - - from easyscience.fitting.Constraints import SelfConstraint - from easyscience.Objects.Base import Parameter - # Create an `a < a.max` constraint - a = Parameter('a', 0.2, max=1) - constraint = SelfConstraint(a, '<=', 'max') - a.user_constraints['MAX'] = constraint - # This works - a.value = 0.85 - # This triggers the constraint - a.value = 2.0 - # `a` is set to the maximum of the constraint (`a = 1`) - """ - super(SelfConstraint, self).__init__(dependent_obj, operator=operator, value=value) - - def _parse_operator(self, obj: V, *args, **kwargs) -> Number: - value = obj.value_no_call_back - - self.aeval.symtable['value1'] = value - self.aeval.symtable['value2'] = getattr(obj, self.value) - try: - self.aeval.eval(f'value3 = value1 {self.operator} value2') - logic = self.aeval.symtable['value3'] - if isinstance(logic, np.ndarray): - value[not logic] = self.aeval.symtable['value2'] - else: - if not logic: - value = self.aeval.symtable['value2'] - except Exception as e: - raise e - finally: - self.aeval = Interpreter() - return value - - def __repr__(self) -> str: - return f'{self.__class__.__name__} with `value` {self.operator} obj.{self.value}' - - -class ObjConstraint(ConstraintBase): - """ - A `ObjConstraint` is a constraint whereby a dependent parameter is something of an independent parameter - value. E.g. a (Dependent Parameter) = 2* b (Independent Parameter) - """ - - def __init__(self, dependent_obj: V, operator: str, independent_obj: V): - """ - A `ObjConstraint` is a constraint whereby a dependent parameter is something of an independent parameter - value. E.g. a (Dependent Parameter) < b (Independent Parameter) - - :param dependent_obj: Dependent Parameter - :param operator: Relation to between the independent parameter and dependent parameter. e.g. ``2 *``, ``1 +`` - :param independent_obj: Independent Parameter - - :example: - - .. code-block:: python - - from easyscience.fitting.Constraints import ObjConstraint - from easyscience.Objects.Base import Parameter - # Create an `a = 2 * b` constraint - a = Parameter('a', 0.2) - b = Parameter('b', 1) - - constraint = ObjConstraint(a, '2*', b) - b.user_constraints['SET_A'] = constraint - b.value = 1 - # This triggers the constraint - a.value # Should equal 2 - - """ - super(ObjConstraint, self).__init__(dependent_obj, independent_obj=independent_obj, operator=operator) - self.external = True - - def _parse_operator(self, obj: V, *args, **kwargs) -> Number: - value = obj.value_no_call_back - - self.aeval.symtable['value1'] = value - try: - self.aeval.eval(f'value2 = {self.operator} value1') - value = self.aeval.symtable['value2'] - except Exception as e: - raise e - finally: - self.aeval = Interpreter() - return value - - def __repr__(self) -> str: - return f'{self.__class__.__name__} with `dependent_obj` = {self.operator} `independent_obj`' - - -class MultiObjConstraint(ConstraintBase): - """ - A `MultiObjConstraint` is similar to :class:`EasyScience.fitting.Constraints.ObjConstraint` except that it relates to - multiple independent objects. - """ - - def __init__( - self, - independent_objs: List[V], - operator: List[str], - dependent_obj: V, - value: Number, - ): - """ - A `MultiObjConstraint` is similar to :class:`EasyScience.fitting.Constraints.ObjConstraint` except that it relates - to one or more independent objects. - - E.g. - * a (Dependent Parameter) + b (Independent Parameter) = 1 - * a (Dependent Parameter) + b (Independent Parameter) - 2*c (Independent Parameter) = 0 - - :param independent_objs: List of Independent Parameters - :param operator: List of operators operating on the Independent Parameters - :param dependent_obj: Dependent Parameter - :param value: Value of the expression - - :example: - - **a + b = 1** - - .. code-block:: python - - from easyscience.fitting.Constraints import MultiObjConstraint - from easyscience.Objects.Base import Parameter - # Create an `a + b = 1` constraint - a = Parameter('a', 0.2) - b = Parameter('b', 0.3) - - constraint = MultiObjConstraint([b], ['+'], a, 1) - b.user_constraints['SET_A'] = constraint - b.value = 0.4 - # This triggers the constraint - a.value # Should equal 0.6 - - **a + b - 2c = 0** - - .. code-block:: python - - from easyscience.fitting.Constraints import MultiObjConstraint - from easyscience.Objects.Base import Parameter - # Create an `a + b - 2c = 0` constraint - a = Parameter('a', 0.5) - b = Parameter('b', 0.3) - c = Parameter('c', 0.1) - - constraint = MultiObjConstraint([b, c], ['+', '-2*'], a, 0) - b.user_constraints['SET_A'] = constraint - c.user_constraints['SET_A'] = constraint - b.value = 0.4 - # This triggers the constraint. Or it could be triggered by changing the value of c - a.value # Should equal 0.2 - - .. note:: This constraint is evaluated as ``dependent`` = ``value`` - SUM(``operator_i`` ``independent_i``) - """ - super(MultiObjConstraint, self).__init__( - dependent_obj, - independent_obj=independent_objs, - operator=operator, - value=value, - ) - self.external = True - - def _parse_operator(self, independent_objs: List[V], *args, **kwargs) -> Number: - - in_str = '' - value = None - for idx, obj in enumerate(independent_objs): - self.aeval.symtable['p' + str(self.independent_obj_ids[idx])] = obj.value_no_call_back - - in_str += ' p' + str(self.independent_obj_ids[idx]) - if idx < len(self.operator): - in_str += ' ' + self.operator[idx] - try: - self.aeval.eval(f'final_value = {self.value} - ({in_str})') - value = self.aeval.symtable['final_value'] - except Exception as e: - raise e - finally: - self.aeval = Interpreter() - return value - - def __repr__(self) -> str: - return f'{self.__class__.__name__}' - - -class FunctionalConstraint(ConstraintBase): - """ - Functional constraints do not depend on other parameters and as such can be more complex. - """ - - def __init__( - self, - dependent_obj: V, - func: Callable, - independent_objs: Optional[List[V]] = None, - ): - """ - Functional constraints do not depend on other parameters and as such can be more complex. - - :param dependent_obj: Dependent Parameter - :param func: Function to be evaluated in the form ``f(value, *args, **kwargs)`` - - :example: - - .. code-block:: python - - import numpy as np - from easyscience.fitting.Constraints import FunctionalConstraint - from easyscience.Objects.Base import Parameter - - a = Parameter('a', 0.2, max=1) - constraint = FunctionalConstraint(a, np.abs) - - a.user_constraints['abs'] = constraint - - # This triggers the constraint - a.value = 0.85 # `a` is set to 0.85 - # This triggers the constraint - a.value = -0.5 # `a` is set to 0.5 - """ - super(FunctionalConstraint, self).__init__(dependent_obj, independent_obj=independent_objs) - self.function = func - if independent_objs is not None: - self.external = True - - def _parse_operator(self, obj: V, *args, **kwargs) -> Number: - - self.aeval.symtable[f'f{id(self.function)}'] = self.function - value_str = f'r_value = f{id(self.function)}(' - if isinstance(obj, list): - for o in obj: - value_str += f'{o.value_no_call_back},' - - value_str = value_str[:-1] - else: - value_str += f'{obj.value_no_call_back}' - - value_str += ')' - try: - self.aeval.eval(value_str) - value = self.aeval.symtable['r_value'] - except Exception as e: - raise e - finally: - self.aeval = Interpreter() - return value - - def __repr__(self) -> str: - return f'{self.__class__.__name__}' - - -def cleanup_constraint(obj_id: str, independent: bool): - try: - obj = global_object.map.get_item_by_key(obj_id) - obj.independent = independent - except ValueError: - if global_object.debug: - print(f'Object with ID {obj_id} has already been deleted') diff --git a/src/easyscience/Objects/ObjectClasses.py b/src/easyscience/Objects/ObjectClasses.py index 020d9fc..376162c 100644 --- a/src/easyscience/Objects/ObjectClasses.py +++ b/src/easyscience/Objects/ObjectClasses.py @@ -1,16 +1,11 @@ from __future__ import annotations -__author__ = 'github.com/wardsimon' -__version__ = '0.1.0' - # SPDX-FileCopyrightText: 2023 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause # © 2021-2023 Contributors to the EasyScience project List[C]: - pars = self.get_parameters() - constraints = [] - for par in pars: - con: Dict[str, C] = par.user_constraints - for key in con.keys(): - constraints.append(con[key]) - return constraints - def get_parameters(self) -> List[Parameter]: """ Get all parameter objects as a list. diff --git a/src/easyscience/Objects/variable/descriptor_number.py b/src/easyscience/Objects/variable/descriptor_number.py index f98e9c0..b80533d 100644 --- a/src/easyscience/Objects/variable/descriptor_number.py +++ b/src/easyscience/Objects/variable/descriptor_number.py @@ -465,6 +465,9 @@ def __abs__(self) -> DescriptorNumber: return descriptor_number def _base_unit(self) -> str: + """ + Extract the base unit from the unit string by removing numeric components and scientific notation. + """ string = str(self._scalar.unit) for i, letter in enumerate(string): if letter == 'e': diff --git a/src/easyscience/Objects/variable/parameter.py b/src/easyscience/Objects/variable/parameter.py index 1cdf4f4..d3b8766 100644 --- a/src/easyscience/Objects/variable/parameter.py +++ b/src/easyscience/Objects/variable/parameter.py @@ -9,13 +9,10 @@ import re import warnings import weakref -from collections import namedtuple -from types import MappingProxyType from typing import Any from typing import Dict from typing import List from typing import Optional -from typing import Tuple from typing import Union import numpy as np @@ -25,16 +22,11 @@ from scipp import Variable from easyscience import global_object -from easyscience.Constraints import ConstraintBase -from easyscience.Constraints import SelfConstraint from easyscience.global_object.undo_redo import property_stack_deco -from easyscience.Utils.Exceptions import CoreSetException from .descriptor_number import DescriptorNumber from .descriptor_number import notify_observers -Constraints = namedtuple('Constraints', ['user', 'builtin', 'virtual']) - class Parameter(DescriptorNumber): """ @@ -119,12 +111,6 @@ def __init__( # Create additional fitting elements self._initial_scalar = copy.deepcopy(self._scalar) - builtin_constraint = { - # Last argument in constructor is the name of the property holding the value of the constraint - 'min': SelfConstraint(self, '>=', 'min'), - 'max': SelfConstraint(self, '<=', 'max'), - } - self._constraints = Constraints(builtin=builtin_constraint, user={}, virtual={}) @classmethod def from_dependency(cls, name: str, dependency_expression: str, dependency_map: Optional[dict] = None, **kwargs) -> Parameter: # noqa: E501 @@ -152,6 +138,8 @@ def _update(self, update_id: int, updating_object: str) -> None: """ if not self._independent: # Check if this parameter has already been updated by the updating object with this update id + if updating_object not in self._dependency_updates: + self._dependency_updates[updating_object] = 0 if self._dependency_updates[updating_object] == update_id: warnings.warn('Warning: Cyclic dependency detected!\n' + f'This parameter, {self.unique_name}, has already been updated by {updating_object} during this update.\n' + # noqa: E501 @@ -179,7 +167,7 @@ def make_dependent(self, dependency_expression: str, dependency_map: Optional[di raise TypeError('`dependency_expression` must be a string representing a valid dependency expression.') if not (isinstance(dependency_map, dict) or dependency_map is None): raise TypeError('`dependency_map` must be a dictionary of dependencies and their corresponding names in the dependecy expression.') # noqa: E501 - for key, value in self._dependency_map.items(): + for key, value in dependency_map.items(): if not isinstance(key, str): raise TypeError('`dependency_map` keys must be strings representing the names of the dependencies in the dependency expression.') # noqa: E501 if not isinstance(value, DescriptorNumber): @@ -216,8 +204,8 @@ def make_dependent(self, dependency_expression: str, dependency_map: Optional[di self._scalar.value = dependency_result.value self._scalar.unit = dependency_result.unit self._scalar.variance = dependency_result.variance - self._min = dependency_result.min if isinstance(dependency_result, Parameter) else dependency_result.value - self._max = dependency_result.max if isinstance(dependency_result, Parameter) else dependency_result.value + self._min.value = dependency_result.min if isinstance(dependency_result, Parameter) else dependency_result.value + self._max.value = dependency_result.max if isinstance(dependency_result, Parameter) else dependency_result.value self._independent = False self._fixed = False self._notify_observers() @@ -337,30 +325,17 @@ def value(self, value: numbers.Number) -> None: :param value: New value of self """ if self._independent: - if not isinstance(value, numbers.Number) or isinstance(value, bool): + if not isinstance(value, numbers.Number): raise TypeError(f'{value=} must be a number') + + value = float(value) + if value < self._min.value: + value = self._min.value + if value > self._max.value: + value = self._max.value - # Need to set the value for constraints to be functional - self._scalar.value = float(value) - # if self._callback.fset is not None: - # self._callback.fset(self._scalar.value) - - # Deals with min/max - value = self._constraint_runner(self.builtin_constraints, self._scalar.value) - - # Deals with user constraints - # Changes should not be registrered in the undo/redo stack - stack_state = global_object.stack.enabled - if stack_state: - global_object.stack.force_state(False) - try: - value = self._constraint_runner(self.user_constraints, value) - finally: - global_object.stack.force_state(stack_state) - - value = self._constraint_runner(self._constraints.virtual, value) + self._scalar.value = value - self._scalar.value = float(value) if self._callback.fset is not None: self._callback.fset(self._scalar.value) @@ -516,95 +491,6 @@ def free(self) -> bool: def free(self, value: bool) -> None: self.fixed = not value - @property - def bounds(self) -> Tuple[numbers.Number, numbers.Number]: - """ - Get the bounds of the parameter. - - :return: Tuple of the parameters minimum and maximum values - """ - return self.min, self.max - - @bounds.setter - def bounds(self, new_bound: Tuple[numbers.Number, numbers.Number]) -> None: - """ - Set the bounds of the parameter. *This will also enable the parameter*. - - :param new_bound: New bounds. This should be a tuple of (min, max). - """ - old_min = self.min - old_max = self.max - new_min, new_max = new_bound - - # Begin macro operation for undo/redo - close_macro = False - if self._global_object.stack.enabled: - self._global_object.stack.beginMacro('Setting bounds') - close_macro = True - - try: - # Update bounds - self.min = new_min - self.max = new_max - except ValueError: - # Rollback on failure - self.min = old_min - self.max = old_max - if close_macro: - self._global_object.stack.endMacro() - raise ValueError(f'Current parameter value: {self._scalar.value} must be within {new_bound=}') - - # Enable the parameter if needed - if not self.independent: - self.independent = True - # Free parameter if needed - if self.fixed: - self.fixed = False - - # End macro operation - if close_macro: - self._global_object.stack.endMacro() - - @property - def builtin_constraints(self) -> Dict[str, SelfConstraint]: - """ - Get the built in constrains of the object. Typically these are the min/max - - :return: Dictionary of constraints which are built into the system - """ - return MappingProxyType(self._constraints.builtin) - - @property - def user_constraints(self) -> Dict[str, ConstraintBase]: - """ - Get the user specified constrains of the object. - - :return: Dictionary of constraints which are user supplied - """ - return self._constraints.user - - @user_constraints.setter - def user_constraints(self, constraints_dict: Dict[str, ConstraintBase]) -> None: - self._constraints.user = constraints_dict - - def _constraint_runner( - self, - this_constraint_type, - value: numbers.Number, - ) -> float: - for constraint in this_constraint_type.values(): - if constraint.external: - constraint() - continue - - constained_value = constraint(no_set=True) - if constained_value != value: - if global_object.debug: - print(f'Constraint `{constraint}` has been applied') - self._scalar.value = constained_value - value = constained_value - return value - def _process_dependency_unique_names(self, dependency_expression: str): """ Add the unique names of the parameters to the ASTEval interpreter. This is used to evaluate the dependency expression. diff --git a/src/easyscience/fitting/fitter.py b/src/easyscience/fitting/fitter.py index daea778..5300787 100644 --- a/src/easyscience/fitting/fitter.py +++ b/src/easyscience/fitting/fitter.py @@ -34,15 +34,6 @@ def __init__(self, fit_object, fit_function: Callable): self._enum_current_minimizer: AvailableMinimizers = None # set in _update_minimizer self._update_minimizer(DEFAULT_MINIMIZER) - def fit_constraints(self) -> list: - return self._minimizer.fit_constraints() - - def add_fit_constraint(self, constraint) -> None: - self._minimizer.add_fit_constraint(constraint) - - def remove_fit_constraint(self, index: int) -> None: - self._minimizer.remove_fit_constraint(index) - def make_model(self, pars=None) -> Callable: return self._minimizer.make_model(pars) @@ -84,9 +75,7 @@ def switch_minimizer(self, minimizer_enum: Union[AvailableMinimizers, str]) -> N print(f'minimizer should be set with enum {minimizer_enum}') minimizer_enum = from_string_to_enum(minimizer_enum) - constraints = self._minimizer.fit_constraints() self._update_minimizer(minimizer_enum) - self._minimizer.set_fit_constraint(constraints) def _update_minimizer(self, minimizer_enum: AvailableMinimizers) -> None: self._minimizer = factory(minimizer_enum=minimizer_enum, fit_object=self._fit_object, fit_function=self.fit_function) @@ -235,11 +224,7 @@ def inner_fit_callable( # Fit fit_fun_org = self._fit_function fit_fun_wrap = self._fit_function_wrapper(x_new, flatten=True) # This should be wrapped. - - # We change the fit function, so have to reset constraints - constraints = self._minimizer.fit_constraints() self.fit_function = fit_fun_wrap - self._minimizer.set_fit_constraint(constraints) f_res = self._minimizer.fit( x_fit, y_new, @@ -251,9 +236,8 @@ def inner_fit_callable( # Postcompute fit_result = self._post_compute_reshaping(f_res, x, y) - # Reset the function and constrains + # Reset the function self.fit_function = fit_fun_org - self._minimizer.set_fit_constraint(constraints) return fit_result return inner_fit_callable diff --git a/src/easyscience/fitting/minimizers/minimizer_base.py b/src/easyscience/fitting/minimizers/minimizer_base.py index 02130a6..511057f 100644 --- a/src/easyscience/fitting/minimizers/minimizer_base.py +++ b/src/easyscience/fitting/minimizers/minimizer_base.py @@ -16,8 +16,6 @@ import numpy as np -from easyscience.Constraints import ObjConstraint - # causes circular import when Parameter is imported # from easyscience.Objects.ObjectClasses import BaseObj from easyscience.Objects.variable import Parameter @@ -52,11 +50,6 @@ def __init__( self._cached_pars_vals: Dict[str, Tuple[float]] = {} self._cached_model = None self._fit_function = None - self._constraints = [] - - @property - def all_constraints(self) -> List[ObjConstraint]: - return [*self._constraints, *self._object._constraints] @property def enum(self) -> AvailableMinimizers: @@ -66,18 +59,6 @@ def enum(self) -> AvailableMinimizers: def name(self) -> str: return self._minimizer_enum.name - def fit_constraints(self) -> List[ObjConstraint]: - return self._constraints - - def set_fit_constraint(self, constraints: List[ObjConstraint]): - self._constraints = constraints - - def add_fit_constraint(self, constraint: ObjConstraint): - self._constraints.append(constraint) - - def remove_fit_constraint(self, index: int) -> None: - del self._constraints[index] - @abstractmethod def fit( self, @@ -237,8 +218,6 @@ def _fit_function(x: np.ndarray, **kwargs): # Since we are calling the parameter fset will be called. # TODO Pre processing here - for constraint in self.fit_constraints(): - constraint() return_data = func(x) # TODO Loading or manipulating data here return return_data diff --git a/tests/unit_tests/Fitting/minimizers/test_minimizer_base.py b/tests/unit_tests/Fitting/minimizers/test_minimizer_base.py index 281b97e..77158ca 100644 --- a/tests/unit_tests/Fitting/minimizers/test_minimizer_base.py +++ b/tests/unit_tests/Fitting/minimizers/test_minimizer_base.py @@ -46,7 +46,6 @@ def test_init(self, minimizer: MinimizerBase): assert minimizer._cached_pars_vals == {} assert minimizer._cached_model == None assert minimizer._fit_function == None - assert minimizer._constraints == [] def test_enum(self, minimizer: MinimizerBase): assert minimizer.enum == self._mock_minimizer_enum @@ -128,9 +127,6 @@ def test_generate_fit_function(self, minimizer: MinimizerBase) -> None: # When minimizer._original_fit_function = MagicMock(return_value='fit_function_result') - mock_fit_constraint = MagicMock() - minimizer.fit_constraints = MagicMock(return_value=[mock_fit_constraint]) - minimizer._object = MagicMock() mock_parm_1 = MagicMock(Parameter) mock_parm_1.unique_name = 'mock_parm_1' @@ -148,7 +144,6 @@ def test_generate_fit_function(self, minimizer: MinimizerBase) -> None: # Expect assert 'fit_function_result' == fit_function_result - mock_fit_constraint.assert_called_once_with() minimizer._original_fit_function.assert_called_once_with([10.0]) assert minimizer._cached_pars['mock_parm_1'] == mock_parm_1 assert minimizer._cached_pars['mock_parm_2'] == mock_parm_2 diff --git a/tests/unit_tests/Fitting/minimizers/test_minimizer_dfo.py b/tests/unit_tests/Fitting/minimizers/test_minimizer_dfo.py index 8c39b8a..1cd14cd 100644 --- a/tests/unit_tests/Fitting/minimizers/test_minimizer_dfo.py +++ b/tests/unit_tests/Fitting/minimizers/test_minimizer_dfo.py @@ -72,9 +72,6 @@ def test_generate_fit_function(self, minimizer: DFO) -> None: # When minimizer._original_fit_function = MagicMock(return_value='fit_function_result') - mock_fit_constraint = MagicMock() - minimizer.fit_constraints = MagicMock(return_value=[mock_fit_constraint]) - minimizer._object = MagicMock() mock_parm_1 = MagicMock() mock_parm_1.unique_name = 'mock_parm_1' @@ -92,7 +89,6 @@ def test_generate_fit_function(self, minimizer: DFO) -> None: # Expect assert 'fit_function_result' == fit_function_result - mock_fit_constraint.assert_called_once_with() minimizer._original_fit_function.assert_called_once_with([10.0]) assert minimizer._cached_pars['mock_parm_1'] == mock_parm_1 assert minimizer._cached_pars['mock_parm_2'] == mock_parm_2 diff --git a/tests/unit_tests/Fitting/test_fitter.py b/tests/unit_tests/Fitting/test_fitter.py index 63783c1..992225c 100644 --- a/tests/unit_tests/Fitting/test_fitter.py +++ b/tests/unit_tests/Fitting/test_fitter.py @@ -24,42 +24,6 @@ def test_constructor(self, fitter: Fitter): assert fitter._minimizer is None fitter._update_minimizer.assert_called_once_with(AvailableMinimizers.LMFit_leastsq) - def test_fit_constraints(self, fitter: Fitter): - # When - mock_minimizer = MagicMock() - mock_minimizer.fit_constraints = MagicMock(return_value='constraints') - fitter._minimizer = mock_minimizer - - # Then - constraints = fitter.fit_constraints() - - # Expect - assert constraints == 'constraints' - - def test_add_fit_constraint(self, fitter: Fitter): - # When - mock_minimizer = MagicMock() - mock_minimizer.add_fit_constraint = MagicMock() - fitter._minimizer = mock_minimizer - - # Then - fitter.add_fit_constraint('constraints') - - # Expect - mock_minimizer.add_fit_constraint.assert_called_once_with('constraints') - - def test_remove_fit_constraint(self, fitter: Fitter): - # When - mock_minimizer = MagicMock() - mock_minimizer.remove_fit_constraint = MagicMock() - fitter._minimizer = mock_minimizer - - # Then - fitter.remove_fit_constraint(10) - - # Expect - mock_minimizer.remove_fit_constraint.assert_called_once_with(10) - def test_make_model(self, fitter: Fitter): # When mock_minimizer = MagicMock() @@ -128,8 +92,6 @@ def test_create(self, fitter: Fitter, monkeypatch): def test_switch_minimizer(self, fitter: Fitter, monkeypatch): # When mock_minimizer = MagicMock() - mock_minimizer.fit_constraints = MagicMock(return_value='constraints') - mock_minimizer.set_fit_constraint = MagicMock() fitter._minimizer = mock_minimizer mock_string_to_enum = MagicMock(return_value=10) monkeypatch.setattr(easyscience.fitting.fitter, 'from_string_to_enum', mock_string_to_enum) @@ -139,8 +101,6 @@ def test_switch_minimizer(self, fitter: Fitter, monkeypatch): # Expect fitter._update_minimizer.count(2) - mock_minimizer.set_fit_constraint.assert_called_once_with('constraints') - mock_minimizer.fit_constraints.assert_called_once() mock_string_to_enum.assert_called_once_with('great-minimizer') def test_update_minimizer(self, monkeypatch): diff --git a/tests/unit_tests/Objects/test_BaseObj.py b/tests/unit_tests/Objects/test_BaseObj.py index ddccd63..67b91e8 100644 --- a/tests/unit_tests/Objects/test_BaseObj.py +++ b/tests/unit_tests/Objects/test_BaseObj.py @@ -148,7 +148,7 @@ def test_baseobj_fit_objects(setup_pars: dict): pass -def test_baseobj_as_dict(setup_pars: dict): +def test_baseobj_as_dict(clear, setup_pars: dict): name = setup_pars["name"] del setup_pars["name"] obj = BaseObj(name, **setup_pars) @@ -266,7 +266,6 @@ def test_baseobj_dir(setup_pars): "encode", "decode", "as_dict", - "constraints", "des1", "des2", "from_dict", diff --git a/tests/unit_tests/Objects/test_Groups.py b/tests/unit_tests/Objects/test_Groups.py index 3850b5f..b546373 100644 --- a/tests/unit_tests/Objects/test_Groups.py +++ b/tests/unit_tests/Objects/test_Groups.py @@ -394,22 +394,6 @@ def test_baseCollection_from_dict(cls): assert item1.value == item2.value -@pytest.mark.parametrize("cls", class_constructors) -def test_baseCollection_constraints(cls): - name = "test" - p1 = Parameter("p1", 1) - p2 = Parameter("p2", 2) - - from easyscience.Constraints import ObjConstraint - - p2.user_constraints["testing"] = ObjConstraint(p2, "2*", p1) - - obj = cls(name, p1, p2) - - cons: List[ObjConstraint] = obj.constraints - assert len(cons) == 1 - - @pytest.mark.parametrize("cls", class_constructors) def test_baseCollection_repr(cls): name = "test" diff --git a/tests/unit_tests/Objects/variable/test_parameter.py b/tests/unit_tests/Objects/variable/test_parameter.py index 1eac02a..476827c 100644 --- a/tests/unit_tests/Objects/variable/test_parameter.py +++ b/tests/unit_tests/Objects/variable/test_parameter.py @@ -24,7 +24,6 @@ def parameter(self) -> Parameter: url="url", display_name="display_name", callback=self.mock_callback, - independent="independent", parent=None, ) return parameter @@ -40,7 +39,7 @@ def test_init(self, parameter: Parameter): assert parameter._max.value == 10 assert parameter._max.unit == "m" assert parameter._callback == self.mock_callback - assert parameter._independent == "independent" + assert parameter._independent == True # From super assert parameter._scalar.value == 1 @@ -69,7 +68,6 @@ def test_init_value_min_exception(self): url="url", display_name="display_name", callback=mock_callback, - independent="independent", parent=None, ) @@ -91,7 +89,6 @@ def test_init_value_max_exception(self): url="url", display_name="display_name", callback=mock_callback, - independent="independent", parent=None, ) @@ -185,56 +182,6 @@ def test_repr_fixed(self, parameter: Parameter): # Then Expect assert repr(parameter) == "" - def test_bounds(self, parameter: Parameter): - # When Then Expect - assert parameter.bounds == (0, 10) - - def test_set_bounds(self, parameter: Parameter): - # When - parameter._independent = False - self.mock_callback.fget.return_value = 1.0 # Ensure fget returns a scalar value - parameter._enabled = False - parameter._fixed = True - - # Then - parameter.bounds = (-10, 5) - - # Expect - assert parameter.min == -10 - assert parameter.max == 5 - assert parameter._independent == True - assert parameter._fixed == False - - def test_set_bounds_exception_min(self, parameter: Parameter): - # When - parameter._independent = False - parameter._fixed = True - - # Then - with pytest.raises(ValueError): - parameter.bounds = (2, 10) - - # Expect - assert parameter.min == 0 - assert parameter.max == 10 - assert parameter._independent == False - assert parameter._fixed == True - - def test_set_bounds_exception_max(self, parameter: Parameter): - # When - parameter._independent = False - parameter._fixed = True - - # Then - with pytest.raises(ValueError): - parameter.bounds = (0, 0.1) - - # Expect - assert parameter.min == 0 - assert parameter.max == 10 - assert parameter._independent == False - assert parameter._fixed == True - def test_independent(self, parameter: Parameter): # When parameter._independent = True @@ -242,13 +189,6 @@ def test_independent(self, parameter: Parameter): # Then Expect assert parameter.independent is True - def test_set_independent(self, parameter: Parameter): - # When - parameter.independent = False - - # Then Expect - assert parameter._independent is False - def test_value_match_callback(self, parameter: Parameter): # When self.mock_callback.fget.return_value = 1.0 @@ -337,7 +277,6 @@ def test_as_data_dict(self, clear, parameter: Parameter): "description": "description", "url": "url", "display_name": "display_name", - "independent": "independent", "unique_name": "Parameter_0", } diff --git a/tests/unit_tests/Objects/variable/test_parameter_from_legacy.py b/tests/unit_tests/Objects/variable/test_parameter_from_legacy.py deleted file mode 100644 index f4dcd2e..0000000 --- a/tests/unit_tests/Objects/variable/test_parameter_from_legacy.py +++ /dev/null @@ -1,424 +0,0 @@ -# SPDX-FileCopyrightText: 2023 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -# © 2021-2023 Contributors to the EasyScience project " - d = Parameter("test", 1, unit="cm") - assert repr(d) == f"<{d.__class__.__name__} 'test': 1.0000 cm, bounds=[-inf:inf]>" - d = Parameter("test", 1, variance=0.1) - assert repr(d) == f"<{d.__class__.__name__} 'test': 1.0000 ± 0.3162, bounds=[-inf:inf]>" - - d = Parameter("test", 1, fixed=True) - assert ( - repr(d) - == f"<{d.__class__.__name__} 'test': 1.0000 (fixed), bounds=[-inf:inf]>" - ) - d = Parameter("test", 1, unit="cm", variance=0.1, fixed=True) - assert ( - repr(d) - == f"<{d.__class__.__name__} 'test': 1.0000 ± 0.3162 cm (fixed), bounds=[-inf:inf]>" - ) - - -def test_parameter_as_dict(): - d = Parameter("test", 1) - result = d.as_dict() - expected = { - "@module": Parameter.__module__, - "@class": Parameter.__name__, - "@version": easyscience.__version__, - "name": "test", - "value": 1.0, - "variance": 0.0, - "min": -np.inf, - "max": np.inf, - "fixed": False, - "unit": "dimensionless", - } - for key in expected.keys(): - assert result[key] == expected[key] - - # Check that additional arguments work - d = Parameter("test", 1, unit="km", url="https://www.boo.com") - result = d.as_dict() - expected = { - "@module": Parameter.__module__, - "@class": Parameter.__name__, - "@version": easyscience.__version__, - "name": "test", - "unit": "km", - "value": 1.0, - "variance": 0.0, - "min": -np.inf, - "max": np.inf, - "fixed": False, - "url": "https://www.boo.com", - } - for key in expected.keys(): - assert result[key] == expected[key] - - -def test_item_from_dict(): - reference = { - "@module": Parameter.__module__, - "@class": Parameter.__name__, - "@version": easyscience.__version__, - "name": "test", - "unit": "km", - "value": 1.0, - "variance": 0.0, - "min": -np.inf, - "max": np.inf, - "fixed": False, - "url": "https://www.boo.com", - } - constructor = Parameter - d = constructor.from_dict(reference) - for key, item in reference.items(): - if key == "callback" or key.startswith("@"): - continue - obtained = getattr(d, key) - assert obtained == item - - -@pytest.mark.parametrize( - "construct", - ( - { - "@module": Parameter.__module__, - "@class": Parameter.__name__, - "@version": easyscience.__version__, - "name": "test", - "unit": "km", - "value": 1.0, - "variance": 0.0, - "min": -np.inf, - "max": np.inf, - "fixed": False, - "url": "https://www.boo.com", - }, - ), - ids=["Parameter"], -) -def test_item_from_Decoder(construct): - - from easyscience.Utils.io.dict import DictSerializer - - d = DictSerializer().decode(construct) - assert d.__class__.__name__ == construct["@class"] - for key, item in construct.items(): - if key == "callback" or key.startswith("@"): - continue - obtained = getattr(d, key) - assert obtained == item - - -@pytest.mark.parametrize("value", (-np.inf, 0, 1.0, 2147483648, np.inf)) -def test_parameter_min(value): - d = Parameter("test", -0.1) - if d.value < value: - with pytest.raises(ValueError): - d.min = value - else: - d.min = value - assert d.min == value - - -@pytest.mark.parametrize("value", [-np.inf, 0, 1.1, 2147483648, np.inf]) -def test_parameter_max(value): - d = Parameter("test", 2147483649) - if d.value > value: - with pytest.raises(ValueError): - d.max = value - else: - d.max = value - assert d.max == value - - -@pytest.mark.parametrize("value", [True, False, 5]) -def test_parameter_fixed(value): - d = Parameter("test", -np.inf) - if isinstance(value, bool): - d.fixed = value - assert d.fixed == value - else: - with pytest.raises(ValueError): - d.fixed = value - - -@pytest.mark.parametrize("value", (-np.inf, -0.1, 0, 1.0, 2147483648, np.inf)) -def test_parameter_error(value): - d = Parameter("test", 1) - if value >= 0: - d.error = value - assert d.error == value - else: - with pytest.raises(ValueError): - d.error = value - - -def _generate_advanced_inputs(): - temp = _generate_inputs() - # These will be the optional parameters - advanced = {"variance": 1.0, "min": -0.1, "max": 2147483648, "fixed": False} - advanced_result = { - "variance": {"name": "variance", "value": advanced["variance"]}, - "min": {"name": "min", "value": advanced["min"]}, - "max": {"name": "max", "value": advanced["max"]}, - "fixed": {"name": "fixed", "value": advanced["fixed"]}, - } - - def create_entry(base, key, value, ref, ref_key=None): - this_temp = deepcopy(base) - for item in base: - test, res = item - new_opt = deepcopy(test[1]) - new_res = deepcopy(res) - if ref_key is None: - ref_key = key - new_res[ref_key] = ref - new_opt[key] = value - this_temp.append(([test[0], new_opt], new_res)) - return this_temp - - for add_opt in advanced.keys(): - if isinstance(advanced[add_opt], list): - for idx, item in enumerate(advanced[add_opt]): - temp = create_entry( - temp, - add_opt, - item, - advanced_result[add_opt]["value"][idx], - ref_key=advanced_result[add_opt]["name"], - ) - else: - temp = create_entry( - temp, - add_opt, - advanced[add_opt], - advanced_result[add_opt]["value"], - ref_key=advanced_result[add_opt]["name"], - ) - return temp - - -@pytest.mark.parametrize("element, expected", _generate_advanced_inputs()) -def test_parameter_advanced_creation(element, expected): - if len(element[0]) > 0: - value = element[0][1] - else: - value = element[1]["value"] - if "min" in element[1].keys(): - if element[1]["min"] > value: - with pytest.raises(ValueError): - d = Parameter(*element[0], **element[1]) - elif "max" in element[1].keys(): - if element[1]["max"] < value: - with pytest.raises(ValueError): - d = Parameter(*element[0], **element[1]) - else: - d = Parameter(*element[0], **element[1]) - for field in expected.keys(): - ref = expected[field] - obtained = getattr(d, field) - assert obtained == ref - - -@pytest.mark.parametrize("value", ("This is ", "a fun ", "test")) -def test_parameter_display_name(value): - p = Parameter("test", 1, display_name=value) - assert p.display_name == value - - -@pytest.mark.parametrize("value", (True, False)) -def test_parameter_bounds(value): - for fixed in (True, False): - p = Parameter("test", 1, enabled=value, fixed=fixed) - assert p.min == -np.inf - assert p.max == np.inf - assert p.fixed == fixed - assert p.bounds == (-np.inf, np.inf) - - p.bounds = (0, 2) - assert p.min == 0 - assert p.max == 2 - assert p.bounds == (0, 2) - assert p.enabled is True - assert p.fixed is False \ No newline at end of file diff --git a/tests/unit_tests/global_object/test_undo_redo.py b/tests/unit_tests/global_object/test_undo_redo.py index 72a2edc..53c7593 100644 --- a/tests/unit_tests/global_object/test_undo_redo.py +++ b/tests/unit_tests/global_object/test_undo_redo.py @@ -116,10 +116,8 @@ def test_DescriptorStrUndoRedo(): for option in [ ("value", 500), ("error", 5), - ("independent", False), ("unit", "km/s"), ("display_name", "boom"), - ("enabled", False), ("fixed", False), ("max", 505), ("min", -1), @@ -135,27 +133,23 @@ def test_ParameterUndoRedo(test): e = doUndoRedo(obj, attr, value) assert not e -@pytest.mark.parametrize("value", (True, False)) -def test_Parameter_Bounds_UndoRedo(value): +def test_Parameter_Bounds_UndoRedo(): from easyscience import global_object global_object.stack.enabled = True - p = Parameter("test", 1, independent=value) - assert p.min == -np.inf - assert p.max == np.inf - assert p.bounds == (-np.inf, np.inf) + parameter = Parameter("test", 1) + assert parameter.min == -np.inf + assert parameter.max == np.inf - p.bounds = (0, 2) - assert p.min == 0 - assert p.max == 2 - assert p.bounds == (0, 2) - assert p.independent is True + parameter.min = 0 + parameter.max = 2 + assert parameter.min == 0 + assert parameter.max == 2 global_object.stack.undo() - assert p.min == -np.inf - assert p.max == np.inf - assert p.bounds == (-np.inf, np.inf) - assert p.independent is value + global_object.stack.undo() + assert parameter.min == -np.inf + assert parameter.max == np.inf def test_BaseObjUndoRedo(): diff --git a/tests/unit_tests/utils/io_tests/test_core.py b/tests/unit_tests/utils/io_tests/test_core.py index 3e87d53..2083ac3 100644 --- a/tests/unit_tests/utils/io_tests/test_core.py +++ b/tests/unit_tests/utils/io_tests/test_core.py @@ -8,7 +8,6 @@ import pytest import easyscience -from easyscience.Objects.ObjectClasses import BaseObj from easyscience.Objects.variable import DescriptorNumber from easyscience.Objects.variable import Parameter @@ -45,7 +44,6 @@ "url": "https://www.boo.com", "description": "", "display_name": "test", - "enabled": True, }, Parameter, ], @@ -123,62 +121,3 @@ def test_variable_as_data_dict_methods(dp_kwargs: dict, dp_cls: Type[DescriptorN assert len(dif) == 0 check_dict(data_dict, enc_d) - - -class A(BaseObj): - def __init__(self, name: str = "A", **kwargs): - super().__init__(name=name, **kwargs) - - -class B(BaseObj): - def __init__(self, a, b, unique_name): - super(B, self).__init__("B", a=a, unique_name=unique_name) - self.b = b - - -@pytest.mark.parametrize(**dp_param_dict) -def test_custom_class_as_dict_methods(dp_kwargs: dict, dp_cls: Type[DescriptorNumber]): - data_dict = {k: v for k, v in dp_kwargs.items() if k[0] != "@"} - - a_kw = {data_dict["name"]: dp_cls(**data_dict)} - - full_d = { - "@module": A.__module__, - "@class": A.__name__, - "@version": None, - "name": "A", - dp_kwargs["name"]: dp_kwargs, - } - - obj = A(**a_kw) - - enc = obj.as_dict() - expected_keys = set(full_d.keys()) - obtained_keys = set(enc.keys()) - - dif = expected_keys.difference(obtained_keys) - - assert len(dif) == 0 - - check_dict(full_d, enc) - - -@pytest.mark.parametrize(**dp_param_dict) -def test_custom_class_as_data_dict_methods(dp_kwargs: dict, dp_cls: Type[DescriptorNumber]): - data_dict = {k: v for k, v in dp_kwargs.items() if k[0] != "@"} - - a_kw = {data_dict["name"]: dp_cls(**data_dict)} - - full_d = {"name": "A", dp_kwargs["name"]: data_dict} - - obj = A(**a_kw) - - enc = obj.as_data_dict() - expected_keys = set(full_d.keys()) - obtained_keys = set(enc.keys()) - - dif = expected_keys.difference(obtained_keys) - - assert len(dif) == 0 - - check_dict(full_d, enc) diff --git a/tests/unit_tests/utils/io_tests/test_dict.py b/tests/unit_tests/utils/io_tests/test_dict.py index 884f86b..a9b8ccd 100644 --- a/tests/unit_tests/utils/io_tests/test_dict.py +++ b/tests/unit_tests/utils/io_tests/test_dict.py @@ -4,17 +4,13 @@ from copy import deepcopy from typing import Type -import numpy as np import pytest -from importlib import metadata from easyscience.Utils.io.dict import DataDictSerializer from easyscience.Utils.io.dict import DictSerializer from easyscience.Objects.variable import DescriptorNumber from easyscience.Objects.ObjectClasses import BaseObj -from .test_core import A -from .test_core import B from .test_core import check_dict from .test_core import dp_param_dict from .test_core import skip_dict @@ -120,144 +116,6 @@ def test_variable_encode_data(dp_kwargs: dict, dp_cls: Type[DescriptorNumber], s check_dict(data_dict, enc_d) -@pytest.mark.parametrize(**skip_dict) -@pytest.mark.parametrize(**dp_param_dict) -def test_custom_class_DictSerializer_encode( - dp_kwargs: dict, dp_cls: Type[DescriptorNumber], skip -): - data_dict = {k: v for k, v in dp_kwargs.items() if k[0] != "@"} - - a_kw = {data_dict["name"]: dp_cls(**data_dict)} - - full_d = { - "@module": A.__module__, - "@class": A.__name__, - "@version": None, - "name": "A", - dp_kwargs["name"]: deepcopy(dp_kwargs), - } - - if not isinstance(skip, list): - skip = [skip] - - full_d = recursive_remove(full_d, skip) - - obj = A(**a_kw) - - enc = obj.encode(skip=skip, encoder=DictSerializer) - expected_keys = set(full_d.keys()) - obtained_keys = set(enc.keys()) - - dif = expected_keys.difference(obtained_keys) - - assert len(dif) == 0 - - check_dict(full_d, enc) - - -@pytest.mark.parametrize(**skip_dict) -@pytest.mark.parametrize(**dp_param_dict) -def test_custom_class_DataDictSerializer( - dp_kwargs: dict, dp_cls: Type[DescriptorNumber], skip -): - data_dict = {k: v for k, v in dp_kwargs.items() if k[0] != "@"} - - a_kw = {data_dict["name"]: dp_cls(**data_dict)} - - full_d = {"name": "A", dp_kwargs["name"]: data_dict} - - full_d = recursive_remove(full_d, skip) - - obj = A(**a_kw) - - enc = obj.encode(skip=skip, encoder=DataDictSerializer) - expected_keys = set(full_d.keys()) - obtained_keys = set(enc.keys()) - - dif = expected_keys.difference(obtained_keys) - - assert len(dif) == 0 - - check_dict(full_d, enc) - - -@pytest.mark.parametrize( - "encoder", [None, DataDictSerializer], ids=["Default", "DataDictSerializer"] -) -@pytest.mark.parametrize(**dp_param_dict) -def test_custom_class_encode_data(dp_kwargs: dict, dp_cls: Type[DescriptorNumber], encoder): - data_dict = {k: v for k, v in dp_kwargs.items() if k[0] != "@"} - - a_kw = {data_dict["name"]: dp_cls(**data_dict)} - - full_d = {"name": "A", dp_kwargs["name"]: data_dict} - - obj = A(**a_kw) - - enc = obj.encode_data(encoder=encoder) - expected_keys = set(full_d.keys()) - obtained_keys = set(enc.keys()) - - dif = expected_keys.difference(obtained_keys) - - assert len(dif) == 0 - - check_dict(full_d, enc) - - -def test_custom_class_full_encode_with_numpy(): - class B(BaseObj): - def __init__(self, a, b, unique_name): - super(B, self).__init__("B", a=a, unique_name=unique_name) - self.b = b - # Same as in __init__.py for easyscience - try: - version = metadata.version('easyscience') # 'easyscience' is the name of the package in 'setup.py - except metadata.PackageNotFoundError: - version = '0.0.0' - - obj = B(DescriptorNumber("a", 1.0, unique_name="a"), np.array([1.0, 2.0, 3.0]), unique_name="B_0") - full_enc = obj.encode(encoder=DictSerializer, full_encode=True) - expected = { - "@module": "tests.unit_tests.utils.io_tests.test_dict", - "@class": "B", - "@version": None, - "unique_name": "B_0", - "b": { - "@module": "numpy", - "@class": "array", - "dtype": "float64", - "data": [1.0, 2.0, 3.0], - }, - "a": { - "@module": "easyscience.Objects.variable.descriptor_number", - "@class": "DescriptorNumber", - "@version": version, - "description": "", - "unit": "dimensionless", - "display_name": "a", - "name": "a", - "value": 1.0, - "variance": None, - "unique_name": "a", - "url": "", - }, - } - check_dict(full_enc, expected) - - -def test_custom_class_full_decode_with_numpy(): - global_object.map._clear() - obj = B(DescriptorNumber("a", 1.0), np.array([1.0, 2.0, 3.0]), unique_name="B_0") - full_enc = obj.encode(encoder=DictSerializer, full_encode=True) - global_object.map._clear() - obj2 = B.decode(full_enc, decoder=DictSerializer) - assert obj.name == obj2.name - assert obj.unique_name == obj2.unique_name - assert obj.a.value == obj2.a.value - assert np.all(obj.b == obj2.b) - - ######################################################################################################################## # TESTING DECODING ######################################################################################################################## @@ -325,95 +183,4 @@ def test_group_encode2(): b = BaseObj("outer", b=BaseCollection("test", d0, d1)) d = b.as_dict() - assert isinstance(d["b"], dict) - - -#TODO: do we need/want this test? -# -# @pytest.mark.parametrize(**dp_param_dict) -# def test_custom_class_DictSerializer_decode(dp_kwargs: dict, dp_cls: Type[Descriptor]): -# -# data_dict = {k: v for k, v in dp_kwargs.items() if k[0] != '@'} -# -# a_kw = { -# data_dict['name']: dp_cls(**data_dict) -# } -# -# obj = A(**a_kw) -# -# enc = obj.encode(encoder=DictSerializer) -# -# stripped_encode = {k: v for k, v in enc.items() if k[0] != '@'} -# stripped_encode[data_dict['name']] = data_dict -# -# dec = obj.decode(enc, decoder=DictSerializer) -# -# def test_objs(reference_obj, test_obj, in_dict): -# if 'value' in in_dict.keys(): -# in_dict['value'] = in_dict.pop('value') -# if 'units' in in_dict.keys(): -# del in_dict['units'] -# for k in in_dict.keys(): -# if hasattr(reference_obj, k) and hasattr(test_obj, k): -# if isinstance(in_dict[k], dict): -# test_objs(getattr(obj, k), getattr(test_obj, k), in_dict[k]) -# assert getattr(obj, k) == getattr(dec, k) -# else: -# raise AttributeError(f"{k} not found in decoded object") -# test_objs(obj, dec, stripped_encode) -# -# -# @pytest.mark.parametrize(**skip_dict) -# @pytest.mark.parametrize(**dp_param_dict) -# def test_custom_class_DataDictSerializer(dp_kwargs: dict, dp_cls: Type[Descriptor], skip): -# data_dict = {k: v for k, v in dp_kwargs.items() if k[0] != '@'} -# -# a_kw = { -# data_dict['name']: dp_cls(**data_dict) -# } -# -# full_d = { -# "name": "A", -# dp_kwargs['name']: data_dict -# } -# -# full_d = recursive_remove(full_d, skip) -# -# obj = A(**a_kw) -# -# enc = obj.encode(skip=skip, encoder=DataDictSerializer) -# expected_keys = set(full_d.keys()) -# obtained_keys = set(enc.keys()) -# -# dif = expected_keys.difference(obtained_keys) -# -# assert len(dif) == 0 -# -# check_dict(full_d, enc) -# -# -# @pytest.mark.parametrize('encoder', [None, DataDictSerializer], ids=['Default', 'DataDictSerializer']) -# @pytest.mark.parametrize(**dp_param_dict) -# def test_custom_class_encode_data(dp_kwargs: dict, dp_cls: Type[Descriptor], encoder): -# data_dict = {k: v for k, v in dp_kwargs.items() if k[0] != '@'} -# -# a_kw = { -# data_dict['name']: dp_cls(**data_dict) -# } -# -# full_d = { -# "name": "A", -# dp_kwargs['name']: data_dict -# } -# -# obj = A(**a_kw) -# -# enc = obj.encode_data(encoder=encoder) -# expected_keys = set(full_d.keys()) -# obtained_keys = set(enc.keys()) -# -# dif = expected_keys.difference(obtained_keys) -# -# assert len(dif) == 0 -# -# check_dict(full_d, enc) + assert isinstance(d["b"], dict) \ No newline at end of file diff --git a/tests/unit_tests/utils/io_tests/test_json.py b/tests/unit_tests/utils/io_tests/test_json.py index cec6e4c..54f9ccb 100644 --- a/tests/unit_tests/utils/io_tests/test_json.py +++ b/tests/unit_tests/utils/io_tests/test_json.py @@ -11,7 +11,6 @@ from easyscience.Utils.io.json import JsonSerializer from easyscience.Objects.variable import DescriptorNumber -from .test_core import A from .test_core import check_dict from .test_core import dp_param_dict from .test_core import skip_dict @@ -93,78 +92,6 @@ def test_variable_DataDictSerializer(dp_kwargs: dict, dp_cls: Type[DescriptorNum check_dict(data_dict, enc_d) - -@pytest.mark.parametrize(**skip_dict) -@pytest.mark.parametrize(**dp_param_dict) -def test_custom_class_DictSerializer_encode( - dp_kwargs: dict, dp_cls: Type[DescriptorNumber], skip -): - data_dict = {k: v for k, v in dp_kwargs.items() if k[0] != "@"} - - a_kw = {data_dict["name"]: dp_cls(**data_dict)} - - full_d = { - "@module": A.__module__, - "@class": A.__name__, - "@version": None, - "name": "A", - dp_kwargs["name"]: deepcopy(dp_kwargs), - } - - if not isinstance(skip, list): - skip = [skip] - - full_d = recursive_remove(full_d, skip) - - obj = A(**a_kw) - - enc = obj.encode(skip=skip, encoder=JsonSerializer) - assert isinstance(enc, str) - - # We can test like this as we don't have "complex" objects yet - dec = json.loads(enc) - - expected_keys = set(full_d.keys()) - obtained_keys = set(dec.keys()) - - dif = expected_keys.difference(obtained_keys) - - assert len(dif) == 0 - - check_dict(full_d, dec) - - -@pytest.mark.parametrize(**skip_dict) -@pytest.mark.parametrize(**dp_param_dict) -def test_custom_class_DataDictSerializer( - dp_kwargs: dict, dp_cls: Type[DescriptorNumber], skip -): - data_dict = {k: v for k, v in dp_kwargs.items() if k[0] != "@"} - - a_kw = {data_dict["name"]: dp_cls(**data_dict)} - - full_d = {"name": "A", dp_kwargs["name"]: data_dict} - - if not isinstance(skip, list): - skip = [skip] - - full_d = recursive_remove(full_d, skip) - - obj = A(**a_kw) - - enc = obj.encode(skip=skip, encoder=JsonDataSerializer) - dec = json.loads(enc) - - expected_keys = set(full_d.keys()) - obtained_keys = set(dec.keys()) - - dif = expected_keys.difference(obtained_keys) - - assert len(dif) == 0 - - check_dict(full_d, dec) - - # ######################################################################################################################## # # TESTING DECODING # ######################################################################################################################## diff --git a/tests/unit_tests/utils/io_tests/test_xml.py b/tests/unit_tests/utils/io_tests/test_xml.py index 2edb761..b382bf8 100644 --- a/tests/unit_tests/utils/io_tests/test_xml.py +++ b/tests/unit_tests/utils/io_tests/test_xml.py @@ -11,7 +11,6 @@ from easyscience.Utils.io.xml import XMLSerializer from easyscience.Objects.variable import DescriptorNumber -from .test_core import A from .test_core import dp_param_dict from .test_core import skip_dict from easyscience import global_object @@ -65,39 +64,6 @@ def test_variable_XMLDictSerializer(dp_kwargs: dict, dp_cls: Type[DescriptorNumb assert data_xml.tag == "data" recursive_test(data_xml, ref_encode) - -@pytest.mark.parametrize(**skip_dict) -@pytest.mark.parametrize(**dp_param_dict) -def test_custom_class_XMLDictSerializer_encode( - dp_kwargs: dict, dp_cls: Type[DescriptorNumber], skip -): - data_dict = {k: v for k, v in dp_kwargs.items() if k[0] != "@"} - - a_kw = {data_dict["name"]: dp_cls(**data_dict)} - - full_d = { - "@module": A.__module__, - "@class": A.__name__, - "@version": None, - "name": "A", - dp_kwargs["name"]: deepcopy(dp_kwargs), - } - - if not isinstance(skip, list): - skip = [skip] - - full_d = recursive_remove(full_d, skip) - - obj = A(**a_kw) - - enc = obj.encode(skip=skip, encoder=XMLSerializer) - ref_encode = obj.encode(skip=skip) - assert isinstance(enc, str) - data_xml = ET.XML(enc) - assert data_xml.tag == "data" - recursive_test(data_xml, ref_encode) - - # ######################################################################################################################## # # TESTING DECODING # ######################################################################################################################## From 9a770fe5120151e8d72583c2a8a073fb6332bd18 Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Fri, 25 Apr 2025 15:38:11 +0200 Subject: [PATCH 16/18] Fix integration tests --- .../integration_tests/Fitting/test_fitter.py | 12 +- .../Fitting/test_multi_fitter.py | 43 ++---- tests/unit_tests/Fitting/test_constraints.py | 134 ------------------ 3 files changed, 10 insertions(+), 179 deletions(-) delete mode 100644 tests/unit_tests/Fitting/test_constraints.py diff --git a/tests/integration_tests/Fitting/test_fitter.py b/tests/integration_tests/Fitting/test_fitter.py index 19e0f87..92217b4 100644 --- a/tests/integration_tests/Fitting/test_fitter.py +++ b/tests/integration_tests/Fitting/test_fitter.py @@ -2,13 +2,9 @@ # SPDX-License-Identifier: BSD-3-Clause # © 2021-2023 Contributors to the EasyScience project -# SPDX-License-Identifier: BSD-3-Clause -# © 2021-2023 Contributors to the EasyScience project Tuple[List[Parameter], List[int]]: - mock_callback = MagicMock() - mock_callback.fget = MagicMock(return_value=-10) - return [Parameter("a", 1, callback=mock_callback), Parameter("b", 2, callback=mock_callback)], [1, 2] - - -@pytest.fixture -def threePars(twoPars) -> Tuple[List[Parameter], List[int]]: - ps, vs = twoPars - ps.append(Parameter("c", 3)) - vs.append(3) - return ps, vs - - -def test_NumericConstraints_Equals(twoPars): - - value = 1 - - # Should skip - c = NumericConstraint(twoPars[0][0], "==", value) - c() - assert twoPars[0][0].value_no_call_back == twoPars[1][0] - - # Should update to new value - c = NumericConstraint(twoPars[0][1], "==", value) - c() - assert twoPars[0][1].value_no_call_back == value - - -def test_NumericConstraints_Greater(twoPars): - value = 1.5 - - # Should update to new value - c = NumericConstraint(twoPars[0][0], ">", value) - c() - assert twoPars[0][0].value_no_call_back == value - - # Should skip - c = NumericConstraint(twoPars[0][1], ">", value) - c() - assert twoPars[0][1].value_no_call_back == twoPars[1][1] - - -def test_NumericConstraints_Less(twoPars): - value = 1.5 - - # Should skip - c = NumericConstraint(twoPars[0][0], "<", value) - c() - assert twoPars[0][0].value_no_call_back == twoPars[1][0] - - # Should update to new value - c = NumericConstraint(twoPars[0][1], "<", value) - c() - assert twoPars[0][1].value_no_call_back == value - - -@pytest.mark.parametrize("multiplication_factor", [None, 1, 2, 3, 4.5]) -def test_ObjConstraintMultiply(twoPars, multiplication_factor): - if multiplication_factor is None: - multiplication_factor = 1 - operator_str = "" - else: - operator_str = f"{multiplication_factor}*" - c = ObjConstraint(twoPars[0][0], operator_str, twoPars[0][1]) - c() - assert twoPars[0][0].value_no_call_back == multiplication_factor * twoPars[1][1] - - -@pytest.mark.parametrize("division_factor", [1, 2, 3, 4.5]) -def test_ObjConstraintDivide(twoPars, division_factor): - operator_str = f"{division_factor}/" - c = ObjConstraint(twoPars[0][0], operator_str, twoPars[0][1]) - c() - assert twoPars[0][0].value_no_call_back == division_factor / twoPars[1][1] - - -def test_ObjConstraint_Multiple(threePars): - - p0 = threePars[0][0] - p1 = threePars[0][1] - p2 = threePars[0][2] - - value = 1.5 - - p0.user_constraints["num_1"] = ObjConstraint(p1, "", p0) - p0.user_constraints["num_2"] = ObjConstraint(p2, "", p0) - - p0.value = value - assert p0.value_no_call_back == value - assert p1.value_no_call_back == value - assert p2.value_no_call_back == value - - -def test_ConstraintEnable_Disable(twoPars): - - assert twoPars[0][0].independent - assert twoPars[0][1].independent - - c = ObjConstraint(twoPars[0][0], "", twoPars[0][1]) - twoPars[0][0].user_constraints["num_1"] = c - - assert c.enabled - assert twoPars[0][1].independent - assert not twoPars[0][0].independent - - c.enabled = False - assert not c.enabled - assert twoPars[0][1].independent - assert twoPars[0][0].independent - - c.enabled = True - assert c.enabled - assert twoPars[0][1].independent - assert not twoPars[0][0].independent From 30f19e36b81f8ac61786158433cc330092d6a3bc Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Fri, 25 Apr 2025 15:54:34 +0200 Subject: [PATCH 17/18] rename property_stack_deco and make_dependent --- .../Objects/variable/descriptor_any_type.py | 4 ++-- .../Objects/variable/descriptor_array.py | 8 ++++---- .../Objects/variable/descriptor_base.py | 6 +++--- .../Objects/variable/descriptor_bool.py | 4 ++-- .../Objects/variable/descriptor_number.py | 8 ++++---- .../Objects/variable/descriptor_str.py | 4 ++-- src/easyscience/Objects/variable/parameter.py | 20 +++++++++---------- src/easyscience/global_object/undo_redo.py | 6 +++--- .../integration_tests/Fitting/test_fitter.py | 2 +- .../Fitting/test_multi_fitter.py | 16 +++++++-------- 10 files changed, 39 insertions(+), 39 deletions(-) diff --git a/src/easyscience/Objects/variable/descriptor_any_type.py b/src/easyscience/Objects/variable/descriptor_any_type.py index 0d117ce..93745d9 100644 --- a/src/easyscience/Objects/variable/descriptor_any_type.py +++ b/src/easyscience/Objects/variable/descriptor_any_type.py @@ -9,7 +9,7 @@ import numpy as np -from easyscience.global_object.undo_redo import property_stack_deco +from easyscience.global_object.undo_redo import property_stack from .descriptor_base import DescriptorBase @@ -62,7 +62,7 @@ def value(self) -> numbers.Number: return self._value @value.setter - @property_stack_deco + @property_stack def value(self, value: Union[list, np.ndarray]) -> None: """ Set the value of self. diff --git a/src/easyscience/Objects/variable/descriptor_array.py b/src/easyscience/Objects/variable/descriptor_array.py index c9b154e..c7a1d8c 100644 --- a/src/easyscience/Objects/variable/descriptor_array.py +++ b/src/easyscience/Objects/variable/descriptor_array.py @@ -16,7 +16,7 @@ from scipp import Variable from easyscience.global_object.undo_redo import PropertyStack -from easyscience.global_object.undo_redo import property_stack_deco +from easyscience.global_object.undo_redo import property_stack from .descriptor_base import DescriptorBase from .descriptor_number import DescriptorNumber @@ -150,7 +150,7 @@ def value(self) -> numbers.Number: return self._array.values @value.setter - @property_stack_deco + @property_stack def value(self, value: Union[list, np.ndarray]) -> None: """ Set the value of self. Ensures the input is an array and matches the shape of the existing array. @@ -225,7 +225,7 @@ def variance(self) -> np.ndarray: return self._array.variances @variance.setter - @property_stack_deco + @property_stack def variance(self, variance: Union[list, np.ndarray]) -> None: """ Set the variance of self. Ensures the input is an array and matches the shape of the existing values. @@ -259,7 +259,7 @@ def error(self) -> Optional[np.ndarray]: return np.sqrt(self._array.variances) @error.setter - @property_stack_deco + @property_stack def error(self, error: Union[list, np.ndarray]) -> None: """ Set the standard deviation for the parameter, which updates the variances. diff --git a/src/easyscience/Objects/variable/descriptor_base.py b/src/easyscience/Objects/variable/descriptor_base.py index b525d4f..b80065a 100644 --- a/src/easyscience/Objects/variable/descriptor_base.py +++ b/src/easyscience/Objects/variable/descriptor_base.py @@ -9,7 +9,7 @@ from typing import Optional from easyscience import global_object -from easyscience.global_object.undo_redo import property_stack_deco +from easyscience.global_object.undo_redo import property_stack from easyscience.Objects.core import ComponentSerializer @@ -94,7 +94,7 @@ def name(self) -> str: return self._name @name.setter - @property_stack_deco + @property_stack def name(self, new_name: str) -> None: """ Set the name. @@ -118,7 +118,7 @@ def display_name(self) -> str: return display_name @display_name.setter - @property_stack_deco + @property_stack def display_name(self, name: str) -> None: """ Set the pretty display name. diff --git a/src/easyscience/Objects/variable/descriptor_bool.py b/src/easyscience/Objects/variable/descriptor_bool.py index 768b35b..2386917 100644 --- a/src/easyscience/Objects/variable/descriptor_bool.py +++ b/src/easyscience/Objects/variable/descriptor_bool.py @@ -3,7 +3,7 @@ from typing import Any from typing import Optional -from easyscience.global_object.undo_redo import property_stack_deco +from easyscience.global_object.undo_redo import property_stack from .descriptor_base import DescriptorBase @@ -46,7 +46,7 @@ def value(self) -> bool: return self._bool_value @value.setter - @property_stack_deco + @property_stack def value(self, value: bool) -> None: """ Set the value of self. diff --git a/src/easyscience/Objects/variable/descriptor_number.py b/src/easyscience/Objects/variable/descriptor_number.py index b80533d..f634a81 100644 --- a/src/easyscience/Objects/variable/descriptor_number.py +++ b/src/easyscience/Objects/variable/descriptor_number.py @@ -13,7 +13,7 @@ from scipp import Variable from easyscience.global_object.undo_redo import PropertyStack -from easyscience.global_object.undo_redo import property_stack_deco +from easyscience.global_object.undo_redo import property_stack from .descriptor_base import DescriptorBase @@ -155,7 +155,7 @@ def value(self) -> numbers.Number: @value.setter @notify_observers - @property_stack_deco + @property_stack def value(self, value: numbers.Number) -> None: """ Set the value of self. This should be usable for most cases. The full value can be obtained from `obj.full_value`. @@ -195,7 +195,7 @@ def variance(self) -> float: @variance.setter @notify_observers - @property_stack_deco + @property_stack def variance(self, variance_float: float) -> None: """ Set the variance. @@ -223,7 +223,7 @@ def error(self) -> float: @error.setter @notify_observers - @property_stack_deco + @property_stack def error(self, value: float) -> None: """ Set the standard deviation for the parameter. diff --git a/src/easyscience/Objects/variable/descriptor_str.py b/src/easyscience/Objects/variable/descriptor_str.py index 1abe4e4..1711016 100644 --- a/src/easyscience/Objects/variable/descriptor_str.py +++ b/src/easyscience/Objects/variable/descriptor_str.py @@ -3,7 +3,7 @@ from typing import Any from typing import Optional -from easyscience.global_object.undo_redo import property_stack_deco +from easyscience.global_object.undo_redo import property_stack from .descriptor_base import DescriptorBase @@ -45,7 +45,7 @@ def value(self) -> str: return self._string @value.setter - @property_stack_deco + @property_stack def value(self, value: str) -> None: """ Set the value of self. diff --git a/src/easyscience/Objects/variable/parameter.py b/src/easyscience/Objects/variable/parameter.py index d3b8766..57c2cac 100644 --- a/src/easyscience/Objects/variable/parameter.py +++ b/src/easyscience/Objects/variable/parameter.py @@ -22,7 +22,7 @@ from scipp import Variable from easyscience import global_object -from easyscience.global_object.undo_redo import property_stack_deco +from easyscience.global_object.undo_redo import property_stack from .descriptor_number import DescriptorNumber from .descriptor_number import notify_observers @@ -124,7 +124,7 @@ def from_dependency(cls, name: str, dependency_expression: str, dependency_map: :return: A new dependent Parameter object. """ # noqa: E501 parameter = cls(name=name, value=0.0, unit='', variance=0.0, min=-np.inf, max=np.inf, **kwargs) - parameter.make_dependent(dependency_expression=dependency_expression, dependency_map=dependency_map) + parameter.make_dependent_on(dependency_expression=dependency_expression, dependency_map=dependency_map) return parameter @@ -156,7 +156,7 @@ def _update(self, update_id: int, updating_object: str) -> None: else: warnings.warn('This parameter is not dependent. It cannot be updated.') - def make_dependent(self, dependency_expression: str, dependency_map: Optional[dict] = None) -> None: + def make_dependent_on(self, dependency_expression: str, dependency_map: Optional[dict] = None) -> None: """ Make this parameter dependent on another parameter. This will overwrite the current value, unit, variance, min and max. @@ -240,7 +240,7 @@ def independent(self) -> bool: @independent.setter def independent(self, value: bool) -> None: - raise AttributeError('This property is read-only. Use `make_independent` and `make_dependent` to change the state of the parameter.') # noqa: E501 + raise AttributeError('This property is read-only. Use `make_independent` and `make_dependent_on` to change the state of the parameter.') # noqa: E501 @property def depedency_expression(self) -> str: @@ -256,7 +256,7 @@ def depedency_expression(self) -> str: @depedency_expression.setter def depedency_expression(self, new_expression: str) -> None: - raise AttributeError('Dependency expression is read-only. Use `make_dependent` to change the dependency expression.') + raise AttributeError('Dependency expression is read-only. Use `make_dependent_on` to change the dependency expression.') @property def dependency_map(self) -> Dict[str, DescriptorNumber]: @@ -272,7 +272,7 @@ def dependency_map(self) -> Dict[str, DescriptorNumber]: @dependency_map.setter def dependency_map(self, new_map: Dict[str, DescriptorNumber]) -> None: - raise AttributeError('Dependency map is read-only. Use `make_dependent` to change the dependency map.') + raise AttributeError('Dependency map is read-only. Use `make_dependent_on` to change the dependency map.') @property def value_no_call_back(self) -> numbers.Number: @@ -317,7 +317,7 @@ def value(self) -> numbers.Number: return self._scalar.value @value.setter - @property_stack_deco + @property_stack def value(self, value: numbers.Number) -> None: """ Set the value of self. This only updates the value of the scipp scalar. @@ -400,7 +400,7 @@ def min(self) -> numbers.Number: return self._min.value @min.setter - @property_stack_deco + @property_stack def min(self, min_value: numbers.Number) -> None: """ Set the minimum value for fitting. @@ -432,7 +432,7 @@ def max(self) -> numbers.Number: return self._max.value @max.setter - @property_stack_deco + @property_stack def max(self, max_value: numbers.Number) -> None: """ Get the maximum value for fitting. @@ -464,7 +464,7 @@ def fixed(self) -> bool: return self._fixed @fixed.setter - @property_stack_deco + @property_stack def fixed(self, fixed: bool) -> None: """ Change the parameter vary while fitting state. diff --git a/src/easyscience/global_object/undo_redo.py b/src/easyscience/global_object/undo_redo.py index 02b3002..e421bcf 100644 --- a/src/easyscience/global_object/undo_redo.py +++ b/src/easyscience/global_object/undo_redo.py @@ -428,18 +428,18 @@ def redo(self) -> NoReturn: self._parent.data = self._new_value -def property_stack_deco(arg: Union[str, Callable], begin_macro: bool = False) -> Callable: +def property_stack(arg: Union[str, Callable], begin_macro: bool = False) -> Callable: """ Decorate a `property` setter with undo/redo functionality This decorator can be used as: - @property_stack_deco + @property_stack def func() .... or - @property_stack_deco("This is the undo/redo text) + @property_stack("This is the undo/redo text) def func() .... diff --git a/tests/integration_tests/Fitting/test_fitter.py b/tests/integration_tests/Fitting/test_fitter.py index 92217b4..0706d3b 100644 --- a/tests/integration_tests/Fitting/test_fitter.py +++ b/tests/integration_tests/Fitting/test_fitter.py @@ -240,7 +240,7 @@ def test_fit_constraints(fit_engine): f = Fitter(sp_sin, sp_sin) - sp_sin.offset.make_dependent(dependency_expression='2*phase', dependency_map={"phase": sp_sin.phase}) + sp_sin.offset.make_dependent_on(dependency_expression='2*phase', dependency_map={"phase": sp_sin.phase}) if fit_engine is not None: try: diff --git a/tests/integration_tests/Fitting/test_multi_fitter.py b/tests/integration_tests/Fitting/test_multi_fitter.py index e73e1c0..3a546b2 100644 --- a/tests/integration_tests/Fitting/test_multi_fitter.py +++ b/tests/integration_tests/Fitting/test_multi_fitter.py @@ -65,9 +65,9 @@ def test_multi_fit(fit_engine, with_errors): ref_sin_2 = AbsSin(np.pi * 0.45, 0.45 * np.pi * 0.5) sp_sin_2 = AbsSin(1, 0.5) - ref_sin_2.offset.make_dependent(dependency_expression="ref_sin1", dependency_map={"ref_sin1": ref_sin_1.offset}) + ref_sin_2.offset.make_dependent_on(dependency_expression="ref_sin1", dependency_map={"ref_sin1": ref_sin_1.offset}) - sp_sin_2.offset.make_dependent(dependency_expression="sp_sin1", dependency_map={"sp_sin1": sp_sin_1.offset}) + sp_sin_2.offset.make_dependent_on(dependency_expression="sp_sin1", dependency_map={"sp_sin1": sp_sin_1.offset}) x1 = np.linspace(0, 5, 200) y1 = ref_sin_1(x1) @@ -121,13 +121,13 @@ def test_multi_fit2(fit_engine, with_errors): sp_sin_2 = AbsSin(1, 0.5)# ref_sin_1_obj = genObjs[0] ref_line_obj = Line(1, 4.6) - ref_sin_2.offset.make_dependent(dependency_expression="ref_sin1", dependency_map={"ref_sin1": ref_sin_1.offset}) - ref_line_obj.m.make_dependent(dependency_expression="ref_sin1", dependency_map={"ref_sin1": ref_sin_1.offset}) + ref_sin_2.offset.make_dependent_on(dependency_expression="ref_sin1", dependency_map={"ref_sin1": ref_sin_1.offset}) + ref_line_obj.m.make_dependent_on(dependency_expression="ref_sin1", dependency_map={"ref_sin1": ref_sin_1.offset}) sp_line = Line(0.43, 6.1) - sp_sin_2.offset.make_dependent(dependency_expression="sp_sin1", dependency_map={"sp_sin1": sp_sin_1.offset}) - sp_line.m.make_dependent(dependency_expression="sp_sin1", dependency_map={"sp_sin1": sp_sin_1.offset}) + sp_sin_2.offset.make_dependent_on(dependency_expression="sp_sin1", dependency_map={"sp_sin1": sp_sin_1.offset}) + sp_line.m.make_dependent_on(dependency_expression="sp_sin1", dependency_map={"sp_sin1": sp_sin_1.offset}) x1 = np.linspace(0, 5, 200) @@ -192,8 +192,8 @@ def test_multi_fit_1D_2D(fit_engine, with_errors): ) # The fit is VERY sensitive to the initial values :-( # Link the parameters - ref_sin2D.offset.make_dependent(dependency_expression="ref_sin1", dependency_map={"ref_sin1": ref_sin1D.offset}) - sp_sin2D.offset.make_dependent(dependency_expression="sp_sin1", dependency_map={"sp_sin1": sp_sin1D.offset}) + ref_sin2D.offset.make_dependent_on(dependency_expression="ref_sin1", dependency_map={"ref_sin1": ref_sin1D.offset}) + sp_sin2D.offset.make_dependent_on(dependency_expression="sp_sin1", dependency_map={"sp_sin1": sp_sin1D.offset}) # Generate data x1D = np.linspace(0.2, 3.8, 400) From 0115eb72c78b2136c43deaef3c4139e15ccedc34 Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Fri, 25 Apr 2025 16:18:47 +0200 Subject: [PATCH 18/18] Pr comments --- .../Objects/variable/descriptor_number.py | 3 +++ src/easyscience/Objects/variable/parameter.py | 20 +++++++++++++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/easyscience/Objects/variable/descriptor_number.py b/src/easyscience/Objects/variable/descriptor_number.py index f634a81..9863154 100644 --- a/src/easyscience/Objects/variable/descriptor_number.py +++ b/src/easyscience/Objects/variable/descriptor_number.py @@ -240,6 +240,8 @@ def error(self, value: float) -> None: else: self._scalar.variance = None + # When we convert units internally, we dont want to notify observers as this can cause infinite recursion. + # Therefore the convert_unit method is split into two methods, a private internal method and a public method. def _convert_unit(self, unit_str: str) -> None: """ Convert the value from one unit system to another. @@ -271,6 +273,7 @@ def set_scalar(obj, scalar): # Update the scalar self._scalar = new_scalar + # When the user calls convert_unit, we want to notify observers of the change to propagate the change. @notify_observers def convert_unit(self, unit_str: str) -> None: """ diff --git a/src/easyscience/Objects/variable/parameter.py b/src/easyscience/Objects/variable/parameter.py index 57c2cac..d18414e 100644 --- a/src/easyscience/Objects/variable/parameter.py +++ b/src/easyscience/Objects/variable/parameter.py @@ -64,7 +64,7 @@ def __init__( :param variance: The variance of the value :param min: The minimum value for fitting :param max: The maximum value for fitting - :param fixed: Can the parameter vary? + :param fixed: If the parameter is free to vary during fitting :param description: A brief summary of what this object is :param url: Lookup url for documentation/information :param display_name: The name of the object as it should be displayed @@ -160,7 +160,15 @@ def make_dependent_on(self, dependency_expression: str, dependency_map: Optional """ Make this parameter dependent on another parameter. This will overwrite the current value, unit, variance, min and max. - :param dependency_expression: The dependency expression to evaluate. This should be a string which can be evaluated by the ASTEval interpreter. + How to use the dependency map: + If a parameter c has a dependency expression of 'a + b', where a and b are parameters belonging to the model class, + then the dependency map needs to have the form {'a': model.a, 'b': model.b}, where model is the model class. + I.e. the values are the actual objects, whereas the keys are how they are represented in the dependency expression. + + The dependency map is not needed if the dependency expression uses the unique names of the parameters. + Unique names in dependency expressions are defined by quotes, e.g. 'Parameter_0' or "Parameter_0" depending on the quotes used for the expression. + + :param dependency_expression: The dependency expression to evaluate. This should be a string which can be evaluated by a python interpreter. :param dependency_map: A dictionary of dependency expression symbol name and dependency object pairs. This is inserted into the asteval interpreter to resolve dependencies. """ # noqa: E501 if not isinstance(dependency_expression, str): @@ -181,13 +189,13 @@ def make_dependent_on(self, dependency_expression: str, dependency_map: Optional self._dependency_string = dependency_expression self._dependency_map = dependency_map if dependency_map is not None else {} self._dependency_interpreter = Interpreter(minimal=True) - self._dependency_interpreter.config['if'] = True + self._dependency_interpreter.config['if'] = True # allows logical statements in the dependency expression self._dependency_updates = {} # Used to track update ids to avoid cyclic dependencies self._process_dependency_unique_names(self._dependency_string) for key, value in self._dependency_map.items(): self._dependency_interpreter.symtable[key] = value - self._dependency_interpreter.readonly_symbols.add(key) + self._dependency_interpreter.readonly_symbols.add(key) # Dont allow overwriting of the dependencies in the dependency expression # noqa: E501 value._attach_observer(self) try: dependency_result = self._dependency_interpreter.eval(self._clean_dependency_string, raise_errors=True) @@ -243,7 +251,7 @@ def independent(self, value: bool) -> None: raise AttributeError('This property is read-only. Use `make_independent` and `make_dependent_on` to change the state of the parameter.') # noqa: E501 @property - def depedency_expression(self) -> str: + def dependency_expression(self) -> str: """ Get the dependency expression of this parameter. @@ -254,7 +262,7 @@ def depedency_expression(self) -> str: else: raise AttributeError('This parameter is independent. It has no dependency expression.') - @depedency_expression.setter + @dependency_expression.setter def depedency_expression(self, new_expression: str) -> None: raise AttributeError('Dependency expression is read-only. Use `make_dependent_on` to change the dependency expression.')