# -*- coding: utf-8 -*-
# bases/algorithm/parameter.py
"""
This module defines a generic parameter class for algorithms.
It contains meta information which allows for automated UI building.
Create sub classes by calling factory() in this module.
It creates a new sub class type which inherits ParameterBase::
>>> from parameter import factory as paramFactory
>>> ParamType = paramFactory("radius", 1.3, valueRange = (0, 2))
Created a new type RadiusParameter:
>>> print(ParamType)
<class 'parameter.RadiusParameter'>
Using methods on instances work as usual:
>>> p = ParamType()
>>> p.name()
'radius'
>>> p.value()
1.3
Update the instance:
>>> p.setValue(2.4)
>>> p.value()
2.4
Changing class default:
>>> ParamType.setValue(3.5)
>>> ParamType.value()
3.5
Existing instance keep their values:
>>> p.value()
2.4
New instances get the updated defaults:
>>> q = ParamType()
>>> q.value()
3.5
Parameter attributes are accessible on type/class as well as on the
instance. Updating an attribute of an instance changes just that
individual instance whereas updating an attribute of the type changes
that attribute in general for all new instances to be created
which is behaves like a default value.
"""
from __future__ import absolute_import
from builtins import object
from builtins import str
import sys
import logging
from math import log10 as math_log10
from math import fabs as math_fabs
from inspect import getmembers
import numpy as np
from utils import (isString, isNumber, isList, isMap, isSet, testfor,
assertName, classname, classproperty, clip, isCallable)
from utils.mixedmethod import mixedmethod
from utils.units import NoUnit
from .numbergenerator import NumberGenerator, RandomUniform
[docs]def generateValues(numberGenerator, defaultRange, lower, upper, count):
# works with vectors of multiple bounds too
vRange = defaultRange
if lower is None:
lower = vRange[0]
if upper is None:
upper = vRange[1]
vRange = (np.maximum(vRange[0], lower),
np.minimum(vRange[1], upper))
if isList(vRange[0]) and isList(vRange[1]):
assert len(vRange[0]) == len(vRange[1]), (
"Provided value range is unsymmetrical!")
try: # update count to length of provided bound vectors
count = max(count, min([len(x) for x in vRange]))
except:
pass
values = numberGenerator.get(count)
# scale numbers to requested range
return values * (vRange[1] - vRange[0]) + vRange[0]
[docs]class ParameterError(Exception):
pass
[docs]class DefaultValueError(ParameterError):
pass
[docs]class ParameterNameError(ParameterError):
pass
[docs]class ValueRangeError(ParameterError):
pass
[docs]class SuffixError(ParameterError):
pass
[docs]class SteppingError(ParameterError):
pass
[docs]class DecimalsError(ParameterError):
pass
[docs]class DisplayValuesError(ParameterError):
pass
[docs]class ParameterGeneratorError(ParameterError):
pass
def _makeGetter(varName):
def getter(selforcls):
return getattr(selforcls, varName)
return mixedmethod(getter)
def _makeSetter(varName):
def setter(selforcls, value):
setattr(selforcls, varName, value)
return mixedmethod(setter)
def _setterName(attrName):
return "set" + attrName[0].upper() + attrName[1:]
[docs]class ParameterBase(object):
"""Base class for algorithm parameters providing additional
information to ease automated GUI building."""
# Be able to manage attributes programmatically also for derived classes
# while maintaining the order of attributes which is not preserved by
# pythons __dict__. Initialization of *decimals* has to occur after
# *valueRange*. Not sure, if this is a good idea but it reduces
# repetition/code drastically.
@classmethod
[docs] def addAttributes(cls, dictionary, *names, **namesAndValues):
"""Sets an *ordered* list of attributes.
Initializes the private variable to None and sets a default getter
method for each name provided. Additionally, sets *attributeNames* to
return all attribute names."""
names += tuple(namesAndValues.keys())
for attrName in names:
varName = "_"+attrName
# set the class variable
dictionary[varName] = namesAndValues.get(attrName, None)
# default getter for the class var
dictionary[attrName] = _makeGetter(varName)
if attrName != "name": # do not create setName() method
dictionary[_setterName(attrName)] = _makeSetter(varName)
try:
# prepend attribute names of the base class
names = cls.attributeNames() + names
except:
pass
# sets the ordered names of attributes
dictionary["_attributeNames"] = names
addAttributes.__func__(None, locals(), "name", "value", "displayName",
"onValueUpdate") # user provided callback function
@classmethod
[docs] def attributeNames(cls):
"""Returns an ordered list of attribute names considering multiple
inheritance and maintaining its order."""
mergedAttrNames = []
for baseCls in reversed(cls.__mro__):
if not hasattr(baseCls, "_attributeNames"):
continue
mergedAttrNames += [attrName
for attrName in baseCls._attributeNames
if attrName not in mergedAttrNames]
return mergedAttrNames
@mixedmethod
def attributes(selforcls, exclude = None):
"""Returns a dictionary with <key, value> pairs of all attributes and
their values in this type or instance.
Helps to avoid having explicit long argument lists"""
base = selforcls
if not isinstance(base, type) and isinstance(base, object):
base = type(base)
# Parameter classes have only one direct subclass
# FIXME: not necessarily true, see FitParameter
base = base.__mro__[1]
# store the direct base class for duplication later
res = dict(cls = base, description = selforcls.__doc__)
attr = selforcls.attributeNames()
# filter a given list of attribute names
if isList(exclude) or isSet(exclude):
attr = [a for a in attr if a not in exclude]
for name in attr:
# if this throws an exception, there is a bug
value = getattr(selforcls, name)()
# use values which differ from the defaults only
defValue = getattr(base, name)()
if value != defValue:
res[name] = value
return res
@mixedmethod
def setAttributes(selforcls, **kwargs):
"""Returns this type with attribute values initialized in the ordering
provided by attributeNames()"""
for key, value in kwargs.items():
# set the attributes for which we find setters
# the setter may raise exceptions for invalid data
setter = getattr(selforcls, _setterName(key), None)
if isCallable(setter): # key exists
setter(value)
return selforcls
[docs] def copy(self):
param = type(self)()
param.setAttributes(**self.attributes())
return param
@classmethod
[docs] def get(cls, key, default = None):
"""metagetter to get an attribute parameter"""
if key in cls.attributeNames():
getterFunc = getattr(cls, key, default)
return getterFunc()
else:
logging.warning(
"parameter {n} attribute {k} not understood in get"
.format(n = cls.name(), k = key))
@classmethod
[docs] def set(cls, key, value):
"""metasetter to set an attribute value"""
if key in cls.attributeNames():
setterFunc = getattr(cls, _setterName(key))
setterFunc(value)
else:
logging.warning(
"parameter {n} attribute {k} not found in set"
.format(n = cls.name(), k = key))
@classmethod
[docs] def setName(cls, name):
"""Changing the name is allowed for the class/type only,
not for instances."""
assertName(name, ParameterNameError)
safename = str(name).translate(str.maketrans("", "", ' \t\n\r'))
cls._name = safename
@mixedmethod
def setValue(selforcls, newValue):
testfor(newValue is not None,
DefaultValueError, "Default value is mandatory!")
if selforcls._value == newValue:
return # no update necessary
selforcls._value = newValue
if isCallable(selforcls.onValueUpdate()):
selforcls.onValueUpdate()()
@mixedmethod
def setDisplayName(selforcls, newName):
if (not isString(newName) or len(newName) <= 0):
newName = selforcls.name()
if newName is not None:
selforcls._displayName = str(newName)
@mixedmethod
def formatDisplayName(selforcls, **kwargs):
unformatted = any([
str('{' + k + '}') in selforcls.displayName()
for k in kwargs.keys()])
if unformatted: # remember the original text for repeated formatting
selforcls._origDisplayName = selforcls.displayName()
if not hasattr(selforcls, "_origDisplayName"):
return # Can not be formatted
selforcls.setDisplayName(selforcls._origDisplayName.format(**kwargs))
@mixedmethod
def displayValue(selforcls):
"""This is scaled to units used. For GUI display."""
return selforcls.value()
@mixedmethod
def setDisplayValue(selforcls, newValue):
"""Set the value scaled to units used. For GUI display."""
selforcls.setValue(newValue)
@classproperty
@classmethod
[docs] def dtype(cls):
return str
@classmethod
[docs] def isDataType(cls, value):
return isinstance(value, cls.dtype)
def __str__(self):
return u"{0}: {1} ({2})".format(
self.displayName(), self.displayValue(), self.value())
__repr__ = __str__
def __eq__(self, other):
if (not isinstance(other, type(self).mro()[1]) or
self.dtype != other.dtype):
return False
try:
# avoid reference loops for objects of bound methods
xlst = ("onValueUpdate",)
equal = (self.attributes(exclude = xlst)
== other.attributes(exclude = xlst))
return equal
except:
return False
def __ne__(self, other):
return not self.__eq__(other)
[docs] def generate(self, lower = None, upper = None, count = 1):
"""Returns a list of valid parameter values within given bounds.
Accepts vectors of individual bounds for lower and upper limit.
This allows for inequality parameter constraints.
lower, upper: arrays for lower and upper bounds
"""
raise NotImplementedError
def __call__(self):
"""Shortcut for Parameter.value().
For instances only (usually in model implementation)."""
return self.value()
[docs] def hdfStoreAsMember(self):
return []
[docs] def hdfWrite(self, hdf):
xlst = ("onValueUpdate",)
selfAttr = self.attributes(exclude = xlst)
selfAttr['cls'] = classname(selfAttr['cls'])
for name in self.hdfStoreAsMember():
if name in selfAttr:
selfAttr.pop(name)
hdf.writeMember(self, name)
hdf.writeAttributes(**selfAttr)
def __reduce__(self):
# remove possible callbacks to other objects
xlst = ("onValueUpdate",)
selfAttr = self.attributes(exclude = xlst)
clsAttr = type(self).attributes(exclude = xlst)
return (_unpickleParameter, (clsAttr, selfAttr))
def _unpickleParameter(clsAttr, selfAttr):
# reconstruct based on parent class and attributes, call constructor
param = factory(**clsAttr)() # reconstruct the class first
param.setAttributes(**selfAttr) # reset to original instance configuration
return param
[docs]class ParameterBoolean(ParameterBase):
@classproperty
@classmethod
[docs] def dtype(cls):
return bool
[docs]class ParameterString(ParameterBase):
"""
String-based parameter class. The default value should be the first
item in the _valueRange list.
"""
ParameterBase.addAttributes(locals(), "valueRange")
@classmethod
[docs] def setValueRange(self, newRange):
testfor(isList(newRange), ValueRangeError,
"A value range for a string type parameter has to be a list!")
testfor(all([isString(v) for v in newRange]), ValueRangeError,
"A value range for a string has to be a list of strings!")
self._valueRange = newRange
if not (self.value() in self.valueRange()):
# where are the default values?
self.setValue(self.valueRange()[0])
@mixedmethod
def valueRange(self):
if self._valueRange is None:
return ()
return self._valueRange
@classproperty
@classmethod
[docs] def dtype(cls):
return str
@classmethod
[docs] def isDataType(cls, value):
return isString(value)
[docs]class ParameterNumerical(ParameterBase):
# defines attributes for this parameter type and creates default
# getter/setter for them. For specialized versions they can be
# overridden as usual.
ParameterBase.addAttributes(locals(), "valueRange", "suffix",
"stepping", "displayValues", "generator")
[docs] def hdfStoreAsMember(self):
return (super(ParameterNumerical, self).hdfStoreAsMember()
+ ['generator',])
@mixedmethod
def setValue(selforcls, newValue, clip = True):
if newValue is None:
return # ignore
testfor(isNumber(newValue), DefaultValueError,
u"A value has to be numerical! ({})".format(newValue))
if clip:
# clip to min/max values:
newValue = selforcls.clip(newValue)
super(ParameterNumerical, selforcls).setValue(newValue)
@mixedmethod
def setValueRange(selforcls, newRange):
testfor(isList(newRange), ValueRangeError,
"A value range is mandatory for a numerical parameter!")
testfor(len(newRange) == 2, ValueRangeError,
"A value range has to consist of two values!")
testfor(all([isNumber(v) for v in newRange]), ValueRangeError,
"A value range has to consist of numbers only!")
minVal, maxVal = min(newRange), max(newRange)
# minVal = max(minVal, -sys.float_info.max)
# maxVal = min(maxVal, sys.float_info.max)
# avoid inf/nan showing up somewhere
# otherwise, inf might be ok if UI elements support it (going in&out)
minVal = max(minVal, -1e200) # as good as -inf?...
maxVal = min(maxVal, 1e200) # as good as inf?...
selforcls._valueRange = minVal, maxVal
# apply limits to value:
selforcls.setValue(selforcls.clip())
@mixedmethod
def setSuffix(selforcls, newSuffix):
if newSuffix is None:
return
testfor(isString(newSuffix) and len(newSuffix) > 0,
SuffixError, "Parameter suffix has to be some text!")
selforcls._suffix = newSuffix
@mixedmethod
def setStepping(selforcls, newStepping):
if newStepping is None:
return
testfor(isNumber(newStepping),
SteppingError, "Parameter has to be a number!")
selforcls._stepping = newStepping
@mixedmethod
def setDisplayValues(selforcls, newDisplayValues):
if newDisplayValues is None:
return
testfor(isMap(newDisplayValues), DisplayValuesError,
"Expected a display value mapping of numbers to text!")
testfor(all([isNumber(v) for v in newDisplayValues.keys()]),
DisplayValuesError, "Display value keys have to be numbers!")
testfor(all([isString(s) for s in newDisplayValues.values()]),
DisplayValuesError, "Display values have to be text!")
# TODO: also add reverse lookup
selforcls._displayValues = newDisplayValues
@mixedmethod
def setGenerator(selforcls, newGenerator):
if isinstance(newGenerator, type):
testfor(issubclass(newGenerator, NumberGenerator),
ParameterGeneratorError, "NumberGenerator type expected!")
else:
newGenerator = RandomUniform
selforcls._generator = newGenerator
# logging.info("Parameter {0} uses {1} distribution."
# .format(selforcls._name, newGenerator.__name__))
@mixedmethod
def valueRange(selforcls):
if selforcls._valueRange is None:
return (None, None)
return selforcls._valueRange
@mixedmethod
def min(selforcls):
return selforcls.valueRange()[0]
@mixedmethod
def max(selforcls):
return selforcls.valueRange()[1]
@mixedmethod
def clip(selforcls, value = None):
if value is None:
value = selforcls.value()
if value is None: # no value set yet
return None
return clip(value, selforcls.min(), selforcls.max())
@mixedmethod
def displayValues(selforcls, key = None, default = None):
if key is None:
return selforcls._displayValues
else:
return selforcls._displayValues.get(key, default)
@classproperty
@classmethod
[docs] def dtype(cls):
return int
@classmethod
[docs] def isDataType(cls, value):
"""ParameterNumerical is a fallback for all number not being float."""
return isNumber(value) and not isinstance(value, float)
def __str__(self):
return (super(ParameterNumerical, self).__str__()
+ u" in [{0}, {1}] ({sfx})"
.format(*(self.valueRange()), sfx = self.suffix()))
[docs] def generate(self, lower = None, upper = None, count = 1):
return generateValues(self.generator(), self.valueRange(),
lower, upper, count).astype(self.dtype)
[docs]class ParameterFloat(ParameterNumerical):
ParameterNumerical.addAttributes(locals(), "decimals", unit = NoUnit())
[docs] def hdfStoreAsMember(self):
return (super(ParameterFloat, self).hdfStoreAsMember()
+ ['unit',])
# some unit wrappers
@mixedmethod
def toDisplay(selforcls, value):
return selforcls.unit().toDisplay(value)
@mixedmethod
def toSi(selforcls, value):
return selforcls.unit().toSi(value)
@mixedmethod
def displayMagnitudeName(selforcls):
return selforcls.unit().displayMagnitudeName
#link suffix directly to displayMagnitudeName of unit metadata
@mixedmethod
def suffix(selforcls):
return selforcls.displayMagnitudeName()
@mixedmethod
def setSuffix(selforcls, newSuffix):
"""deprecated"""
pass
@mixedmethod
def displayValue(selforcls):
"""shows value converted to display units (str in displayValueUnit)"""
return selforcls.toDisplay(selforcls.value())
@mixedmethod
def setDisplayValue(selforcls, newVal):
"""sets value given in display units (str in displayValueUnit)"""
selforcls.setValue(selforcls.toSi(newVal), clip = True)
@mixedmethod
def displayValueRange(selforcls):
"""Upper and lower limits a parameter can assume in display unit"""
vRange = selforcls.valueRange()
newRange = (selforcls.toDisplay(min(vRange)),
selforcls.toDisplay(max(vRange)))
return newRange
@mixedmethod
def setDecimals(selforcls, newDecimals):
if newDecimals is not None:
testfor(isNumber(newDecimals) and newDecimals >= 0, DecimalsError,
"Parameter decimals has to be a positive number!")
else:
start, end = selforcls._valueRange
newDecimals = round(math_log10(math_fabs(end - start)))
newDecimals = max(newDecimals, 0)
newDecimals = min(newDecimals, sys.float_info.max_10_exp)
selforcls._decimals = int(newDecimals)
@classproperty
@classmethod
[docs] def dtype(cls):
return float
@classmethod
[docs] def isDataType(cls, value):
return isinstance(value, cls.dtype)
[docs]class ParameterLog(ParameterFloat):
"""Used to select an UI input widget with logarithmic behaviour."""
pass
[docs]def factory(name, value, paramTypes = None, **kwargs):
"""
Generates a new Parameter type derived from one of the predefined
base classes choosen by the supplied value: Providing a string value
results in a type derived from ParameterBase, providing an integer
value produces a ParameterNumerical type and a float value results
in a ParameterFloat type.
Alternatively, a class type cls can be provided which is used as base
class for the resulting Parameter class type. Make sure in this case,
all attributes mandatory for this base type are provided too.
- *name*: short name of the new parameter without spaces
- *value*: default value from which the type is derived if cls is not given
Optional arguments:
- *paramTypes*: tuple of available parameter types instead of the default
- *cls*: forces a certain Parameter type.
- *description*: Updates the __doc__ attribute. May be displayed in the UI
somewhere.
"""
kwargs.update(name = name, value = value)
name = kwargs.get("name", None)
assertName(name, ParameterNameError)
value = kwargs.get("value", None)
cls = kwargs.pop("cls", None) # remove 'cls' keyword before forwarding
if paramTypes is None:
paramTypes = (ParameterBoolean, ParameterFloat,
ParameterNumerical, ParameterBase)
if not (cls is not None and (
(isinstance(cls, super) and
issubclass(cls.__thisclass__, ParameterBase)) or
issubclass(cls, ParameterBase))):
for cls in paramTypes[:-1]:
if cls.isDataType(value):
break
else:
cls = paramTypes[-1] # ParameterBase usually
# embed description as class documentation
clsdict = dict()
description = kwargs.get("description", None)
if isString(description) and len(description) > 0:
clsdict['__doc__'] = description
# create a new class/type with given name and base class
# translate works different for unicode strings:
typeName = (str(name.title())
.translate(str.maketrans("", "", ' \t\n\r'))
+ "Parameter")
NewType = None
try:
NewType = type(typeName, (cls,), clsdict)
except TypeError: # Python 2: type() argument 1 must be string, not unicode
NewType = type(typeName.encode('ascii', 'ignore'), (cls,), clsdict)
# set up the new class before return
return NewType.setAttributes(**kwargs)
if __name__ == "__main__":
import doctest
doctest.testmod()
# vim: set ts=4 sts=4 sw=4 tw=0: