Source code for experimentor.models.properties

"""
Properties
==========

Every model in Experimentor has a set of properties that define their state. A camera has, for example, an exposure
time, a DAQ card has a delay between data points, and an Experiment holds global parameters, such as the number of
repetitions a measurement should take.

In many situations, the parameters are stored as a dictionary, mainly because they are easy to retrieve from a file on
the hard drive and to access from within the class. We want to keep that same approach, but adding extra features.

Features of Properties
----------------------
Each parameter stored on a property will have three values: new_value, value, old_value, which represent the value which
will be set, the value that is currently set and the value that was there before. In this way it is possible to just
update on the device those values that need updating, it is also possible to revert back to the previously known value.

Each value will also be marked with a flag to_update in case the value was changed, but not yet transmitted to the
device. This allows us to collect all the values we need, for example looping through a user interface, reading a config
file, and applying only those needed whenever desired.

The Properties have also another smart feature, achieved through linking. Linking means building a relationship between
the parameters stored within the class and the methods that need to be executed in order to get or set those values. In
the linking procedure, we can set only getter methods for read-only properties, or both methods. A general apply
function then allows to use the known methods to set the values that need to be updated to the device.

Future Roadmap
--------------
We can consider forcing methods to always act on properties defined as new/known/old in order to use that information as
a form of cache and validation strategy.

:license: MIT, see LICENSE for more details
:copyright: 2021 Aquiles Carattino
"""
import warnings
from typing import List

from experimentor.lib.log import get_logger
from experimentor.models import BaseModel
from experimentor.models.exceptions import LinkException, PropertyException


[docs]class Properties: """ Class to store the properties of models. It keeps track of changes in order to monitor whether a specific value needs to be updated. It also allows to keep track of what method should be triggered for each update. """ def __init__(self, parent: BaseModel, **kwargs): self._parent = parent self._properties = dict() self._links = dict() self.logger = get_logger() if kwargs: for key, value in kwargs.items(): self.__setitem__(key, value) def __setitem__(self, key, value): if key not in self._properties: self._properties.update({ key: { 'new_value': value, 'value': None, 'old_value': None, 'to_update': True } }) else: self._properties[key].update({ 'new_value': value, 'to_update': True, }) def __getitem__(self, item): if isinstance(item, int): key = list(self._properties.keys())[item] return {key: self._properties[key]['value']} if item in self._properties: return self._properties[item]['value'] if item in self._parent._features: return None raise KeyError(f'Property {item} unknown')
[docs] def all(self): """ Returns a dictionary with all the known values. Returns ------- properties : dict All the known values """ p = dict() for key, value in self._properties.items(): if key: p.update({ key: value['value'], }) return p
[docs] def update(self, values: dict): """Updates the values in the same way the update method of a dictionary works. It, however, stores the values as a new value, it does not alter the values stored. For updating the proper values use :func:`self.upgrade`. After updating the values, use :func:`self.apply_all` to send the new values to the device. """ for key, value in values.items(): self.__setitem__(key, value)
[docs] def upgrade(self, values, force=False): """This method actually overwrites the values stored in the properties. This method should be used only when the real values generated by a device are known. It will change the new values to None, it will set the value to value, and it will set the ``to_update`` flag to false. Parameters ---------- values: dict Dictionary in the form {property: new_value} force: bool If force is set to True, it will create the missing properties instead of raising an exception. """ for key, value in values.items(): if key not in self._properties: if not force: raise PropertyException(f'Trying to upgrade {key} but is not a listed property') self.__setitem__(key, value) self._properties[key].update({ 'new_value': None, 'value': value, 'to_update': False, })
[docs] def fetch(self, prop): """ Fetches the desired property from the device, provided that a link is available. """ if prop in self._links: getter = self._links[prop][0] if callable(getter): value = getter() else: value = getattr(self._parent, getter) self.logger.debug(f'Fetched {prop} -> {value}') return value else: # It may be a Model Property that has not been linked yet if prop in self._parent._features: self._links.update({prop: [prop, prop]}) return self.fetch(prop) self.logger.error(f'{prop} is not a valid property') raise KeyError(f'{prop} is not a valid property')
[docs] def fetch_all(self): """ Fetches all the properties for which a link has been established and updates the value. This method does not alter the to_update flag, new_value, nor old_value. """ self.logger.info(f'Fetching all properties of {self._parent}') keys = {key for key in self._links} | {key for key in self._parent._features} for key in keys: value = self.fetch(key) self.upgrade({key: value}, force=True)
[docs] def apply(self, property, force=False): """ Applies the new value to the property. This is provided that the property is marked as to_update, or forced to be updated. Parameters ---------- property: str The string identifying the property force: bool (default: False) If set to true it will update the propery on the device, regardless of whether it is marked as to_update or not. """ if property in self._links: if property in self._properties: property_value = self.get_property(property) if property_value['to_update'] or force: setter = self._links[property][1] if setter is not None: property_value['old_value'] = property_value['value'] new_value = property_value['new_value'] if callable(setter): value = setter(new_value) else: self._parent.__setattr__(setter, new_value) value = None if value is None: value = self.fetch(property) self.upgrade({property: value}) else: self.logger.warning(f'Trying to change the value of {property}, but it is read-only') else: self.logger.info(f'{property} will not be updated') else: raise PropertyException('Trying to update a property which is not registered') else: # The property may have been defined as a Model Property, we can add it to the links if property in self._parent._features: self._links.update({property: [property, property]}) self.apply(property) else: raise LinkException(f'Trying to update {property}, but it is not linked to any setter method')
[docs] def apply_all(self): """ Applies all changes marked as 'to_update', using the links to methods generated with :meth:~link """ values_to_update = self.to_update() for key, values in values_to_update.items(): self.apply(key)
[docs] def get_property(self, prop): """Get the information of a given property, including the new value, value, old value and if it is marked as to be updated. Returns ------- prop : dict The requested property as a dictionary """ return self._properties[prop]
[docs] def to_update(self): """Returns a dictionary containing all the properties marked to be updated. Returns ------- props : dict all the properties that still need to be updated """ props = {} for key, values in self._properties.items(): if values['to_update']: props[key] = values return props
[docs] @classmethod def from_dict(cls, parent, data): """Create a Properties object from a dictionary, including the linking information for methods. The data has to be passed in the following form: {property: [value, getter, setter]}, where `getter` and `setter` are the methods used by :meth:~link. Parameters ---------- parent : class to which the properties are attached data : dict Information on the values, getter and setter for each property """ parameters = dict() links = dict() for key, values in data.items(): parameters.update({ key: values[0] }) links.update({ key: values[1:] }) props = cls(parent, **parameters) props.link(links) return props
def __repr__(self): return repr(self.all())