Source code for easyscience.Objects.Variable

#  SPDX-FileCopyrightText: 2023 EasyScience contributors  <core@easyscience.software>
#  SPDX-License-Identifier: BSD-3-Clause
#  © 2021-2023 Contributors to the EasyScience project <https://github.com/easyScience/EasyScience

from __future__ import annotations

__author__ = 'github.com/wardsimon'
__version__ = '0.1.0'

import numbers
import warnings
import weakref
from copy import deepcopy
from inspect import getfullargspec
from types import MappingProxyType
from typing import TYPE_CHECKING
from typing import Any
from typing import Callable
from typing import Dict
from typing import List
from typing import Optional
from typing import Set
from typing import Tuple
from typing import Type
from typing import TypeVar
from typing import Union

import numpy as np

from easyscience import global_object
from easyscience import pint
from easyscience import ureg
from easyscience.Constraints import SelfConstraint
from easyscience.global_object.undo_redo import property_stack_deco
from easyscience.Objects.core import ComponentSerializer
from easyscience.Utils.classTools import addProp
from easyscience.Utils.Exceptions import CoreSetException

if TYPE_CHECKING:
    from easyscience.Constraints import C

Q_ = ureg.Quantity
M_ = ureg.Measurement


[docs] class Descriptor(ComponentSerializer): """ This is the base of all variable descriptions for models. It contains all information to describe a single unique property of an object. This description includes a name and value as well as optionally a unit, description and url (for reference material). Also implemented is a callback so that the value can be read/set from a linked library object. A `Descriptor` is typically something which describes part of a model and is non-fittable and generally changes the state of an object. """ _constructor = Q_ _global_object = global_object _REDIRECT = { 'value': lambda obj: obj.raw_value, 'units': lambda obj: obj._args['units'], 'parent': None, 'callback': None, '_finalizer': None, } def __init__( self, name: str, value: Any, units: Optional[Union[str, ureg.Unit]] = None, unique_name: Optional[str] = None, description: Optional[str] = None, url: Optional[str] = None, display_name: Optional[str] = None, callback: Optional[property] = property(), enabled: Optional[bool] = True, parent: Optional[Union[Any, None]] = None, ): # noqa: S107 """ This is the base of all variable descriptions for models. It contains all information to describe a single unique property of an object. This description includes a name and value as well as optionally a unit, description and url (for reference material). Also implemented is a callback so that the value can be read/set from a linked library object. A `Descriptor` is typically something which describes part of a model and is non-fittable and generally changes the state of an object. Units are provided by pint: https://github.com/hgrecco/pint :param name: Name of this object :param value: Value of this object :param units: This object can have a physical unit associated with it :param description: A brief summary of what this object is :param url: Lookup url for documentation/information :param callback: The property which says how the object is linked to another one :param parent: The object which is the parent to this one .. code-block:: python from easyscience.Objects.Base import Descriptor # Describe a color by text color_text = Descriptor('fav_colour', 'red') # Describe a color by RGB color_num = Descriptor('fav_colour', [1, 0, 0]) .. note:: Undo/Redo functionality is implemented for the attributes `value`, `unit` and `display name`. """ if not hasattr(self, '_args'): self._args = {'value': None, 'units': ''} if unique_name is None: unique_name = self._global_object.generate_unique_name(self.__class__.__name__) self._unique_name = unique_name self.name = name # Let the collective know we've been assimilated self._global_object.map.add_vertex(self, obj_type='created') # Make the connection between self and parent if parent is not None: self._global_object.map.add_edge(parent, self) # Attach units if necessary if isinstance(units, ureg.Unit): self._units = ureg.Quantity(1, units=deepcopy(units)) elif isinstance(units, (str, type(None))): self._units = ureg.parse_expression(units) else: raise AttributeError('Units must be a string or a pint unit object') # Clunky method of keeping self.value up to date self._type = type(value) self.__isBooleanValue = isinstance(value, bool) if self.__isBooleanValue: value = int(value) self._args['value'] = value self._args['units'] = str(self.unit) self._value = self.__class__._constructor(**self._args) self._enabled = enabled if description is None: description = '' self.description: str = description self._display_name: str = display_name if url is None: url = '' self.url: str = url if callback is None: callback = property() self._callback: property = callback self.user_data: dict = {} finalizer = None if self._callback.fdel is not None: weakref.finalize(self, self._callback.fdel) self._finalizer = finalizer @property def _arg_spec(self) -> Set[str]: base_cls = getattr(self, '__old_class__', self.__class__) mro = base_cls.__mro__ idx = mro.index(ComponentSerializer) names = set() for i in range(idx): cls = mro[i] if hasattr(cls, '_CORE'): spec = getfullargspec(cls.__init__) names = names.union(set(spec.args[1:])) return names def __reduce__(self): """ Make the class picklable. Due to the nature of the dynamic class definitions special measures need to be taken. :return: Tuple consisting of how to make the object :rtype: tuple """ state = self.encode() cls = self.__class__ if hasattr(self, '__old_class__'): cls = self.__old_class__ return cls.from_dict, (state,) @property def unique_name(self) -> str: """ Get the unique name of this object. :return: Unique name of this object """ return self._unique_name @unique_name.setter def unique_name(self, new_unique_name: str): """Set a new unique name for the object. The old name is still kept in the map. :param new_unique_name: New unique name for the object""" if not isinstance(new_unique_name, str): raise TypeError('Unique name has to be a string.') self._unique_name = new_unique_name self._global_object.map.add_vertex(self) @property def display_name(self) -> str: """ Get a pretty display name. :return: The pretty display name. """ # TODO This might be better implementing fancy f-strings where we can return html,latex, markdown etc display_name = self._display_name if display_name is None: display_name = self.name return display_name @display_name.setter @property_stack_deco def display_name(self, name_str: str): """ Set the pretty display name. :param name_str: Pretty display name of the object. :return: None """ self._display_name = name_str @property def unit(self) -> pint.UnitRegistry: """ Get the unit associated with the object. :return: Unit associated with self in `pint` form. """ return self._units.units @unit.setter @property_stack_deco def unit(self, unit_str: str): """ Set the unit to a new one. :param unit_str: String representation of the unit required. i.e `m/s` :return: None """ if not isinstance(unit_str, str): unit_str = str(unit_str) new_unit = ureg.parse_expression(unit_str) self._units = new_unit self._args['units'] = str(new_unit) self._value = self.__class__._constructor(**self._args) @property def value(self) -> Any: """ Get the value of self as a pint. This is should be usable for most cases. If a pint is not acceptable then the raw value can be obtained through `obj.raw_value`. :return: Value of self with unit. """ # Cached property? Should reference callback. # Also should reference for undo/redo if self._callback.fget is not None: try: value = self._callback.fget() if hasattr(self._value, 'magnitude'): if value != self._value.magnitude: self.__deepValueSetter(value) elif value != self._value: self.__deepValueSetter(value) except Exception as e: raise ValueError(f'Unable to return value:\n{e}') r_value = self._value if self.__isBooleanValue: r_value = bool(r_value) return r_value def __deepValueSetter(self, value: Any): """ Set the value of self. This creates a pint with a unit. :param value: New value of self :return: None """ # TODO there should be a callback to the collective, logging this as a return(if from a non `EasyScience` class) if hasattr(value, 'magnitude'): value = value.magnitude if hasattr(value, 'nominal_value'): value = value.nominal_value self._type = type(value) self.__isBooleanValue = isinstance(value, bool) if self.__isBooleanValue: value = int(value) self._args['value'] = value self._value = self.__class__._constructor(**self._args) @value.setter @property_stack_deco def value(self, value: Any): """ Set the value of self. This creates a pint with a unit. :param value: New value of self :return: None """ self.__deepValueSetter(value) if self._callback.fset is not None: try: self._callback.fset(value) except Exception as e: raise CoreSetException(e) @property def raw_value(self) -> Any: """ Return the raw value of self without a unit. :return: The raw value of self """ value = self._value if hasattr(value, 'magnitude'): value = value.magnitude if hasattr(value, 'nominal_value'): value = value.nominal_value if self.__isBooleanValue: value = bool(value) return value @property def enabled(self) -> bool: """ Logical property to see if the objects value can be directly set. :return: Can the objects value be set """ return self._enabled @enabled.setter @property_stack_deco def enabled(self, value: bool): """ 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
[docs] def convert_unit(self, unit_str: str): """ Convert the value from one unit system to another. You will should use `compatible_units` to see if your new unit is compatible. :param unit_str: New unit in string form """ new_unit = ureg.parse_expression(unit_str) self._value = self._value.to(new_unit) self._units = new_unit self._args['value'] = self.raw_value self._args['units'] = str(self.unit)
# @cached_property @property def compatible_units(self) -> List[str]: """ Returns all possible units for which the current unit can be converted. :return: Possible conversion units """ return [str(u) for u in self.unit.compatible_units()] def __repr__(self): """Return printable representation of a Descriptor/Parameter object.""" class_name = self.__class__.__name__ obj_name = self.name if self.__isBooleanValue: obj_value = self.raw_value else: obj_value = self._value.magnitude if isinstance(obj_value, float): obj_value = '{:0.04f}'.format(obj_value) obj_units = '' if not self.unit.dimensionless: obj_units = ' {:~P}'.format(self.unit) out_str = f"<{class_name} '{obj_name}': {obj_value}{obj_units}>" return out_str
[docs] def to_obj_type(self, data_type: Type[Parameter], *kwargs): """ Convert between a `Parameter` and a `Descriptor`. :param data_type: class constructor of what we want to be :param kwargs: Additional keyword/value pairs for conversion :return: self as a new type """ pickled_obj = self.encode() pickled_obj.update(kwargs) if '@class' in pickled_obj.keys(): pickled_obj['@class'] = data_type.__name__ return data_type.from_dict(pickled_obj)
def __copy__(self): return self.__class__.from_dict(self.as_dict())
V = TypeVar('V', bound=Descriptor) class ComboDescriptor(Descriptor): """ This class is an extension of a ``EasyScience.Object.Base.Descriptor``. This class has a selection of values which can be checked against. For example, combo box styling. """ def __init__(self, *args, available_options: list = None, **kwargs): super(ComboDescriptor, self).__init__(*args, **kwargs) if available_options is None: available_options = [] self._available_options = available_options # We have initialized from the Descriptor class where value has it's own undo/redo decorator # This needs to be bypassed to use the Parameter undo/redo stack fun = self.__class__.value.fset if hasattr(fun, 'func'): fun = getattr(fun, 'func') self.__previous_set: Callable[[V, Union[numbers.Number, np.ndarray]], Union[numbers.Number, np.ndarray]] = fun # Monkey patch the unit and the value to take into account the new max/min situation addProp( self, 'value', fget=self.__class__.value.fget, fset=self.__class__._property_value.fset, fdel=self.__class__.value.fdel, ) @property def _property_value(self) -> Union[numbers.Number, np.ndarray]: return self.value @_property_value.setter @property_stack_deco def _property_value(self, set_value: Union[numbers.Number, np.ndarray, Q_]): """ Verify value against constraints. This hasn't really been implemented as fitting is tricky. :param set_value: value to be verified :return: new value from constraint """ if isinstance(set_value, Q_): set_value = set_value.magnitude # Save the old state and create the new state old_value = self._value state = self._global_object.stack.enabled if state: self._global_object.stack.force_state(False) try: new_value = old_value if set_value in self.available_options: new_value = set_value finally: self._global_object.stack.force_state(state) # Restore to the old state self.__previous_set(self, new_value) @property def available_options(self) -> List[Union[numbers.Number, np.ndarray, Q_]]: return self._available_options @available_options.setter @property_stack_deco def available_options(self, available_options: List[Union[numbers.Number, np.ndarray, Q_]]) -> None: self._available_options = available_options def as_dict(self, **kwargs) -> Dict[str, Any]: import json d = super().as_dict(**kwargs) d['name'] = self.name d['available_options'] = json.dumps(self.available_options) return d
[docs] class Parameter(Descriptor): """ This class is an extension of a ``EasyScience.Object.Base.Descriptor``. Where the descriptor was for static objects, a `Parameter` is for dynamic objects. A parameter has the ability to be used in fitting and has additional fields to facilitate this. """ _constructor = M_ def __init__( self, name: str, value: Union[numbers.Number, np.ndarray], error: Optional[Union[numbers.Number, np.ndarray]] = 0.0, min: Optional[numbers.Number] = -np.inf, max: Optional[numbers.Number] = np.inf, fixed: Optional[bool] = False, **kwargs, ): """ This class is an extension of a ``EasyScience.Object.Base.Descriptor``. Where the descriptor was for static objects, a `Parameter` is for dynamic objects. A parameter has the ability to be used in fitting and has additional fields to facilitate this. :param name: Name of this obj :param value: Value of this object :param error: Error associated as sigma for this parameter :param min: Minimum value for fitting :param max: Maximum value for fitting :param fixed: Should this parameter vary when fitting? :param kwargs: Key word arguments for the `Descriptor` class. .. code-block:: python from easyscience.Objects.Base import Parameter # Describe a phase phase_basic = Parameter('phase', 3) # Describe a phase with a unit phase_unit = Parameter('phase', 3, units,='rad/s') .. note:: Undo/Redo functionality is implemented for the attributes `value`, `error`, `min`, `max`, `fixed` """ # Set the error self._args = {'value': value, 'units': '', 'error': error} if not isinstance(value, numbers.Number) or isinstance(value, np.ndarray): raise ValueError('In a parameter the `value` must be numeric') if value < min: raise ValueError('`value` can not be less than `min`') if value > max: raise ValueError('`value` can not be greater than `max`') if error < 0: raise ValueError('Standard deviation `error` must be positive') super().__init__(name=name, value=value, **kwargs) self._args['units'] = str(self.unit) # Warnings if we are given a boolean if isinstance(self._type, bool): warnings.warn( 'Boolean values are not officially supported in Parameter. Use a Descriptor instead', UserWarning, ) # Create additional fitting elements self._min: numbers.Number = min self._max: numbers.Number = max self._fixed: bool = fixed self.initial_value = self.value self._constraints: dict = { 'user': {}, 'builtin': { 'min': SelfConstraint(self, '>=', '_min'), 'max': SelfConstraint(self, '<=', '_max'), }, 'virtual': {}, } # This is for the serialization. Otherwise we wouldn't catch the values given to `super()` self._kwargs = kwargs # We have initialized from the Descriptor class where value has it's own undo/redo decorator # This needs to be bypassed to use the Parameter undo/redo stack fun = self.__class__.value.fset if hasattr(fun, 'func'): fun = getattr(fun, 'func') self.__previous_set: Callable[ [V, Union[numbers.Number, np.ndarray]], Union[numbers.Number, np.ndarray], ] = fun # Monkey patch the unit and the value to take into account the new max/min situation addProp( self, 'value', fget=self.__class__.value.fget, fset=self.__class__._property_value.fset, fdel=self.__class__.value.fdel, ) @property def _property_value(self) -> Union[numbers.Number, np.ndarray, M_]: return self.value @_property_value.setter @property_stack_deco def _property_value(self, set_value: Union[numbers.Number, np.ndarray, M_]) -> None: """ Verify value against constraints. This hasn't really been implemented as fitting is tricky. :param set_value: value to be verified :return: new value from constraint """ if isinstance(set_value, M_): set_value = set_value.magnitude.nominal_value # Save the old state and create the new state old_value = self._value self._value = self.__class__._constructor(value=set_value, units=self._args['units'], error=self._args['error']) # First run the built in constraints. i.e. min/max constraint_type: MappingProxyType[str, C] = self.builtin_constraints new_value = self.__constraint_runner(constraint_type, set_value) # Then run any user constraints. constraint_type: dict = self.user_constraints state = self._global_object.stack.enabled if state: self._global_object.stack.force_state(False) try: new_value = self.__constraint_runner(constraint_type, new_value) finally: self._global_object.stack.force_state(state) # And finally update any virtual constraints constraint_type: dict = self._constraints['virtual'] _ = self.__constraint_runner(constraint_type, new_value) # Restore to the old state self._value = old_value self.__previous_set(self, new_value)
[docs] def convert_unit(self, new_unit: str): # noqa: S1144 """ Perform unit conversion. The value, max and min can change on unit change. :param new_unit: new unit :return: None """ old_unit = str(self._args['units']) super().convert_unit(new_unit) # Deal with min/max. Error is auto corrected if not self.value.unitless and old_unit != 'dimensionless': self._min = Q_(self.min, old_unit).to(self._units).magnitude self._max = Q_(self.max, old_unit).to(self._units).magnitude # Log the new converted error self._args['error'] = self.value.error.magnitude
@property def min(self) -> numbers.Number: """ Get the minimum value for fitting. :return: minimum value """ return self._min @min.setter @property_stack_deco def min(self, value: numbers.Number): """ Set the minimum value for fitting. - implements undo/redo functionality. :param value: new minimum value :return: None """ if value <= self.raw_value: self._min = value else: raise ValueError(f'The current set value ({self.raw_value}) is less than the desired min value ({value}).') @property def max(self) -> numbers.Number: """ Get the maximum value for fitting. :return: maximum value """ return self._max @max.setter @property_stack_deco def max(self, value: numbers.Number): """ Get the maximum value for fitting. - implements undo/redo functionality. :param value: new maximum value :return: None """ if value >= self.raw_value: self._max = value else: raise ValueError(f'The current set value ({self.raw_value}) is greater than the desired max value ({value}).') @property def fixed(self) -> bool: """ Can the parameter vary while fitting? :return: True = fixed, False = can vary :rtype: bool """ return self._fixed @fixed.setter @property_stack_deco def fixed(self, value: bool): """ Change the parameter vary while fitting state. - implements undo/redo functionality. :param value: True = fixed, False = can vary :return: None """ if not self.enabled: if self._global_object.stack.enabled: self._global_object.stack.pop() if global_object.debug: raise CoreSetException(f'{str(self)} is not enabled.') return # TODO Should we try and cast value to bool rather than throw ValueError? if not isinstance(value, bool): raise ValueError self._fixed = value @property def free(self) -> bool: return not self.fixed @free.setter def free(self, value: bool) -> None: self.fixed = not value @property def error(self) -> float: """ The error associated with the parameter. :return: Error associated with parameter """ return float(self._value.error.magnitude) @error.setter @property_stack_deco def error(self, value: float): """ Set the error associated with the parameter. - implements undo/redo functionality. :param value: New error value :return: None """ if value < 0: raise ValueError self._args['error'] = value self._value = self.__class__._constructor(**self._args) def __repr__(self) -> str: """ Return printable representation of a Parameter object. """ super_str = super().__repr__() super_str = super_str[:-1] s = [] if self.fixed: super_str += ' (fixed)' s.append(super_str) s.append('bounds=[%s:%s]' % (repr(self.min), repr(self.max))) return '%s>' % ', '.join(s) def __float__(self) -> float: return float(self.raw_value) @property def builtin_constraints(self) -> MappingProxyType[str, C]: """ 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, C]: """ 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, C]) -> None: self._constraints['user'] = constraints_dict def _quick_set( self, set_value: float, run_builtin_constraints: bool = False, run_user_constraints: bool = False, run_virtual_constraints: bool = False, ) -> None: """ This is a quick setter for the parameter. It bypasses all the checks and constraints, just setting the value and issuing the interface callbacks. WARNING: This is a dangerous function and should only be used when you know what you are doing. """ # First run the built-in constraints. i.e. min/max if run_builtin_constraints: constraint_type: MappingProxyType = self.builtin_constraints set_value = self.__constraint_runner(constraint_type, set_value) # Then run any user constraints. if run_user_constraints: constraint_type: dict = self.user_constraints state = self._global_object.stack.enabled if state: self._global_object.stack.force_state(False) try: set_value = self.__constraint_runner(constraint_type, set_value) finally: self._global_object.stack.force_state(state) if run_virtual_constraints: # And finally update any virtual constraints constraint_type: dict = self._constraints['virtual'] _ = self.__constraint_runner(constraint_type, set_value) # Finally set the value self._property_value._magnitude._nominal_value = set_value self._args['value'] = set_value if self._callback.fset is not None: self._callback.fset(set_value) def __constraint_runner( self, this_constraint_type: Union[dict, MappingProxyType[str, C]], newer_value: numbers.Number, ) -> float: for constraint in this_constraint_type.values(): if constraint.external: constraint() continue this_new_value = constraint(no_set=True) if this_new_value != newer_value: if global_object.debug: print(f'Constraint `{constraint}` has been applied') self._value = self.__class__._constructor( value=this_new_value, units=self._args['units'], error=self._args['error'], ) newer_value = this_new_value return newer_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: Union[Tuple[numbers.Number, numbers.Number], numbers.Number]) -> None: """ Set the bounds of the parameter. *This will also enable the parameter*. :param new_bound: New bounds. This can be a tuple of (min, max) or a single number (min). For changing the max use (None, max_value). """ # Macro checking and opening for undo/redo close_macro = False if self._global_object.stack.enabled: self._global_object.stack.beginMacro('Setting bounds') close_macro = True # Have we only been given a single number (MIN)? if isinstance(new_bound, numbers.Number): self.min = new_bound # Have we been given a tuple? if isinstance(new_bound, tuple): new_min, new_max = new_bound # Are there any None values? if isinstance(new_min, numbers.Number): self.min = new_min if isinstance(new_max, numbers.Number): self.max = new_max # Enable the parameter if needed if not self.enabled: self.enabled = True # This parameter is now not fixed. self.fixed = False # Close the macro if we opened it if close_macro: self._global_object.stack.endMacro()