# SPDX-FileCopyrightText: 2023 easyCore contributors <core@easyscience.software>
# SPDX-License-Identifier: BSD-3-Clause
# © 2021-2023 Contributors to the easyCore project <https://github.com/easyScience/easyCore
from __future__ import annotations
__author__ = 'github.com/wardsimon'
__version__ = '0.1.0'
import weakref
from abc import ABCMeta
from abc import abstractmethod
from numbers import Number
from typing import TYPE_CHECKING
from typing import Callable
from typing import List
from typing import Optional
from typing import TypeVar
from typing import Union
import numpy as np
from asteval import Interpreter
from easyCore import borg
from easyCore.Objects.core import ComponentSerializer
if TYPE_CHECKING:
from easyCore.Objects.Variable import V
[docs]class ConstraintBase(ComponentSerializer, metaclass=ABCMeta):
"""
A base class used to describe a constraint to be applied to easyCore base objects.
"""
_borg = borg
def __init__(
self,
dependent_obj: V,
independent_obj: Optional[Union[V, List[V]]] = None,
operator: Optional[Union[str, List[str]]] = None,
value: Optional[Number] = None,
):
self.aeval = Interpreter()
self.dependent_obj_ids = self.get_key(dependent_obj)
self.independent_obj_ids = None
self._enabled = True
self.external = False
self._finalizer = None
if independent_obj is not None:
if isinstance(independent_obj, list):
self.independent_obj_ids = [self.get_key(obj) for obj in independent_obj]
if self.dependent_obj_ids in self.independent_obj_ids:
raise AttributeError('A dependent object can not be an independent object')
else:
self.independent_obj_ids = self.get_key(independent_obj)
if self.dependent_obj_ids == self.independent_obj_ids:
raise AttributeError('A dependent object can not be an independent object')
# 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:
raise AssertionError('A dependent object needs to be initially enabled.')
if borg.debug:
print(f'Dependent variable {dependent_obj}. It should be a `Descriptor`.' f'Setting to fixed')
dependent_obj.enabled = False
self._finalizer = weakref.finalize(self, cleanup_constraint, self.dependent_obj_ids, True)
self.operator = operator
self.value = value
@property
def enabled(self) -> 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).enabled = False
self()
else:
self.get_obj(self.dependent_obj_ids).enabled = 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, int):
dependent_obj = self.get_obj(self.dependent_obj_ids)
else:
raise AttributeError
if isinstance(self.independent_obj_ids, int):
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.enabled:
dependent_obj.enabled = True
toggle = True
dependent_obj.value = value
if toggle:
dependent_obj.enabled = False
return value
[docs] @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
"""
[docs] @abstractmethod
def __repr__(self):
pass
[docs] def get_key(self, obj) -> int:
"""
Get the unique key of a easyCore object
:param obj: easyCore object
:return: key for easyCore object
"""
return self._borg.map.convert_id_to_key(obj)
[docs] def get_obj(self, key: int) -> V:
"""
Get an easyCore object from its unique key
:param key: an easyCore objects unique key
:return: easyCore object
"""
return self._borg.map.get_item_by_key(key)
C = TypeVar('C', bound=ConstraintBase)
[docs]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 easyCore.Fitting.Constraints import NumericConstraint
from easyCore.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:
value = obj.raw_value
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}'
[docs]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 easyCore 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 easyCore.Fitting.Constraints import SelfConstraint
from easyCore.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.raw_value
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}'
[docs]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 easyCore.Fitting.Constraints import ObjConstraint
from easyCore.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.raw_value
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`'
[docs]class MultiObjConstraint(ConstraintBase):
"""
A `MultiObjConstraint` is similar to :class:`easyCore.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:`easyCore.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 easyCore.Fitting.Constraints import MultiObjConstraint
from easyCore.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 easyCore.Fitting.Constraints import MultiObjConstraint
from easyCore.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.raw_value
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__}'
[docs]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 easyCore.Fitting.Constraints import FunctionalConstraint
from easyCore.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.raw_value},'
value_str = value_str[:-1]
else:
value_str += f'{obj.raw_value}'
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, enabled: bool):
try:
obj = borg.map.get_item_by_key(obj_id)
obj.enabled = enabled
except ValueError:
if borg.debug:
print(f'Object with ID {obj_id} has already been deleted')