"""
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] def link(self, linking):
"""Link properties to methods for update and retrieve them.
Parameters
-----------
linking : dict
Dictionary in where information is stored as parameter=>[getter, setter], for example::
linking = {'exposure_time': [self.get_exposure, self.set_exposure]}
In this case, ``exposure_time`` is the property stored, while ``get_exposure`` is the method that will be
called for getting the latest value, and set_exposure will be called to set the value. In case set_exposure
returns something different from None, no extra call to get_exposure will be made.
"""
for key, value in linking.items():
if key in self._links and self._links[key] is not None:
raise LinkException(f'That property is already linked to {self._links[key]}. Please, unlink first')
if not isinstance(value, list):
value = [value, None]
else:
if len(value) == 1:
value.append(None)
elif len(value) > 2:
raise PropertyException(f'Properties only accept setters and getter, trying to link {key} with {len(value)} methods')
getter = getattr(self._parent, value[0])
getter = getter if callable(getter) else value[0]
setter = getattr(self._parent, value[1]) if value[1] else None
setter = setter if callable(setter) else value[1]
self._links[key] = [getter, setter]
[docs] def unlink(self, unlink_list):
""" Unlinks the properties and the methods. This is just to prevent overwriting linkings under the hood and
forcing the user to actively unlink before linking again.
Parameters
----------
unlink_list : list
List containing the names of the properties to be unlinked.
"""
for link in unlink_list:
if link in self._links:
self._links[link] = None
else:
warnings.warn('Unlinking a property which was not previously linked.')
[docs] def autolink(self):
""" Links the properties defined as :class:`~ModelProp` in the models using their setters and getters. """
for prop_name, prop in self._parent._features.items():
if prop.fset:
self.link({
prop_name: [prop.fget.__name__, prop.fset.__name__]
})
else:
self.link({
prop_name: prop.fget.__name__
})
[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())